diff --git a/server/mcp-server.mjs b/server/mcp-server.mjs index 00021cf..a72b63c 100644 --- a/server/mcp-server.mjs +++ b/server/mcp-server.mjs @@ -46,7 +46,7 @@ import { // // id: remote_mcp_origin_validation // given: a request supplies an Origin header -// then: only an explicitly allowed origin is accepted +// then: only an explicitly allowed origin is accepted, including for the browser-visible health route // class: security // // id: remote_mcp_registry_source_is_verified_projection @@ -104,6 +104,13 @@ function isOriginAllowed(request, allowedOrigins) { return !origin || allowedOrigins.has(origin); } +function corsHeaders(request, allowedOrigins) { + const origin = request.headers.origin; + return origin && allowedOrigins.has(origin) + ? { 'access-control-allow-origin': origin } + : {}; +} + async function readJsonBody(request) { let size = 0; const chunks = []; @@ -174,12 +181,15 @@ export function createInterdependencyMcpServer(registryData, { const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`); if (request.method === 'GET' && url.pathname === '/health') { + if (!isOriginAllowed(request, allowedOrigins)) { + return sendJson(response, 403, rpcError(null, -32000, 'Forbidden origin')); + } return sendJson(response, 200, { ok: true, service: 'the-interdependency-mcp', endpoint: '/mcp', skill_count: protocol.registry.getRegistryStatus().skill_count - }); + }, corsHeaders(request, allowedOrigins)); } if (url.pathname !== '/mcp') { @@ -195,9 +205,8 @@ export function createInterdependencyMcpServer(registryData, { } if (request.method === 'OPTIONS') { - const origin = request.headers.origin; return sendEmpty(response, 204, { - ...(origin && allowedOrigins.has(origin) ? { 'access-control-allow-origin': origin } : {}), + ...corsHeaders(request, allowedOrigins), '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' @@ -236,10 +245,7 @@ export function createInterdependencyMcpServer(registryData, { 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 } : {}) - }); + return sendJson(response, 200, result, corsHeaders(request, allowedOrigins)); }); } diff --git a/src/assets/js/webmcp.js b/src/assets/js/webmcp.js index 164a0eb..42e4ec3 100644 --- a/src/assets/js/webmcp.js +++ b/src/assets/js/webmcp.js @@ -2,30 +2,33 @@ import { createSkillRegistry } from './webmcp-registry.js'; // === MODULE_BUILD === // id: interdependency_webmcp_surface -// purpose: Register the website-owned, read-only WebMCP tool surface over the commit-pinned skill-lib registry projection. +// purpose: Register the website-owned, read-only WebMCP tool surface and provide the same registry operations to the human-facing demo page. // entrypoint: /webmcp/ // tests: tests/webmcp.test.mjs // === END MODULE_BUILD === // === BOUNDARIES === // id: interdependency_webmcp_surface_boundary -// network: same-origin GET of /assets/data/skill-registry.json only +// network: same-origin GET of /assets/data/skill-registry.json plus read-only health GET to the website-owned Render MCP runtime // storage: none // user_data: none // operational_effects: none; v0 exposes registry discovery and dependency resolution only -// authority: the website owns tool registration; The-Interdependency/skill-lib remains authority for skill definitions +// authority: the website owns tool registration and remote runtime; The-Interdependency/skill-lib remains authority for skill definitions // === END BOUNDARIES === // === CONTRACTS === // id: webmcp_tools_are_read_only_registry_operations -// given: an agent invokes any v0 tool +// given: a human control or browser agent invokes any v0 operation // then: execution reads the generated registry projection and returns structured results without mutating the site, GitHub, or skill-lib // class: safety // === END CONTRACTS === -// Usage: open `/webmcp/` in a WebMCP-capable browser or in-app browser. The page registers five read-only tools through `navigator.modelContext`. Unsupported browsers still resolve and display registry provenance without polyfilling or faking WebMCP support. +// Usage: open `/webmcp/` in any browser to exercise the five registry operations directly. In a WebMCP-capable browser the same operations register through `document.modelContext.registerTool(...)`. The remote MCP endpoint is independently health-checked and remains read-only. const REGISTRY_URL = '/assets/data/skill-registry.json'; +const REMOTE_MCP_BASE = 'https://the-interdependency-mcp.onrender.com'; const statusElement = () => document.querySelector('[data-webmcp-status]'); const sourceElement = () => document.querySelector('[data-webmcp-source]'); -const modelContext = () => globalThis.navigator?.modelContext; +const remoteStatusElement = () => document.querySelector('[data-remote-mcp-status]'); +const outputElement = () => document.querySelector('[data-webmcp-output]'); +const modelContext = () => globalThis.document?.modelContext; function setStatus(message, state = 'hmmm') { const target = statusElement(); @@ -34,10 +37,23 @@ function setStatus(message, state = 'hmmm') { target.dataset.state = state; } +function setRemoteStatus(message, state = 'hmmm') { + const target = remoteStatusElement(); + if (!target) return; + target.textContent = message; + target.dataset.state = state; +} + function jsonResult(value) { return JSON.stringify(value, null, 2); } +function showResult(label, value) { + const target = outputElement(); + if (!target) return; + target.textContent = `${label}\n\n${jsonResult(value)}`; +} + async function loadRegistry() { const response = await fetch(REGISTRY_URL, { headers: { accept: 'application/json' } }); if (!response.ok) throw new Error(`registry HTTP ${response.status}`); @@ -51,6 +67,56 @@ function updateSource(status) { target.textContent = `${status.source.repository}@${status.source.commit.slice(0, 12)}:${status.source.path}${suffix}`; } +async function checkRemoteMcp() { + try { + const response = await fetch(`${REMOTE_MCP_BASE}/health`, { headers: { accept: 'application/json' } }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const health = await response.json(); + if (!health?.ok || health.endpoint !== '/mcp') throw new Error('invalid health response'); + setRemoteStatus(`Remote MCP LIVE · ${health.skill_count} skills · ${REMOTE_MCP_BASE}/mcp`, 'implemented'); + return health; + } catch (error) { + setRemoteStatus(`Remote MCP health unresolved: ${error.message}`, 'hmmm'); + return null; + } +} + +function bindHumanControls(registry) { + document.querySelector('[data-webmcp-action="status"]')?.addEventListener('click', () => { + showResult('STATUS', registry.getRegistryStatus()); + }); + + document.querySelector('[data-webmcp-action="list"]')?.addEventListener('click', () => { + showResult('LIST', registry.listSkills()); + }); + + document.querySelector('[data-webmcp-find]')?.addEventListener('submit', event => { + event.preventDefault(); + const query = String(new FormData(event.currentTarget).get('query') || '').trim(); + showResult('FIND', registry.findSkills({ query })); + }); + + document.querySelector('[data-webmcp-inspect]')?.addEventListener('submit', event => { + event.preventDefault(); + const name = String(new FormData(event.currentTarget).get('name') || '').trim(); + try { + showResult('INSPECT', registry.inspectSkill({ name })); + } catch (error) { + showResult('INSPECT', { error: error.message }); + } + }); + + document.querySelector('[data-webmcp-closure]')?.addEventListener('submit', event => { + event.preventDefault(); + const name = String(new FormData(event.currentTarget).get('name') || '').trim(); + try { + showResult('RESOLVE CLOSURE', registry.resolveSkillClosure({ name })); + } catch (error) { + showResult('RESOLVE CLOSURE', { error: error.message }); + } + }); +} + async function registerTool(tool) { const context = modelContext(); if (!context?.registerTool) throw new Error('WebMCP API unavailable during tool registration'); @@ -62,9 +128,11 @@ export async function registerInterdependencyWebMCP() { const registry = createSkillRegistry(data); const status = registry.getRegistryStatus(); updateSource(status); + bindHumanControls(registry); + void checkRemoteMcp(); if (!modelContext()?.registerTool) { - setStatus(`WebMCP API unavailable in this browser. Registry provenance resolved for ${status.skill_count} skills; tool registration requires a WebMCP-capable browser.`, 'hmmm'); + setStatus(`Registry live for ${status.skill_count} skills. Browser WebMCP registration requires a WebMCP-capable browser; the human controls and remote MCP server remain usable.`, 'hmmm'); return { registered: false, reason: 'webmcp-unavailable', registry: status }; } if (globalThis.__interdependencyWebMcpRegistered) { @@ -142,7 +210,7 @@ export async function registerInterdependencyWebMCP() { }); globalThis.__interdependencyWebMcpRegistered = true; - setStatus(`WebMCP live: 5 read-only tools registered over ${status.skill_count} skills.`, status.fallback ? 'hmmm' : 'implemented'); + setStatus(`WebMCP LIVE · 5 read-only tools registered over ${status.skill_count} skills.`, status.fallback ? 'hmmm' : 'implemented'); return { registered: true, tools: 5, registry: status }; } diff --git a/src/webmcp/index.njk b/src/webmcp/index.njk index cdadcba..d864ad6 100644 --- a/src/webmcp/index.njk +++ b/src/webmcp/index.njk @@ -1,53 +1,64 @@ --- layout: layouts/base.njk title: The Interdependency WebMCP -description: Browser-native, read-only WebMCP access to the commit-pinned The Interdependency skill registry. +description: Live browser-native WebMCP and remote MCP access to the commit-pinned The Interdependency skill registry. permalink: /webmcp/ webmcp: true ---
-

WebMCP Challenge · implemented public surface

+

WebMCP Challenge · live public surface

The Interdependency WebMCP

-

This page is both the human-readable boundary and the browser-native WebMCP provider for The Interdependency skill registry. The website owns the registered tools; The-Interdependency/skill-lib remains the canonical source for skill definitions.

+

The page is the provider. Humans can operate the registry here; WebMCP-capable browsers receive the same five tools through document.modelContext.registerTool(...); remote MCP clients connect to the website-owned Render runtime.

+

Remote MCP: https://the-interdependency-mcp.onrender.com/mcp

+

Open remote MCP health check

-

Runtime status

-

Loading the commit-pinned skill registry and checking this browser for WebMCP support…

+

LIVE

+

Loading the commit-pinned registry and checking browser WebMCP support…

+

Checking remote MCP health…

Registry source: resolving…

- +
-

Registered v0 tools

-
-
tiw_registry_status
-
Provenance, registry version, skill count, and fallback state.
-
tiw_list_skills
-
List registered skills, optionally filtered by exact skill kind.
-
tiw_find_skill
-
Search the registry by task words, names, paths, and descriptions while returning only a bounded relevant set.
-
tiw_inspect_skill
-
Inspect one skill and receive its declared dependencies plus a commit-pinned canonical source URL.
-
tiw_resolve_skill_closure
-
Resolve the smallest dependency-first transitive closure required by one registered skill.
-
-
+

Five registry operations

+

The controls below execute the same read-only registry logic exposed to agents.

-
-

Authority boundary

-
skill-lib/SKILL.md + skills.json
-        ↓ commit-pinned build projection
-interdependentway.org skill registry
-        ↓ browser-native WebMCP registration
-read-only agent tools
-

The browser tools do not mutate GitHub, install skills, change canon, or create a competing skill definition. Their job is discovery, inspection, and dependency resolution.

+

+ + +

+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
Choose STATUS, LIST, FIND, INSPECT, or RESOLVE CLOSURE.
-
-

Usage guidance

-

Open this route in a WebMCP-capable browser or in-app browser, then ask the browser agent to find or inspect an Interdependency skill. For example: “Find the skill for auditing and repairing a repository, then resolve its dependency closure.”

-

In an ordinary browser without WebMCP support, this page remains a truthful status and documentation surface; it does not fake tool availability.

+
+

One source, two agent surfaces

+
skill-lib/SKILL.md + skills.json
+        ↓ commit-pinned projection
+website registry logic
+        ├── browser WebMCP provider
+        └── remote Streamable HTTP MCP server
+

The website owns both delivery surfaces. The-Interdependency/skill-lib remains authority for the skills themselves. All v0 operations are read-only.

-
hmmmWrite-capable operations are intentionally absent from v0. Any future install, propagation, or repository-changing tool requires a separate authenticated boundary rather than quietly upgrading this read-only surface.
+
hmmmWrite-capable operations remain intentionally absent. Installing, propagating, or changing repository state requires a separate authenticated boundary rather than silently enlarging this public surface.
diff --git a/tests/webmcp.test.mjs b/tests/webmcp.test.mjs index 8c41d9a..a1f239a 100644 --- a/tests/webmcp.test.mjs +++ b/tests/webmcp.test.mjs @@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises'; import { normalizeRegistry, readFallback } from '../scripts/fetch-skill-registry.mjs'; 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. +// Usage: run with `npm test`; these checks verify registry provenance, dependency closure, clean-checkout fallback identity, current WebMCP Document API usage, live human controls, and the visible remote MCP endpoint without requiring a WebMCP-capable test browser. const BOOTSTRAP_SNAPSHOT_COMMIT = '260671303733a45c8f8d5563e41d8854e09856e6'; const SNAPSHOT_PATH = 'src/_data/snapshots/skill-registry.last-known-good.json'; @@ -73,7 +73,7 @@ test('registry adapter finds skills and resolves the smallest dependency-first c assert.match(closure[1].canonical_url, /The-Interdependency\/skill-lib\/blob\/0123456789abcdef/); }); -test('WebMCP provider registers only the five declared read-only registry tools through navigator.modelContext', async () => { +test('WebMCP provider registers only the five declared read-only registry tools through document.modelContext', async () => { const source = await readFile('src/assets/js/webmcp.js', 'utf8'); for (const name of [ 'tiw_registry_status', @@ -85,21 +85,23 @@ test('WebMCP provider registers only the five declared read-only registry tools assert.match(source, new RegExp(`name: '${name}'`)); } assert.equal((source.match(/annotations: \{ readOnlyHint: true/g) || []).length, 5); - assert.match(source, /globalThis\.navigator\?\.modelContext/); - assert.doesNotMatch(source, /document\.modelContext/); + assert.match(source, /globalThis\.document\?\.modelContext/); + assert.doesNotMatch(source, /navigator\?\.modelContext|provideContext/); assert.doesNotMatch(source, /unregisterTool|install_skill|propagate_skill|update_file|create_file/); }); -test('registry provenance resolves before unsupported-browser WebMCP return', async () => { +test('registry provenance and human controls resolve before unsupported-browser WebMCP return', async () => { const source = await readFile('src/assets/js/webmcp.js', 'utf8'); const loadIndex = source.indexOf('const data = await loadRegistry();'); const sourceIndex = source.indexOf('updateSource(status);'); + const controlsIndex = source.indexOf('bindHumanControls(registry);'); const unsupportedIndex = source.indexOf("if (!modelContext()?.registerTool)"); assert.ok(loadIndex >= 0 && sourceIndex > loadIndex); - assert.ok(unsupportedIndex > sourceIndex); + assert.ok(controlsIndex > sourceIndex); + assert.ok(unsupportedIndex > controlsIndex); }); -test('dedicated WebMCP route loads the provider explicitly and keeps unsupported browsers truthful', async () => { +test('dedicated WebMCP route is the visible browser and remote MCP front door', async () => { const [page, layout, packageJson] = await Promise.all([ readFile('src/webmcp/index.njk', 'utf8'), readFile('src/_includes/layouts/base.njk', 'utf8'), @@ -108,8 +110,14 @@ test('dedicated WebMCP route loads the provider explicitly and keeps unsupported assert.match(page, /permalink: \/webmcp\//); assert.match(page, /webmcp: true/); assert.match(page, /The Interdependency WebMCP/); - assert.match(page, /data-webmcp-status/); - assert.match(page, /does not fake tool availability/); + assert.match(page, /The page is the provider/); + assert.match(page, /document\.modelContext\.registerTool/); + assert.match(page, /https:\/\/the-interdependency-mcp\.onrender\.com\/mcp/); + assert.match(page, /data-remote-mcp-status/); + assert.match(page, /data-webmcp-action="status"/); + assert.match(page, /data-webmcp-find/); + assert.match(page, /data-webmcp-inspect/); + assert.match(page, /data-webmcp-closure/); assert.match(layout, /\{% if webmcp %\}