Skip to content

Commit cf26618

Browse files
authored
Merge pull request #7 from crup/next
fix(mcp): bundle docs server in package
2 parents cb3e795 + 0cdcbba commit cf26618

13 files changed

Lines changed: 217 additions & 28 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,11 @@ jobs:
5050
id: docs
5151
run: pnpm docs:build
5252

53-
- name: 9. Check README
53+
- name: 9. Check bundled MCP server
54+
id: mcp
55+
run: pnpm mcp:check
56+
57+
- name: 10. Check README
5458
id: readme
5559
run: pnpm readme:check
5660

@@ -67,5 +71,6 @@ jobs:
6771
echo "| Tests | ${{ steps.test.outcome }} |"
6872
echo "| Package build | ${{ steps.build.outcome }} |"
6973
echo "| Docs build | ${{ steps.docs.outcome }} |"
74+
echo "| MCP server check | ${{ steps.mcp.outcome }} |"
7075
echo "| README check | ${{ steps.readme.outcome }} |"
7176
} >> "$GITHUB_STEP_SUMMARY"

.github/workflows/release.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,10 @@ jobs:
169169
id: docs
170170
run: pnpm docs:build
171171

172+
- name: Check bundled MCP server
173+
id: mcp
174+
run: pnpm mcp:check
175+
172176
- name: README check
173177
id: readme
174178
run: pnpm readme:check
@@ -196,6 +200,7 @@ jobs:
196200
echo "| Tests | ${{ steps.test.outcome }} |"
197201
echo "| Package build | ${{ steps.build.outcome }} |"
198202
echo "| Docs build | ${{ steps.docs.outcome }} |"
203+
echo "| MCP server check | ${{ steps.mcp.outcome }} |"
199204
echo "| README check | ${{ steps.readme.outcome }} |"
200205
echo "| Size report | ${{ steps.size.outcome }} |"
201206
echo "| Pack dry run | ${{ steps.pack.outcome }} |"

README.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +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 |
260261

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

@@ -269,17 +270,34 @@ Agents and docs-aware IDEs can use:
269270

270271
Optional local MCP docs server:
271272

273+
Use `npx` if the package is not installed in the current project:
274+
275+
```json
276+
{
277+
"mcpServers": {
278+
"react-timer-hook-docs": {
279+
"command": "npx",
280+
"args": ["-y", "@crup/react-timer-hook@latest"]
281+
}
282+
}
283+
}
284+
```
285+
286+
If the package is installed locally, npm also creates a bin shim in `node_modules/.bin`:
287+
272288
```json
273289
{
274290
"mcpServers": {
275291
"react-timer-hook-docs": {
276-
"command": "node",
277-
"args": ["/absolute/path/to/react-timer-hook/mcp/server.mjs"]
292+
"command": "./node_modules/.bin/react-timer-hook-mcp",
293+
"args": []
278294
}
279295
}
280296
}
281297
```
282298

299+
The same bundled and minified server is available at `node_modules/@crup/react-timer-hook/dist/mcp/server.js`.
300+
283301
It exposes:
284302

285303
```txt

commitlint.config.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ module.exports = {
1212
'deps',
1313
'docs',
1414
'group',
15+
'mcp',
1516
'release',
1617
'schedules',
1718
'state',

docs-site/docs/ai.mdx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ pnpm ai:context
2929

3030
## MCP server
3131

32-
Run the local docs MCP server:
32+
Run the published docs MCP server:
3333

3434
```sh
35-
node /absolute/path/to/react-timer-hook/mcp/server.mjs
35+
npx -y @crup/react-timer-hook@latest
3636
```
3737

3838
MCP client config:
@@ -41,13 +41,21 @@ MCP client config:
4141
{
4242
"mcpServers": {
4343
"react-timer-hook-docs": {
44-
"command": "node",
45-
"args": ["/absolute/path/to/react-timer-hook/mcp/server.mjs"]
44+
"command": "npx",
45+
"args": ["-y", "@crup/react-timer-hook@latest"]
4646
}
4747
}
4848
}
4949
```
5050

51+
If the package is already installed locally, you can also run:
52+
53+
```sh
54+
./node_modules/.bin/react-timer-hook-mcp
55+
```
56+
57+
The bin shim points at the same bundled file: `node_modules/@crup/react-timer-hook/dist/mcp/server.js`.
58+
5159
It exposes:
5260

5361
```txt
@@ -62,4 +70,4 @@ Verify locally:
6270
printf '{"jsonrpc":"2.0","id":1,"method":"resources/list"}\n' | pnpm mcp:docs
6371
```
6472

65-
The npm package stays runtime-focused. AI context and MCP helpers live in the source repository for contributors and coding agents.
73+
The MCP server is bundled and minified into the npm package. It only exposes documentation resources; the React runtime remains separate from the MCP helper.

docs-site/static/llms-full.txt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,15 @@ Local docs MCP server:
9595
{
9696
"mcpServers": {
9797
"react-timer-hook-docs": {
98-
"command": "node",
99-
"args": ["/absolute/path/to/react-timer-hook/mcp/server.mjs"]
98+
"command": "npx",
99+
"args": ["-y", "@crup/react-timer-hook@latest"]
100100
}
101101
}
102102
}
103103
```
104104

105+
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.
106+
105107
## Boundaries
106108

107109
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: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,47 @@
11
import { createInterface } from 'node:readline';
22
import { readFileSync } from 'node:fs';
33

4-
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
4+
const pkg = readPackage();
5+
const apiText = `# @crup/react-timer-hook
6+
7+
A lightweight React hooks library for building timers, stopwatches, and real-time clocks with minimal boilerplate.
8+
9+
Docs: https://crup.github.io/react-timer-hook/
10+
Package: @crup/react-timer-hook
11+
Install: npm install @crup/react-timer-hook@latest
12+
Runtime: Node 18+ and React 18+
13+
Repository: https://github.com/crup/react-timer-hook
14+
15+
Public exports:
16+
- @crup/react-timer-hook: useTimer(options) for one timer lifecycle.
17+
- @crup/react-timer-hook/group: useTimerGroup(options) for many keyed independent lifecycles with one shared scheduler.
18+
- @crup/react-timer-hook/schedules: useScheduledTimer(options) for schedule-enabled timers with timing context.
19+
- @crup/react-timer-hook/duration: durationParts(milliseconds) for duration display helper values.
20+
- @crup/react-timer-hook/diagnostics: consoleTimerDiagnostics(options) for optional event logging.
21+
22+
Core rules:
23+
- Use timer.now for wall-clock deadlines and clocks.
24+
- Use timer.elapsedMilliseconds for active elapsed duration.
25+
- Use endWhen(snapshot) to end a lifecycle.
26+
- Use onError(error, snapshot, controls) when onEnd can throw or reject.
27+
- Use cancel(reason) for terminal early stops.
28+
- Keep formatting, timezone, retries, and business rules in userland.
29+
30+
Schedules:
31+
- Use useScheduledTimer() from @crup/react-timer-hook/schedules.
32+
- Schedules are opt-in and default to overlap: "skip".
33+
- Schedule callbacks receive context with scheduledAt, firedAt, nextRunAt, overdueCount, and effectiveEveryMs.
34+
- Schedule callbacks can define onError(error, snapshot, controls, context); otherwise timer or item onError is used.
35+
36+
Recipes:
37+
- Wall clock: new Date(timer.now).
38+
- Stopwatch: render timer.elapsedMilliseconds.
39+
- Absolute countdown: Math.max(0, expiresAt - timer.now).
40+
- Pausable countdown: durationMs - timer.elapsedMilliseconds.
41+
- OTP resend: disable the resend button until elapsedMilliseconds reaches the cooldown.
42+
- Polling: use schedules with overlap: "skip".
43+
- Many independent timers: use useTimerGroup().
44+
`;
545

646
const resources = {
747
'react-timer-hook://package': {
@@ -12,7 +52,7 @@ const resources = {
1252
name: pkg.name,
1353
version: pkg.version,
1454
docs: 'https://crup.github.io/react-timer-hook/',
15-
install: `npm install ${pkg.name}@alpha`,
55+
install: `npm install ${pkg.name}@latest`,
1656
},
1757
null,
1858
2,
@@ -21,7 +61,7 @@ const resources = {
2161
'react-timer-hook://api': {
2262
name: 'API',
2363
mimeType: 'text/markdown',
24-
text: readFileSync(new URL('../docs-site/static/llms-full.txt', import.meta.url), 'utf8'),
64+
text: apiText,
2565
},
2666
'react-timer-hook://recipes': {
2767
name: 'Recipes',
@@ -101,3 +141,15 @@ function respond(id, result) {
101141
function respondError(id, code, message) {
102142
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } })}\n`);
103143
}
144+
145+
function readPackage() {
146+
for (const path of ['../../package.json', '../package.json']) {
147+
try {
148+
return JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf8'));
149+
} catch {
150+
// Try the next path. The bundled file runs from dist/mcp, while the source file runs from mcp.
151+
}
152+
}
153+
154+
return { name: '@crup/react-timer-hook', version: '0.0.0' };
155+
}

package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
"main": "./dist/index.cjs",
1414
"module": "./dist/index.js",
1515
"types": "./dist/index.d.ts",
16+
"bin": {
17+
"react-timer-hook-mcp": "./dist/mcp/server.js"
18+
},
1619
"exports": {
1720
".": {
1821
"types": "./dist/index.d.ts",
@@ -38,6 +41,9 @@
3841
"types": "./dist/diagnostics.d.ts",
3942
"import": "./dist/diagnostics.js",
4043
"require": "./dist/diagnostics.cjs"
44+
},
45+
"./mcp/server": {
46+
"import": "./dist/mcp/server.js"
4147
}
4248
},
4349
"files": [
@@ -60,6 +66,7 @@
6066
"docs:dev": "NO_UPDATE_NOTIFIER=1 docusaurus start docs-site",
6167
"docs:preview": "NO_UPDATE_NOTIFIER=1 docusaurus serve docs-site/build",
6268
"mcp:docs": "node mcp/server.mjs",
69+
"mcp:check": "node scripts/check-mcp-server.mjs",
6370
"prepare": "husky",
6471
"readme:check": "node scripts/check-readme.mjs",
6572
"release": "changeset publish",

scripts/ai-context.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const context = {
88
docs: 'https://crup.github.io/react-timer-hook/',
99
repository: 'https://github.com/crup/react-timer-hook',
1010
install: {
11-
alpha: `npm install ${pkg.name}@alpha`,
11+
latest: `npm install ${pkg.name}@latest`,
1212
},
1313
runtime: {
1414
node: '>=18.0.0',

scripts/check-mcp-server.mjs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { spawn } from 'node:child_process';
2+
import { existsSync } from 'node:fs';
3+
4+
const serverPath = 'dist/mcp/server.js';
5+
6+
if (!existsSync(serverPath)) {
7+
console.error(`${serverPath} is missing. Run pnpm build first.`);
8+
process.exit(1);
9+
}
10+
11+
const child = spawn(process.execPath, [serverPath], {
12+
stdio: ['pipe', 'pipe', 'pipe'],
13+
});
14+
15+
let stdout = '';
16+
let stderr = '';
17+
18+
child.stdout.on('data', chunk => {
19+
stdout += chunk;
20+
});
21+
22+
child.stderr.on('data', chunk => {
23+
stderr += chunk;
24+
});
25+
26+
child.stdin.end(
27+
[
28+
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }),
29+
JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'resources/list', params: {} }),
30+
JSON.stringify({
31+
jsonrpc: '2.0',
32+
id: 3,
33+
method: 'resources/read',
34+
params: { uri: 'react-timer-hook://api' },
35+
}),
36+
'',
37+
].join('\n'),
38+
);
39+
40+
const timeout = setTimeout(() => {
41+
child.kill('SIGTERM');
42+
console.error('MCP server check timed out.');
43+
process.exit(1);
44+
}, 2000);
45+
46+
child.on('close', code => {
47+
clearTimeout(timeout);
48+
49+
if (code !== 0) {
50+
console.error(stderr || `MCP server exited with code ${code}.`);
51+
process.exit(1);
52+
}
53+
54+
const responses = stdout
55+
.trim()
56+
.split('\n')
57+
.filter(Boolean)
58+
.map(line => JSON.parse(line));
59+
60+
const list = responses.find(response => response.id === 2)?.result?.resources ?? [];
61+
const api = responses.find(response => response.id === 3)?.result?.contents?.[0]?.text ?? '';
62+
63+
if (list.length !== 3) {
64+
console.error(`Expected 3 MCP resources, received ${list.length}.`);
65+
process.exit(1);
66+
}
67+
68+
if (!api.includes('@crup/react-timer-hook') || !api.includes('useTimerGroup')) {
69+
console.error('MCP API resource is missing expected package context.');
70+
process.exit(1);
71+
}
72+
});

0 commit comments

Comments
 (0)