UNPKG

@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
// 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