#!/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 { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; 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 moduleDirPath = dirname(fileURLToPath(import.meta.url)); const pkgPath = findPackage.up({ cwd: moduleDirPath }); if (!pkgPath) { throw new Error(`Failed to locate package.json from ${moduleDirPath}`); } const pkg = JSON.parse(await readFile(pkgPath, 'utf-8')) as { version: string; name: string; description: string; }; const MCP_SERVER_NAME = 'lynx-docs'; function registerCodexCompatibilityHandlers(mcpServer: McpServer) { // Codex expects tool discovery to exist during startup. Register one disabled // placeholder so the SDK exposes tools/list while keeping the catalog empty. const dummyTool = mcpServer.registerTool( 'dummyTool', { description: 'Internal compatibility shim for Codex MCP startup.', }, async () => ({ content: [ { type: 'text', text: 'This compatibility shim should never be called.', }, ], }), ); dummyTool.disable(); } async function crawlAndRegisterResources( baseURL: string, mcpServer: McpServer, fromMarkdownText: string, visited: Set = new Set(), ) { 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 } }); for (const [strippedUrl, link] of linkUrls) { // 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) { continue; } if (strippedUrl.endsWith('llms.txt')) { if (visited.has(strippedUrl)) { continue; } debug(`Recursively fetching index: ${link.url}`); try { const response = await fetch(link.url); if (!response.ok) { debug( `Failed to fetch nested index ${link.url}: ${response.status} ${response.statusText}`, ); continue; } const nestedMarkdown = await response.text(); visited.add(strippedUrl); mcpServer.registerResource( title, `lynx-docs://${strippedUrl}`, { title, description: title, mimeType: 'text/markdown', }, () => ({ contents: [ { uri: `lynx-docs://${strippedUrl}`, text: nestedMarkdown, mimeType: 'text/markdown', }, ], }), ); await crawlAndRegisterResources( baseURL, mcpServer, nestedMarkdown, visited, ); } catch (e) { debug(`Failed to fetch nested index ${link.url}: %o`, e); } continue; } debug( `Registering resource: lynx-docs://${strippedUrl} from [${title}](${baseURL}/${strippedUrl})`, ); mcpServer.registerResource( title, `lynx-docs://${strippedUrl}`, { title, description: title, mimeType: 'text/markdown', }, async () => { const response = await fetch(link.url); if (!response.ok) { throw new Error( `Failed to fetch resource ${link.url}: ${response.status} ${response.statusText}`, ); } return { contents: [ { uri: `lynx-docs://${strippedUrl}`, text: await response.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', }, ], }), ); const visited = new Set(['llms.txt']); await crawlAndRegisterResources( baseUrl, mcpServer, ROOT_DOC_MARKDOWN, visited, ); registerCodexCompatibilityHandlers(mcpServer); 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);