Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ This site displays exact, provenance-bearing chapter snapshots. It does not merg
- `/about-me/` preserves the founder-authored **In Service to Love** narrative.
- `/home/` is the newcomer-oriented Start Here route, organized by visitor intent rather than repository or content type.
- `/about-the-site/` owns orientation, construction practices, and verification limits; `/start/` preserves its former address.
- `/webmcp/` publishes the complete commit-pinned skill-lib catalogue and a browser-native agent handoff. A separate, human-triggered MCP client can discover and call actual tools at a trusted endpoint; a skill runs only when an authorized executor advertises and implements `tiw_execute_skill`. The default public MCP server remains read-only. See [`docs/webmcp-execution.md`](docs/webmcp-execution.md).
- `/by-the-builder/` renders the append-only, attributed builder journal.
- `/research/` is the evidence index over admitted studies, bounded findings, and visible gaps; `/research/method/` is its subordinate admission/review method.
- `/preamble/` remains available within the canonical reading system and primary navigation.
Expand All @@ -52,6 +53,7 @@ This site displays exact, provenance-bearing chapter snapshots. It does not merg
npm install
npm run dev
npm run check
npm run test:mcp
```

Refresh only the textbook source set:
Expand Down
60 changes: 60 additions & 0 deletions docs/webmcp-execution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Skill readout and remote MCP invocation

The source of truth is [`The-Interdependency/skill-lib/skills.json`](https://github.com/The-Interdependency/skill-lib/blob/main/skills.json). The website publishes its verified, commit-pinned registry projection. Every registered skill, regardless of kind, appears in the human catalogue and the five read-only registry tools.

## What exists

- **Readout**: description, kind, dependencies, source URL, and dependency-first closure for each registered skill. The browser and remote registry expose the same set.
- **Browser-agent handoff**: repo selection, skill and outcome, followed by explicit **Send**; this publishes a page-session *read-only* WebMCP tool. It does not execute work.
- **Remote MCP client**: direct browser POST to the entered Streamable HTTP endpoint; **Discover** lists the server's actual tools. **Call selected MCP tool** invokes the exact discovered tool with human-entered JSON arguments. Errors and results are shown verbatim, without implying a completed skill.
- **Skill execution**: enabled only if the connected endpoint advertises an operational tool named `tiw_execute_skill`. The human must select a repository, skill, outcome and explicitly confirm execution. The default public endpoint does not advertise an executor.

A skill's `SKILL.md` is an instruction contract, *not* a callable method. An MCP registry tool is not a general agent runner. A successful `tools/call` proves that the endpoint replied, not that a repository changed or a task succeeded.

## Executor contract

A separately authorized MCP executor may expose:

```text
Tool: tiw_execute_skill
Transport: Streamable HTTP MCP, JSON tool responses
Arguments:
target_repository: {
name, canonical_url, observed_head_sha, default_branch,
status, category, identity_source
}
skill: {
name, kind, description, depends_on, human_title,
canonical_path, canonical_url
}
required_skills: [skill record, ...] (dependency-first)
skill_registry: {
registry_version, skill_count, source_skill_count,
public_scope, source: { repository, commit, path, sha256 },
fallback, hmmm
}
human_request: string
```

The executor must independently revalidate the registry source digest, skill identity, repository permissions, current repo head, tool capabilities and authorization. If the observed head is stale it must block or explicitly rebind with a new approval, not treat it as authority. The server must fetch the actual canonical skill and prerequisites; their summaries are not sufficient instructions.

The operational endpoint must provide an execution result and an evidence-bearing receipt: terminal status (`completed`, `failed`, `blocked`, `running`, or `accepted`), source/ref identities, authorized effects, tested outcomes, errors, and `hmmm` for unresolved constraints. A queued or accepted run is not `completed`. Require explicit permission for writes and privileged actions; never allow a public tokenless endpoint to run arbitrary repository, VM or shell operations.

If execution needs a long-running runner or callbacks, the runner owns that job lifecycle. The website cannot upgrade a pending result to completed. A stable URL to an authenticated receipt may be provided by the runner, but the website retains no credential or job state.

## Transport and security

The first-party read-only endpoint is currently `https://the-interdependency-mcp.onrender.com/mcp`. It is not an executor. Changing its tool list would require a separately reviewed server authority change.

The browser client supports JSON responses to stateless Streamable HTTP `tools/list` and `tools/call` requests. SSE-only, session-required and stdio MCP services need an approved bridge. HTTPS is required except for loopback development. Browser CORS must allow `https://interdependentway.org` with the needed headers. The client does not follow redirects, send cookies, persist or log tokens, or treat discovered tool descriptions as permissions. Optional bearer credentials are sent **only to the endpoint explicitly entered** and cleared when the endpoint changes; avoid entering sensitive credentials into an untrusted endpoint.

No URL or client-side authorization can grant VM or GitHub authority: the *connected executor* must enforce it. The website does not proxy tokens into its own read-only MCP service.

## Local verification

```sh
npm run test:mcp
npm run check
```

The MCP client integration test opens a loopback instance of the actual read-only server, lists tools, and calls `tiw_list_skills`. Other tests reject invalid endpoints, bad responses, SSE, duplicate tools, and mismatched call identities. Full end-to-end skill enactment remains **hmmm** until an authorized executor exists and supplies a verifiable run receipt.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
"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/project-docs.test.mjs tests/research-ledger.test.mjs tests/works-registry.test.mjs tests/site-contract.test.mjs tests/sdk22-contract.test.mjs tests/sitrep.test.mjs tests/webmcp.test.mjs tests/webmcp-repository-output.test.mjs tests/mcp-server.test.mjs tests/builder-history.test.mjs tests/forge.test.mjs tests/support-links.test.mjs tests/support-only-deployment.test.mjs",
"test:mcp": "node --test tests/mcp-server.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/project-docs.test.mjs tests/research-ledger.test.mjs tests/works-registry.test.mjs tests/site-contract.test.mjs tests/sdk22-contract.test.mjs tests/sitrep.test.mjs tests/webmcp.test.mjs tests/mcp-client.test.mjs tests/webmcp-repository-output.test.mjs tests/mcp-server.test.mjs tests/builder-history.test.mjs tests/forge.test.mjs tests/support-links.test.mjs tests/support-only-deployment.test.mjs",
"test:mcp": "node --test tests/mcp-server.test.mjs tests/mcp-client.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",
Expand Down
7 changes: 7 additions & 0 deletions src/_data/builder.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,5 +124,12 @@
"time": "06:57:00+00:00",
"model": "model identity unavailable",
"body": "A recovery path should preserve what it knows and make what it cannot observe explicit. Missing documentation is not proof that a project has none. The same limit applies to attribution: this runtime does not expose its exact model identity. Erin authorized recording that limitation for this public-site release."
},
{
"id": "2026-10-17-contract-is-not-an-executor",
"date": "2026-10-17",
"time": "15:13-07:00",
"model": "GPT-6",
"body": "An instruction may be exact without being executable. A page that lists skills has created visibility, not action; a page that can send an MCP request has created contact, not authority. Each new connection makes one more boundary visible. The evidence that matters is the executor's admitted capability, its actual response, and the state that response can prove. Anything in between belongs in hmmm."
}
]
3 changes: 2 additions & 1 deletion src/_includes/layouts/base.njk
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
{% set inWay = page.url == "/way/" or "/way/" in page.url or "/lab/" in page.url or "/articles/" in page.url or "/narratives/" in page.url or "/works/" in page.url %}
{% set inTextbook = "/chapters/" in page.url %}
{% set inResearch = "/research/" in page.url %}
{% set inProjects = "/projects/" in page.url or page.url == "/sitrep/" or "/artifacts/" in page.url %}
{% set inProjects = "/projects/" in page.url or page.url == "/sitrep/" or "/artifacts/" in page.url or page.url == "/webmcp/" %}
{% set inAbout = page.url == "/about/" or page.url == "/about-me/" or page.url == "/about-the-site/" or page.url == "/by-the-builder/" or page.url == "/accessibility/" %}
<body>
<a class="skip-link" href="#content">Skip to content</a>
Expand Down Expand Up @@ -72,6 +72,7 @@
<a href="/sitrep/"{% if page.url == "/sitrep/" %} aria-current="page"{% endif %}>Repository SITREP</a>
<a href="/artifacts/"{% if "/artifacts/" in page.url %} aria-current="page"{% endif %}>Artifacts</a>
<a href="/projects/map/"{% if page.url == "/projects/map/" %} aria-current="page"{% endif %}>Organization map</a>
<a href="/webmcp/"{% if page.url == "/webmcp/" %} aria-current="page"{% endif %}>Skill library &amp; MCP</a>
</nav>
{% elif inAbout %}
<nav class="section-nav" aria-label="About section">
Expand Down
105 changes: 105 additions & 0 deletions src/assets/js/mcp-client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// === MODULE_BUILD ===
// id: browser_mcp_client
// purpose: Discover and invoke advertised Streamable HTTP MCP tools from an explicitly selected trusted endpoint.
// entrypoint: imported by /assets/js/webmcp.js
// tests: tests/mcp-client.test.mjs
// === END MODULE_BUILD ===
// === BOUNDARIES ===
// id: browser_mcp_client_boundary
// network: direct browser POST to the human-selected HTTPS MCP endpoint (or loopback development endpoint)
// storage: none; optional bearer credential stays in the DOM until page exit or endpoint change
// effects: tools/call can mutate; never call automatically and require explicit invocation from the UI
// response: only JSON MCP replies; SSE and session-required transports fail visibly
// === END BOUNDARIES ===
// Usage: discoverMcpTools(url, { token }); invokeMcpTool(url, name, argumentsObject, { token }).
// Never treat registry skill descriptions as executable tool definitions.

export const SKILL_EXECUTION_TOOL = 'tiw_execute_skill';
export const MCP_PROTOCOL_VERSION = '2026-07-28';
const MAX_RESPONSE_CHARS = 262144;
const MAX_ARGUMENT_CHARS = 65536;
let nextId = 1;

export function normalizeMcpEndpoint(value) {
let url;
try { url = new URL(String(value || '').trim()); }
catch { throw new Error('MCP endpoint must be an absolute URL'); }

const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
if (!(url.protocol === 'https:' || (loopback && url.protocol === 'http:'))) {
throw new Error('MCP endpoint requires HTTPS (HTTP permitted only for loopback)');
}
if (url.username || url.password || url.hash || url.search) {
throw new Error('MCP endpoint must not contain credentials, a query, or a fragment');
}
return url.href;
}

async function mcpRequest(endpoint, method, params, { token = '', fetchImpl = fetch, signal } = {}) {
if (method !== 'tools/list' && method !== 'tools/call') throw new Error('Unsupported MCP method');
if (token && (typeof token !== 'string' || /[\r\n]/.test(token))) throw new Error('Invalid bearer token');
const url = normalizeMcpEndpoint(endpoint);
const id = nextId++;
const headers = {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
'mcp-protocol-version': MCP_PROTOCOL_VERSION,
'mcp-method': method
};
if (method === 'tools/call') headers['mcp-name'] = params.name;
if (token) headers.authorization = `Bearer ${token}`;
const body = JSON.stringify({
jsonrpc: '2.0', id, method,
params: { ...params, _meta: { 'io.modelcontextprotocol/protocolVersion': MCP_PROTOCOL_VERSION } }
});
if (body.length > MAX_ARGUMENT_CHARS) throw new Error('MCP arguments exceed the request limit');

let response;
try {
Comment on lines +55 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Time out stalled MCP requests

When an endpoint accepts the connection but never completes the response or stalls while sending its body, this fetch has no timeout unless an external caller supplies a signal, and the page binding supplies none. The surrounding busy state therefore never clears, leaving discovery and invocation controls disabled even after the endpoint is edited; create a bounded abort signal for browser requests.

Useful? React with 👍 / 👎.

response = await fetchImpl(url, {
method: 'POST', headers, body,
cache: 'no-store', credentials: 'omit', redirect: 'error', signal
});
} catch {
throw new Error('MCP request failed: check endpoint availability, HTTPS, CORS, and network access');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse modern JSON-RPC errors before rejecting HTTP 400

For a 2026-era request, a conforming server can return HTTP 400 with a matching JSON-RPC error body, such as a header-mismatch or missing-capability error; the official TypeScript SDK documentation treats these as in-band protocol errors. Rejecting on response.ok before reading that body replaces the actionable MCP error with only MCP HTTP 400, so the page cannot display the endpoint's actual failure as promised.

Useful? React with 👍 / 👎.


if (!response.ok) throw new Error(`MCP HTTP ${response.status} ${response.statusText || ''}`.trim());
const type = response.headers.get('content-type') || '';
if (!/^application\/(?:json|[^;]+\+json)(?:\s*;|$)/i.test(type)) {
throw new Error('MCP endpoint did not return JSON (SSE/session transports require a separate adapter)');
}
const size = Number(response.headers.get('content-length') || 0);
Comment on lines +71 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce the response limit while reading the body

For a chunked response or any response without a trustworthy Content-Length, response.text() buffers the entire payload before the character limit is checked. A misconfigured or compromised endpoint can therefore consume unbounded browser memory despite MAX_RESPONSE_CHARS; stream the body and abort as soon as the limit is exceeded.

Useful? React with 👍 / 👎.

if (size > MAX_RESPONSE_CHARS) throw new Error('MCP response exceeds the display limit');
const raw = await response.text();
if (raw.length > MAX_RESPONSE_CHARS) throw new Error('MCP response exceeds the display limit');

let message;
try { message = JSON.parse(raw); }
catch { throw new Error('MCP endpoint returned malformed JSON'); }
if (message?.jsonrpc !== '2.0' || message.id !== id) throw new Error('MCP response identity mismatch');
if (message.error) throw new Error(`MCP error ${message.error.code}: ${message.error.message || 'Unknown error'}`);
if (!message.result || typeof message.result !== 'object') throw new Error('MCP result missing');
return message.result;
}

export async function discoverMcpTools(endpoint, options = {}) {
const result = await mcpRequest(endpoint, 'tools/list', {}, options);
if (!Array.isArray(result.tools)) throw new Error('MCP tools/list returned no tool catalogue');
const names = new Set();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Follow pagination when discovering tools

When tools/list returns a nextCursor, this reads only the initial page and silently ignores every later tool. An executor located on a later page is consequently absent from the selector and leaves skill enactment disabled even though the endpoint advertises it; the official MCP SDK pagination documentation specifies that clients follow nextCursor until it is omitted.

Useful? React with 👍 / 👎.

for (const tool of result.tools) {
if (!tool || typeof tool.name !== 'string' || !tool.name.trim() || names.has(tool.name)) {
throw new Error('MCP tools/list contains an invalid or duplicate tool name');
}
names.add(tool.name);
}
return result.tools;
}

export async function invokeMcpTool(endpoint, name, args = {}, options = {}) {
if (!name || typeof name !== 'string') throw new Error('A discovered MCP tool must be selected');
if (!args || typeof args !== 'object' || Array.isArray(args)) {
throw new Error('MCP tool arguments must be a JSON object');
}
return mcpRequest(endpoint, 'tools/call', { name, arguments: args }, options);
}
21 changes: 7 additions & 14 deletions src/assets/js/webmcp-registry.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// === MODULE_BUILD ===
// id: webmcp_skill_registry_adapter
// purpose: Provide deterministic read-only operations over the public human-and-agent view of the website's commit-pinned skill-lib registry projection.
// purpose: Provide deterministic read-only operations over every registered skill in the website's commit-pinned skill-lib 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 ===
Expand All @@ -12,9 +12,9 @@
// 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 msdmd metadata-block applications, the exact meta skill, fresh-making, and epac-selection-display, matching the human card catalogue
// id: webmcp_full_catalogue_matches_source_registry
// given: the verified skill-lib projection contains both metadata-block and procedural skills
// then: public list/find/inspect/closure expose every registered skill and its source, matching the human catalogue
// class: correctness
//
// id: webmcp_registry_smallest_dependency_closure
Expand All @@ -28,13 +28,6 @@ function normalizeText(value) {
return String(value || '').trim().toLowerCase();
}

function isPresentedSkill(skill) {
return skill?.kind === 'metadata-block'
|| skill?.name === 'meta'
|| skill?.name === 'fresh-making'
|| skill?.name === 'epac-selection-display';
}

export function humanTitle(description) {
const text = String(description || '').trim();
if (!text) return '';
Expand Down Expand Up @@ -62,13 +55,13 @@ export function createSkillRegistry(registryData) {
}

const source = registryData.source;
const skills = registryData.skills.filter(isPresentedSkill);
const skills = registryData.skills;
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}`);
throw new Error(`registered skill dependency is not registered: ${skill.name} -> ${dependency}`);
}
}
}
Expand Down Expand Up @@ -142,7 +135,7 @@ export function createSkillRegistry(registryData) {
registry_version: registryData.version,
skill_count: skills.length,
source_skill_count: registryData.skills.length,
public_scope: 'metadata-block plus meta plus fresh-making plus epac-selection-display',
public_scope: 'all registered skill-lib skills',
source: { ...source },
fallback: Boolean(registryData.fallback),
hmmm: Array.isArray(registryData.hmmm) ? [...registryData.hmmm] : []
Expand Down
Loading
Loading