Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions docs-site/docs/ai.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs-site/static/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
119 changes: 118 additions & 1 deletion mcp/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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;
}
Expand Down Expand Up @@ -131,17 +186,79 @@ 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}`);
});

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 {
Expand Down
26 changes: 26 additions & 0 deletions scripts/check-mcp-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
);
Expand All @@ -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}.`);
Expand All @@ -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);
}
});
Loading