From 0cdcbbab05c39c0df6d33f6a9d35ecc61ededf83 Mon Sep 17 00:00:00 2001 From: Rajender Joshi <2614954+crup@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:42:42 +0530 Subject: [PATCH] fix(mcp): bundle docs server in package --- .github/workflows/ci.yml | 7 +++- .github/workflows/release.yml | 5 +++ README.md | 22 ++++++++++- commitlint.config.cjs | 1 + docs-site/docs/ai.mdx | 18 ++++++--- docs-site/static/llms-full.txt | 6 ++- mcp/server.mjs | 58 +++++++++++++++++++++++++-- package.json | 7 ++++ scripts/ai-context.mjs | 2 +- scripts/check-mcp-server.mjs | 72 ++++++++++++++++++++++++++++++++++ scripts/check-readme.mjs | 1 + scripts/size-report.mjs | 1 + tsup.config.ts | 45 ++++++++++++++------- 13 files changed, 217 insertions(+), 28 deletions(-) create mode 100644 scripts/check-mcp-server.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f39f87..f6665c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,11 @@ jobs: id: docs run: pnpm docs:build - - name: 9. Check README + - name: 9. Check bundled MCP server + id: mcp + run: pnpm mcp:check + + - name: 10. Check README id: readme run: pnpm readme:check @@ -67,5 +71,6 @@ jobs: echo "| Tests | ${{ steps.test.outcome }} |" echo "| Package build | ${{ steps.build.outcome }} |" echo "| Docs build | ${{ steps.docs.outcome }} |" + echo "| MCP server check | ${{ steps.mcp.outcome }} |" echo "| README check | ${{ steps.readme.outcome }} |" } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7d9400..acc8a58 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -169,6 +169,10 @@ jobs: id: docs run: pnpm docs:build + - name: Check bundled MCP server + id: mcp + run: pnpm mcp:check + - name: README check id: readme run: pnpm readme:check @@ -196,6 +200,7 @@ jobs: echo "| Tests | ${{ steps.test.outcome }} |" echo "| Package build | ${{ steps.build.outcome }} |" echo "| Docs build | ${{ steps.docs.outcome }} |" + echo "| MCP server check | ${{ steps.mcp.outcome }} |" echo "| README check | ${{ steps.readme.outcome }} |" echo "| Size report | ${{ steps.size.outcome }} |" echo "| Pack dry run | ${{ steps.pack.outcome }} |" diff --git a/README.md b/README.md index 685a555..7c3cf63 100644 --- a/README.md +++ b/README.md @@ -257,6 +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 | CI writes a size summary to the GitHub Actions UI and posts bundle-size reports on pull requests. @@ -269,17 +270,34 @@ Agents and docs-aware IDEs can use: Optional local MCP docs server: +Use `npx` if the package is not installed in the current project: + +```json +{ + "mcpServers": { + "react-timer-hook-docs": { + "command": "npx", + "args": ["-y", "@crup/react-timer-hook@latest"] + } + } +} +``` + +If the package is installed locally, npm also creates a bin shim in `node_modules/.bin`: + ```json { "mcpServers": { "react-timer-hook-docs": { - "command": "node", - "args": ["/absolute/path/to/react-timer-hook/mcp/server.mjs"] + "command": "./node_modules/.bin/react-timer-hook-mcp", + "args": [] } } } ``` +The same bundled and minified server is available at `node_modules/@crup/react-timer-hook/dist/mcp/server.js`. + It exposes: ```txt diff --git a/commitlint.config.cjs b/commitlint.config.cjs index c8615f7..4547042 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -12,6 +12,7 @@ module.exports = { 'deps', 'docs', 'group', + 'mcp', 'release', 'schedules', 'state', diff --git a/docs-site/docs/ai.mdx b/docs-site/docs/ai.mdx index bb32648..24bd3a2 100644 --- a/docs-site/docs/ai.mdx +++ b/docs-site/docs/ai.mdx @@ -29,10 +29,10 @@ pnpm ai:context ## MCP server -Run the local docs MCP server: +Run the published docs MCP server: ```sh -node /absolute/path/to/react-timer-hook/mcp/server.mjs +npx -y @crup/react-timer-hook@latest ``` MCP client config: @@ -41,13 +41,21 @@ MCP client config: { "mcpServers": { "react-timer-hook-docs": { - "command": "node", - "args": ["/absolute/path/to/react-timer-hook/mcp/server.mjs"] + "command": "npx", + "args": ["-y", "@crup/react-timer-hook@latest"] } } } ``` +If the package is already installed locally, you can also run: + +```sh +./node_modules/.bin/react-timer-hook-mcp +``` + +The bin shim points at the same bundled file: `node_modules/@crup/react-timer-hook/dist/mcp/server.js`. + It exposes: ```txt @@ -62,4 +70,4 @@ Verify locally: printf '{"jsonrpc":"2.0","id":1,"method":"resources/list"}\n' | pnpm mcp:docs ``` -The npm package stays runtime-focused. AI context and MCP helpers live in the source repository for contributors and coding agents. +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. diff --git a/docs-site/static/llms-full.txt b/docs-site/static/llms-full.txt index 40ae3e9..30e6011 100644 --- a/docs-site/static/llms-full.txt +++ b/docs-site/static/llms-full.txt @@ -95,13 +95,15 @@ Local docs MCP server: { "mcpServers": { "react-timer-hook-docs": { - "command": "node", - "args": ["/absolute/path/to/react-timer-hook/mcp/server.mjs"] + "command": "npx", + "args": ["-y", "@crup/react-timer-hook@latest"] } } } ``` +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. + ## 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 6f01ed9..7a560e2 100644 --- a/mcp/server.mjs +++ b/mcp/server.mjs @@ -1,7 +1,47 @@ import { createInterface } from 'node:readline'; import { readFileSync } from 'node:fs'; -const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); +const pkg = readPackage(); +const apiText = `# @crup/react-timer-hook + +A lightweight React hooks library for building timers, stopwatches, and real-time clocks with minimal boilerplate. + +Docs: https://crup.github.io/react-timer-hook/ +Package: @crup/react-timer-hook +Install: npm install @crup/react-timer-hook@latest +Runtime: Node 18+ and React 18+ +Repository: https://github.com/crup/react-timer-hook + +Public exports: +- @crup/react-timer-hook: useTimer(options) for one timer lifecycle. +- @crup/react-timer-hook/group: useTimerGroup(options) for many keyed independent lifecycles with one shared scheduler. +- @crup/react-timer-hook/schedules: useScheduledTimer(options) for schedule-enabled timers with timing context. +- @crup/react-timer-hook/duration: durationParts(milliseconds) for duration display helper values. +- @crup/react-timer-hook/diagnostics: consoleTimerDiagnostics(options) for optional event logging. + +Core rules: +- Use timer.now for wall-clock deadlines and clocks. +- Use timer.elapsedMilliseconds for active elapsed duration. +- Use endWhen(snapshot) to end a lifecycle. +- Use onError(error, snapshot, controls) when onEnd can throw or reject. +- Use cancel(reason) for terminal early stops. +- Keep formatting, timezone, retries, and business rules in userland. + +Schedules: +- Use useScheduledTimer() from @crup/react-timer-hook/schedules. +- Schedules are opt-in and default to overlap: "skip". +- Schedule callbacks receive context with scheduledAt, firedAt, nextRunAt, overdueCount, and effectiveEveryMs. +- Schedule callbacks can define onError(error, snapshot, controls, context); otherwise timer or item onError is used. + +Recipes: +- Wall clock: new Date(timer.now). +- Stopwatch: render timer.elapsedMilliseconds. +- Absolute countdown: Math.max(0, expiresAt - timer.now). +- Pausable countdown: durationMs - timer.elapsedMilliseconds. +- OTP resend: disable the resend button until elapsedMilliseconds reaches the cooldown. +- Polling: use schedules with overlap: "skip". +- Many independent timers: use useTimerGroup(). +`; const resources = { 'react-timer-hook://package': { @@ -12,7 +52,7 @@ const resources = { name: pkg.name, version: pkg.version, docs: 'https://crup.github.io/react-timer-hook/', - install: `npm install ${pkg.name}@alpha`, + install: `npm install ${pkg.name}@latest`, }, null, 2, @@ -21,7 +61,7 @@ const resources = { 'react-timer-hook://api': { name: 'API', mimeType: 'text/markdown', - text: readFileSync(new URL('../docs-site/static/llms-full.txt', import.meta.url), 'utf8'), + text: apiText, }, 'react-timer-hook://recipes': { name: 'Recipes', @@ -101,3 +141,15 @@ function respond(id, result) { function respondError(id, code, message) { process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } })}\n`); } + +function readPackage() { + for (const path of ['../../package.json', '../package.json']) { + try { + return JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf8')); + } catch { + // Try the next path. The bundled file runs from dist/mcp, while the source file runs from mcp. + } + } + + return { name: '@crup/react-timer-hook', version: '0.0.0' }; +} diff --git a/package.json b/package.json index 1efa3f0..fc353cb 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", + "bin": { + "react-timer-hook-mcp": "./dist/mcp/server.js" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -38,6 +41,9 @@ "types": "./dist/diagnostics.d.ts", "import": "./dist/diagnostics.js", "require": "./dist/diagnostics.cjs" + }, + "./mcp/server": { + "import": "./dist/mcp/server.js" } }, "files": [ @@ -60,6 +66,7 @@ "docs:dev": "NO_UPDATE_NOTIFIER=1 docusaurus start docs-site", "docs:preview": "NO_UPDATE_NOTIFIER=1 docusaurus serve docs-site/build", "mcp:docs": "node mcp/server.mjs", + "mcp:check": "node scripts/check-mcp-server.mjs", "prepare": "husky", "readme:check": "node scripts/check-readme.mjs", "release": "changeset publish", diff --git a/scripts/ai-context.mjs b/scripts/ai-context.mjs index cc5a609..4a42b87 100644 --- a/scripts/ai-context.mjs +++ b/scripts/ai-context.mjs @@ -8,7 +8,7 @@ const context = { docs: 'https://crup.github.io/react-timer-hook/', repository: 'https://github.com/crup/react-timer-hook', install: { - alpha: `npm install ${pkg.name}@alpha`, + latest: `npm install ${pkg.name}@latest`, }, runtime: { node: '>=18.0.0', diff --git a/scripts/check-mcp-server.mjs b/scripts/check-mcp-server.mjs new file mode 100644 index 0000000..0f5d678 --- /dev/null +++ b/scripts/check-mcp-server.mjs @@ -0,0 +1,72 @@ +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; + +const serverPath = 'dist/mcp/server.js'; + +if (!existsSync(serverPath)) { + console.error(`${serverPath} is missing. Run pnpm build first.`); + process.exit(1); +} + +const child = spawn(process.execPath, [serverPath], { + stdio: ['pipe', 'pipe', 'pipe'], +}); + +let stdout = ''; +let stderr = ''; + +child.stdout.on('data', chunk => { + stdout += chunk; +}); + +child.stderr.on('data', chunk => { + stderr += chunk; +}); + +child.stdin.end( + [ + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }), + JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'resources/list', params: {} }), + JSON.stringify({ + jsonrpc: '2.0', + id: 3, + method: 'resources/read', + params: { uri: 'react-timer-hook://api' }, + }), + '', + ].join('\n'), +); + +const timeout = setTimeout(() => { + child.kill('SIGTERM'); + console.error('MCP server check timed out.'); + process.exit(1); +}, 2000); + +child.on('close', code => { + clearTimeout(timeout); + + if (code !== 0) { + console.error(stderr || `MCP server exited with code ${code}.`); + process.exit(1); + } + + const responses = stdout + .trim() + .split('\n') + .filter(Boolean) + .map(line => JSON.parse(line)); + + const list = responses.find(response => response.id === 2)?.result?.resources ?? []; + const api = responses.find(response => response.id === 3)?.result?.contents?.[0]?.text ?? ''; + + if (list.length !== 3) { + console.error(`Expected 3 MCP resources, received ${list.length}.`); + process.exit(1); + } + + if (!api.includes('@crup/react-timer-hook') || !api.includes('useTimerGroup')) { + console.error('MCP API resource is missing expected package context.'); + process.exit(1); + } +}); diff --git a/scripts/check-readme.mjs b/scripts/check-readme.mjs index e89f898..3bd97d0 100644 --- a/scripts/check-readme.mjs +++ b/scripts/check-readme.mjs @@ -8,6 +8,7 @@ const required = [ 'durationParts', 'https://crup.github.io/react-timer-hook/', '@crup/react-timer-hook@latest', + 'react-timer-hook-mcp', 'Bundle size', 'AI-friendly', ]; diff --git a/scripts/size-report.mjs b/scripts/size-report.mjs index 0f7921e..ed2f9da 100644 --- a/scripts/size-report.mjs +++ b/scripts/size-report.mjs @@ -9,6 +9,7 @@ const entries = [ ['schedules add-on', 'dist/schedules.js'], ['duration helper', 'dist/duration.js'], ['diagnostics helper', 'dist/diagnostics.js'], + ['MCP docs server', 'dist/mcp/server.js'], ['core CJS', 'dist/index.cjs'], ['timer group CJS', 'dist/group.cjs'], ['schedules CJS', 'dist/schedules.cjs'], diff --git a/tsup.config.ts b/tsup.config.ts index 4155432..5b1ced8 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,17 +1,34 @@ import { defineConfig } from 'tsup'; -export default defineConfig({ - entry: { - index: 'src/index.ts', - group: 'src/group.ts', - duration: 'src/duration.ts', - schedules: 'src/schedules.ts', - diagnostics: 'src/diagnostics.ts', +export default defineConfig([ + { + entry: { + index: 'src/index.ts', + group: 'src/group.ts', + duration: 'src/duration.ts', + schedules: 'src/schedules.ts', + diagnostics: 'src/diagnostics.ts', + }, + format: ['esm', 'cjs'], + dts: true, + minify: true, + sourcemap: false, + clean: true, + external: ['react'], }, - format: ['esm', 'cjs'], - dts: true, - minify: true, - sourcemap: false, - clean: true, - external: ['react'], -}); + { + entry: { + 'mcp/server': 'mcp/server.mjs', + }, + format: ['esm'], + platform: 'node', + target: 'node18', + dts: false, + minify: true, + sourcemap: false, + clean: false, + banner: { + js: '#!/usr/bin/env node', + }, + }, +]);