From 5335a3a3667458a365f673d33d2cf8c207e6450d Mon Sep 17 00:00:00 2001 From: Rajender Joshi <2614954+crup@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:06:43 +0530 Subject: [PATCH] feat(mcp): add callable docs tools --- README.md | 10 ++- docs-site/docs/ai.mdx | 8 +++ docs-site/static/llms-full.txt | 5 ++ mcp/server.mjs | 119 ++++++++++++++++++++++++++++++++- scripts/check-mcp-server.mjs | 26 +++++++ 5 files changed, 166 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7c3cf63..3cae94f 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ The default import stays small. Add the other pieces only when that screen needs | 📡 Schedules | `@crup/react-timer-hook/schedules` | Polling, cadence callbacks, overdue timing context | 8.62 kB | 3.02 kB | 2.78 kB | | 🧩 Duration | `@crup/react-timer-hook/duration` | `days`, `hours`, `minutes`, `seconds`, `milliseconds` | 318 B | 224 B | 192 B | | 🔎 Diagnostics | `@crup/react-timer-hook/diagnostics` | Optional lifecycle and schedule event logging | 105 B | 115 B | 90 B | -| 🤖 MCP docs server | `react-timer-hook-mcp` | Optional local docs context for MCP clients and coding agents | 3.80 kB | 1.63 kB | 1.40 kB | +| 🤖 MCP docs server | `react-timer-hook-mcp` | Optional local docs context for MCP clients and coding agents | 6.69 kB | 2.60 kB | 2.25 kB | CI writes a size summary to the GitHub Actions UI and posts bundle-size reports on pull requests. @@ -306,6 +306,14 @@ react-timer-hook://api react-timer-hook://recipes ``` +It also exposes MCP tools that editors are more likely to call directly: + +```txt +get_api_docs +get_recipe +search_docs +``` + ## Contributing Issues, recipes, docs improvements, and focused bug reports are welcome. diff --git a/docs-site/docs/ai.mdx b/docs-site/docs/ai.mdx index 24bd3a2..2d8b831 100644 --- a/docs-site/docs/ai.mdx +++ b/docs-site/docs/ai.mdx @@ -64,6 +64,14 @@ react-timer-hook://api react-timer-hook://recipes ``` +It also exposes callable tools for MCP clients that prefer tool calls over resource reads: + +```txt +get_api_docs +get_recipe +search_docs +``` + Verify locally: ```sh diff --git a/docs-site/static/llms-full.txt b/docs-site/static/llms-full.txt index 30e6011..793b919 100644 --- a/docs-site/static/llms-full.txt +++ b/docs-site/static/llms-full.txt @@ -104,6 +104,11 @@ Local docs MCP server: The package bundles the MCP server at node_modules/@crup/react-timer-hook/dist/mcp/server.js and exposes it through the react-timer-hook-mcp bin. +MCP tools: +- get_api_docs +- get_recipe +- search_docs + ## Boundaries Use the hook for timer lifecycle, elapsed time, schedules, and controls. Keep UI display and data fetching in your app. diff --git a/mcp/server.mjs b/mcp/server.mjs index 7a560e2..eb67a25 100644 --- a/mcp/server.mjs +++ b/mcp/server.mjs @@ -78,6 +78,61 @@ const resources = { }, }; +const recipes = { + 'wall-clock': 'Use useTimer({ autoStart: true }) and render new Date(timer.now). Keep locale and timezone formatting in userland.', + stopwatch: 'Use useTimer({ updateIntervalMs: 100 }). Render timer.elapsedMilliseconds and wire start, pause, resume, restart, and reset to buttons.', + 'absolute-countdown': 'Use timer.now for server deadlines: const remainingMs = Math.max(0, expiresAt - timer.now). Use endWhen: snapshot => snapshot.now >= expiresAt.', + 'pausable-countdown': 'Use timer.elapsedMilliseconds for active elapsed time: const remainingMs = durationMs - timer.elapsedMilliseconds. Paused time is excluded.', + 'otp-resend': 'Use a duration countdown. Disable the resend button while timer.isRunning and enable it after timer.isEnded or remainingMs <= 0.', + polling: 'Import useScheduledTimer from @crup/react-timer-hook/schedules. Add schedules: [{ id, everyMs, overlap: "skip", callback }].', + 'autosave-heartbeat': 'Use useScheduledTimer with a schedule every 5000-15000ms. Keep retry/backoff and request state in app code.', + 'timer-group': 'Import useTimerGroup from @crup/react-timer-hook/group for many keyed timers that each need independent pause, resume, cancel, restart, or onEnd.', + 'per-item-polling': 'Use useTimerGroup with item schedules when each row needs independent polling cadence or cancel conditions.', + diagnostics: 'Import consoleTimerDiagnostics from @crup/react-timer-hook/diagnostics and pass diagnostics only while debugging.', +}; + +const tools = [ + { + name: 'get_api_docs', + description: 'Return the compact API notes for @crup/react-timer-hook.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: 'get_recipe', + description: 'Return guidance for a named recipe or use case.', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: `Recipe name. Known values: ${Object.keys(recipes).join(', ')}.`, + }, + }, + required: ['name'], + additionalProperties: false, + }, + }, + { + name: 'search_docs', + description: 'Search API and recipe notes for a query.', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Search query such as countdown, polling, group, diagnostics, or OTP.', + }, + }, + required: ['query'], + additionalProperties: false, + }, + }, +]; + const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false }); rl.on('line', line => { @@ -96,7 +151,7 @@ rl.on('line', line => { respond(id, { protocolVersion: '2024-11-05', serverInfo: { name: 'react-timer-hook-docs', version: pkg.version }, - capabilities: { resources: {} }, + capabilities: { resources: {}, tools: {} }, }); return; } @@ -131,6 +186,51 @@ rl.on('line', line => { return; } + if (method === 'tools/list') { + respond(id, { tools }); + return; + } + + if (method === 'tools/call') { + const name = params?.name; + const args = params?.arguments ?? {}; + + if (name === 'get_api_docs') { + respondTool(id, apiText); + return; + } + + if (name === 'get_recipe') { + const recipe = recipes[normalizeRecipeName(args.name)]; + if (!recipe) { + respondError(id, -32602, `Unknown recipe: ${args.name ?? 'missing name'}`); + return; + } + + respondTool(id, recipe); + return; + } + + if (name === 'search_docs') { + const query = String(args.query ?? '').trim().toLowerCase(); + if (!query) { + respondError(id, -32602, 'search_docs requires a non-empty query.'); + return; + } + + const matches = [ + ...searchEntries('api', { api: apiText }, query), + ...searchEntries('recipe', recipes, query), + ]; + + respondTool(id, matches.length > 0 ? matches.join('\n\n') : `No matches for "${query}".`); + return; + } + + respondError(id, -32601, `Tool not found: ${name ?? 'missing name'}`); + return; + } + respondError(id, -32601, `Method not found: ${method}`); }); @@ -138,10 +238,27 @@ function respond(id, result) { process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`); } +function respondTool(id, text) { + respond(id, { content: [{ type: 'text', text }] }); +} + function respondError(id, code, message) { process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } })}\n`); } +function normalizeRecipeName(value) { + return String(value ?? '') + .trim() + .toLowerCase() + .replace(/\s+/g, '-'); +} + +function searchEntries(kind, values, query) { + return Object.entries(values) + .filter(([name, text]) => `${name}\n${text}`.toLowerCase().includes(query)) + .map(([name, text]) => `## ${kind}: ${name}\n${text}`); +} + function readPackage() { for (const path of ['../../package.json', '../package.json']) { try { diff --git a/scripts/check-mcp-server.mjs b/scripts/check-mcp-server.mjs index 0f5d678..30d8607 100644 --- a/scripts/check-mcp-server.mjs +++ b/scripts/check-mcp-server.mjs @@ -33,6 +33,19 @@ child.stdin.end( method: 'resources/read', params: { uri: 'react-timer-hook://api' }, }), + JSON.stringify({ jsonrpc: '2.0', id: 4, method: 'tools/list', params: {} }), + JSON.stringify({ + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { name: 'get_recipe', arguments: { name: 'otp-resend' } }, + }), + JSON.stringify({ + jsonrpc: '2.0', + id: 6, + method: 'tools/call', + params: { name: 'search_docs', arguments: { query: 'polling' } }, + }), '', ].join('\n'), ); @@ -59,6 +72,9 @@ child.on('close', code => { const list = responses.find(response => response.id === 2)?.result?.resources ?? []; const api = responses.find(response => response.id === 3)?.result?.contents?.[0]?.text ?? ''; + const tools = responses.find(response => response.id === 4)?.result?.tools ?? []; + const recipe = responses.find(response => response.id === 5)?.result?.content?.[0]?.text ?? ''; + const search = responses.find(response => response.id === 6)?.result?.content?.[0]?.text ?? ''; if (list.length !== 3) { console.error(`Expected 3 MCP resources, received ${list.length}.`); @@ -69,4 +85,14 @@ child.on('close', code => { console.error('MCP API resource is missing expected package context.'); process.exit(1); } + + if (!tools.some(tool => tool.name === 'get_recipe') || !tools.some(tool => tool.name === 'search_docs')) { + console.error('MCP tools list is missing expected docs tools.'); + process.exit(1); + } + + if (!recipe.includes('resend button') || !search.toLowerCase().includes('polling')) { + console.error('MCP tool responses are missing expected recipe/search context.'); + process.exit(1); + } });