@apideck/docs-mcp
Version:
MCP server that lets agents search and retrieve your documentation at query time instead of relying on stale training data.
83 lines • 3.96 kB
JavaScript
// SPDX-License-Identifier: MIT
const CURRENT_PAGE_TOOL = 'read_current_page';
const findModelContext = () => globalThis.document?.modelContext;
const parseRpc = async (res, id) => {
const body = await res.text();
if (!(res.headers.get('content-type') ?? '').includes('text/event-stream'))
return JSON.parse(body);
for (const line of body.split('\n')) {
if (!line.startsWith('data:'))
continue;
const msg = JSON.parse(line.slice(5).trim());
if (msg.id === id)
return msg;
}
throw new Error('No JSON-RPC response in event stream');
};
export const registerDocsTools = async (opts) => {
const modelContext = opts.modelContext ?? findModelContext();
if (!modelContext)
return { tools: [], unregister: () => undefined };
const doFetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
let nextId = 1;
const rpc = async (method, params, signal) => {
const id = nextId++;
const res = await doFetch(opts.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'X-MCP-Client': 'webmcp',
...opts.headers,
},
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
...(signal !== undefined ? { signal } : {}),
});
if (!res.ok)
throw new Error(`${method} failed: HTTP ${res.status}`);
const msg = await parseRpc(res, id);
if (msg.error)
throw new Error(`${method} failed: ${msg.error.message}`);
return msg.result;
};
const callTool = async (name, args, signal) => {
const result = (await rpc('tools/call', { name, arguments: args }, signal));
const text = (result.content ?? [])
.filter((c) => c.type === 'text' && c.text !== undefined)
.map((c) => c.text)
.join('\n\n');
// Error results are returned as text too: they carry suggestions the agent can act on.
return result.isError ? `Error: ${text}` : text;
};
const { tools: listed } = (await rpc('tools/list', {}));
const wanted = opts.tools !== undefined ? new Set(opts.tools) : undefined;
const defs = listed
.filter((t) => wanted === undefined || wanted.has(t.name))
.map((t) => ({
name: t.name,
description: t.description ?? t.name,
inputSchema: t.inputSchema ?? { type: 'object', properties: {} },
annotations: { readOnlyHint: t.annotations?.readOnlyHint === true },
execute: (inputs, ctx) => callTool(t.name, inputs, ctx?.signal),
}));
if (opts.currentPageTool !== false && listed.some((t) => t.name === 'get_doc')) {
const location = opts.location ?? globalThis.location;
if (location !== undefined) {
defs.push({
name: CURRENT_PAGE_TOOL,
description: 'Return the documentation page the user is viewing as clean markdown, with a section outline. Pass `section` (an anchor or heading) to get one section; by default the section in the URL fragment is returned, if any.',
inputSchema: {
type: 'object',
properties: { section: { type: 'string', description: 'Anchor or heading text of one section.' } },
},
annotations: { readOnlyHint: true },
execute: (inputs, ctx) => callTool('get_doc', { path: location.href, ...(typeof inputs['section'] === 'string' ? { section: inputs['section'] } : {}) }, ctx?.signal),
});
}
}
const controller = new AbortController();
for (const def of defs)
await modelContext.registerTool(def, { signal: controller.signal });
return { tools: defs.map((d) => d.name), unregister: () => controller.abort() };
};
//# sourceMappingURL=webmcp.js.map