From 9e5331b754b01d1949c7172af71699168af790a7 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 6 Oct 2026 11:01:09 +0000 Subject: [PATCH] fleet module + modules can answer MCP tools/call in-process (1.6.0) mcp_modules/fleet: fleet_list_services, fleet_get_service, fleet_search_services and fleet_get_auth over @profullstack/stack/fleet (the 62-service catalog, all on CoinPay OAuth), plus /fleet HTTP routes. Core: a module that exports callTool(name, args) now gets real results from MCP tools/call instead of the 'use the HTTP endpoint' note, and tools declared as objects keep their own description and inputSchema in tools/list. Modules without either behave exactly as before. Co-Authored-By: Claude Opus 5.5 --- mcp_modules/fleet/README.md | 19 +++++ mcp_modules/fleet/index.js | 131 +++++++++++++++++++++++++++++++++ mcp_modules/fleet/package.json | 15 ++++ package.json | 3 +- pnpm-lock.yaml | 46 ++++++++++++ src/core/moduleLoader.js | 4 + src/core/routes.js | 20 ++++- test/core/fleet-tools.test.js | 52 +++++++++++++ 8 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 mcp_modules/fleet/README.md create mode 100644 mcp_modules/fleet/index.js create mode 100644 mcp_modules/fleet/package.json create mode 100644 test/core/fleet-tools.test.js diff --git a/mcp_modules/fleet/README.md b/mcp_modules/fleet/README.md new file mode 100644 index 0000000..490a856 --- /dev/null +++ b/mcp_modules/fleet/README.md @@ -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/`. + +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). diff --git a/mcp_modules/fleet/index.js b/mcp_modules/fleet/index.js new file mode 100644 index 0000000..9d7e35b --- /dev/null +++ b/mcp_modules/fleet/index.js @@ -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/ 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 })), + ], +}; diff --git a/mcp_modules/fleet/package.json b/mcp_modules/fleet/package.json new file mode 100644 index 0000000..e505cd7 --- /dev/null +++ b/mcp_modules/fleet/package.json @@ -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": {} +} diff --git a/package.json b/package.json index 486d0e9..23f5de9 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b21de7..ccae9e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@hono/node-server': specifier: ^1.19.17 version: 1.19.17(hono@4.13.2) + '@profullstack/stack': + specifier: 0.4.0 + version: 0.4.0 dotenv: specifier: ^16.6.1 version: 16.6.1 @@ -128,6 +131,37 @@ packages: '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@profullstack/emailer@1.0.3': + resolution: {integrity: sha512-VyAtCcfnSI6LOwJvWogCr3ZNmEsaZw+iuWrqnosLsdcKsbRyCrCmXigMNZJZEjlgYlR+lK//PwNPG+xtCFvWAw==} + + '@profullstack/form-guard@0.1.1': + resolution: {integrity: sha512-RGLGfQhjq40f+JqEhSmsJnS0I5sFWjYdrYx4VYtkUqz8xvioN2yyR+6uFK0mnDAStKrYytlngg3meUsrChm/OQ==} + engines: {node: '>=20'} + + '@profullstack/referrals@0.1.0': + resolution: {integrity: sha512-u66SdBVpsv3kc0N+NWISPoYD5vjCERyv5wfD07iSkZwQeC2IA+ihX5jNA4e7Xr+Y4AUvxLycG+3b4VaROqzgRg==} + engines: {node: '>=18'} + peerDependencies: + react: '>=18' + peerDependenciesMeta: + react: + optional: true + + '@profullstack/stack@0.4.0': + resolution: {integrity: sha512-8iQzEiPp9UbrW7qqF5nHZJhQr3lOXQvMDQf+HyMWbZd4uEiYlSaKEbgBpKk7LM4/CADLZPNO10t5ovBqBJ9V9A==} + engines: {node: '>=18'} + peerDependencies: + '@supabase/ssr': '>=0.5.0' + next: '>=13.0.0' + react: '>=18.0.0' + peerDependenciesMeta: + '@supabase/ssr': + optional: true + next: + optional: true + react: + optional: true + '@puppeteer/browsers@3.2.0': resolution: {integrity: sha512-LlBrE8oqGfU7b1Nk2d5Q1SbuPhZxTj0cJEMDPEws28OjNMELlflekmPPuf4FnK03x0ZRjKaYwJElUcKK4kyqJA==} engines: {node: '>=22.12.0'} @@ -1439,6 +1473,18 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@profullstack/emailer@1.0.3': {} + + '@profullstack/form-guard@0.1.1': {} + + '@profullstack/referrals@0.1.0': {} + + '@profullstack/stack@0.4.0': + dependencies: + '@profullstack/emailer': 1.0.3 + '@profullstack/form-guard': 0.1.1 + '@profullstack/referrals': 0.1.0 + '@puppeteer/browsers@3.2.0(yauzl@2.10.0)': dependencies: modern-tar: 0.8.4 diff --git a/src/core/moduleLoader.js b/src/core/moduleLoader.js index 9057c79..65e90c3 100644 --- a/src/core/moduleLoader.js +++ b/src/core/moduleLoader.js @@ -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, diff --git a/src/core/routes.js b/src/core/routes.js index 0122761..c52d8e1 100644 --- a/src/core/routes.js +++ b/src/core/routes.js @@ -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 }, }; }); }); @@ -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. diff --git a/test/core/fleet-tools.test.js b/test/core/fleet-tools.test.js new file mode 100644 index 0000000..9dae601 --- /dev/null +++ b/test/core/fleet-tools.test.js @@ -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'); + }); +});