diff --git a/package.json b/package.json index 6bf46af..4f56cf0 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@iconify-json/simple-icons": "^1.2.93", "@iconify-json/vscode-icons": "^1.2.74", "@iconify/vue": "^5.0.1", + "@modelcontextprotocol/sdk": "^1.30.0", "@nuxt/kit": "^4.5.2", "@nuxt/ui": "^4.11.1", "@nuxtjs/mcp-toolkit": "^0.18.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e711b3f..dd838a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@iconify/vue': specifier: ^5.0.1 version: 5.0.1(vue@3.5.41(typescript@6.0.3)) + '@modelcontextprotocol/sdk': + specifier: ^1.30.0 + version: 1.30.0(supports-color@10.2.2)(zod@4.4.3) '@nuxt/kit': specifier: ^4.5.2 version: 4.5.2(magic-string@1.2.3)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.5)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.5)(rollup@4.62.5)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.1))) diff --git a/server/mcp/resources/page.ts b/server/mcp/resources/page.ts new file mode 100644 index 0000000..40a97c9 --- /dev/null +++ b/server/mcp/resources/page.ts @@ -0,0 +1,33 @@ +import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js' +import { useEvent } from 'nitropack/runtime' +import { getAgentDocument, listAgentPages } from '#agent-discovery' + +export default defineMcpResource({ + name: 'page', + title: 'Documentation page', + description: + 'A documentation page as markdown, the same document `/raw/.md` serves. Listing the resources lists every page; read one by its `docs://page/` URI.', + uri: new ResourceTemplate(`${PAGE_RESOURCE_PREFIX}{+route}`, { + list: async () => { + const pages = await listAgentPages(useEvent()) + return { + resources: pages.map((page) => ({ + uri: pageResourceUri(page.route), + name: page.title ?? page.route, + description: page.description, + mimeType: 'text/markdown', + })), + } + }, + }), + handler: async (uri: URL) => { + const route = pageRouteFromUri(uri) + const document = route ? await getAgentDocument(useEvent(), route) : null + if (!document || 'redirect' in document) { + throw new Error(`Page not found: ${uri.href}. List the resources to see every page.`) + } + return { + contents: [{ uri: uri.href, mimeType: 'text/markdown', text: document.markdown }], + } + }, +}) diff --git a/server/utils/agent-resources.ts b/server/utils/agent-resources.ts new file mode 100644 index 0000000..74abb7f --- /dev/null +++ b/server/utils/agent-resources.ts @@ -0,0 +1,17 @@ +import { withLeadingSlash } from 'ufo' + +/** URI prefix of the MCP resource that serves a documentation page. */ +export const PAGE_RESOURCE_PREFIX = 'docs://page' + +/** `/getting-started/installation` -> `docs://page/getting-started/installation` */ +export function pageResourceUri(route: string): string { + return `${PAGE_RESOURCE_PREFIX}${withLeadingSlash(route)}` +} + +/** The page route a resource URI names, or `null` when the URI is not a page resource. */ +export function pageRouteFromUri(uri: string | URL): string | null { + const href = typeof uri === 'string' ? uri : uri.href + if (!href.startsWith(`${PAGE_RESOURCE_PREFIX}/`)) return null + const route = href.slice(PAGE_RESOURCE_PREFIX.length).replace(/[?#].*$/, '') + return route === '/' ? null : route +} diff --git a/test/agent-resources.test.ts b/test/agent-resources.test.ts new file mode 100644 index 0000000..f7779f5 --- /dev/null +++ b/test/agent-resources.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { pageResourceUri, pageRouteFromUri } from '../server/utils/agent-resources' + +describe('pageResourceUri', () => { + it('prefixes a page route', () => { + expect(pageResourceUri('/getting-started/installation')).toBe('docs://page/getting-started/installation') + expect(pageResourceUri('getting-started/installation')).toBe('docs://page/getting-started/installation') + }) +}) + +describe('pageRouteFromUri', () => { + it('returns the route of a page resource, from a string or a URL', () => { + expect(pageRouteFromUri('docs://page/getting-started/installation')).toBe('/getting-started/installation') + expect(pageRouteFromUri(new URL('docs://page/getting-started/installation'))).toBe('/getting-started/installation') + }) + + it('drops a query or hash', () => { + expect(pageRouteFromUri('docs://page/syntax/markdown#lists')).toBe('/syntax/markdown') + expect(pageRouteFromUri('docs://page/syntax/markdown?x=1')).toBe('/syntax/markdown') + }) + + it('rejects anything that is not a page resource', () => { + expect(pageRouteFromUri('docs://page')).toBeNull() + expect(pageRouteFromUri('docs://page/')).toBeNull() + expect(pageRouteFromUri('docs://pages/syntax')).toBeNull() + expect(pageRouteFromUri('https://comark.dev/raw/syntax/markdown.md')).toBeNull() + }) +})