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.
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.
-
+
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 %}
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 %}
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 %\}