- Playbook + RAG doc; Cursor rule; sync script + manifest snapshot - mcp-wormhole-docs: resources + wormhole_doc_search (read-only) - verify-wormhole-ai-docs-setup.sh health check Wire pnpm-workspace + lockfile + AGENTS/MCP_SETUP/MASTER_INDEX in a follow-up if not already committed. Made-with: Cursor
290 lines
8.8 KiB
JavaScript
290 lines
8.8 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
import {
|
|
CallToolRequestSchema,
|
|
ListResourcesRequestSchema,
|
|
ListToolsRequestSchema,
|
|
ReadResourceRequestSchema,
|
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
import fetch from 'node-fetch';
|
|
import { readFileSync, existsSync } from 'fs';
|
|
import { join, dirname } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
|
|
const REPO_ROOT = join(__dirname, '..');
|
|
const DEFAULT_MIRROR = join(REPO_ROOT, 'third-party', 'wormhole-ai-docs');
|
|
const ALLOWED_FETCH_PREFIX = 'https://wormhole.com/docs';
|
|
const LLMS_TXT_URL = `${ALLOWED_FETCH_PREFIX}/llms.txt`;
|
|
|
|
const CATEGORIES = [
|
|
'basics',
|
|
'ntt',
|
|
'connect',
|
|
'wtt',
|
|
'settlement',
|
|
'executor',
|
|
'multigov',
|
|
'queries',
|
|
'transfer',
|
|
'typescript-sdk',
|
|
'solidity-sdk',
|
|
'cctp',
|
|
'reference',
|
|
];
|
|
|
|
function mirrorRoot() {
|
|
return process.env.WORMHOLE_DOCS_MIRROR || DEFAULT_MIRROR;
|
|
}
|
|
|
|
function allowFetch() {
|
|
return process.env.WORMHOLE_DOCS_FETCH === '1' || process.env.WORMHOLE_DOCS_FETCH === 'true';
|
|
}
|
|
|
|
function maxResourceBytes() {
|
|
const n = parseInt(process.env.WORMHOLE_MAX_RESOURCE_BYTES || '5242880', 10);
|
|
return Number.isFinite(n) && n > 0 ? n : 5242880;
|
|
}
|
|
|
|
/** @param {string} uri */
|
|
function uriToHttps(uri) {
|
|
if (uri === 'wormhole://ai/llms.txt') return LLMS_TXT_URL;
|
|
if (uri === 'wormhole://ai/site-index.json')
|
|
return `${ALLOWED_FETCH_PREFIX}/ai/site-index.json`;
|
|
if (uri === 'wormhole://ai/llms-full.jsonl')
|
|
return `${ALLOWED_FETCH_PREFIX}/ai/llms-full.jsonl`;
|
|
const m = uri.match(/^wormhole:\/\/ai\/categories\/([a-z0-9-]+\.md)$/);
|
|
if (m) return `${ALLOWED_FETCH_PREFIX}/ai/categories/${m[1]}`;
|
|
return null;
|
|
}
|
|
|
|
/** @param {string} uri */
|
|
function uriToRelativePath(uri) {
|
|
if (uri === 'wormhole://ai/llms.txt') return 'llms.txt';
|
|
if (uri === 'wormhole://ai/site-index.json') return 'site-index.json';
|
|
if (uri === 'wormhole://ai/llms-full.jsonl') return 'llms-full.jsonl';
|
|
const m = uri.match(/^wormhole:\/\/ai\/categories\/([a-z0-9-]+\.md)$/);
|
|
if (m) return join('categories', m[1]);
|
|
return null;
|
|
}
|
|
|
|
/** @param {string} url */
|
|
async function fetchAllowed(url) {
|
|
if (!url || !url.startsWith(ALLOWED_FETCH_PREFIX)) {
|
|
throw new Error(`Fetch URL not allowlisted: ${url}`);
|
|
}
|
|
const res = await fetch(url, {
|
|
redirect: 'follow',
|
|
headers: { 'user-agent': 'mcp-wormhole-docs/1.0' },
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
|
const len = res.headers.get('content-length');
|
|
if (len && parseInt(len, 10) > 80 * 1024 * 1024) {
|
|
throw new Error(`Refusing to fetch body larger than 80MB (${len} bytes)`);
|
|
}
|
|
return Buffer.from(await res.arrayBuffer());
|
|
}
|
|
|
|
/**
|
|
* @param {string} uri
|
|
*/
|
|
async function readResourceContentAsync(uri) {
|
|
const rel = uriToRelativePath(uri);
|
|
if (!rel) throw new Error(`Unknown resource URI: ${uri}`);
|
|
const root = mirrorRoot();
|
|
const localPath = join(root, rel);
|
|
let buf;
|
|
if (existsSync(localPath)) buf = readFileSync(localPath);
|
|
else if (allowFetch()) {
|
|
const u = uriToHttps(uri);
|
|
if (!u) throw new Error(`No HTTPS mapping for ${uri}`);
|
|
buf = await fetchAllowed(u);
|
|
} else {
|
|
throw new Error(`Missing ${localPath}. Sync mirror or set WORMHOLE_DOCS_FETCH=1`);
|
|
}
|
|
return formatBuffer(uri, rel, buf);
|
|
}
|
|
|
|
/**
|
|
* @param {string} uri
|
|
* @param {string} rel
|
|
* @param {Buffer} buf
|
|
*/
|
|
function formatBuffer(uri, rel, buf) {
|
|
const isJsonl = rel === 'llms-full.jsonl';
|
|
const max = maxResourceBytes();
|
|
if (isJsonl && buf.length > max) {
|
|
const head = buf.subarray(0, max).toString('utf8');
|
|
return {
|
|
mimeType: 'text/plain; charset=utf-8',
|
|
text:
|
|
`[Truncated: ${buf.length} bytes, showing first ${max} bytes. Set WORMHOLE_MAX_RESOURCE_BYTES or read ${join(mirrorRoot(), rel)} on disk for RAG.]\n\n` +
|
|
head,
|
|
};
|
|
}
|
|
|
|
if (rel.endsWith('.json')) {
|
|
return { mimeType: 'application/json; charset=utf-8', text: buf.toString('utf8') };
|
|
}
|
|
if (rel.endsWith('.jsonl')) {
|
|
return { mimeType: 'application/x-ndjson; charset=utf-8', text: buf.toString('utf8') };
|
|
}
|
|
return { mimeType: 'text/plain; charset=utf-8', text: buf.toString('utf8') };
|
|
}
|
|
|
|
function buildResourceList() {
|
|
const resources = [
|
|
{
|
|
uri: 'wormhole://ai/llms.txt',
|
|
name: 'Wormhole llms.txt',
|
|
description: 'Tier 1: site map and links (llms.txt standard)',
|
|
mimeType: 'text/plain',
|
|
},
|
|
{
|
|
uri: 'wormhole://ai/site-index.json',
|
|
name: 'Wormhole site-index.json',
|
|
description: 'Tier 2: lightweight page index with previews',
|
|
mimeType: 'application/json',
|
|
},
|
|
{
|
|
uri: 'wormhole://ai/llms-full.jsonl',
|
|
name: 'Wormhole llms-full.jsonl',
|
|
description: 'Tier 4: full doc corpus (large; may truncate via MCP read)',
|
|
mimeType: 'application/x-ndjson',
|
|
},
|
|
];
|
|
for (const c of CATEGORIES) {
|
|
resources.push({
|
|
uri: `wormhole://ai/categories/${c}.md`,
|
|
name: `Wormhole category: ${c}`,
|
|
description: `Tier 3: bundled markdown for ${c}`,
|
|
mimeType: 'text/markdown',
|
|
});
|
|
}
|
|
return resources;
|
|
}
|
|
|
|
async function loadSiteIndex() {
|
|
const root = mirrorRoot();
|
|
const p = join(root, 'site-index.json');
|
|
if (existsSync(p)) {
|
|
return JSON.parse(readFileSync(p, 'utf8'));
|
|
}
|
|
if (allowFetch()) {
|
|
const buf = await fetchAllowed(`${ALLOWED_FETCH_PREFIX}/ai/site-index.json`);
|
|
return JSON.parse(buf.toString('utf8'));
|
|
}
|
|
throw new Error(
|
|
`site-index.json not found under ${root}. Run scripts/doc/sync-wormhole-ai-resources.sh or set WORMHOLE_DOCS_FETCH=1`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param {string} query
|
|
* @param {number} limit
|
|
*/
|
|
async function searchDocs(query, limit) {
|
|
const q = (query || '').toLowerCase().trim();
|
|
if (!q) return { results: [], note: 'Empty query' };
|
|
|
|
const index = await loadSiteIndex();
|
|
if (!Array.isArray(index)) {
|
|
return { results: [], note: 'site-index.json is not an array' };
|
|
}
|
|
|
|
const scored = [];
|
|
for (const page of index) {
|
|
const title = (page.title || '').toLowerCase();
|
|
const prev = (page.preview || '').toLowerCase();
|
|
const slug = (page.slug || '').toLowerCase();
|
|
const id = (page.id || '').toLowerCase();
|
|
const cats = Array.isArray(page.categories) ? page.categories.join(' ').toLowerCase() : '';
|
|
|
|
let score = 0;
|
|
if (title.includes(q)) score += 10;
|
|
if (slug.includes(q) || id.includes(q)) score += 8;
|
|
if (prev.includes(q)) score += 5;
|
|
if (cats.includes(q)) score += 3;
|
|
for (const word of q.split(/\s+/).filter((w) => w.length > 2)) {
|
|
if (title.includes(word)) score += 2;
|
|
if (prev.includes(word)) score += 1;
|
|
}
|
|
|
|
if (score > 0) {
|
|
scored.push({
|
|
score,
|
|
title: page.title,
|
|
id: page.id,
|
|
html_url: page.html_url,
|
|
resolved_md_url: page.resolved_md_url,
|
|
preview: page.preview
|
|
? page.preview.slice(0, 400) + (page.preview.length > 400 ? '…' : '')
|
|
: undefined,
|
|
categories: page.categories,
|
|
});
|
|
}
|
|
}
|
|
|
|
scored.sort((a, b) => b.score - a.score);
|
|
return { results: scored.slice(0, limit) };
|
|
}
|
|
|
|
const server = new Server(
|
|
{ name: 'wormhole-docs', version: '1.0.0' },
|
|
{ capabilities: { resources: {}, tools: {} } }
|
|
);
|
|
|
|
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
resources: buildResourceList(),
|
|
}));
|
|
|
|
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
const uri = request.params.uri;
|
|
const { text, mimeType } = await readResourceContentAsync(uri);
|
|
return {
|
|
contents: [{ uri, mimeType, text }],
|
|
};
|
|
});
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
tools: [
|
|
{
|
|
name: 'wormhole_doc_search',
|
|
description:
|
|
'Search Wormhole documentation site-index (titles, previews, categories). Use for tier-2 retrieval before loading full category markdown or llms-full.jsonl.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
query: { type: 'string', description: 'Search string (e.g. NTT, VAA, CCTP)' },
|
|
limit: { type: 'number', description: 'Max results (default 10)', default: 10 },
|
|
},
|
|
required: ['query'],
|
|
},
|
|
},
|
|
],
|
|
}));
|
|
|
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
if (request.params.name !== 'wormhole_doc_search') {
|
|
throw new Error(`Unknown tool: ${request.params.name}`);
|
|
}
|
|
const args = request.params.arguments || {};
|
|
const limit = Math.min(50, Math.max(1, parseInt(String(args.limit || 10), 10) || 10));
|
|
const { results, note } = await searchDocs(String(args.query || ''), limit);
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: JSON.stringify({ note, count: results.length, results }, null, 2),
|
|
},
|
|
],
|
|
};
|
|
});
|
|
|
|
const transport = new StdioServerTransport();
|
|
await server.connect(transport);
|