Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions mcp_modules/fleet/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# fleet

The Profullstack fleet catalog over MCP: 62 services, their APIs, MCP servers,
installers and pwamart listings, from `@profullstack/stack/fleet`. Every service
signs in with CoinPay OAuth.

| Tool | Does |
| --- | --- |
| `fleet_list_services` | All services, filtered by `category` or a surface (`has: "mcp"`) |
| `fleet_get_service` | One service by domain, URL or name |
| `fleet_search_services` | Search by what you need |
| `fleet_get_auth` | CoinPay OAuth issuer, discovery, scopes, PKCE, callback path |

HTTP: `GET /fleet`, `GET /fleet/services?category=&has=`, `GET /fleet/services/:domain`,
`POST /tools/fleet/<tool>`.

This module exports `callTool(name, args)`, so MCP `tools/call` runs it in-process
and returns the result (the core falls back to the HTTP-endpoint note for modules
without it).
131 changes: 131 additions & 0 deletions mcp_modules/fleet/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Fleet module: the catalog of every Profullstack service, over MCP and HTTP.
*
* The data is @profullstack/stack/fleet (one source of truth for MCP servers,
* webhooks, CLIs and sites). Every service signs in with CoinPay OAuth, so
* `get_auth` is the one thing a client needs to talk to any of them.
*
* MCP: tools/call runs callTool() below in-process.
* HTTP: GET /fleet, GET /fleet/services, GET /fleet/services/:domain,
* POST /tools/fleet/<tool> with the tool's arguments as JSON.
*/
import {
FLEET,
FLEET_AUTH,
categories,
getService,
listServices,
searchServices,
} from '@profullstack/stack/fleet';
import { logger } from '../../src/utils/logger.js';

const SURFACES = ['api', 'openapi', 'mcp', 'openmcp', 'llms', 'install', 'pwa', 'openaccess'];

export const tools = [
{
name: 'fleet_list_services',
description:
'List the Profullstack fleet: every product with its URL, category, and which surfaces it has (API, MCP, llms.txt, installer, PWA). Filter by category or by a surface.',
inputSchema: {
type: 'object',
properties: {
category: { type: 'string', description: 'e.g. developer-tools, ai, finance, media, security' },
has: { type: 'string', enum: SURFACES, description: 'only services with this surface' },
},
additionalProperties: false,
},
},
{
name: 'fleet_get_service',
description: 'Everything about one Profullstack service by domain, URL or name: surfaces, MCP endpoint, npm packages, pwamart install page, auth.',
inputSchema: {
type: 'object',
properties: { service: { type: 'string', description: 'domain, URL or name, e.g. "hqtui.com"' } },
required: ['service'],
additionalProperties: false,
},
},
{
name: 'fleet_search_services',
description: 'Search the Profullstack fleet by what you need, e.g. "podcast directory" or "webhooks".',
inputSchema: {
type: 'object',
properties: { query: { type: 'string' }, limit: { type: 'integer', minimum: 1, maximum: 62 } },
required: ['query'],
additionalProperties: false,
},
},
{
name: 'fleet_get_auth',
description: 'How to sign in to any Profullstack service: CoinPay OAuth (OIDC) issuer, discovery URL, scopes, PKCE and callback path.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
},
];

const brief = (s) => ({
domain: s.domain,
name: s.name,
category: s.category,
url: s.url,
description: s.description,
surfaces: Object.fromEntries(Object.entries(s.surfaces).filter(([, v]) => v)),
pwamart: s.pwamart,
});

/** MCP tools/call entry point (see src/core/routes.js). */
export async function callTool(name, args = {}) {
switch (name) {
case 'fleet_list_services':
return { total: FLEET.services.length, categories: categories(), services: listServices(args).map(brief) };
case 'fleet_get_service': {
const s = getService(args.service);
if (!s) throw new Error(`no Profullstack service matches "${args.service}"`);
return s;
}
case 'fleet_search_services':
return searchServices(args.query, args.limit ?? 10).map(brief);
case 'fleet_get_auth':
return FLEET_AUTH;
default:
throw new Error(`unknown fleet tool ${name}`);
}
}

export async function register(app) {
logger.info('Registering fleet module');
app.get('/fleet', (c) => c.json({ module: 'fleet', services: FLEET.services.length, updated: FLEET.updated, auth: FLEET_AUTH }));
app.get('/fleet/services', async (c) => c.json(await callTool('fleet_list_services', c.req.query())));
app.get('/fleet/services/:domain', (c) => {
const s = getService(c.req.param('domain'));
return s ? c.json(s) : c.json({ error: 'no such service' }, 404);
});
app.get('/tools/fleet/info', (c) => c.json({ name: 'fleet', tools }));
for (const t of tools) {
app.post(`/tools/fleet/${t.name}`, async (c) => {
try {
const args = await c.req.json().catch(() => ({}));
return c.json(await callTool(t.name, args));
} catch (error) {
return c.json({ error: error.message }, 400);
}
});
}
}

export async function unregister() {
logger.info('Unregistering fleet module');
}

export const metadata = {
name: 'fleet',
version: '1.0.0',
description: 'The Profullstack fleet catalog: 62 services, their APIs, MCP servers and installers, all on CoinPay OAuth',
author: 'Profullstack, Inc.',
tools,
endpoints: [
{ path: '/fleet', method: 'GET', description: 'Catalog summary and auth' },
{ path: '/fleet/services', method: 'GET', description: 'List services (?category=&has=)' },
{ path: '/fleet/services/:domain', method: 'GET', description: 'One service' },
...tools.map((t) => ({ path: `/tools/fleet/${t.name}`, method: 'POST', description: t.description })),
],
};
15 changes: 15 additions & 0 deletions mcp_modules/fleet/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "mcp-module-fleet",
"version": "1.0.0",
"description": "The Profullstack fleet catalog: 62 services, their APIs, MCP servers and installers, all on CoinPay OAuth",
"main": "index.js",
"type": "module",
"scripts": {
"test": "mocha test/**/*.test.js"
},
"keywords": ["mcp", "module", "fleet", "catalog", "profullstack", "coinpay", "oauth"],
"author": "Profullstack, Inc.",
"license": "ISC",
"engines": { "node": ">=20.0.0" },
"dependencies": {}
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/mcp-server",
"version": "1.5.1",
"version": "1.6.0",
"description": "A generic, modular server for implementing the Model Context Protocol (MCP)",
"main": "index.js",
"type": "module",
Expand Down Expand Up @@ -39,6 +39,7 @@
},
"dependencies": {
"@hono/node-server": "^1.19.17",
"@profullstack/stack": "0.4.0",
"dotenv": "^16.6.1",
"form-data": "^4.0.6",
"hono": "^4.13.2",
Expand Down
46 changes: 46 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/core/moduleLoader.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ export async function getModulesInfo() {
: await import(moduleUrl);

// If the module has metadata, merge it with the package.json info
// A module that exports callTool(name, args) answers MCP tools/call itself
// (routes.js); one without it keeps the HTTP-endpoint behaviour.
if (typeof module.callTool === 'function') moduleInfo.callTool = module.callTool;

if (module.metadata) {
moduleInfo = {
...moduleInfo,
Expand Down
20 changes: 18 additions & 2 deletions src/core/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,11 @@ export function setupCoreRoutes(app) {
const moduleTools = Array.isArray(m.tools) ? m.tools : [];
return moduleTools.map(t => {
const name = typeof t === 'string' ? t : (t?.name ?? String(t));
// A tool may be declared as an object with its own description and schema.
return {
name,
description: m.description || `Tool from module ${m.name}`,
inputSchema: { type: 'object', additionalProperties: true },
description: (typeof t === 'object' && t?.description) || m.description || `Tool from module ${m.name}`,
inputSchema: (typeof t === 'object' && t?.inputSchema) || { type: 'object', additionalProperties: true },
};
});
});
Expand Down Expand Up @@ -158,6 +159,21 @@ export function setupCoreRoutes(app) {
};
}

// A module with callTool runs the tool in-process and returns its result.
if (typeof found.callTool === 'function') {
try {
const value = await found.callTool(name, args);
const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
return { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text }], isError: false } };
} catch (error) {
return {
jsonrpc: '2.0',
id,
result: { content: [{ type: 'text', text: error?.message || String(error) }], isError: true },
};
}
}

// Attempt to call a conventional endpoint provided by the module:
// POST /tools/:module/:tool with JSON body = args
// Since we're inside the same Hono app instance, we can forward internally by constructing a Request.
Expand Down
52 changes: 52 additions & 0 deletions test/core/fleet-tools.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Hono } from 'hono';
import { setupCoreRoutes } from '../../src/core/routes.js';
import { callTool, tools } from '../../mcp_modules/fleet/index.js';

// Modules that export callTool(name, args) answer MCP tools/call in-process;
// the fleet module is the first. The real module directory is loaded here.
const rpc = async (app, method, params) => {
const res = await app.request('/mcp', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
});
return res.json();
};

describe('fleet module', () => {
it('declares four tools with schemas', () => {
expect(tools.map((t) => t.name)).to.deep.equal([
'fleet_list_services',
'fleet_get_service',
'fleet_search_services',
'fleet_get_auth',
]);
for (const t of tools) expect(t.inputSchema.type).to.equal('object');
});

it('answers each tool from the stack catalog', async () => {
const all = await callTool('fleet_list_services', {});
expect(all.total).to.equal(62);
expect((await callTool('fleet_get_service', { service: 'https://www.hqtui.com/' })).name).to.equal('HQTUI');
expect((await callTool('fleet_search_services', { query: 'app store' })).some((s) => s.domain === 'pwamart.com')).to.equal(true);
expect((await callTool('fleet_get_auth')).issuer).to.equal('https://coinpayportal.com');
let threw = false;
try {
await callTool('fleet_get_service', { service: 'nope.example' });
} catch {
threw = true;
}
expect(threw).to.equal(true);
});

it('runs through MCP tools/list and tools/call on the real server routes', async () => {
const app = new Hono();
setupCoreRoutes(app);
const list = await rpc(app, 'tools/list', {});
const fleet = list.result.tools.find((t) => t.name === 'fleet_get_service');
expect(fleet.inputSchema.required).to.deep.equal(['service']);
const call = await rpc(app, 'tools/call', { name: 'fleet_get_service', arguments: { service: 'pwamart.com' } });
expect(call.result.isError).to.equal(false);
expect(JSON.parse(call.result.content[0].text).auth.provider).to.equal('coinpay');
});
});
Loading