diff --git a/src/assets/js/webmcp-registry.js b/src/assets/js/webmcp-registry.js index 6f9b1e4..33d0d51 100644 --- a/src/assets/js/webmcp-registry.js +++ b/src/assets/js/webmcp-registry.js @@ -1,8 +1,8 @@ // === MODULE_BUILD === // id: webmcp_skill_registry_adapter -// purpose: Provide deterministic, read-only operations over the website's commit-pinned skill-lib registry projection. -// entrypoint: imported by /assets/js/webmcp.js and tests/webmcp.test.mjs -// tests: tests/webmcp.test.mjs +// purpose: Provide deterministic read-only operations over the public human-and-agent view of the website's commit-pinned skill-lib registry projection. +// entrypoint: imported by /assets/js/webmcp.js, server/mcp-protocol.mjs, and tests +// tests: tests/webmcp.test.mjs, tests/mcp-server.test.mjs // === END MODULE_BUILD === // === BOUNDARIES === // id: webmcp_skill_registry_read_boundary @@ -12,17 +12,26 @@ // operational_effects: none; all exported operations are read-only transformations over supplied registry data // === END BOUNDARIES === // === CONTRACTS === +// id: webmcp_public_catalogue_matches_human_catalogue +// given: the source registry contains public-facing and internal/specialist skills +// then: public list/find/inspect/closure expose only metadata-block msdmd applications plus the exact meta skill, matching the human card catalogue +// class: correctness +// // id: webmcp_registry_smallest_dependency_closure -// given: a registered skill name -// then: resolveSkillClosure returns that skill plus every transitive depends_on prerequisite exactly once in dependency-first order +// given: a presented registered skill name +// then: resolveSkillClosure returns that skill plus every presented transitive depends_on prerequisite exactly once in dependency-first order // class: correctness // === END CONTRACTS === -// Usage: `const registry = createSkillRegistry(data); registry.findSkills({ query: 'repository audit' })`. Consumers must pass the generated `/assets/data/skill-registry.json` projection, never invent skill records locally. +// Usage: `const registry = createSkillRegistry(data); registry.findSkills({ query: 'documentation' })`. Consumers pass the generated registry projection; the adapter derives one shared public view rather than inventing separate human and agent registries. function normalizeText(value) { return String(value || '').trim().toLowerCase(); } +function isPresentedSkill(skill) { + return skill?.kind === 'metadata-block' || skill?.name === 'meta'; +} + function publicSkill(skill, source) { return { name: skill.name, @@ -40,17 +49,26 @@ export function createSkillRegistry(registryData) { } const source = registryData.source; - const byName = new Map(registryData.skills.map(skill => [skill.name, skill])); + const skills = registryData.skills.filter(isPresentedSkill); + const byName = new Map(skills.map(skill => [skill.name, skill])); + + for (const skill of skills) { + for (const dependency of skill.depends_on) { + if (!byName.has(dependency)) { + throw new Error(`presented skill dependency is not presented: ${skill.name} -> ${dependency}`); + } + } + } function requireSkill(name) { const skill = byName.get(String(name || '').trim()); - if (!skill) throw new Error(`unknown skill: ${name}`); + if (!skill) throw new Error(`unknown public skill: ${name}`); return skill; } function listSkills({ kind = '' } = {}) { const normalizedKind = normalizeText(kind); - return registryData.skills + return skills .filter(skill => !normalizedKind || normalizeText(skill.kind) === normalizedKind) .map(skill => publicSkill(skill, source)); } @@ -61,7 +79,7 @@ export function createSkillRegistry(registryData) { const boundedLimit = Math.max(1, Math.min(Number(limit) || 8, 20)); if (terms.length === 0) return listSkills({ kind }).slice(0, boundedLimit); - return registryData.skills + return skills .filter(skill => !normalizedKind || normalizeText(skill.kind) === normalizedKind) .map(skill => { const name = normalizeText(skill.name); @@ -109,7 +127,9 @@ export function createSkillRegistry(registryData) { function getRegistryStatus() { return { registry_version: registryData.version, - skill_count: registryData.skills.length, + skill_count: skills.length, + source_skill_count: registryData.skills.length, + public_scope: 'metadata-block plus meta', source: { ...source }, fallback: Boolean(registryData.fallback), hmmm: Array.isArray(registryData.hmmm) ? [...registryData.hmmm] : [] diff --git a/src/assets/js/webmcp.js b/src/assets/js/webmcp.js index 2000a53..5cdbfcf 100644 --- a/src/assets/js/webmcp.js +++ b/src/assets/js/webmcp.js @@ -2,7 +2,7 @@ import { createSkillRegistry } from './webmcp-registry.js'; // === MODULE_BUILD === // id: interdependency_webmcp_surface -// purpose: Register the website-owned, read-only WebMCP tool surface, provide the same registry operations to the human-facing demo page, and filter the build-time human skill catalogue. +// purpose: Register the website-owned read-only WebMCP tools and bind the human-readable skill-selection surface to the same commit-pinned registry records. // entrypoint: /webmcp/ // tests: tests/webmcp.test.mjs // === END MODULE_BUILD === @@ -11,21 +11,21 @@ import { createSkillRegistry } from './webmcp-registry.js'; // 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 +// operational_effects: none; selection changes only page state and URL query, while v0 MCP operations remain read-only // 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: a human control or browser agent invokes any v0 operation +// given: a 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 // -// id: webmcp_human_catalogue_remains_source_bound -// given: a visitor browses or filters the human-readable skill catalogue -// then: filtering only hides or reveals build-time cards derived from the same generated registry and never creates a second skill definition +// id: webmcp_human_selection_is_exact_registry_identity +// given: a human selects a presented skill card +// then: the page opens that card's description, records the exact registered skill name in visible state and the URL, and derives inspection/closure from the same registry object without requiring typed internal identifiers // class: correctness // === END CONTRACTS === -// Usage: open `/webmcp/` in any browser to browse the human-readable skill catalogue and 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. +// Usage: open `/webmcp/`; humans select from the curated cards while agents receive the same canonical registry material through `document.modelContext.registerTool(...)`. Selection is instruction, not write authority. const REGISTRY_URL = '/assets/data/skill-registry.json'; const REMOTE_MCP_BASE = 'https://the-interdependency-mcp.onrender.com'; @@ -33,6 +33,7 @@ const statusElement = () => document.querySelector('[data-webmcp-status]'); const sourceElement = () => document.querySelector('[data-webmcp-source]'); const remoteStatusElement = () => document.querySelector('[data-remote-mcp-status]'); const outputElement = () => document.querySelector('[data-webmcp-output]'); +const selectedElement = () => document.querySelector('[data-selected-skill]'); const modelContext = () => globalThis.document?.modelContext; function setStatus(message, state = 'hmmm') { @@ -86,64 +87,81 @@ async function checkRemoteMcp() { } } -function bindHumanCatalogue() { +function bindHumanCatalogue(registry) { const form = document.querySelector('[data-human-skill-filter-form]'); const input = document.querySelector('[data-human-skill-filter]'); const count = document.querySelector('[data-human-skill-count]'); const cards = [...document.querySelectorAll('[data-human-skill]')]; + const selectedActions = [...document.querySelectorAll('[data-selected-action]')]; + let selectedName = ''; form?.addEventListener('submit', event => event.preventDefault()); - if (!input || cards.length === 0) return; const applyFilter = () => { - const query = String(input.value || '').trim().toLowerCase(); + const query = String(input?.value || '').trim().toLowerCase(); let visible = 0; for (const card of cards) { const show = !query || card.textContent.toLowerCase().includes(query); card.hidden = !show; if (show) visible += 1; } - if (count) count.textContent = `${visible} of ${cards.length} skills shown`; + if (count) count.textContent = `${visible} of ${cards.length} presented skills shown`; }; - input.addEventListener('input', applyFilter); - applyFilter(); -} + const setSelected = (name, { updateUrl = true } = {}) => { + const card = cards.find(candidate => candidate.dataset.skillName === name); + if (!card) return false; -function bindHumanControls(registry) { - document.querySelector('[data-webmcp-action="status"]')?.addEventListener('click', () => { - showResult('STATUS', registry.getRegistryStatus()); - }); + const skill = registry.inspectSkill({ name }); + selectedName = name; - document.querySelector('[data-webmcp-action="list"]')?.addEventListener('click', () => { - showResult('LIST', registry.listSkills()); - }); + for (const candidate of cards) { + const selected = candidate === card; + candidate.dataset.selected = selected ? 'true' : 'false'; + candidate.querySelector('[data-select-skill]')?.setAttribute('aria-pressed', String(selected)); + if (selected) candidate.querySelector('[data-skill-description]')?.setAttribute('open', ''); + } - 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 })); - }); + const target = selectedElement(); + if (target) { + target.textContent = `${skill.name} · ${skill.kind} · ${skill.canonical_path}`; + target.dataset.skillName = skill.name; + } - 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 }); + for (const action of selectedActions) action.disabled = false; + showResult('SELECTED SKILL', skill); + + if (updateUrl) { + const url = new URL(globalThis.location.href); + url.searchParams.set('skill', skill.name); + globalThis.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`); } + return true; + }; + + for (const card of cards) { + card.querySelector('[data-select-skill]')?.addEventListener('click', () => { + setSelected(card.dataset.skillName || ''); + }); + } + + document.querySelector('[data-selected-action="inspect"]')?.addEventListener('click', () => { + if (!selectedName) return; + showResult('INSPECT SELECTED', registry.inspectSkill({ name: selectedName })); }); - 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 }); - } + document.querySelector('[data-selected-action="closure"]')?.addEventListener('click', () => { + if (!selectedName) return; + showResult('REQUIRED SKILL SET', registry.resolveSkillClosure({ name: selectedName })); }); + + input?.addEventListener('input', applyFilter); + applyFilter(); + + const requested = new URL(globalThis.location.href).searchParams.get('skill'); + if (requested) setSelected(requested, { updateUrl: false }); + + return { getSelectedName: () => selectedName }; } async function registerTool(tool) { @@ -157,12 +175,11 @@ export async function registerInterdependencyWebMCP() { const registry = createSkillRegistry(data); const status = registry.getRegistryStatus(); updateSource(status); - bindHumanCatalogue(); - bindHumanControls(registry); + bindHumanCatalogue(registry); void checkRemoteMcp(); if (!modelContext()?.registerTool) { - setStatus(`Registry live for ${status.skill_count} skills. Browser WebMCP registration requires a WebMCP-capable browser; the human catalogue, controls, and remote MCP server remain usable.`, 'hmmm'); + setStatus(`Registry live for ${status.skill_count} skills. Browser WebMCP registration requires a WebMCP-capable browser; human selection and the remote MCP server remain usable.`, 'hmmm'); return { registered: false, reason: 'webmcp-unavailable', registry: status }; } if (globalThis.__interdependencyWebMcpRegistered) { diff --git a/src/webmcp/index.njk b/src/webmcp/index.njk index ff4daae..d5c7091 100644 --- a/src/webmcp/index.njk +++ b/src/webmcp/index.njk @@ -8,7 +8,7 @@ webmcp: true

WebMCP Challenge · live public surface

The Interdependency WebMCP

-

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

+

The page is the provider. Human and agent use the same commit-pinned skill material. A human chooses the skill; the page records that exact selection; the agent can then use the selected canonical skill with its separately authorized tools to deliver the requested change.

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

Open remote MCP health check

@@ -18,77 +18,89 @@ webmcp: true

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

Checking remote MCP health…

Registry source: resolving…

- +
-

Human surface · {{ generated.skillRegistry.skills.length }} skills

-

Skill catalogue

-

These are the skills exposed by the same commit-pinned registry used by WebMCP and the remote MCP server. The description shown here is the registry's human-readable statement of when and why each skill is used.

+

Human selection surface

+

Choose the skill the agent should use

+

The public human catalogue is deliberately narrow: msdmd and its metadata-block applications first, followed by the METAPAT meta skill. Other skill-lib skills remain canonical and available to agents, but are not presented as primary human choices here.

- - - {{ generated.skillRegistry.skills.length }} skills shown + + + Presented skills shown
-
+

msdmd skills

+
{% for skill in generated.skillRegistry.skills %} -
+ {% if skill.kind == "metadata-block" %} +

{{ skill.kind }}

-

{{ skill.name | replace("-", " ") }}

+

{{ skill.name | replace("-", " ") }}

{{ skill.name }}

-

{{ skill.description }}

+
+ Description +

{{ skill.description }}

+
{% if skill.depends_on and skill.depends_on.length %}

Depends on: {% for dependency in skill.depends_on %}{{ dependency }}{% if not loop.last %}, {% endif %}{% endfor %}

{% else %}

Depends on: none declared

{% endif %} +

Read canonical SKILL.md

+ {% endif %} {% endfor %}
-
-
-

Five registry operations

-

These controls execute the same read-only registry logic exposed to browser agents and remote MCP clients. The raw output is intentionally shown here as the machine-facing view of the same catalogue above.

+

meta skills

+
+ {% for skill in generated.skillRegistry.skills %} + {% if skill.name == "meta" %} +
+

{{ skill.kind }}

+

{{ skill.name | replace("-", " ") }}

+

{{ skill.name }}

+
+ Description +

{{ skill.description }}

+
+ {% if skill.depends_on and skill.depends_on.length %} +

Depends on: {% for dependency in skill.depends_on %}{{ dependency }}{% if not loop.last %}, {% endif %}{% endfor %}

+ {% else %} +

Depends on: none declared

+ {% endif %} +

+

Read canonical SKILL.md

+
+ {% endif %} + {% endfor %} +
+
+ Selected skill + No skill selected. Choose a card above; the exact canonical skill identity will be carried automatically. +

- - + +

- -
- - - -
- -
- - - -
- -
- - - -
- -
Choose STATUS, LIST, FIND, INSPECT, or RESOLVE CLOSURE.
+
Select a skill above. No internal skill name needs to be typed.
-

One source, three views

+

Same material, meaningful human selection

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

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

+human-readable card ↔ selected exact skill ↔ agent-visible page state + ↓ + browser WebMCP / remote MCP + ↓ + agent's authorized change tools +

The human selects meaning, not an internal identifier. The agent receives the exact skill identity, dependencies, provenance, and canonical source from that selection. Selection constrains how the agent should work; actual changes still occur only through the agent's separately authorized tool boundary.

-
hmmmWrite-capable operations remain intentionally absent. Installing, propagating, or changing repository state requires a separate authenticated boundary rather than silently enlarging this public surface.
+
hmmmSelection is instruction, not permission. PEBKAC should not have to memorize the machine's nouns.
diff --git a/tests/webmcp.test.mjs b/tests/webmcp.test.mjs index e17e612..b803fad 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, current WebMCP Document API usage, the build-time human-readable skill catalogue, live human controls, and the visible remote MCP endpoint without requiring a WebMCP-capable test browser. +// Usage: run with `npm test`; these checks verify registry provenance, the shared curated human/agent catalogue, current WebMCP Document API usage, click-driven human selection, 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'; @@ -17,6 +17,7 @@ const sourceRegistry = JSON.stringify({ skills: [ { name: 'msdmd', path: 'msdmd/SKILL.md', kind: 'metadata-block', description: 'foundational metadata convention' }, { name: 'cap-build', path: 'cap-build/SKILL.md', kind: 'metadata-block', depends_on: ['msdmd'], description: 'capability inventory' }, + { name: 'meta', path: 'meta/SKILL.md', kind: 'procedural', description: 'METAPAT consultation router' }, { name: 'repo-audit-repair', path: 'repo-audit-repair/SKILL.md', kind: 'procedural', description: 'audit and repair a repository' } ] }); @@ -63,14 +64,19 @@ test('committed or refreshed fallback snapshot is exact, usable, and retains sou assert.ok(snapshot.skills.some(skill => skill.name === 'repo-audit-repair')); }); -test('registry adapter finds skills and resolves the smallest dependency-first closure', () => { +test('public registry presents the same msdmd-plus-meta catalogue to human and agent surfaces', () => { const registry = createSkillRegistry(sampleProjection()); - const matches = registry.findSkills({ query: 'audit repository' }); - assert.equal(matches[0].name, 'repo-audit-repair'); + assert.deepEqual(registry.listSkills().map(skill => skill.name), ['msdmd', 'cap-build', 'meta']); + assert.equal(registry.findSkills({ query: 'audit repository' }).length, 0); + assert.equal(registry.findSkills({ query: 'capability' })[0].name, 'cap-build'); + assert.throws(() => registry.inspectSkill({ name: 'repo-audit-repair' }), /unknown public skill/); const closure = registry.resolveSkillClosure({ name: 'cap-build' }); assert.deepEqual(closure.map(skill => skill.name), ['msdmd', 'cap-build']); - assert.match(closure[1].canonical_url, /The-Interdependency\/skill-lib\/blob\/0123456789abcdef/); + const status = registry.getRegistryStatus(); + assert.equal(status.skill_count, 3); + assert.equal(status.source_skill_count, 4); + assert.equal(status.public_scope, 'metadata-block plus meta'); }); test('WebMCP provider registers only the five declared read-only registry tools through document.modelContext', async () => { @@ -90,20 +96,17 @@ test('WebMCP provider registers only the five declared read-only registry tools assert.doesNotMatch(source, /unregisterTool|install_skill|propagate_skill|update_file|create_file/); }); -test('human catalogue and controls resolve before unsupported-browser WebMCP return', async () => { +test('human selection carries exact registry identity without typed internal skill names', 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 catalogueIndex = source.indexOf('bindHumanCatalogue();'); - const controlsIndex = source.indexOf('bindHumanControls(registry);'); - const unsupportedIndex = source.indexOf("if (!modelContext()?.registerTool)"); - assert.ok(loadIndex >= 0 && sourceIndex > loadIndex); - assert.ok(catalogueIndex > sourceIndex); - assert.ok(controlsIndex > catalogueIndex); - assert.ok(unsupportedIndex > controlsIndex); + assert.match(source, /bindHumanCatalogue\(registry\)/); + assert.match(source, /dataset\.skillName/); + assert.match(source, /registry\.inspectSkill\(\{ name \}\)/); + assert.match(source, /searchParams\.set\('skill', skill\.name\)/); + assert.match(source, /history\.replaceState/); + assert.match(source, /data-selected-action/); }); -test('dedicated WebMCP route is a human-readable skill browser and the agent front door', async () => { +test('dedicated WebMCP route presents collapsible click-select cards before agent delivery', async () => { const [page, layout, packageJson] = await Promise.all([ readFile('src/webmcp/index.njk', 'utf8'), readFile('src/_includes/layouts/base.njk', 'utf8'), @@ -111,25 +114,18 @@ test('dedicated WebMCP route is a human-readable skill browser and the agent fro ]); assert.match(page, /permalink: \/webmcp\//); assert.match(page, /webmcp: true/); - assert.match(page, /The Interdependency WebMCP/); 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, /id="skill-catalog-title">Skill catalogue/); - assert.match(page, /generated\.skillRegistry\.skills/); - assert.match(page, /data-human-skill-filter/); - assert.match(page, /data-human-skill-catalog/); - assert.match(page, /data-human-skill/); - assert.match(page, /skill\.description/); - assert.match(page, /Read canonical SKILL\.md/); - - 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(page, /skill\.kind == "metadata-block"/); + assert.match(page, /skill\.name == "meta"/); + assert.match(page, /
/); + assert.match(page, /Description<\/summary>/); + assert.match(page, /data-select-skill/); + assert.match(page, /data-selected-skill/); + assert.match(page, /data-selected-action="inspect"/); + assert.match(page, /data-selected-action="closure"/); + assert.doesNotMatch(page, /data-webmcp-find|data-webmcp-inspect|data-webmcp-closure/); + assert.match(page, /No internal skill name needs to be typed/); assert.match(layout, /\{% if webmcp %\}