diff --git a/package.json b/package.json index 6f46995..80f274c 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,9 @@ "validate": "node scripts/validate-content.mjs && node scripts/verify-generated-routes.mjs && node scripts/verify-article-canon.mjs", "build": "npm run validate && eleventy && pagefind --site _site && node scripts/write-build-info.mjs", "pretest": "node scripts/prepare-tests.mjs", - "test": "node --test tests/aicontext.test.mjs tests/edcm-mathematics.test.mjs tests/gonol-relationships.test.mjs tests/org-msdmd.test.mjs tests/post-merge-reconciliation.test.mjs tests/llms-build.test.mjs tests/canon-parser.test.mjs tests/canon-integrity.test.mjs tests/textbook-integrity.test.mjs tests/math-rendering.test.mjs tests/narratives.test.mjs tests/offline-project-snapshot.test.mjs tests/repo-coverage.test.mjs tests/research-ledger.test.mjs tests/works-registry.test.mjs tests/site-contract.test.mjs tests/sitrep.test.mjs tests/webmcp.test.mjs", + "test": "node --test tests/aicontext.test.mjs tests/edcm-mathematics.test.mjs tests/gonol-relationships.test.mjs tests/org-msdmd.test.mjs tests/post-merge-reconciliation.test.mjs tests/llms-build.test.mjs tests/canon-parser.test.mjs tests/canon-integrity.test.mjs tests/textbook-integrity.test.mjs tests/math-rendering.test.mjs tests/narratives.test.mjs tests/offline-project-snapshot.test.mjs tests/repo-coverage.test.mjs tests/research-ledger.test.mjs tests/works-registry.test.mjs tests/site-contract.test.mjs tests/sitrep.test.mjs tests/webmcp.test.mjs tests/mcp-server.test.mjs", + "test:mcp": "node --test tests/mcp-server.test.mjs", + "start:mcp": "node server/mcp-server.mjs", "test:generated": "node --test tests/generated-site.test.mjs tests/human-ui-generated.test.mjs tests/textbook-generated.test.mjs tests/math-generated.test.mjs && node tests/links.test.mjs", "test:browser": "playwright test", "test:e2e": "playwright test tests/site.spec.mjs", diff --git a/server/mcp-protocol.mjs b/server/mcp-protocol.mjs new file mode 100644 index 0000000..9e34399 --- /dev/null +++ b/server/mcp-protocol.mjs @@ -0,0 +1,272 @@ +import { createSkillRegistry } from '../src/assets/js/webmcp-registry.js'; + +// === MODULE_BUILD === +// id: interdependency_remote_mcp_protocol +// module_name: mcp_protocol +// module_kind: service +// summary: Serve the website-owned skill registry as a real read-only MCP tool surface for modern 2026 and legacy 2025 protocol clients. +// owner: Erin Spencer +// public_surface: createMcpProtocol, TOOL_DEFINITIONS, SUPPORTED_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSION +// internal_surface: protocol negotiation, tool dispatch, modern response envelopes +// auth_boundary: none +// storage_boundary: none +// network_boundary: none +// user_data_boundary: none +// admin_only: false +// tests: tests/mcp-server.test.mjs +// rollout: imported by server/mcp-server.mjs +// rollback: remove server deployment and this protocol module; browser-native WebMCP remains independent +// === END MODULE_BUILD === +// === BOUNDARIES === +// id: interdependency_remote_mcp_protocol_boundary +// summary: exposes only read-only transformations over a supplied public skill registry projection +// auth_boundary: none +// storage_boundary: none +// network_boundary: none +// user_data_boundary: none +// admin_only: false +// pii: none +// secrets: none +// side_effects: none +// owner: website-runtime +// === END BOUNDARIES === +// === CONTRACTS === +// id: remote_mcp_exposes_same_five_registry_tools +// given: a client lists MCP tools +// then: exactly the five website registry operations are returned with read-only annotations +// class: correctness +// +// id: remote_mcp_supports_modern_and_legacy_eras +// given: a client uses MCP 2026-07-28 server/discover or a 2025 initialize handshake +// then: the server returns the correct era-shaped response and the same tool semantics +// class: interoperability +// +// id: remote_mcp_tool_calls_do_not_mutate +// given: any registered tool is called +// then: only supplied registry data is read and a structured result is returned +// class: safety +// === END CONTRACTS === +// Usage: create a protocol with `createMcpProtocol(registryData)`, then pass incoming JSON-RPC messages to `handle(message, { protocolVersion })`. + +export const MODERN_PROTOCOL_VERSION = '2026-07-28'; +export const SUPPORTED_PROTOCOL_VERSIONS = [ + '2025-11-25', + '2025-06-18', + '2025-03-26' +]; + +export const SERVER_INFO = Object.freeze({ + name: 'the-interdependency-mcp', + title: 'The Interdependency MCP', + version: '0.1.0', + description: 'Read-only MCP server over the commit-pinned The-Interdependency/skill-lib registry.', + websiteUrl: 'https://interdependentway.org/webmcp/' +}); + +export const TOOL_DEFINITIONS = Object.freeze([ + { + name: 'tiw_registry_status', + title: 'The Interdependency registry status', + description: 'Return provenance, registry version, skill count, and fallback state for the commit-pinned skill-lib projection.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } + }, + { + name: 'tiw_list_skills', + title: 'List Interdependency skills', + description: 'List registered skills, optionally filtered by exact skill kind.', + inputSchema: { + type: 'object', + properties: { kind: { type: 'string', description: 'Optional exact kind such as procedural or metadata-block.' } }, + additionalProperties: false + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } + }, + { + name: 'tiw_find_skill', + title: 'Find an Interdependency skill', + description: 'Search the registry by task words, skill name, path, and description.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Task or capability to search for.' }, + kind: { type: 'string', description: 'Optional exact kind filter.' }, + limit: { type: 'integer', minimum: 1, maximum: 20, default: 8 } + }, + required: ['query'], + additionalProperties: false + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } + }, + { + name: 'tiw_inspect_skill', + title: 'Inspect an Interdependency skill', + description: 'Return one registered skill with its kind, description, dependencies, canonical path, and commit-pinned source URL.', + inputSchema: { + type: 'object', + properties: { name: { type: 'string', description: 'Exact registered skill name.' } }, + required: ['name'], + additionalProperties: false + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } + }, + { + name: 'tiw_resolve_skill_closure', + title: 'Resolve Interdependency skill closure', + description: 'Resolve the smallest dependency-first transitive closure required by one registered skill.', + inputSchema: { + type: 'object', + properties: { name: { type: 'string', description: 'Exact registered skill name.' } }, + required: ['name'], + additionalProperties: false + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } + } +]); + +const TOOL_ARGUMENT_KEYS = Object.freeze({ + tiw_registry_status: [], + tiw_list_skills: ['kind'], + tiw_find_skill: ['query', 'kind', 'limit'], + tiw_inspect_skill: ['name'], + tiw_resolve_skill_closure: ['name'] +}); + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function rpcError(id, code, message, data) { + return { + jsonrpc: '2.0', + id: id ?? null, + error: { code, message, ...(data === undefined ? {} : { data }) } + }; +} + +function rpcResult(id, result) { + return { jsonrpc: '2.0', id, result }; +} + +function validateArguments(name, value) { + const args = value === undefined ? {} : value; + if (!isObject(args)) throw new Error('arguments must be an object'); + + const allowed = TOOL_ARGUMENT_KEYS[name]; + if (!allowed) throw new Error(`unknown tool: ${name}`); + for (const key of Object.keys(args)) { + if (!allowed.includes(key)) throw new Error(`unexpected argument: ${key}`); + } + + if (name === 'tiw_find_skill' && typeof args.query !== 'string') { + throw new Error('query is required and must be a string'); + } + if ((name === 'tiw_inspect_skill' || name === 'tiw_resolve_skill_closure') && typeof args.name !== 'string') { + throw new Error('name is required and must be a string'); + } + if (args.kind !== undefined && typeof args.kind !== 'string') throw new Error('kind must be a string'); + if (args.limit !== undefined && (!Number.isInteger(args.limit) || args.limit < 1 || args.limit > 20)) { + throw new Error('limit must be an integer from 1 to 20'); + } + return args; +} + +function toolResult(value) { + return { + content: [{ type: 'text', text: JSON.stringify(value, null, 2) }], + structuredContent: value, + isError: false + }; +} + +function toolError(message) { + return { content: [{ type: 'text', text: message }], isError: true }; +} + +function negotiateLegacyVersion(requested) { + return SUPPORTED_PROTOCOL_VERSIONS.includes(requested) + ? requested + : SUPPORTED_PROTOCOL_VERSIONS[0]; +} + +function modernMeta() { + return { 'io.modelcontextprotocol/serverInfo': { ...SERVER_INFO } }; +} + +function modernResult(id, result, { cacheable = false } = {}) { + return rpcResult(id, { + ...result, + resultType: 'complete', + ...(cacheable ? { ttlMs: 60_000, cacheScope: 'public' } : {}), + _meta: { ...(result?._meta || {}), ...modernMeta() } + }); +} + +function discoverResult(id) { + return modernResult(id, { + supportedVersions: [MODERN_PROTOCOL_VERSION, ...SUPPORTED_PROTOCOL_VERSIONS], + capabilities: { tools: { listChanged: false } }, + instructions: 'Use the five read-only tiw_* tools to discover, inspect, and resolve dependency closure for The Interdependency skills.' + }, { cacheable: true }); +} + +export function createMcpProtocol(registryData) { + const registry = createSkillRegistry(registryData); + const toolHandlers = { + tiw_registry_status: args => registry.getRegistryStatus(args), + tiw_list_skills: args => registry.listSkills(args), + tiw_find_skill: args => registry.findSkills(args), + tiw_inspect_skill: args => registry.inspectSkill(args), + tiw_resolve_skill_closure: args => registry.resolveSkillClosure(args) + }; + + function handleToolCall(message, modern) { + const name = message.params?.name; + if (typeof name !== 'string' || !toolHandlers[name]) { + return rpcError(message.id, -32602, 'Invalid params', { reason: `unknown tool: ${name}` }); + } + try { + const args = validateArguments(name, message.params?.arguments); + const result = toolResult(toolHandlers[name](args)); + return modern ? modernResult(message.id, result) : rpcResult(message.id, result); + } catch (error) { + const result = toolError(error instanceof Error ? error.message : String(error)); + return modern ? modernResult(message.id, result) : rpcResult(message.id, result); + } + } + + function handle(message, { protocolVersion = null } = {}) { + if (!isObject(message) || message.jsonrpc !== '2.0' || typeof message.method !== 'string') { + return rpcError(message?.id, -32600, 'Invalid Request'); + } + + const hasId = Object.prototype.hasOwnProperty.call(message, 'id'); + if (!hasId) return { notification: true }; + + const modern = protocolVersion === MODERN_PROTOCOL_VERSION || message.method === 'server/discover'; + + switch (message.method) { + case 'server/discover': + return discoverResult(message.id); + case 'initialize': + return rpcResult(message.id, { + protocolVersion: negotiateLegacyVersion(message.params?.protocolVersion), + capabilities: { tools: { listChanged: false } }, + serverInfo: { ...SERVER_INFO }, + instructions: 'Use the five read-only tiw_* tools to discover, inspect, and resolve dependency closure for The Interdependency skills. Skill definitions remain authoritative in The-Interdependency/skill-lib.' + }); + case 'ping': + return rpcResult(message.id, {}); + case 'tools/list': { + const result = { tools: TOOL_DEFINITIONS.map(tool => ({ ...tool })) }; + return modern ? modernResult(message.id, result, { cacheable: true }) : rpcResult(message.id, result); + } + case 'tools/call': + return handleToolCall(message, modern); + default: + return rpcError(message.id, -32601, 'Method not found', { method: message.method }); + } + } + + return { handle, registry }; +} diff --git a/server/mcp-server.mjs b/server/mcp-server.mjs new file mode 100644 index 0000000..00021cf --- /dev/null +++ b/server/mcp-server.mjs @@ -0,0 +1,258 @@ +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { readFallback } from '../scripts/fetch-skill-registry.mjs'; +import { + createMcpProtocol, + MODERN_PROTOCOL_VERSION, + SUPPORTED_PROTOCOL_VERSIONS +} from './mcp-protocol.mjs'; + +// === MODULE_BUILD === +// id: interdependency_remote_mcp_http_server +// module_name: remote_mcp_server +// module_kind: service +// summary: Public stateless Streamable HTTP MCP endpoint over the website-owned skill registry projection. +// owner: Erin Spencer +// public_surface: POST /mcp, GET /health +// internal_surface: createInterdependencyMcpServer, loadRegistryProjection +// auth_boundary: none +// storage_boundary: none +// network_boundary: external +// user_data_boundary: none +// admin_only: false +// tests: tests/mcp-server.test.mjs +// rollout: Render web service using `node server/mcp-server.mjs` +// rollback: disable the Render service; browser-native WebMCP remains live independently +// === END MODULE_BUILD === +// === BOUNDARIES === +// id: interdependency_remote_mcp_http_boundary +// summary: accepts public MCP requests and exposes only read-only operations over public skill registry data +// auth_boundary: none +// storage_boundary: none +// network_boundary: external +// user_data_boundary: none +// admin_only: false +// pii: none +// secrets: none +// side_effects: none +// owner: website-runtime +// === END BOUNDARIES === +// === CONTRACTS === +// id: remote_mcp_streamable_http_single_endpoint +// given: a client sends MCP JSON-RPC traffic +// then: POST /mcp returns JSON MCP responses and GET /mcp returns 405 because this server has no unsolicited SSE stream +// class: interoperability +// +// id: remote_mcp_origin_validation +// given: a request supplies an Origin header +// then: only an explicitly allowed origin is accepted +// class: security +// +// id: remote_mcp_registry_source_is_verified_projection +// given: the service starts +// then: it loads the generated commit-pinned registry projection or the verified last-known-good fallback and never invents skill records +// class: evidence +// === END CONTRACTS === +// Usage: `PORT=3000 node server/mcp-server.mjs`; connect an MCP client to `http://127.0.0.1:3000/mcp`. The production deployment is intentionally public and read-only; adding mutation requires a separate authenticated service boundary. + +const PUBLIC_REGISTRY_PATH = 'src/assets/data/skill-registry.json'; +const DEFAULT_PORT = 3000; +const MAX_BODY_BYTES = 1_000_000; + +const DEFAULT_ALLOWED_ORIGINS = new Set([ + 'https://interdependentway.org', + 'https://www.interdependentway.org', + 'https://chatgpt.com', + 'https://chat.openai.com' +]); + +function rpcError(id, code, message, data) { + return { + jsonrpc: '2.0', + id: id ?? null, + error: { code, message, ...(data === undefined ? {} : { data }) } + }; +} + +function sendJson(response, status, payload, headers = {}) { + const body = JSON.stringify(payload); + response.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'content-length': Buffer.byteLength(body), + 'cache-control': 'no-store', + ...headers + }); + response.end(body); +} + +function sendEmpty(response, status, headers = {}) { + response.writeHead(status, headers); + response.end(); +} + +function allowedOriginsFromEnvironment() { + const configured = String(process.env.MCP_ALLOWED_ORIGINS || '') + .split(',') + .map(value => value.trim()) + .filter(Boolean); + return configured.length ? new Set(configured) : DEFAULT_ALLOWED_ORIGINS; +} + +function isOriginAllowed(request, allowedOrigins) { + const origin = request.headers.origin; + return !origin || allowedOrigins.has(origin); +} + +async function readJsonBody(request) { + let size = 0; + const chunks = []; + for await (const chunk of request) { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + const error = new Error('request body too large'); + error.statusCode = 413; + throw error; + } + chunks.push(chunk); + } + + const text = Buffer.concat(chunks).toString('utf8'); + if (!text) { + const error = new Error('empty request body'); + error.statusCode = 400; + throw error; + } + + try { + return JSON.parse(text); + } catch { + const error = new Error('invalid JSON'); + error.statusCode = 400; + error.parseError = true; + throw error; + } +} + +function protocolVersionFor(request, message) { + return request.headers['mcp-protocol-version'] + || message?.params?._meta?.['io.modelcontextprotocol/protocolVersion'] + || null; +} + +function validateRoutingHeaders(request, message) { + const methodHeader = request.headers['mcp-method']; + if (methodHeader && methodHeader !== message.method) { + return `Mcp-Method header ${methodHeader} does not match body method ${message.method}`; + } + const nameHeader = request.headers['mcp-name']; + if (nameHeader && message.method === 'tools/call' && nameHeader !== message.params?.name) { + return `Mcp-Name header ${nameHeader} does not match body tool ${message.params?.name}`; + } + return null; +} + +export async function loadRegistryProjection() { + try { + return JSON.parse(await readFile(PUBLIC_REGISTRY_PATH, 'utf8')); + } catch { + const fallback = await readFallback(); + return { + ...fallback, + fallback: true, + hmmm: ['remote MCP service started from the verified last-known-good registry snapshot'] + }; + } +} + +export function createInterdependencyMcpServer(registryData, { + allowedOrigins = allowedOriginsFromEnvironment() +} = {}) { + const protocol = createMcpProtocol(registryData); + + return createServer(async (request, response) => { + const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`); + + if (request.method === 'GET' && url.pathname === '/health') { + return sendJson(response, 200, { + ok: true, + service: 'the-interdependency-mcp', + endpoint: '/mcp', + skill_count: protocol.registry.getRegistryStatus().skill_count + }); + } + + if (url.pathname !== '/mcp') { + return sendJson(response, 404, rpcError(null, -32601, 'Not Found')); + } + + if (!isOriginAllowed(request, allowedOrigins)) { + return sendJson(response, 403, rpcError(null, -32000, 'Forbidden origin')); + } + + if (request.method === 'GET') { + return sendEmpty(response, 405, { allow: 'POST, OPTIONS' }); + } + + if (request.method === 'OPTIONS') { + const origin = request.headers.origin; + return sendEmpty(response, 204, { + ...(origin && allowedOrigins.has(origin) ? { 'access-control-allow-origin': origin } : {}), + 'access-control-allow-methods': 'POST, OPTIONS', + 'access-control-allow-headers': 'content-type, accept, mcp-protocol-version, mcp-method, mcp-name', + 'access-control-max-age': '600' + }); + } + + if (request.method !== 'POST') { + return sendEmpty(response, 405, { allow: 'POST, OPTIONS' }); + } + + let message; + try { + message = await readJsonBody(request); + } catch (error) { + return sendJson( + response, + error.statusCode || 400, + rpcError(null, error.parseError ? -32700 : -32600, error.message) + ); + } + + const routingError = validateRoutingHeaders(request, message); + if (routingError) { + return sendJson(response, 400, rpcError(message?.id, -32602, 'Invalid routing headers', { reason: routingError })); + } + + const protocolVersion = protocolVersionFor(request, message); + const supported = [MODERN_PROTOCOL_VERSION, ...SUPPORTED_PROTOCOL_VERSIONS]; + if (protocolVersion && !supported.includes(protocolVersion)) { + return sendJson(response, 400, rpcError(message?.id, -32602, 'Unsupported protocol version', { + supported, + requested: protocolVersion + })); + } + + const result = protocol.handle(message, { protocolVersion }); + if (result?.notification) return sendEmpty(response, 202); + + const origin = request.headers.origin; + return sendJson(response, 200, result, { + ...(origin && allowedOrigins.has(origin) ? { 'access-control-allow-origin': origin } : {}) + }); + }); +} + +async function main() { + const registryData = await loadRegistryProjection(); + const server = createInterdependencyMcpServer(registryData); + const port = Number(process.env.PORT) || DEFAULT_PORT; + const host = process.env.HOST || '0.0.0.0'; + server.listen(port, host, () => { + console.log(`The Interdependency MCP listening on http://${host}:${port}/mcp`); + }); +} + +if (fileURLToPath(import.meta.url) === process.argv[1]) { + await main(); +} diff --git a/tests/mcp-server.test.mjs b/tests/mcp-server.test.mjs new file mode 100644 index 0000000..4463f8e --- /dev/null +++ b/tests/mcp-server.test.mjs @@ -0,0 +1,198 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { + createMcpProtocol, + MODERN_PROTOCOL_VERSION, + TOOL_DEFINITIONS +} from '../server/mcp-protocol.mjs'; +import { createInterdependencyMcpServer } from '../server/mcp-server.mjs'; + +// === CHECKS === +// id: check_remote_mcp_exposes_same_five_registry_tools +// proves: remote_mcp_exposes_same_five_registry_tools +// call: self::test_protocol_tool_catalog +// mutates: none +// cleanup: none +// +// id: check_remote_mcp_supports_modern_and_legacy_eras +// proves: remote_mcp_supports_modern_and_legacy_eras +// call: self::test_protocol_dual_era +// mutates: none +// cleanup: none +// +// id: check_remote_mcp_tool_calls_do_not_mutate +// proves: remote_mcp_tool_calls_do_not_mutate +// call: self::test_protocol_read_only_call +// mutates: none +// cleanup: none +// +// id: check_remote_mcp_streamable_http_single_endpoint +// proves: remote_mcp_streamable_http_single_endpoint +// call: self::test_http_endpoint +// mutates: network +// cleanup: close_listener +// +// id: check_remote_mcp_origin_validation +// proves: remote_mcp_origin_validation +// call: self::test_http_origin_validation +// mutates: network +// cleanup: close_listener +// +// id: check_remote_mcp_registry_source_is_verified_projection +// proves: remote_mcp_registry_source_is_verified_projection +// call: self::test_health_exposes_fixture_registry_count +// mutates: network +// cleanup: close_listener +// === END CHECKS === +// Usage: `node --test tests/mcp-server.test.mjs`. The suite uses only a local ephemeral listener and a deterministic two-skill fixture; it never calls an external service. + +const fixture = { + version: 1, + source: { + repository: 'The-Interdependency/skill-lib', + commit: '0123456789abcdef0123456789abcdef01234567', + path: 'skills.json', + sha256: 'fixture' + }, + fallback: false, + hmmm: [], + skills: [ + { + name: 'msdmd', + path: 'msdmd/SKILL.md', + kind: 'metadata-block', + depends_on: [], + description: 'Module Self-Declared Metadata in Markdown.' + }, + { + name: 'meta-module-build', + path: 'meta-module-build/SKILL.md', + kind: 'metadata-block', + depends_on: ['msdmd'], + description: 'Metadata-first module scaffolding.' + } + ] +}; + +function test_protocol_tool_catalog() { + const { handle } = createMcpProtocol(fixture); + const response = handle({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); + assert.equal(response.result.tools.length, 5); + assert.deepEqual( + response.result.tools.map(tool => tool.name), + TOOL_DEFINITIONS.map(tool => tool.name) + ); + assert.ok(response.result.tools.every(tool => tool.annotations.readOnlyHint === true)); +} + +test('remote MCP exposes exactly the five read-only registry tools', test_protocol_tool_catalog); + +function test_protocol_dual_era() { + const { handle } = createMcpProtocol(fixture); + const modern = handle({ + jsonrpc: '2.0', + id: 'discover', + method: 'server/discover', + params: { _meta: { 'io.modelcontextprotocol/protocolVersion': MODERN_PROTOCOL_VERSION } } + }, { protocolVersion: MODERN_PROTOCOL_VERSION }); + assert.ok(modern.result.supportedVersions.includes(MODERN_PROTOCOL_VERSION)); + assert.equal(modern.result.resultType, 'complete'); + assert.equal(modern.result.capabilities.tools.listChanged, false); + + const legacy = handle({ + jsonrpc: '2.0', + id: 2, + method: 'initialize', + params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'test', version: '1' } } + }); + assert.equal(legacy.result.protocolVersion, '2025-11-25'); + assert.equal(legacy.result.serverInfo.name, 'the-interdependency-mcp'); +} + +test('remote MCP supports modern discovery and legacy initialization', test_protocol_dual_era); + +function test_protocol_read_only_call() { + const { handle } = createMcpProtocol(fixture); + const response = handle({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'tiw_resolve_skill_closure', arguments: { name: 'meta-module-build' } } + }); + assert.equal(response.result.isError, false); + assert.deepEqual(response.result.structuredContent.map(skill => skill.name), ['msdmd', 'meta-module-build']); + assert.equal(fixture.skills.length, 2); +} + +test('remote MCP calls are read-only and preserve dependency-first closure', test_protocol_read_only_call); + +async function withServer(fn) { + const server = createInterdependencyMcpServer(fixture, { + allowedOrigins: new Set(['https://interdependentway.org']) + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + try { + return await fn(`http://127.0.0.1:${address.port}`); + } finally { + server.close(); + await once(server, 'close'); + } +} + +async function test_http_endpoint() { + await withServer(async base => { + const getResponse = await fetch(`${base}/mcp`); + assert.equal(getResponse.status, 405); + + const postResponse = await fetch(`${base}/mcp`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept': 'application/json, text/event-stream', + 'mcp-protocol-version': MODERN_PROTOCOL_VERSION, + 'mcp-method': 'tools/list' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 4, + method: 'tools/list', + params: { _meta: { 'io.modelcontextprotocol/protocolVersion': MODERN_PROTOCOL_VERSION } } + }) + }); + assert.equal(postResponse.status, 200); + const body = await postResponse.json(); + assert.equal(body.result.resultType, 'complete'); + assert.equal(body.result.tools.length, 5); + }); +} + +test('Streamable HTTP uses one POST endpoint and rejects GET streaming', test_http_endpoint); + +async function test_http_origin_validation() { + await withServer(async base => { + const response = await fetch(`${base}/mcp`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: 'https://evil.example' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 5, method: 'tools/list', params: {} }) + }); + assert.equal(response.status, 403); + }); +} + +test('Streamable HTTP rejects unapproved browser origins', test_http_origin_validation); + +async function test_health_exposes_fixture_registry_count() { + await withServer(async base => { + const response = await fetch(`${base}/health`); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.ok, true); + assert.equal(body.skill_count, 2); + assert.equal(body.endpoint, '/mcp'); + }); +} + +test('health route reports the loaded registry projection', test_health_exposes_fixture_registry_count); diff --git a/tests/webmcp.test.mjs b/tests/webmcp.test.mjs index 209ae31..8c41d9a 100644 --- a/tests/webmcp.test.mjs +++ b/tests/webmcp.test.mjs @@ -6,6 +6,9 @@ import { createSkillRegistry } from '../src/assets/js/webmcp-registry.js'; // Usage: run with `npm test`; these checks verify registry provenance, dependency closure, clean-checkout fallback identity, WebMCP tool names/read-only annotations, and the dedicated provider route without requiring a WebMCP-capable test browser. +const BOOTSTRAP_SNAPSHOT_COMMIT = '260671303733a45c8f8d5563e41d8854e09856e6'; +const SNAPSHOT_PATH = 'src/_data/snapshots/skill-registry.last-known-good.json'; + const sourceRegistry = JSON.stringify({ version: 1, repo: 'The-Interdependency/skill-lib', @@ -41,12 +44,21 @@ test('skill registry projection preserves exact source identity and rejects unre assert.throws(() => normalizeRegistry(broken, 'abc'), /unresolved skill dependency/); }); -test('committed fallback snapshot is exact, usable on a clean checkout, and retains source provenance', async () => { +test('committed or refreshed fallback snapshot is exact, usable, and retains source provenance', async () => { + const rawSnapshot = JSON.parse(await readFile(SNAPSHOT_PATH, 'utf8')); const snapshot = await readFallback(); assert.equal(snapshot.source.repository, 'The-Interdependency/skill-lib'); - assert.equal(snapshot.source.commit, '260671303733a45c8f8d5563e41d8854e09856e6'); assert.equal(snapshot.source.path, 'skills.json'); + assert.match(snapshot.source.commit, /^[a-f0-9]{40}$/); assert.match(snapshot.source.sha256, /^[a-f0-9]{64}$/); + + if (rawSnapshot?.source?.commit) { + assert.equal(snapshot.source.commit, rawSnapshot.source.commit); + assert.equal(snapshot.source.sha256, rawSnapshot.source.sha256); + } else { + assert.equal(snapshot.source.commit, BOOTSTRAP_SNAPSHOT_COMMIT); + } + assert.ok(snapshot.skills.length > 0); assert.ok(snapshot.skills.some(skill => skill.name === 'repo-audit-repair')); });