Repository navigation
WebMCP: publish full skill-lib catalogue and invoke live MCP tools #87
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b31006a
d4ba064
8fc32e3
e5a1a26
4b6e94d
595d681
b16a41a
eb76b9a
f6bb3bd
ce6b1f9
ac50b59
cec3597
80e462d
b23b73b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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 { | ||
| 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'); | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a chunked response or any response without a trustworthy 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(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When 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); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
busystate 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 👍 / 👎.