Skip to content

Commit 748d585

Browse files
authored
Merge pull request #8 from crup/next
feat(mcp): add callable docs tools
2 parents cf26618 + 5335a3a commit 748d585

File tree

5 files changed

+166
-2
lines changed

5 files changed

+166
-2
lines changed

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ The default import stays small. Add the other pieces only when that screen needs
257257
| 📡 Schedules | `@crup/react-timer-hook/schedules` | Polling, cadence callbacks, overdue timing context | 8.62 kB | 3.02 kB | 2.78 kB |
258258
| 🧩 Duration | `@crup/react-timer-hook/duration` | `days`, `hours`, `minutes`, `seconds`, `milliseconds` | 318 B | 224 B | 192 B |
259259
| 🔎 Diagnostics | `@crup/react-timer-hook/diagnostics` | Optional lifecycle and schedule event logging | 105 B | 115 B | 90 B |
260-
| 🤖 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 |
260+
| 🤖 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 |
261261

262262
CI writes a size summary to the GitHub Actions UI and posts bundle-size reports on pull requests.
263263

@@ -306,6 +306,14 @@ react-timer-hook://api
306306
react-timer-hook://recipes
307307
```
308308

309+
It also exposes MCP tools that editors are more likely to call directly:
310+
311+
```txt
312+
get_api_docs
313+
get_recipe
314+
search_docs
315+
```
316+
309317
## Contributing
310318

311319
Issues, recipes, docs improvements, and focused bug reports are welcome.

docs-site/docs/ai.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ react-timer-hook://api
6464
react-timer-hook://recipes
6565
```
6666

67+
It also exposes callable tools for MCP clients that prefer tool calls over resource reads:
68+
69+
```txt
70+
get_api_docs
71+
get_recipe
72+
search_docs
73+
```
74+
6775
Verify locally:
6876

6977
```sh

docs-site/static/llms-full.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ Local docs MCP server:
104104

105105
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.
106106

107+
MCP tools:
108+
- get_api_docs
109+
- get_recipe
110+
- search_docs
111+
107112
## Boundaries
108113

109114
Use the hook for timer lifecycle, elapsed time, schedules, and controls. Keep UI display and data fetching in your app.

mcp/server.mjs

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,61 @@ const resources = {
7878
},
7979
};
8080

81+
const recipes = {
82+
'wall-clock': 'Use useTimer({ autoStart: true }) and render new Date(timer.now). Keep locale and timezone formatting in userland.',
83+
stopwatch: 'Use useTimer({ updateIntervalMs: 100 }). Render timer.elapsedMilliseconds and wire start, pause, resume, restart, and reset to buttons.',
84+
'absolute-countdown': 'Use timer.now for server deadlines: const remainingMs = Math.max(0, expiresAt - timer.now). Use endWhen: snapshot => snapshot.now >= expiresAt.',
85+
'pausable-countdown': 'Use timer.elapsedMilliseconds for active elapsed time: const remainingMs = durationMs - timer.elapsedMilliseconds. Paused time is excluded.',
86+
'otp-resend': 'Use a duration countdown. Disable the resend button while timer.isRunning and enable it after timer.isEnded or remainingMs <= 0.',
87+
polling: 'Import useScheduledTimer from @crup/react-timer-hook/schedules. Add schedules: [{ id, everyMs, overlap: "skip", callback }].',
88+
'autosave-heartbeat': 'Use useScheduledTimer with a schedule every 5000-15000ms. Keep retry/backoff and request state in app code.',
89+
'timer-group': 'Import useTimerGroup from @crup/react-timer-hook/group for many keyed timers that each need independent pause, resume, cancel, restart, or onEnd.',
90+
'per-item-polling': 'Use useTimerGroup with item schedules when each row needs independent polling cadence or cancel conditions.',
91+
diagnostics: 'Import consoleTimerDiagnostics from @crup/react-timer-hook/diagnostics and pass diagnostics only while debugging.',
92+
};
93+
94+
const tools = [
95+
{
96+
name: 'get_api_docs',
97+
description: 'Return the compact API notes for @crup/react-timer-hook.',
98+
inputSchema: {
99+
type: 'object',
100+
properties: {},
101+
additionalProperties: false,
102+
},
103+
},
104+
{
105+
name: 'get_recipe',
106+
description: 'Return guidance for a named recipe or use case.',
107+
inputSchema: {
108+
type: 'object',
109+
properties: {
110+
name: {
111+
type: 'string',
112+
description: `Recipe name. Known values: ${Object.keys(recipes).join(', ')}.`,
113+
},
114+
},
115+
required: ['name'],
116+
additionalProperties: false,
117+
},
118+
},
119+
{
120+
name: 'search_docs',
121+
description: 'Search API and recipe notes for a query.',
122+
inputSchema: {
123+
type: 'object',
124+
properties: {
125+
query: {
126+
type: 'string',
127+
description: 'Search query such as countdown, polling, group, diagnostics, or OTP.',
128+
},
129+
},
130+
required: ['query'],
131+
additionalProperties: false,
132+
},
133+
},
134+
];
135+
81136
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false });
82137

83138
rl.on('line', line => {
@@ -96,7 +151,7 @@ rl.on('line', line => {
96151
respond(id, {
97152
protocolVersion: '2024-11-05',
98153
serverInfo: { name: 'react-timer-hook-docs', version: pkg.version },
99-
capabilities: { resources: {} },
154+
capabilities: { resources: {}, tools: {} },
100155
});
101156
return;
102157
}
@@ -131,17 +186,79 @@ rl.on('line', line => {
131186
return;
132187
}
133188

189+
if (method === 'tools/list') {
190+
respond(id, { tools });
191+
return;
192+
}
193+
194+
if (method === 'tools/call') {
195+
const name = params?.name;
196+
const args = params?.arguments ?? {};
197+
198+
if (name === 'get_api_docs') {
199+
respondTool(id, apiText);
200+
return;
201+
}
202+
203+
if (name === 'get_recipe') {
204+
const recipe = recipes[normalizeRecipeName(args.name)];
205+
if (!recipe) {
206+
respondError(id, -32602, `Unknown recipe: ${args.name ?? 'missing name'}`);
207+
return;
208+
}
209+
210+
respondTool(id, recipe);
211+
return;
212+
}
213+
214+
if (name === 'search_docs') {
215+
const query = String(args.query ?? '').trim().toLowerCase();
216+
if (!query) {
217+
respondError(id, -32602, 'search_docs requires a non-empty query.');
218+
return;
219+
}
220+
221+
const matches = [
222+
...searchEntries('api', { api: apiText }, query),
223+
...searchEntries('recipe', recipes, query),
224+
];
225+
226+
respondTool(id, matches.length > 0 ? matches.join('\n\n') : `No matches for "${query}".`);
227+
return;
228+
}
229+
230+
respondError(id, -32601, `Tool not found: ${name ?? 'missing name'}`);
231+
return;
232+
}
233+
134234
respondError(id, -32601, `Method not found: ${method}`);
135235
});
136236

137237
function respond(id, result) {
138238
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`);
139239
}
140240

241+
function respondTool(id, text) {
242+
respond(id, { content: [{ type: 'text', text }] });
243+
}
244+
141245
function respondError(id, code, message) {
142246
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } })}\n`);
143247
}
144248

249+
function normalizeRecipeName(value) {
250+
return String(value ?? '')
251+
.trim()
252+
.toLowerCase()
253+
.replace(/\s+/g, '-');
254+
}
255+
256+
function searchEntries(kind, values, query) {
257+
return Object.entries(values)
258+
.filter(([name, text]) => `${name}\n${text}`.toLowerCase().includes(query))
259+
.map(([name, text]) => `## ${kind}: ${name}\n${text}`);
260+
}
261+
145262
function readPackage() {
146263
for (const path of ['../../package.json', '../package.json']) {
147264
try {

scripts/check-mcp-server.mjs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@ child.stdin.end(
3333
method: 'resources/read',
3434
params: { uri: 'react-timer-hook://api' },
3535
}),
36+
JSON.stringify({ jsonrpc: '2.0', id: 4, method: 'tools/list', params: {} }),
37+
JSON.stringify({
38+
jsonrpc: '2.0',
39+
id: 5,
40+
method: 'tools/call',
41+
params: { name: 'get_recipe', arguments: { name: 'otp-resend' } },
42+
}),
43+
JSON.stringify({
44+
jsonrpc: '2.0',
45+
id: 6,
46+
method: 'tools/call',
47+
params: { name: 'search_docs', arguments: { query: 'polling' } },
48+
}),
3649
'',
3750
].join('\n'),
3851
);
@@ -59,6 +72,9 @@ child.on('close', code => {
5972

6073
const list = responses.find(response => response.id === 2)?.result?.resources ?? [];
6174
const api = responses.find(response => response.id === 3)?.result?.contents?.[0]?.text ?? '';
75+
const tools = responses.find(response => response.id === 4)?.result?.tools ?? [];
76+
const recipe = responses.find(response => response.id === 5)?.result?.content?.[0]?.text ?? '';
77+
const search = responses.find(response => response.id === 6)?.result?.content?.[0]?.text ?? '';
6278

6379
if (list.length !== 3) {
6480
console.error(`Expected 3 MCP resources, received ${list.length}.`);
@@ -69,4 +85,14 @@ child.on('close', code => {
6985
console.error('MCP API resource is missing expected package context.');
7086
process.exit(1);
7187
}
88+
89+
if (!tools.some(tool => tool.name === 'get_recipe') || !tools.some(tool => tool.name === 'search_docs')) {
90+
console.error('MCP tools list is missing expected docs tools.');
91+
process.exit(1);
92+
}
93+
94+
if (!recipe.includes('resend button') || !search.toLowerCase().includes('polling')) {
95+
console.error('MCP tool responses are missing expected recipe/search context.');
96+
process.exit(1);
97+
}
7298
});

0 commit comments

Comments
 (0)