diff --git a/.changeset/public-carrots-search.md b/.changeset/public-carrots-search.md new file mode 100644 index 0000000000..853d812bb3 --- /dev/null +++ b/.changeset/public-carrots-search.md @@ -0,0 +1,3 @@ +--- + +--- diff --git a/packages/mcp-servers/docs-mcp-server/README.md b/packages/mcp-servers/docs-mcp-server/README.md new file mode 100644 index 0000000000..fa6fe6c309 --- /dev/null +++ b/packages/mcp-servers/docs-mcp-server/README.md @@ -0,0 +1,109 @@ +# @lynx-js/docs-mcp-server + +> A MCP Server providing Lynx documentation resources for LLMs, with carefully designed prompting. + +`@lynx-js/docs-mcp-server` lets your coding agent (such as Gemini, Claude, Cursor or Copilot) +access Lynx documentation to assist you in development tasks. Therefore, +we have specifically optimized [llms.txt](https://lynxjs.org/next/llms.txt), +a condensed version of the documentation site optimized for reading large models. + +## Requirements + +- [Node.js](https://nodejs.org/) v18.17 or a newer [latest maintenance LTS](https://github.com/nodejs/Release#release-schedule) version. + +## Getting started + +Add the following config to your MCP client: + +```json +{ + "mcpServers": { + "lynx-docs": { + "command": "npx", + "args": [ + "-y", + "@lynx-js/docs-mcp-server@latest" + ] + } + } +} +``` + +`@lynx-js/docs-mcp-server` works best with MCP clients that supports [Server Instructions](https://modelcontextprotocol.io/specification/draft/schema#initializeresult), such as Claude Code. +If you find your MCP client don't know about the MCP server, +you can manually provide the following instructions +(e.g. in your `AGENTS.md`, `CLAUDE.md`, or just send it along with your question): + +```md +For any questions or requirements regarding Lynx: + +1. Use the "List Resources Tool" to list all Resources provided in MCP "lynx-docs". +2. First read MCP Resources "lynx-docs://llms.txt" (**REQUIRED**), this document is an ENTRYPOINT of all Lynx Docs. +3. After reading "lynx-docs://llms.txt", use the "Read MCP Resources Tool" to retrieve docs you need based on the user's questions or requirements, please read them proactively. +4. If available, prioritize obtaining Lynx-related information through MCP Resources tools over external web searches. +``` + +
+ Claude Code + Use the Claude Code CLI to add the Lynx Docs MCP server (guide): + +```bash +claude mcp add lynx-docs npx @lynx-js/docs-mcp-server@latest +``` + +
+ +
+ Codex + Follow the configure MCP guide + using the standard config from above. You can also install the Lynx Docs MCP server using the Codex CLI: + +```bash +codex mcp add lynx-docs -- npx @lynx-js/docs-mcp-server@latest +``` + +
+ +
+ Copilot / VS Code + Follow the MCP install guide, + with the standard config from above. You can also install the Lynx Docs MCP server using the VS Code CLI: + +```bash +code --add-mcp '{"name":"lynx-docs","command":"npx","args":["@lynx-js/docs-mcp-server@latest"]}' +``` + +
+ +
+ Cursor + +**Install manually:** + +Go to `Cursor Settings` -> `MCP` -> `New MCP Server`. Use the config provided above. + +
+ +
+ Gemini CLI +Install the Lynx Docs MCP server using the Gemini CLI. + +**Project wide:** + +```bash +gemini mcp add lynx-docs npx @lynx-js/docs-mcp-server@latest +``` + +**Globally:** + +```bash +gemini mcp add -s user lynx-docs npx @lynx-js/docs-mcp-server@latest +``` + +Alternatively, follow the MCP guide and use the standard config from above. + +
+ +## Credits + +This project is inspired by [Svelte MCP server](https://svelte.dev/docs/mcp/overview). Both the implementation and documentation have been adapted and referenced from the original MCP server. diff --git a/packages/mcp-servers/docs-mcp-server/main.ts b/packages/mcp-servers/docs-mcp-server/main.ts new file mode 100644 index 0000000000..48b17b50d0 --- /dev/null +++ b/packages/mcp-servers/docs-mcp-server/main.ts @@ -0,0 +1,207 @@ +#!/usr/bin/env node + +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { readFile } from 'node:fs/promises'; + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { Command } from 'commander'; +import createDebug from 'debug'; +import * as findPackage from 'empathic/package'; +import type { Link, Node } from 'mdast'; +import { fromMarkdown } from 'mdast-util-from-markdown'; +import { toMarkdown } from 'mdast-util-to-markdown'; +import { fetch } from 'undici'; + +// NOTE: Un comment below to enable caching and debug for undici fetch requests +// import { +// interceptors, +// EnvHttpProxyAgent +// setGlobalDispatcher, +// // @ts-expect-error missing types +// cacheStores, +// } from 'undici'; +// const agent = new EnvHttpProxyAgent().compose( +// interceptors.cache({ +// store: new cacheStores.MemoryCacheStore({ +// maxSize: 100 * 1024 * 1024, // 100MB +// maxCount: 1000, +// maxEntrySize: 5 * 1024 * 1024, // 5MB +// }), +// methods: ['GET', 'HEAD'], // Optional: specify which methods to cache +// }), +// ); +// setGlobalDispatcher(agent); + +const debug = createDebug('lynx-docs-mcp'); + +const pkgPath = findPackage.up({ cwd: new URL('.', import.meta.url).pathname }); +const pkg = JSON.parse(await readFile(pkgPath!, 'utf-8')) as { + version: string; + name: string; + description: string; +}; + +const MCP_SERVER_NAME = 'lynx-docs'; + +function registerResources( + baseURL: string, + mcpServer: McpServer, + fromMarkdownText: string, +) { + const tree = fromMarkdown(fromMarkdownText); // verify markdown is valid + + const forEachLink = (node: Node, cb: (link: Link) => void) => { + if (node.type === 'link') { + cb(node as Link); + } else if ('children' in node && Array.isArray(node.children)) { + for (const child of node.children) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + forEachLink(child, cb); + } + } + }; + + const linkUrls: Map = new Map(); + forEachLink(tree, (link) => { + try { + const base = new URL(baseURL); + const u = new URL(link.url); + if ( + u.hostname === base.hostname + // Some links may be absolute URLs to lynxjs.org + || u.hostname === 'lynxjs.org' + ) { + // Strip versioned path prefixes like /next/ or /1.2.3/ + const strippedUrl = u.pathname.replace( + /^\/(?:next|\d+(?:\.\d+)*)?\/?/, + '', + ); + linkUrls.set(strippedUrl, link); + } + } catch { + // Ignore invalid URLs + } + }); + + linkUrls.forEach((link, strippedUrl) => { + // Generate a title for the resource by converting the link node back to markdown + // NOTE: The title generation is complex because link titles may contain nested formatting, DON'T just use link.title + const title = toMarkdown({ ...link, type: 'root' }).trim(); + + if (!title) { + return; + } + + debug( + `Registering resource: lynx-docs://${strippedUrl} from [${title}](${baseURL}/${strippedUrl})`, + ); + + mcpServer.registerResource( + title, + `lynx-docs://${strippedUrl}`, + { + title, + description: title, + mimeType: 'text/markdown', + }, + async () => ({ + contents: [ + { + uri: `lynx-docs://${strippedUrl}`, + text: await fetch(link.url).then((res) => res.text()), + mimeType: 'text/markdown', + }, + ], + }), + ); + }); +} + +async function main(baseUrl: string) { + const ROOT_DOC_URL = `${baseUrl}/llms.txt`; + const ROOT_DOC_MARKDOWN = await fetch(ROOT_DOC_URL).then((res) => res.text()); + const ROOT_DOC_RESOURCE_URI = 'lynx-docs://llms.txt'; + const ROOT_DOC_TITLE = 'Read This Before Building with Lynx'; + const ROOT_DOC_DESCRIPTION = + 'Before building with Lynx, be sure to read this document to understand important information and best practices.'; + + const mcpServer = new McpServer({ + name: MCP_SERVER_NAME, + version: pkg.version, + }, { + // NOTE: This instruction for now is not supported by Codex's MCP support, + // see https://github.com/openai/codex/issues/6148 + instructions: `\ + +For any questions or requirements regarding Lynx: + +1. Use the "List Resources Tool" to list all Resources provided in MCP "${MCP_SERVER_NAME}". +2. First read MCP Resources "${ROOT_DOC_RESOURCE_URI}" (**REQUIRED**), this document is an ENTRYPOINT of all Lynx Docs. +3. After reading "${ROOT_DOC_RESOURCE_URI}", use the "Read MCP Resources Tool" to retrieve docs you need based on the user's questions or requirements, please read them proactively. +4. If available, prioritize obtaining Lynx-related information through MCP Resources tools over external web searches. + + +`, + }); + + mcpServer.registerResource( + ROOT_DOC_TITLE, + ROOT_DOC_RESOURCE_URI, + { + title: ROOT_DOC_TITLE, + description: ROOT_DOC_DESCRIPTION, + mimeType: 'text/markdown', + }, + () => ({ + contents: [ + { + uri: ROOT_DOC_RESOURCE_URI, + text: ROOT_DOC_MARKDOWN, + mimeType: 'text/markdown', + }, + ], + }), + ); + + registerResources(baseUrl, mcpServer, ROOT_DOC_MARKDOWN); + + const transport = new StdioServerTransport(); + await mcpServer.connect(transport); +} + +const program = new Command(); + +program + .name(`npx -y ${pkg.name}`) + .description(pkg.description) + .option( + '--base-url ', + 'Base URL for fetching Lynx docs. Set if you want versioned docs.', + 'https://lynxjs.org/next/', + ) + .version(pkg.version) + .addHelpText( + 'after', + ` +Usage as a MCP Server: + { + "mcpServers": { + "${MCP_SERVER_NAME}": { + "command": "npx", + "args": ["-y", "${pkg.name}"] + } + } + } +`, + ) + .action(async (options: { baseUrl: string }) => { + await main( + // need to remove trailing slash if any + options.baseUrl.replace(/\/+$/, ''), + ); + }); + +program.parse(process.argv); diff --git a/packages/mcp-servers/docs-mcp-server/package.json b/packages/mcp-servers/docs-mcp-server/package.json new file mode 100644 index 0000000000..9634dac857 --- /dev/null +++ b/packages/mcp-servers/docs-mcp-server/package.json @@ -0,0 +1,34 @@ +{ + "name": "@lynx-js/docs-mcp-server", + "version": "0.2.1", + "description": "A MCP Server providing Lynx documentation resources for LLMs, with carefully designed prompting.", + "type": "module", + "bin": "./main.ts", + "files": [ + "dist", + "main.ts" + ], + "scripts": { + "build": "tsc" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.20.0", + "commander": "^13.1.0", + "debug": "^4.4.3", + "empathic": "^2.0.0", + "mdast-util-from-markdown": "^2.0.2", + "mdast-util-to-markdown": "^2.1.2", + "undici": "^6.22.0" + }, + "devDependencies": { + "@types/debug": "^4.1.12", + "@types/mdast": "^4.0.4", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18.17" + }, + "publishConfig": { + "bin": "./dist/main.js" + } +} diff --git a/packages/mcp-servers/docs-mcp-server/tsconfig.json b/packages/mcp-servers/docs-mcp-server/tsconfig.json new file mode 100644 index 0000000000..549daeed0e --- /dev/null +++ b/packages/mcp-servers/docs-mcp-server/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "isolatedDeclarations": false, + "emitDeclarationOnly": false, + "types": ["node"], + "outDir": "dist", + }, + "include": ["src", "main.ts"], +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ee0831cf7..6933e45c35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -315,6 +315,40 @@ importers: specifier: ^3.25.76 version: 3.25.76 + packages/mcp-servers/docs-mcp-server: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.20.0 + version: 1.20.0 + commander: + specifier: ^13.1.0 + version: 13.1.0 + debug: + specifier: ^4.4.3 + version: 4.4.3 + empathic: + specifier: ^2.0.0 + version: 2.0.0 + mdast-util-from-markdown: + specifier: ^2.0.2 + version: 2.0.2 + mdast-util-to-markdown: + specifier: ^2.1.2 + version: 2.1.2 + undici: + specifier: ^6.22.0 + version: 6.22.0 + devDependencies: + '@types/debug': + specifier: ^4.1.12 + version: 4.1.12 + '@types/mdast': + specifier: ^4.0.4 + version: 4.0.4 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + packages/react: dependencies: preact: @@ -8849,6 +8883,10 @@ packages: undici-types@7.13.0: resolution: {integrity: sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==} + undici@6.22.0: + resolution: {integrity: sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==} + engines: {node: '>=18.17'} + unhead@2.0.17: resolution: {integrity: sha512-xX3PCtxaE80khRZobyWCVxeFF88/Tg9eJDcJWY9us727nsTC7C449B8BUfVBmiF2+3LjPcmqeoB2iuMs0U4oJQ==} @@ -18369,6 +18407,8 @@ snapshots: undici-types@7.13.0: {} + undici@6.22.0: {} + unhead@2.0.17: dependencies: hookable: 5.5.3