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
22 changes: 14 additions & 8 deletions server/mcp-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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') {
Expand All @@ -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'
Expand Down Expand Up @@ -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));
});
}

Expand Down
84 changes: 76 additions & 8 deletions src/assets/js/webmcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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}`);
Expand All @@ -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');
Expand All @@ -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) {
Expand Down Expand Up @@ -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 };
}

Expand Down
77 changes: 44 additions & 33 deletions src/webmcp/index.njk
Original file line number Diff line number Diff line change
@@ -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
---
<section class="panel" aria-labelledby="webmcp-title">
<p class="eyebrow">WebMCP Challenge · implemented public surface</p>
<p class="eyebrow">WebMCP Challenge · live public surface</p>
<h1 id="webmcp-title">The Interdependency WebMCP</h1>
<p>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; <code>The-Interdependency/skill-lib</code> remains the canonical source for skill definitions.</p>
<p><strong>The page is the provider.</strong> Humans can operate the registry here; WebMCP-capable browsers receive the same five tools through <code>document.modelContext.registerTool(...)</code>; remote MCP clients connect to the website-owned Render runtime.</p>
<p><strong>Remote MCP:</strong> <code>https://the-interdependency-mcp.onrender.com/mcp</code></p>
<p><a href="https://the-interdependency-mcp.onrender.com/health">Open remote MCP health check</a></p>
</section>

<section class="panel" aria-labelledby="webmcp-status-title">
<h2 id="webmcp-status-title">Runtime status</h2>
<p data-webmcp-status data-state="hmmm">Loading the commit-pinned skill registry and checking this browser for WebMCP support…</p>
<h2 id="webmcp-status-title">LIVE</h2>
<p data-webmcp-status data-state="hmmm">Loading the commit-pinned registry and checking browser WebMCP support…</p>
<p data-remote-mcp-status data-state="hmmm">Checking remote MCP health…</p>
<p><strong>Registry source:</strong> <span data-webmcp-source>resolving…</span></p>
<noscript><p>JavaScript is disabled, so WebMCP tools cannot register. The rest of the website remains statically readable.</p></noscript>
<noscript><p>JavaScript is disabled, so browser WebMCP registration and the human controls below cannot run. The remote MCP endpoint remains independent.</p></noscript>
</section>

<section class="panel" aria-labelledby="webmcp-tools-title">
<h2 id="webmcp-tools-title">Registered v0 tools</h2>
<dl>
<dt><code>tiw_registry_status</code></dt>
<dd>Provenance, registry version, skill count, and fallback state.</dd>
<dt><code>tiw_list_skills</code></dt>
<dd>List registered skills, optionally filtered by exact skill kind.</dd>
<dt><code>tiw_find_skill</code></dt>
<dd>Search the registry by task words, names, paths, and descriptions while returning only a bounded relevant set.</dd>
<dt><code>tiw_inspect_skill</code></dt>
<dd>Inspect one skill and receive its declared dependencies plus a commit-pinned canonical source URL.</dd>
<dt><code>tiw_resolve_skill_closure</code></dt>
<dd>Resolve the smallest dependency-first transitive closure required by one registered skill.</dd>
</dl>
</section>
<h2 id="webmcp-tools-title">Five registry operations</h2>
<p>The controls below execute the same read-only registry logic exposed to agents.</p>

<section class="panel" aria-labelledby="webmcp-architecture-title">
<h2 id="webmcp-architecture-title">Authority boundary</h2>
<pre tabindex="0">skill-lib/SKILL.md + skills.json
↓ commit-pinned build projection
interdependentway.org skill registry
↓ browser-native WebMCP registration
read-only agent tools</pre>
<p>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.</p>
<p>
<button type="button" data-webmcp-action="status">STATUS</button>
<button type="button" data-webmcp-action="list">LIST</button>
</p>

<form data-webmcp-find>
<label for="webmcp-find-query">FIND</label>
<input id="webmcp-find-query" name="query" type="text" value="repository audit repair" required>
<button type="submit">Find skill</button>
</form>

<form data-webmcp-inspect>
<label for="webmcp-inspect-name">INSPECT</label>
<input id="webmcp-inspect-name" name="name" type="text" value="repo-audit-repair" required>
<button type="submit">Inspect skill</button>
</form>

<form data-webmcp-closure>
<label for="webmcp-closure-name">RESOLVE CLOSURE</label>
<input id="webmcp-closure-name" name="name" type="text" value="repo-audit-repair" required>
<button type="submit">Resolve closure</button>
</form>

<pre tabindex="0" data-webmcp-output>Choose STATUS, LIST, FIND, INSPECT, or RESOLVE CLOSURE.</pre>
</section>

<section class="panel" aria-labelledby="webmcp-usage-title">
<h2 id="webmcp-usage-title">Usage guidance</h2>
<p>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: <em>“Find the skill for auditing and repairing a repository, then resolve its dependency closure.”</em></p>
<p>In an ordinary browser without WebMCP support, this page remains a truthful status and documentation surface; it does not fake tool availability.</p>
<section class="panel" aria-labelledby="webmcp-boundary-title">
<h2 id="webmcp-boundary-title">One source, two agent surfaces</h2>
<pre tabindex="0">skill-lib/SKILL.md + skills.json
↓ commit-pinned projection
website registry logic
├── browser WebMCP provider
└── remote Streamable HTTP MCP server</pre>
<p>The website owns both delivery surfaces. <code>The-Interdependency/skill-lib</code> remains authority for the skills themselves. All v0 operations are read-only.</p>
</section>

<div class="hmmm-boundary"><strong>hmmm</strong><span>Write-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.</span></div>
<div class="hmmm-boundary"><strong>hmmm</strong><span>Write-capable operations remain intentionally absent. Installing, propagating, or changing repository state requires a separate authenticated boundary rather than silently enlarging this public surface.</span></div>
Loading
Loading