From c6249c05863795d39cf4b9773e5224b7916f0bbf Mon Sep 17 00:00:00 2001 From: Faraazuddin Mohammed Date: Mon, 11 May 2026 23:41:39 -0400 Subject: [PATCH 1/5] feat(mcp): add @tokenometer/mcp Model Context Protocol server New publishable package exposing 10 MCP tools wrapping @tokenometer/core (estimate_cost, estimate_cost_matrix, count_tokens_empirical, get_model_info, list_models, get_rates_version, estimate_vision_cost, budget_check, measure_latency, count_tokens_empirical_matrix). Stdio transport for Claude Desktop / Cursor / Zed; missing provider keys surface as structured key_missing errors instead of throwing. Joins the fixed group with tokenometer + @tokenometer/core so rate data stays version-aligned. Workflow detects unpublished version and publishes alongside the CLI. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/add-mcp-server.md | 7 + .changeset/config.json | 2 +- .github/workflows/release.yml | 26 +- package-lock.json | 795 +++++++++++++++++- packages/mcp/README.md | 137 +++ packages/mcp/package.json | 66 ++ packages/mcp/src/e2e.test.ts | 148 ++++ packages/mcp/src/env.ts | 25 + packages/mcp/src/errors.ts | 53 ++ packages/mcp/src/index.ts | 22 + packages/mcp/src/schemas.ts | 92 ++ packages/mcp/src/server.test.ts | 346 ++++++++ packages/mcp/src/server.ts | 69 ++ packages/mcp/src/tools/budget-check.ts | 78 ++ .../tools/count-tokens-empirical-matrix.ts | 87 ++ .../mcp/src/tools/count-tokens-empirical.ts | 55 ++ .../mcp/src/tools/estimate-cost-matrix.ts | 67 ++ packages/mcp/src/tools/estimate-cost.ts | 47 ++ .../mcp/src/tools/estimate-vision-cost.ts | 100 +++ packages/mcp/src/tools/get-model-info.ts | 35 + packages/mcp/src/tools/get-rates-version.ts | 22 + packages/mcp/src/tools/index.ts | 31 + packages/mcp/src/tools/list-models.ts | 26 + packages/mcp/src/tools/measure-latency.ts | 58 ++ packages/mcp/src/tools/types.ts | 36 + packages/mcp/tsconfig.json | 11 + tsconfig.json | 3 +- 27 files changed, 2412 insertions(+), 32 deletions(-) create mode 100644 .changeset/add-mcp-server.md create mode 100644 packages/mcp/README.md create mode 100644 packages/mcp/package.json create mode 100644 packages/mcp/src/e2e.test.ts create mode 100644 packages/mcp/src/env.ts create mode 100644 packages/mcp/src/errors.ts create mode 100644 packages/mcp/src/index.ts create mode 100644 packages/mcp/src/schemas.ts create mode 100644 packages/mcp/src/server.test.ts create mode 100644 packages/mcp/src/server.ts create mode 100644 packages/mcp/src/tools/budget-check.ts create mode 100644 packages/mcp/src/tools/count-tokens-empirical-matrix.ts create mode 100644 packages/mcp/src/tools/count-tokens-empirical.ts create mode 100644 packages/mcp/src/tools/estimate-cost-matrix.ts create mode 100644 packages/mcp/src/tools/estimate-cost.ts create mode 100644 packages/mcp/src/tools/estimate-vision-cost.ts create mode 100644 packages/mcp/src/tools/get-model-info.ts create mode 100644 packages/mcp/src/tools/get-rates-version.ts create mode 100644 packages/mcp/src/tools/index.ts create mode 100644 packages/mcp/src/tools/list-models.ts create mode 100644 packages/mcp/src/tools/measure-latency.ts create mode 100644 packages/mcp/src/tools/types.ts create mode 100644 packages/mcp/tsconfig.json diff --git a/.changeset/add-mcp-server.md b/.changeset/add-mcp-server.md new file mode 100644 index 0000000..cc8a732 --- /dev/null +++ b/.changeset/add-mcp-server.md @@ -0,0 +1,7 @@ +--- +'@tokenometer/mcp': minor +'@tokenometer/core': minor +'tokenometer': minor +--- + +Add `@tokenometer/mcp` — Model Context Protocol server wrapping `@tokenometer/core`. Exposes 10 tools (cost estimation, token counting, model info, vision cost, budget check, latency benchmarking) over stdio so any MCP client (Claude Desktop, Cursor, Zed) can call tokenometer natively. Run with `npx -y @tokenometer/mcp`. diff --git a/.changeset/config.json b/.changeset/config.json index 74a05c6..a9250c5 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -2,7 +2,7 @@ "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", "changelog": ["@changesets/changelog-github", { "repo": "faraa2m/tokenometer" }], "commit": false, - "fixed": [["tokenometer", "@tokenometer/core"]], + "fixed": [["tokenometer", "@tokenometer/core", "@tokenometer/mcp"]], "linked": [], "access": "public", "baseBranch": "main", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ef1ee2..5627d7b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,8 +98,10 @@ jobs: run: | CORE_VERSION=$(node -p "require('./packages/core/package.json').version") CLI_VERSION=$(node -p "require('./packages/cli/package.json').version") + MCP_VERSION=$(node -p "require('./packages/mcp/package.json').version") echo "core_version=$CORE_VERSION" >> $GITHUB_OUTPUT echo "cli_version=$CLI_VERSION" >> $GITHUB_OUTPUT + echo "mcp_version=$MCP_VERSION" >> $GITHUB_OUTPUT NEEDS_PUBLISH=false if ! npm view "@tokenometer/core@$CORE_VERSION" version >/dev/null 2>&1; then @@ -114,6 +116,12 @@ jobs: else echo "tokenometer@$CLI_VERSION already on npm — skip" fi + if ! npm view "@tokenometer/mcp@$MCP_VERSION" version >/dev/null 2>&1; then + echo "@tokenometer/mcp@$MCP_VERSION not on npm — will publish" + NEEDS_PUBLISH=true + else + echo "@tokenometer/mcp@$MCP_VERSION already on npm — skip" + fi echo "needs_publish=$NEEDS_PUBLISH" >> $GITHUB_OUTPUT - name: Publish @tokenometer/core @@ -134,6 +142,15 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Publish @tokenometer/mcp + id: publish_mcp + if: ${{ steps.detect.outputs.needs_publish == 'true' }} + working-directory: packages/mcp + continue-on-error: true + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # Set `published` based on step outcomes, NOT registry re-check. # npm registry has propagation lag (1-30s), so re-querying right after # a successful publish would intermittently return "not found" and @@ -150,16 +167,17 @@ jobs: NEEDS_PUBLISH="${{ steps.detect.outputs.needs_publish }}" CORE_OUTCOME="${{ steps.publish_core.outcome }}" CLI_OUTCOME="${{ steps.publish_cli.outcome }}" + MCP_OUTCOME="${{ steps.publish_mcp.outcome }}" if [ "$NEEDS_PUBLISH" = "false" ]; then PUBLISHED=true - echo "Both packages already on npm → published=true (idempotent path)" - elif [ "$CORE_OUTCOME" = "success" ] && [ "$CLI_OUTCOME" = "success" ]; then + echo "All packages already on npm → published=true (idempotent path)" + elif [ "$CORE_OUTCOME" = "success" ] && [ "$CLI_OUTCOME" = "success" ] && [ "$MCP_OUTCOME" = "success" ]; then PUBLISHED=true - echo "Both publish steps succeeded → published=true" + echo "All publish steps succeeded → published=true" else PUBLISHED=false - echo "Publish gate failed: needs_publish=$NEEDS_PUBLISH core=$CORE_OUTCOME cli=$CLI_OUTCOME" + echo "Publish gate failed: needs_publish=$NEEDS_PUBLISH core=$CORE_OUTCOME cli=$CLI_OUTCOME mcp=$MCP_OUTCOME" fi echo "published=$PUBLISHED" >> $GITHUB_OUTPUT diff --git a/package-lock.json b/package-lock.json index 1f8b8a0..75d4f94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1660,6 +1660,18 @@ "node": ">=18.0.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@inquirer/external-editor": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", @@ -1814,6 +1826,46 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -3353,6 +3405,10 @@ "resolved": "packages/core", "link": true }, + "node_modules/@tokenometer/mcp": { + "resolved": "packages/mcp", + "link": true + }, "node_modules/@tokenometer/react": { "resolved": "packages/react", "link": true @@ -3897,6 +3953,44 @@ "node": ">=6.5" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -3936,7 +4030,6 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -3949,6 +4042,23 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -4164,6 +4274,30 @@ "readable-stream": "^3.4.0" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -4313,6 +4447,15 @@ "esbuild": ">=0.18" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -4340,7 +4483,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4576,6 +4718,28 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -4596,11 +4760,36 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -4738,7 +4927,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4885,6 +5073,15 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/deprecation": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", @@ -5051,6 +5248,12 @@ "url": "https://bevry.me/fund" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.353", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", @@ -5065,6 +5268,15 @@ "dev": true, "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/encoding-sniffer": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", @@ -5262,6 +5474,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -5286,6 +5504,15 @@ "@types/estree": "^1.0.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -5295,6 +5522,27 @@ "node": ">=6" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -5316,6 +5564,101 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/extendable-error": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/extendable-error/-/extendable-error-0.1.7.tgz", @@ -5327,7 +5670,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -5357,7 +5699,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "dev": true, "funding": [ { "type": "github", @@ -5393,6 +5734,27 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -5492,6 +5854,24 @@ "node": ">= 12.20" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -5757,6 +6137,16 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -5836,6 +6226,26 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -5887,7 +6297,6 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -5971,9 +6380,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/ini": { "version": "1.3.8", @@ -5983,6 +6390,24 @@ "license": "ISC", "optional": true }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-ci": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", @@ -6094,6 +6519,12 @@ "dev": true, "license": "MIT" }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-subdir": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-subdir/-/is-subdir-1.2.0.tgz", @@ -6137,7 +6568,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istextorbinary": { @@ -6185,6 +6615,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -6323,9 +6762,14 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -6880,6 +7324,27 @@ "dev": true, "license": "MIT" }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -7087,6 +7552,15 @@ "license": "MIT", "optional": true }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-abi": { "version": "3.92.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", @@ -7267,7 +7741,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7277,7 +7750,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7296,6 +7768,18 @@ "node": ">= 0.4" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7534,6 +8018,15 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7548,7 +8041,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7581,6 +8073,16 @@ "node": "20 || >=22" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -7655,6 +8157,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -7823,6 +8334,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -7859,7 +8383,6 @@ "version": "6.15.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -7909,6 +8432,30 @@ ], "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -8138,7 +8685,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8217,6 +8763,22 @@ "dev": true, "license": "MIT" }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/rrweb-cssom": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", @@ -8286,7 +8848,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/sax": { @@ -8423,17 +8984,92 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -8446,7 +9082,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8456,7 +9091,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8476,7 +9110,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8493,7 +9126,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -8512,7 +9144,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -8726,6 +9357,15 @@ "fast-sha256": "^1.3.0" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -9188,6 +9828,15 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tokenlens": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/tokenlens/-/tokenlens-1.3.1.tgz", @@ -9346,6 +9995,45 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typed-rest-client": { "version": "1.8.11", "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", @@ -9437,6 +10125,15 @@ "node": ">= 4.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -9494,6 +10191,15 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/version-range": { "version": "4.15.0", "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", @@ -9803,7 +10509,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -9929,7 +10634,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -9979,6 +10683,25 @@ "buffer-crc32": "~0.2.3" } }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "packages/action": { "name": "@tokenometer/action", "version": "1.0.2", @@ -10532,6 +11255,25 @@ "node": ">=18.0.0" } }, + "packages/mcp": { + "name": "@tokenometer/mcp", + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4", + "@tokenometer/core": "1.0.1", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.0" + }, + "bin": { + "tokenometer-mcp": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.2", + "vitest": "^3.0.0" + } + }, "packages/react": { "name": "@tokenometer/react", "version": "0.1.0", @@ -11050,6 +11792,7 @@ "@anthropic-ai/sdk": "^0.40.0", "@google/generative-ai": "^0.21.0", "@tokenometer/core": "1.0.1", + "@tokenometer/react": "0.1.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.15.0" diff --git a/packages/mcp/README.md b/packages/mcp/README.md new file mode 100644 index 0000000..1f07599 --- /dev/null +++ b/packages/mcp/README.md @@ -0,0 +1,137 @@ +# @tokenometer/mcp + +[![npm @tokenometer/mcp](https://img.shields.io/npm/v/@tokenometer/mcp.svg?label=%40tokenometer%2Fmcp)](https://www.npmjs.com/package/@tokenometer/mcp) +[![License: MIT](https://img.shields.io/github/license/faraa2m/tokenometer.svg)](https://github.com/faraa2m/tokenometer/blob/main/LICENSE) + +> Model Context Protocol (MCP) server that wraps [`@tokenometer/core`](https://www.npmjs.com/package/@tokenometer/core). Lets AI agents — Claude Desktop, Cursor, or anything else that speaks MCP — estimate LLM token cost, run empirical token counts, check budgets, and measure real generation latency *before* dispatching a request. + +The server exposes 10 tools over stdio. It runs as a child process started by your MCP host (Claude Desktop, Cursor, etc.); the host calls `tools/list` to discover the schema and `tools/call` to invoke a tool. Cost estimation is offline by default; empirical and latency modes hit each provider's API (free `countTokens` for empirical, real metered streaming for latency). + +## What it is + +Tokenometer's core library knows how to: + +- Estimate token cost across **63 models / 5 providers** (Anthropic, OpenAI, Google, Mistral, Cohere) using each provider's tokenizer. +- Call provider `countTokens` endpoints (Anthropic, Google, OpenAI, Cohere) for exact counts. +- Measure real streaming TTFT / tokens-per-sec latency. +- Compute vision-token cost for image inputs. + +This package surfaces all of that as MCP tools so an agent can self-monitor spend, A/B model choices, or fail fast on a budget violation without leaving its tool-use loop. + +## Install + +`npx` runs the published bin directly — no global install needed: + +```bash +npx -y @tokenometer/mcp +``` + +The first time `npx` fetches it; later invocations reuse the cache. + +## Claude Desktop + +Add this block to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows): + +```json +{ + "mcpServers": { + "tokenometer": { + "command": "npx", + "args": ["-y", "@tokenometer/mcp"], + "env": { + "ANTHROPIC_API_KEY": "sk-ant-..." + } + } + } +} +``` + +Restart Claude Desktop. Tokenometer tools will appear in the tools picker. + +## Cursor + +Add the same block to `~/.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "tokenometer": { + "command": "npx", + "args": ["-y", "@tokenometer/mcp"], + "env": { + "ANTHROPIC_API_KEY": "sk-ant-...", + "OPENAI_API_KEY": "sk-..." + } + } + } +} +``` + +Restart Cursor. The tools become available to the agent loop automatically. + +## Tool reference + +| Tool | Purpose | Needs API key? | +|---|---|---| +| `estimate_cost` | Estimate token cost for one (text, model) using the offline tokenizer. Optional `outputTokens` adds the completion cost. | No | +| `estimate_cost_matrix` | Same as above, but for the cross-product of `models x formats`. Includes cheapest / most expensive cells. | No | +| `count_tokens_empirical` | Exact token count via the provider's `countTokens` API (free, no completion charge). | Anthropic / Google / Cohere only; OpenAI uses local tiktoken | +| `count_tokens_empirical_matrix` | Empirical count across many models; per-cell errors stay inline so one missing key doesn't abort the matrix. | Per-cell | +| `get_model_info` | Provider, context window, max output tokens, USD per 1k input / output, and the rates dataset version. | No | +| `list_models` | Every registered model; optional `provider` filter. | No | +| `get_rates_version` | The `RATES_VERSION` date stamp from the bundled rates registry. | No | +| `estimate_vision_cost` | Per-image vision tokens for Anthropic / OpenAI / Google. Optional `model` adds per-image USD. | No | +| `budget_check` | Pre-flight: does this prompt fit a `maxCostUsd` or `maxTokens` ceiling on this model? Returns pass/fail plus headroom. | No | +| `measure_latency` | Real metered streaming generations (default 3 trials) per provider; reports TTFT, total ms, tokens/sec as p50 / p95 / mean. **Each trial is a paid chat completion.** | Yes (per provider) | + +### Environment variables + +| Variable | Used by | +|---|---| +| `ANTHROPIC_API_KEY` | empirical + latency on Anthropic | +| `OPENAI_API_KEY` | latency on OpenAI (empirical uses local tiktoken) | +| `GOOGLE_API_KEY` or `GEMINI_API_KEY` | empirical + latency on Google | +| `MISTRAL_API_KEY` | latency on Mistral (empirical is unsupported by upstream) | +| `COHERE_API_KEY` | empirical + latency on Cohere | + +Missing keys surface a structured error: `{ "code": "key_missing", "required": "ANTHROPIC_API_KEY", "docs": "..." }`. Tools that don't need keys never fail on missing env. + +### Error shape + +All tool errors return `isError: true` with a single JSON-encoded text content block: + +```json +{ "code": "user_error", "message": "Unknown model \"fake\". Known models: ..." } +``` + +Stable error codes: + +- `user_error` — a `UserFacingError` from `@tokenometer/core` (unknown model, bad format, etc.). +- `key_missing` — the required provider env var is not set. +- `invalid_args` — input failed zod validation (includes `issues` array). +- `unknown_tool` — the requested tool name is not registered. +- `unsupported_provider` — vision tokens on Mistral / Cohere. +- `provider_error` — empirical call to a provider failed (rate limit, network, etc.). +- `internal` — anything else; check the `message` field. + +## Limitations + +- **Request / response only.** No `prompts`, `resources`, or `roots` capabilities — this server is purely a tool surface. The MCP host drives every invocation. +- **Vision uses bundled math.** Vision-token counts come from the providers' published formulas, not a live API call. Numbers match the providers' documented behavior; they don't account for unannounced changes. +- **Mistral empirical is unsupported.** Mistral does not expose a public token-count endpoint; offline mode (`mistral-tokenizer-js` for V1/V2/V3, chars-over-4 for Tekken) is the only option. +- **Latency is metered.** Every `measure_latency` trial sends a real `max_tokens=200` chat completion. Default trials = 3; bound to 1..10. Budget accordingly. +- **stdio transport only.** Streamable HTTP is on the MCP SDK; this server ships with stdio because that's what Claude Desktop and Cursor use today. + +## Verifying the install + +After the host has started the server you should see this on stderr in the host's log: + +``` +tokenometer-mcp ready +``` + +Calling `tools/list` should return 10 tools. The smallest smoke test is `estimate_cost` with `text: "hello"` and `model: "gpt-4o"` — it runs offline and returns a token count within milliseconds. + +## License + +MIT diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 0000000..36a396a --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,66 @@ +{ + "name": "@tokenometer/mcp", + "version": "1.0.1", + "description": "Model Context Protocol server for tokenometer — lets AI agents self-monitor LLM token spend, estimate costs, and budget-check before sending prompts.", + "license": "MIT", + "author": "Faraazuddin Mohammed ", + "homepage": "https://tokenometer.vercel.app", + "repository": { + "type": "git", + "url": "git+https://github.com/faraa2m/tokenometer.git", + "directory": "packages/mcp" + }, + "bugs": { + "url": "https://github.com/faraa2m/tokenometer/issues" + }, + "keywords": [ + "mcp", + "model-context-protocol", + "ai", + "agent", + "llm", + "tokens", + "cost", + "budget", + "anthropic", + "openai", + "claude", + "gpt", + "gemini", + "mistral", + "cohere" + ], + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "tokenometer-mcp": "./dist/index.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": ["dist", "README.md"], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "tsc -b && chmod +x dist/index.js", + "prepack": "chmod +x dist/index.js", + "clean": "rm -rf dist" + }, + "dependencies": { + "@tokenometer/core": "1.0.1", + "@modelcontextprotocol/sdk": "^1.0.4", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.2", + "vitest": "^3.0.0" + } +} diff --git a/packages/mcp/src/e2e.test.ts b/packages/mcp/src/e2e.test.ts new file mode 100644 index 0000000..f1fecac --- /dev/null +++ b/packages/mcp/src/e2e.test.ts @@ -0,0 +1,148 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const here = dirname(fileURLToPath(import.meta.url)); +const DIST_ENTRY = join(here, '..', 'dist', 'index.js'); +const HAS_BUILD = existsSync(DIST_ENTRY); + +interface JsonRpcMessage { + jsonrpc: '2.0'; + id?: number | string; + method?: string; + params?: Record; + result?: unknown; + error?: { code: number; message: string }; +} + +/** + * Minimal stdio-mode MCP client for end-to-end testing. The protocol uses + * newline-delimited JSON over stdin/stdout, so we write `${JSON.stringify(msg)}\n` + * and split incoming stdout on `\n`. Each line is one JSON-RPC message. + */ +class StdioClient { + private proc: ChildProcess | null = null; + private buffer = ''; + private nextId = 1; + private pending = new Map void>(); + + async start(): Promise { + const proc = spawn('node', [DIST_ENTRY], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env }, + }); + this.proc = proc; + proc.stdout?.on('data', (chunk: Buffer) => { + this.buffer += chunk.toString('utf8'); + while (true) { + const nl = this.buffer.indexOf('\n'); + if (nl === -1) break; + const line = this.buffer.slice(0, nl).trim(); + this.buffer = this.buffer.slice(nl + 1); + if (!line) continue; + let msg: JsonRpcMessage; + try { + msg = JSON.parse(line) as JsonRpcMessage; + } catch { + continue; + } + const id = msg.id; + if (id !== undefined) { + const handler = this.pending.get(id); + if (handler) { + this.pending.delete(id); + handler(msg); + } + } + } + }); + // Wait for the ready line on stderr before doing the initialize handshake. + await new Promise((resolve) => { + const onData = (chunk: Buffer): void => { + if (chunk.toString('utf8').includes('tokenometer-mcp ready')) { + proc.stderr?.off('data', onData); + resolve(); + } + }; + proc.stderr?.on('data', onData); + }); + + // initialize handshake + await this.request('initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'e2e-test', version: '0.0.0' }, + }); + // initialized notification (no id, no response expected) + this.notify('notifications/initialized', {}); + } + + request(method: string, params: Record): Promise { + const id = this.nextId++; + const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params }); + return new Promise((resolve) => { + this.pending.set(id, resolve); + this.proc?.stdin?.write(`${msg}\n`); + }); + } + + notify(method: string, params: Record): void { + const msg = JSON.stringify({ jsonrpc: '2.0', method, params }); + this.proc?.stdin?.write(`${msg}\n`); + } + + stop(): void { + this.proc?.kill('SIGTERM'); + } +} + +describe.skipIf(!HAS_BUILD)('e2e via spawned stdio server', () => { + const client = new StdioClient(); + + beforeAll(async () => { + await client.start(); + }, 30_000); + + afterAll(() => { + client.stop(); + }); + + it('lists the registered tools', async () => { + const response = await client.request('tools/list', {}); + const result = response.result as { tools: Array<{ name: string }> }; + const names = result.tools.map((t) => t.name); + expect(names).toContain('estimate_cost'); + expect(names).toContain('list_models'); + expect(names).toContain('get_rates_version'); + }); + + it('calls estimate_cost and returns a parseable payload', async () => { + const response = await client.request('tools/call', { + name: 'estimate_cost', + arguments: { text: 'hello world', model: 'gpt-4o' }, + }); + const result = response.result as { + content: Array<{ type: string; text: string }>; + isError?: boolean; + }; + expect(result.isError).toBeFalsy(); + const payload = JSON.parse(result.content[0]?.text ?? '{}') as { tokens: number }; + expect(payload.tokens).toBeGreaterThan(0); + }); + + it('returns isError for an unknown tool name', async () => { + const response = await client.request('tools/call', { + name: 'does_not_exist', + arguments: {}, + }); + const result = response.result as { + content: Array<{ type: string; text: string }>; + isError?: boolean; + }; + expect(result.isError).toBe(true); + const payload = JSON.parse(result.content[0]?.text ?? '{}') as { code: string }; + expect(payload.code).toBe('unknown_tool'); + }); +}); diff --git a/packages/mcp/src/env.ts b/packages/mcp/src/env.ts new file mode 100644 index 0000000..6190911 --- /dev/null +++ b/packages/mcp/src/env.ts @@ -0,0 +1,25 @@ +import type { EmpiricalEnv } from '@tokenometer/core'; + +/** + * Read provider API keys from process.env into the EmpiricalEnv shape + * consumed by `@tokenometer/core`. Mirrors the CLI's readEnv() verbatim so + * both surfaces resolve keys identically. + */ +export const readEnv = (): EmpiricalEnv => { + const env: EmpiricalEnv = {}; + const { + ANTHROPIC_API_KEY, + COHERE_API_KEY, + GEMINI_API_KEY, + GOOGLE_API_KEY, + MISTRAL_API_KEY, + OPENAI_API_KEY, + } = process.env; + if (ANTHROPIC_API_KEY) env.anthropicApiKey = ANTHROPIC_API_KEY; + if (COHERE_API_KEY) env.cohereApiKey = COHERE_API_KEY; + const googleKey = GOOGLE_API_KEY ?? GEMINI_API_KEY; + if (googleKey) env.googleApiKey = googleKey; + if (MISTRAL_API_KEY) env.mistralApiKey = MISTRAL_API_KEY; + if (OPENAI_API_KEY) env.openaiApiKey = OPENAI_API_KEY; + return env; +}; diff --git a/packages/mcp/src/errors.ts b/packages/mcp/src/errors.ts new file mode 100644 index 0000000..84e156a --- /dev/null +++ b/packages/mcp/src/errors.ts @@ -0,0 +1,53 @@ +import { UserFacingError } from '@tokenometer/core'; + +/** + * Standard error result shape returned from tool handlers when a call fails. + * Mirrors the MCP `tools/call` error convention: `isError: true` plus a single + * text content block containing a JSON-encoded payload the calling agent can + * parse for structured handling. + */ +export interface ToolErrorResult { + isError: true; + content: Array<{ type: 'text'; text: string }>; +} + +export interface ToolErrorPayload { + code: string; + message: string; + [key: string]: unknown; +} + +const wrap = (payload: ToolErrorPayload): ToolErrorResult => ({ + isError: true, + content: [{ type: 'text', text: JSON.stringify(payload) }], +}); + +/** + * Map an unknown error (most often a `UserFacingError` from core) onto the + * standard tool error result. Unknown error types are reported under the + * `internal` code with their `.message` (no stack — agents don't need it). + */ +export const toMcpError = (err: unknown): ToolErrorResult => { + if (err instanceof UserFacingError) { + return wrap({ code: 'user_error', message: err.message }); + } + const message = err instanceof Error ? err.message : String(err); + return wrap({ code: 'internal', message }); +}; + +/** + * Convenience constructor for the well-known `key_missing` error returned by + * empirical and latency tools when the required provider env var is unset. + */ +export const keyMissingError = ( + required: string, + docs = 'https://github.com/faraa2m/tokenometer#empirical-mode', +): ToolErrorResult => + wrap({ + code: 'key_missing', + message: `Missing required environment variable: ${required}`, + required, + docs, + }); + +export const errorResult = (payload: ToolErrorPayload): ToolErrorResult => wrap(payload); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts new file mode 100644 index 0000000..37e1628 --- /dev/null +++ b/packages/mcp/src/index.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env node +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { createServer } from './server.js'; + +const main = async (): Promise => { + const server = createServer(); + const transport = new StdioServerTransport(); + await server.connect(transport); + // Logs to stderr only — stdout is reserved for the protocol. + process.stderr.write('tokenometer-mcp ready\n'); + const cleanup = (): void => { + void transport.close(); + process.exit(0); + }; + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); +}; + +main().catch((err: unknown) => { + process.stderr.write(`tokenometer-mcp fatal: ${(err as Error).message}\n`); + process.exit(1); +}); diff --git a/packages/mcp/src/schemas.ts b/packages/mcp/src/schemas.ts new file mode 100644 index 0000000..d5af84f --- /dev/null +++ b/packages/mcp/src/schemas.ts @@ -0,0 +1,92 @@ +import { z } from 'zod'; + +const FORMATS = ['json', 'yaml', 'xml', 'markdown', 'text'] as const; +const PROVIDERS = ['anthropic', 'cohere', 'google', 'mistral', 'openai'] as const; +const VISION_DETAIL = ['low', 'high', 'auto'] as const; + +const MAX_TEXT_LEN = 1_000_000; + +export const FormatEnum = z.enum(FORMATS); +export const ProviderEnum = z.enum(PROVIDERS); +export const VisionDetailEnum = z.enum(VISION_DETAIL); + +export const EstimateCostInput = z.object({ + text: z.string().min(1).max(MAX_TEXT_LEN), + model: z.string().min(1), + format: FormatEnum.optional(), + outputTokens: z.number().int().min(0).optional(), +}); +export type EstimateCostInput = z.infer; + +export const EstimateCostMatrixInput = z.object({ + text: z.string().min(1).max(MAX_TEXT_LEN), + models: z.array(z.string().min(1)).min(1), + formats: z.array(FormatEnum).min(1).optional(), +}); +export type EstimateCostMatrixInput = z.infer; + +export const CountTokensEmpiricalInput = z.object({ + text: z.string().min(1).max(MAX_TEXT_LEN), + model: z.string().min(1), + format: FormatEnum.optional(), +}); +export type CountTokensEmpiricalInput = z.infer; + +export const CountTokensEmpiricalMatrixInput = z.object({ + text: z.string().min(1).max(MAX_TEXT_LEN), + models: z.array(z.string().min(1)).min(1), + formats: z.array(FormatEnum).min(1).optional(), +}); +export type CountTokensEmpiricalMatrixInput = z.infer; + +export const GetModelInfoInput = z.object({ + model: z.string().min(1), +}); +export type GetModelInfoInput = z.infer; + +export const ListModelsInput = z.object({ + provider: ProviderEnum.optional(), + // Reserved for future capability filters (e.g., 'vision', 'streaming'). + // Accepted but currently ignored to keep the schema forward-compatible. + capability: z.string().min(1).optional(), +}); +export type ListModelsInput = z.infer; + +export const GetRatesVersionInput = z.object({}).strict(); +export type GetRatesVersionInput = z.infer; + +const VisionImage = z.object({ + width: z.number().positive().finite(), + height: z.number().positive().finite(), + detail: VisionDetailEnum.optional(), +}); + +export const EstimateVisionCostInput = z.object({ + provider: ProviderEnum, + images: z.array(VisionImage).min(1), + // Optional model — when provided, per-image USD is computed from the model's + // input-per-1k rate. Without it, only token counts come back. + model: z.string().min(1).optional(), +}); +export type EstimateVisionCostInput = z.infer; + +export const BudgetCheckInput = z + .object({ + text: z.string().min(1).max(MAX_TEXT_LEN), + model: z.string().min(1), + maxCostUsd: z.number().positive().finite().optional(), + maxTokens: z.number().int().positive().optional(), + format: FormatEnum.optional(), + }) + .refine((v) => v.maxCostUsd !== undefined || v.maxTokens !== undefined, { + message: 'budget_check requires at least one of maxCostUsd or maxTokens', + }); +export type BudgetCheckInput = z.infer; + +export const MeasureLatencyInput = z.object({ + model: z.string().min(1), + prompt: z.string().min(1).max(MAX_TEXT_LEN), + trials: z.number().int().min(1).max(10).optional(), + maxTokens: z.number().int().min(1).max(4096).optional(), +}); +export type MeasureLatencyInput = z.infer; diff --git a/packages/mcp/src/server.test.ts b/packages/mcp/src/server.test.ts new file mode 100644 index 0000000..aebe8ff --- /dev/null +++ b/packages/mcp/src/server.test.ts @@ -0,0 +1,346 @@ +import { describe, expect, it } from 'vitest'; +import { TOOLS } from './tools/index.js'; + +const findTool = (name: string) => { + const t = TOOLS.find((x) => x.name === name); + if (!t) throw new Error(`tool not registered: ${name}`); + return t; +}; + +const parsePayload = (result: { content: Array<{ type: 'text'; text: string }> }): unknown => { + const first = result.content[0]; + if (!first) throw new Error('empty content'); + return JSON.parse(first.text); +}; + +describe('TOOLS registry', () => { + it('exposes the expected 10 tools', () => { + const names = TOOLS.map((t) => t.name).sort(); + expect(names).toEqual( + [ + 'budget_check', + 'count_tokens_empirical', + 'count_tokens_empirical_matrix', + 'estimate_cost', + 'estimate_cost_matrix', + 'estimate_vision_cost', + 'get_model_info', + 'get_rates_version', + 'list_models', + 'measure_latency', + ].sort(), + ); + }); + + it('each tool has a non-empty description', () => { + for (const t of TOOLS) { + expect(typeof t.description).toBe('string'); + expect(t.description.length).toBeGreaterThan(10); + } + }); +}); + +describe('estimate_cost', () => { + const tool = findTool('estimate_cost'); + + it('returns tokens and inputCost for a known offline model', async () => { + const result = await tool.handler({ text: 'hello world', model: 'gpt-4o', format: 'text' }); + expect(result.isError).toBeFalsy(); + const payload = parsePayload(result) as { + tokens: number; + inputCost: number; + totalCost: number; + model: string; + }; + expect(payload.tokens).toBeGreaterThan(0); + expect(payload.inputCost).toBeGreaterThan(0); + expect(payload.totalCost).toBeCloseTo(payload.inputCost, 10); + expect(payload.model).toBe('gpt-4o'); + }); + + it('adds outputCost when outputTokens is provided', async () => { + const result = await tool.handler({ + text: 'hi', + model: 'gpt-4o', + outputTokens: 100, + }); + const payload = parsePayload(result) as { + outputCost?: number; + totalCost: number; + inputCost: number; + }; + expect(payload.outputCost).toBeGreaterThan(0); + expect(payload.totalCost).toBeGreaterThan(payload.inputCost); + }); + + it('returns isError for an unknown model', async () => { + const result = await tool.handler({ text: 'hi', model: 'fake-model-xyz' }); + expect(result.isError).toBe(true); + const payload = parsePayload(result) as { code: string }; + expect(payload.code).toBe('user_error'); + }); +}); + +describe('estimate_cost_matrix', () => { + const tool = findTool('estimate_cost_matrix'); + + it('returns one cell per (model, format) pair plus cheapest/mostExpensive', async () => { + const result = await tool.handler({ + text: 'hello world', + models: ['gpt-4o', 'gpt-4o-mini'], + formats: ['text', 'json'], + }); + const payload = parsePayload(result) as { + results: Array<{ model: string; format: string }>; + cheapest: { model: string }; + mostExpensive: { model: string }; + }; + expect(payload.results.length).toBe(4); + expect(payload.cheapest).toBeDefined(); + expect(payload.mostExpensive).toBeDefined(); + }); + + it('defaults to format=text when formats is omitted', async () => { + const result = await tool.handler({ text: 'hi', models: ['gpt-4o'] }); + const payload = parsePayload(result) as { results: Array<{ format: string }> }; + expect(payload.results).toHaveLength(1); + expect(payload.results[0]?.format).toBe('text'); + }); +}); + +describe('count_tokens_empirical', () => { + const tool = findTool('count_tokens_empirical'); + + it('runs locally for openai (no API key needed)', async () => { + const result = await tool.handler({ text: 'hello world', model: 'gpt-4o' }); + expect(result.isError).toBeFalsy(); + const payload = parsePayload(result) as { inputTokens: number }; + expect(payload.inputTokens).toBeGreaterThan(0); + }); + + it.skipIf(!process.env.ANTHROPIC_API_KEY)('hits the live Anthropic count endpoint', async () => { + const result = await tool.handler({ text: 'hello', model: 'claude-opus-4-7' }); + expect(result.isError).toBeFalsy(); + const payload = parsePayload(result) as { inputTokens: number }; + expect(payload.inputTokens).toBeGreaterThan(0); + }); + + it('returns key_missing when the required env var is absent', async () => { + const saved = process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_API_KEY; + try { + const result = await tool.handler({ text: 'hi', model: 'claude-opus-4-7' }); + expect(result.isError).toBe(true); + const payload = parsePayload(result) as { code: string; required: string }; + expect(payload.code).toBe('key_missing'); + expect(payload.required).toBe('ANTHROPIC_API_KEY'); + } finally { + if (saved !== undefined) process.env.ANTHROPIC_API_KEY = saved; + } + }); +}); + +describe('count_tokens_empirical_matrix', () => { + const tool = findTool('count_tokens_empirical_matrix'); + + it('returns per-cell results with inline errors rather than failing wholesale', async () => { + const saved = process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_API_KEY; + try { + const result = await tool.handler({ + text: 'hi', + models: ['gpt-4o', 'claude-opus-4-7'], + }); + expect(result.isError).toBeFalsy(); + const payload = parsePayload(result) as { + results: Array<{ isError?: boolean; model: string; inputTokens?: number; code?: string }>; + }; + expect(payload.results).toHaveLength(2); + const openai = payload.results.find((r) => r.model === 'gpt-4o'); + const anth = payload.results.find((r) => r.model === 'claude-opus-4-7'); + expect(openai?.isError).toBeFalsy(); + expect((openai as { inputTokens: number }).inputTokens).toBeGreaterThan(0); + expect(anth?.isError).toBe(true); + expect(anth?.code).toBe('key_missing'); + } finally { + if (saved !== undefined) process.env.ANTHROPIC_API_KEY = saved; + } + }); +}); + +describe('get_model_info', () => { + const tool = findTool('get_model_info'); + + it('returns metadata for a known model', async () => { + const result = await tool.handler({ model: 'gpt-4o' }); + expect(result.isError).toBeFalsy(); + const payload = parsePayload(result) as { + id: string; + provider: string; + ratePer1k: { input: number; output: number }; + ratesVersion: string; + }; + expect(payload.id).toBe('gpt-4o'); + expect(payload.provider).toBe('openai'); + expect(payload.ratePer1k.input).toBeGreaterThan(0); + expect(payload.ratePer1k.output).toBeGreaterThan(0); + expect(payload.ratesVersion).toMatch(/\d{4}-\d{2}-\d{2}/); + }); + + it('errors on unknown model', async () => { + const result = await tool.handler({ model: 'nope-not-real' }); + expect(result.isError).toBe(true); + }); +}); + +describe('list_models', () => { + const tool = findTool('list_models'); + + it('returns every model when no filter is set', async () => { + const result = await tool.handler({}); + expect(result.isError).toBeFalsy(); + const payload = parsePayload(result) as { models: Array<{ provider: string }> }; + expect(payload.models.length).toBeGreaterThan(0); + }); + + it('filters by provider', async () => { + const result = await tool.handler({ provider: 'openai' }); + const payload = parsePayload(result) as { models: Array<{ provider: string }> }; + expect(payload.models.length).toBeGreaterThan(0); + for (const m of payload.models) { + expect(m.provider).toBe('openai'); + } + }); +}); + +describe('get_rates_version', () => { + const tool = findTool('get_rates_version'); + + it('returns the rates version', async () => { + const result = await tool.handler({}); + expect(result.isError).toBeFalsy(); + const payload = parsePayload(result) as { ratesVersion: string }; + expect(payload.ratesVersion).toMatch(/\d{4}-\d{2}-\d{2}/); + }); +}); + +describe('estimate_vision_cost', () => { + const tool = findTool('estimate_vision_cost'); + + it('computes tokens for an Anthropic image', async () => { + const result = await tool.handler({ + provider: 'anthropic', + images: [{ width: 800, height: 600 }], + }); + expect(result.isError).toBeFalsy(); + const payload = parsePayload(result) as { + totalTokens: number; + images: Array<{ tokens: number }>; + }; + expect(payload.totalTokens).toBeGreaterThan(0); + expect(payload.images[0]?.tokens).toBe(640); // ceil(480000/750) + }); + + it('includes per-image USD when model is provided', async () => { + const result = await tool.handler({ + provider: 'openai', + images: [{ width: 800, height: 600 }], + model: 'gpt-4o', + }); + const payload = parsePayload(result) as { + images: Array<{ costUsd?: number }>; + totalCostUsd?: number; + }; + expect(payload.images[0]?.costUsd).toBeGreaterThan(0); + expect(payload.totalCostUsd).toBeGreaterThan(0); + }); + + it('returns unsupported_provider for mistral', async () => { + const result = await tool.handler({ + provider: 'mistral', + images: [{ width: 100, height: 100 }], + }); + expect(result.isError).toBe(true); + const payload = parsePayload(result) as { code: string }; + expect(payload.code).toBe('unsupported_provider'); + }); +}); + +describe('budget_check', () => { + const tool = findTool('budget_check'); + + it('passes when prompt fits both budgets', async () => { + const result = await tool.handler({ + text: 'hello', + model: 'gpt-4o', + maxCostUsd: 1, + maxTokens: 10_000, + }); + expect(result.isError).toBeFalsy(); + const payload = parsePayload(result) as { pass: boolean; headroom: number }; + expect(payload.pass).toBe(true); + expect(payload.headroom).toBeGreaterThan(0); + }); + + it('fails with a reason when token budget is too small', async () => { + const result = await tool.handler({ + text: 'hello world this is a longer prompt to exceed the small budget', + model: 'gpt-4o', + maxTokens: 1, + }); + const payload = parsePayload(result) as { pass: boolean; reason?: string }; + expect(payload.pass).toBe(false); + expect(payload.reason).toMatch(/exceeds/); + }); + + it('rejects calls with neither budget set', () => { + // Schema enforces this at parse time, not in the handler. Verify via safeParse. + const parsed = tool.schema.safeParse({ text: 'hi', model: 'gpt-4o' }); + expect(parsed.success).toBe(false); + }); +}); + +describe('measure_latency', () => { + const tool = findTool('measure_latency'); + + it('returns key_missing without a provider API key', async () => { + const saved = process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_API_KEY; + try { + const result = await tool.handler({ + model: 'claude-opus-4-7', + prompt: 'hi', + trials: 1, + }); + expect(result.isError).toBe(true); + const payload = parsePayload(result) as { code: string }; + expect(payload.code).toBe('key_missing'); + } finally { + if (saved !== undefined) process.env.ANTHROPIC_API_KEY = saved; + } + }); +}); + +describe('schema validation', () => { + it('estimate_cost rejects empty text', () => { + const tool = findTool('estimate_cost'); + const parsed = tool.schema.safeParse({ text: '', model: 'gpt-4o' }); + expect(parsed.success).toBe(false); + }); + + it('estimate_cost_matrix rejects empty models array', () => { + const tool = findTool('estimate_cost_matrix'); + const parsed = tool.schema.safeParse({ text: 'hi', models: [] }); + expect(parsed.success).toBe(false); + }); + + it('measure_latency clamps trials to 10', () => { + const tool = findTool('measure_latency'); + const parsed = tool.schema.safeParse({ + model: 'gpt-4o', + prompt: 'hi', + trials: 20, + }); + expect(parsed.success).toBe(false); + }); +}); diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts new file mode 100644 index 0000000..f4190d9 --- /dev/null +++ b/packages/mcp/src/server.ts @@ -0,0 +1,69 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import { TOOLS } from './tools/index.js'; +import type { ToolResult } from './tools/types.js'; + +const SERVER_NAME = 'tokenometer'; +const SERVER_VERSION = '1.0.1'; + +/** + * Build an MCP `Server` instance with the tokenometer tool set wired up. + * Transport layering (stdio, sse, etc.) is handled separately by the + * caller; this function just registers request handlers. + */ +export const createServer = (): Server => { + const server = new Server( + { name: SERVER_NAME, version: SERVER_VERSION }, + { capabilities: { tools: {} } }, + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: TOOLS.map((t) => ({ + name: t.name, + description: t.description, + inputSchema: zodToJsonSchema(t.schema, { target: 'jsonSchema7' }), + })), + })); + + const handleCallTool = async (req: { + params: { name: string; arguments?: unknown }; + }): Promise => { + const tool = TOOLS.find((t) => t.name === req.params.name); + if (!tool) { + return { + isError: true, + content: [ + { + type: 'text', + text: JSON.stringify({ code: 'unknown_tool', name: req.params.name }), + }, + ], + }; + } + const parsed = tool.schema.safeParse(req.params.arguments ?? {}); + if (!parsed.success) { + return { + isError: true, + content: [ + { + type: 'text', + text: JSON.stringify({ code: 'invalid_args', issues: parsed.error.issues }), + }, + ], + }; + } + return tool.handler(parsed.data); + }; + + // The SDK's request-handler signature has tightened across protocol versions + // (newer types include an optional `task` field for long-running operations). + // Our handler always returns a plain ToolResult; cast through `unknown` to + // accommodate either signature without losing internal type safety. + server.setRequestHandler( + CallToolRequestSchema, + handleCallTool as unknown as Parameters[1], + ); + + return server; +}; diff --git a/packages/mcp/src/tools/budget-check.ts b/packages/mcp/src/tools/budget-check.ts new file mode 100644 index 0000000..e8b181d --- /dev/null +++ b/packages/mcp/src/tools/budget-check.ts @@ -0,0 +1,78 @@ +import { tokenize } from '@tokenometer/core'; +import type { Format } from '@tokenometer/core'; +import { toMcpError } from '../errors.js'; +import { BudgetCheckInput } from '../schemas.js'; +import type { ToolDef, ToolResult } from './types.js'; + +const DEFAULT_FORMAT: Format = 'text'; + +interface BudgetCheckEntry { + kind: 'cost' | 'tokens'; + pass: boolean; + headroom: number; + reason?: string; +} + +export const budgetCheck: ToolDef = { + name: 'budget_check', + description: + 'Pre-flight check: would sending this prompt to this model fit within a maxCostUsd or maxTokens budget? Returns pass/fail, the actual token + cost, headroom remaining, and a reason on failure. Agents can call this before dispatching the real LLM request.', + schema: BudgetCheckInput, + handler: async (input) => { + try { + const format = input.format ?? DEFAULT_FORMAT; + const cell = tokenize({ format, modelId: input.model, prompt: input.text }); + + const checks: BudgetCheckEntry[] = []; + + if (input.maxCostUsd !== undefined) { + const headroom = input.maxCostUsd - cell.inputCost; + const pass = headroom >= 0; + const entry: BudgetCheckEntry = { kind: 'cost', pass, headroom }; + if (!pass) { + entry.reason = `Input cost $${cell.inputCost.toFixed(6)} exceeds maxCostUsd $${input.maxCostUsd.toFixed(6)}`; + } + checks.push(entry); + } + if (input.maxTokens !== undefined) { + const headroom = input.maxTokens - cell.inputTokens; + const pass = headroom >= 0; + const entry: BudgetCheckEntry = { kind: 'tokens', pass, headroom }; + if (!pass) { + entry.reason = `Input tokens ${cell.inputTokens} exceeds maxTokens ${input.maxTokens}`; + } + checks.push(entry); + } + + const failed = checks.find((c) => !c.pass); + const passed = failed === undefined; + + // Headroom is reported as the *tighter* (smaller) constraint's headroom + // when both limits are passed; agents care about the binding limit. + const firstHeadroom = checks[0]?.headroom ?? 0; + const headroom = + checks.length === 0 + ? 0 + : checks.reduce((min, c) => (c.headroom < min ? c.headroom : min), firstHeadroom); + + const payload = { + pass: passed, + actualTokens: cell.inputTokens, + actualCost: cell.inputCost, + headroom, + checks, + ...(failed?.reason ? { reason: failed.reason } : {}), + model: cell.model, + format: cell.format, + approximate: cell.approximate, + }; + + const result: ToolResult = { + content: [{ type: 'text', text: JSON.stringify(payload) }], + }; + return result; + } catch (err) { + return toMcpError(err); + } + }, +}; diff --git a/packages/mcp/src/tools/count-tokens-empirical-matrix.ts b/packages/mcp/src/tools/count-tokens-empirical-matrix.ts new file mode 100644 index 0000000..34f421c --- /dev/null +++ b/packages/mcp/src/tools/count-tokens-empirical-matrix.ts @@ -0,0 +1,87 @@ +import { getModel, tokenizeEmpirical } from '@tokenometer/core'; +import type { Format, TokenizeResult } from '@tokenometer/core'; +import { readEnv } from '../env.js'; +import { toMcpError } from '../errors.js'; +import { CountTokensEmpiricalMatrixInput } from '../schemas.js'; +import type { ToolDef, ToolResult } from './types.js'; + +const DEFAULT_FORMATS: readonly Format[] = ['text']; + +const REQUIRED_KEY: Record = { + anthropic: 'ANTHROPIC_API_KEY', + cohere: 'COHERE_API_KEY', + google: 'GOOGLE_API_KEY', + openai: '', + mistral: '', +}; + +const ENV_FIELD: Record = { + anthropic: 'anthropicApiKey', + cohere: 'cohereApiKey', + google: 'googleApiKey', +}; + +type CellOk = TokenizeResult; +type CellErr = { isError: true; model: string; format: Format; code: string; message: string }; + +export const countTokensEmpiricalMatrix: ToolDef = { + name: 'count_tokens_empirical_matrix', + description: + 'Run empirical token counts across the cross-product of models and formats. Per-cell errors (e.g. missing API key, unsupported provider) are returned inline rather than aborting the whole matrix.', + schema: CountTokensEmpiricalMatrixInput, + handler: async (input) => { + try { + const formats: readonly Format[] = input.formats ?? DEFAULT_FORMATS; + const env = readEnv(); + + const tasks: Promise[] = []; + for (const modelId of input.models) { + for (const format of formats) { + tasks.push( + (async (): Promise => { + try { + const model = getModel(modelId); + const requiredVar = REQUIRED_KEY[model.provider]; + if (requiredVar) { + const fieldKey = ENV_FIELD[model.provider]; + if (!fieldKey || !env[fieldKey]) { + return { + isError: true, + model: modelId, + format, + code: 'key_missing', + message: `Missing ${requiredVar}`, + }; + } + } + return await tokenizeEmpirical({ + env, + format, + modelId, + prompt: input.text, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + isError: true, + model: modelId, + format, + code: 'provider_error', + message, + }; + } + })(), + ); + } + } + const results = await Promise.all(tasks); + const payload = { results }; + const result: ToolResult = { + content: [{ type: 'text', text: JSON.stringify(payload) }], + }; + return result; + } catch (err) { + return toMcpError(err); + } + }, +}; diff --git a/packages/mcp/src/tools/count-tokens-empirical.ts b/packages/mcp/src/tools/count-tokens-empirical.ts new file mode 100644 index 0000000..2d2df6a --- /dev/null +++ b/packages/mcp/src/tools/count-tokens-empirical.ts @@ -0,0 +1,55 @@ +import { getModel, tokenizeEmpirical } from '@tokenometer/core'; +import type { Format } from '@tokenometer/core'; +import { readEnv } from '../env.js'; +import { keyMissingError, toMcpError } from '../errors.js'; +import { CountTokensEmpiricalInput } from '../schemas.js'; +import type { ToolDef, ToolResult } from './types.js'; + +const DEFAULT_FORMAT: Format = 'text'; + +const REQUIRED_KEY: Record = { + anthropic: 'ANTHROPIC_API_KEY', + cohere: 'COHERE_API_KEY', + google: 'GOOGLE_API_KEY', + openai: '', // OpenAI uses local tiktoken — no key needed. + mistral: '', // Mistral has no public token-count API — empirical mode unsupported. +}; + +const ENV_FIELD: Record = { + anthropic: 'anthropicApiKey', + cohere: 'cohereApiKey', + google: 'googleApiKey', +}; + +export const countTokensEmpirical: ToolDef = { + name: 'count_tokens_empirical', + description: + "Count tokens using each provider's official countTokens API (free, exact). Anthropic via messages.countTokens, Google via model.countTokens, OpenAI via local tiktoken o200k_base, Cohere via POST /v1/tokenize. Mistral is unsupported (no public endpoint).", + schema: CountTokensEmpiricalInput, + handler: async (input) => { + try { + const model = getModel(input.model); + const requiredVar = REQUIRED_KEY[model.provider]; + if (requiredVar) { + const env = readEnv(); + const fieldKey = ENV_FIELD[model.provider]; + if (!fieldKey || !env[fieldKey]) { + return keyMissingError(requiredVar); + } + } + const format = input.format ?? DEFAULT_FORMAT; + const cell = await tokenizeEmpirical({ + env: readEnv(), + format, + modelId: input.model, + prompt: input.text, + }); + const result: ToolResult = { + content: [{ type: 'text', text: JSON.stringify(cell) }], + }; + return result; + } catch (err) { + return toMcpError(err); + } + }, +}; diff --git a/packages/mcp/src/tools/estimate-cost-matrix.ts b/packages/mcp/src/tools/estimate-cost-matrix.ts new file mode 100644 index 0000000..b872029 --- /dev/null +++ b/packages/mcp/src/tools/estimate-cost-matrix.ts @@ -0,0 +1,67 @@ +import { tokenizeMatrix } from '@tokenometer/core'; +import type { Format, TokenizeResult } from '@tokenometer/core'; +import { toMcpError } from '../errors.js'; +import { EstimateCostMatrixInput } from '../schemas.js'; +import type { ToolDef, ToolResult } from './types.js'; + +const DEFAULT_FORMATS: readonly Format[] = ['text']; + +interface MatrixCell { + model: string; + format: Format; + tokens: number; + inputCost: number; + approximate: boolean; + tokenizer: TokenizeResult['tokenizer']; +} + +const pickCheapest = (cells: readonly MatrixCell[]): MatrixCell | undefined => + cells.reduce( + (acc, c) => (acc === undefined || c.inputCost < acc.inputCost ? c : acc), + undefined, + ); + +const pickPriciest = (cells: readonly MatrixCell[]): MatrixCell | undefined => + cells.reduce( + (acc, c) => (acc === undefined || c.inputCost > acc.inputCost ? c : acc), + undefined, + ); + +export const estimateCostMatrix: ToolDef = { + name: 'estimate_cost_matrix', + description: + 'Estimate token cost across the cross-product of models and formats. Returns one cell per (model, format) plus pointers to the cheapest and most expensive cells.', + schema: EstimateCostMatrixInput, + handler: async (input) => { + try { + const formats: readonly Format[] = input.formats ?? DEFAULT_FORMATS; + const cells = tokenizeMatrix({ + formats, + modelIds: input.models, + prompt: input.text, + }); + + const results: MatrixCell[] = cells.map((c) => ({ + model: c.model, + format: c.format, + tokens: c.inputTokens, + inputCost: c.inputCost, + approximate: c.approximate, + tokenizer: c.tokenizer, + })); + + const payload = { + results, + cheapest: pickCheapest(results), + mostExpensive: pickPriciest(results), + }; + + const result: ToolResult = { + content: [{ type: 'text', text: JSON.stringify(payload) }], + }; + return result; + } catch (err) { + return toMcpError(err); + } + }, +}; diff --git a/packages/mcp/src/tools/estimate-cost.ts b/packages/mcp/src/tools/estimate-cost.ts new file mode 100644 index 0000000..cdd93e6 --- /dev/null +++ b/packages/mcp/src/tools/estimate-cost.ts @@ -0,0 +1,47 @@ +import { getModel, getRate, tokenize } from '@tokenometer/core'; +import type { Format } from '@tokenometer/core'; +import { toMcpError } from '../errors.js'; +import { EstimateCostInput } from '../schemas.js'; +import type { ToolDef, ToolResult } from './types.js'; + +const DEFAULT_FORMAT: Format = 'text'; + +export const estimateCost: ToolDef = { + name: 'estimate_cost', + description: + 'Estimate input (and optional output) token cost for a single prompt + model. Uses the offline tokenizer for the provider (cl100k for Anthropic, o200k for OpenAI, chars/4 heuristic for Google/Cohere, SentencePiece for Mistral V1/V2/V3).', + schema: EstimateCostInput, + handler: async (input) => { + try { + const format = input.format ?? DEFAULT_FORMAT; + const cell = tokenize({ format, modelId: input.model, prompt: input.text }); + const rate = getRate(input.model); + // Touch getModel to surface UserFacingError early for unknown models even + // when tokenize() happens to succeed first. (Defensive; tokenize already + // calls it, but this keeps the error path consistent.) + getModel(input.model); + + const outputTokens = input.outputTokens; + const outputCost = + outputTokens !== undefined ? (outputTokens / 1000) * rate.outputPer1k : undefined; + const totalCost = cell.inputCost + (outputCost ?? 0); + + const payload = { + tokens: cell.inputTokens, + inputCost: cell.inputCost, + ...(outputCost !== undefined ? { outputCost } : {}), + totalCost, + model: cell.model, + format: cell.format, + approximate: cell.approximate, + tokenizer: cell.tokenizer, + }; + const result: ToolResult = { + content: [{ type: 'text', text: JSON.stringify(payload) }], + }; + return result; + } catch (err) { + return toMcpError(err); + } + }, +}; diff --git a/packages/mcp/src/tools/estimate-vision-cost.ts b/packages/mcp/src/tools/estimate-vision-cost.ts new file mode 100644 index 0000000..93d53f4 --- /dev/null +++ b/packages/mcp/src/tools/estimate-vision-cost.ts @@ -0,0 +1,100 @@ +import { + anthropicVisionTokens, + getModel, + getRate, + googleVisionTokens, + openaiVisionTokens, +} from '@tokenometer/core'; +import type { Provider } from '@tokenometer/core'; +import { errorResult, toMcpError } from '../errors.js'; +import { EstimateVisionCostInput } from '../schemas.js'; +import type { ToolDef, ToolResult } from './types.js'; + +const computeTokens = ( + provider: Provider, + img: { width: number; height: number; detail?: 'low' | 'high' | 'auto' | undefined }, +): number => { + switch (provider) { + case 'anthropic': + return anthropicVisionTokens( + img.detail !== undefined + ? { width: img.width, height: img.height, detail: img.detail } + : { width: img.width, height: img.height }, + ); + case 'openai': + return openaiVisionTokens( + img.detail !== undefined + ? { width: img.width, height: img.height, detail: img.detail } + : { width: img.width, height: img.height }, + ); + case 'google': + return googleVisionTokens( + img.detail !== undefined + ? { width: img.width, height: img.height, detail: img.detail } + : { width: img.width, height: img.height }, + ); + default: + throw new Error(`Vision tokens not supported for provider "${provider}"`); + } +}; + +export const estimateVisionCost: ToolDef = { + name: 'estimate_vision_cost', + description: + "Estimate vision-token cost for one or more images using the provider's published formula (Anthropic, OpenAI, or Google). Optionally include `model` to get per-image USD using that model's input rate. Mistral and Cohere are not supported.", + schema: EstimateVisionCostInput, + handler: async (input) => { + try { + if (input.provider === 'mistral' || input.provider === 'cohere') { + return errorResult({ + code: 'unsupported_provider', + message: `Vision tokens are not published for provider "${input.provider}". Use anthropic, openai, or google.`, + }); + } + + const inputPer1k = (() => { + if (input.model === undefined) return undefined; + const model = getModel(input.model); + if (model.provider !== input.provider) { + throw new Error( + `Model "${input.model}" belongs to provider "${model.provider}" but request specifies "${input.provider}".`, + ); + } + return getRate(input.model).inputPer1k; + })(); + + const images = input.images.map((img) => { + const tokens = computeTokens(input.provider, img); + const costUsd = inputPer1k !== undefined ? (tokens / 1000) * inputPer1k : undefined; + return { + width: img.width, + height: img.height, + ...(img.detail !== undefined ? { detail: img.detail } : {}), + tokens, + ...(costUsd !== undefined ? { costUsd } : {}), + }; + }); + + const totalTokens = images.reduce((sum, i) => sum + i.tokens, 0); + const totalCostUsd = images.reduce((sum, i) => { + if (i.costUsd === undefined) return sum; + return (sum ?? 0) + i.costUsd; + }, undefined); + + const payload = { + provider: input.provider, + ...(input.model !== undefined ? { model: input.model } : {}), + images, + totalTokens, + ...(totalCostUsd !== undefined ? { totalCostUsd } : {}), + }; + + const result: ToolResult = { + content: [{ type: 'text', text: JSON.stringify(payload) }], + }; + return result; + } catch (err) { + return toMcpError(err); + } + }, +}; diff --git a/packages/mcp/src/tools/get-model-info.ts b/packages/mcp/src/tools/get-model-info.ts new file mode 100644 index 0000000..0177715 --- /dev/null +++ b/packages/mcp/src/tools/get-model-info.ts @@ -0,0 +1,35 @@ +import { RATES_VERSION, getModel, getRate } from '@tokenometer/core'; +import { toMcpError } from '../errors.js'; +import { GetModelInfoInput } from '../schemas.js'; +import type { ToolDef, ToolResult } from './types.js'; + +export const getModelInfo: ToolDef = { + name: 'get_model_info', + description: + 'Return registry metadata for a model: provider, context window, max output tokens, input/output USD per 1k, and the rates dataset version.', + schema: GetModelInfoInput, + handler: async (input) => { + try { + const descriptor = getModel(input.model); + const rate = getRate(input.model); + const payload = { + id: descriptor.id, + provider: descriptor.provider, + contextWindow: descriptor.contextWindow, + maxOutput: descriptor.maxOutputTokens, + ratePer1k: { + input: rate.inputPer1k, + output: rate.outputPer1k, + ...(rate.cachedInputPer1k !== undefined ? { cachedInput: rate.cachedInputPer1k } : {}), + }, + ratesVersion: RATES_VERSION, + }; + const result: ToolResult = { + content: [{ type: 'text', text: JSON.stringify(payload) }], + }; + return result; + } catch (err) { + return toMcpError(err); + } + }, +}; diff --git a/packages/mcp/src/tools/get-rates-version.ts b/packages/mcp/src/tools/get-rates-version.ts new file mode 100644 index 0000000..e23b08e --- /dev/null +++ b/packages/mcp/src/tools/get-rates-version.ts @@ -0,0 +1,22 @@ +import { RATES_VERSION } from '@tokenometer/core'; +import { toMcpError } from '../errors.js'; +import { GetRatesVersionInput } from '../schemas.js'; +import type { ToolDef, ToolResult } from './types.js'; + +export const getRatesVersion: ToolDef = { + name: 'get_rates_version', + description: + 'Return the version stamp of the rates dataset bundled with this server. Use this to detect when pricing data may be stale relative to a published date.', + schema: GetRatesVersionInput, + handler: async () => { + try { + const payload = { ratesVersion: RATES_VERSION }; + const result: ToolResult = { + content: [{ type: 'text', text: JSON.stringify(payload) }], + }; + return result; + } catch (err) { + return toMcpError(err); + } + }, +}; diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts new file mode 100644 index 0000000..6d80530 --- /dev/null +++ b/packages/mcp/src/tools/index.ts @@ -0,0 +1,31 @@ +import { budgetCheck } from './budget-check.js'; +import { countTokensEmpiricalMatrix } from './count-tokens-empirical-matrix.js'; +import { countTokensEmpirical } from './count-tokens-empirical.js'; +import { estimateCostMatrix } from './estimate-cost-matrix.js'; +import { estimateCost } from './estimate-cost.js'; +import { estimateVisionCost } from './estimate-vision-cost.js'; +import { getModelInfo } from './get-model-info.js'; +import { getRatesVersion } from './get-rates-version.js'; +import { listModels } from './list-models.js'; +import { measureLatencyTool } from './measure-latency.js'; +import type { AnyToolDef } from './types.js'; + +// Each concrete ToolDef is widened to `AnyToolDef` via `as unknown as` so the +// heterogeneous array type-checks under TypeScript's strict function-parameter +// variance. The server safeParses each tool's schema before invoking handler, +// so the runtime input shape is always validated even though the array has +// lost the specific input type at compile time. +export const TOOLS: ReadonlyArray = [ + estimateCost, + estimateCostMatrix, + countTokensEmpirical, + countTokensEmpiricalMatrix, + getModelInfo, + listModels, + getRatesVersion, + estimateVisionCost, + budgetCheck, + measureLatencyTool, +] as unknown as ReadonlyArray; + +export type { AnyToolDef, ToolDef, ToolResult } from './types.js'; diff --git a/packages/mcp/src/tools/list-models.ts b/packages/mcp/src/tools/list-models.ts new file mode 100644 index 0000000..9a4c48b --- /dev/null +++ b/packages/mcp/src/tools/list-models.ts @@ -0,0 +1,26 @@ +import { MODELS } from '@tokenometer/core'; +import type { ModelDescriptor } from '@tokenometer/core'; +import { toMcpError } from '../errors.js'; +import { ListModelsInput } from '../schemas.js'; +import type { ToolDef, ToolResult } from './types.js'; + +export const listModels: ToolDef = { + name: 'list_models', + description: + 'List every registered model in the rates registry, optionally filtered by provider. Each entry includes id, provider, context window, max output tokens, and pricing source.', + schema: ListModelsInput, + handler: async (input) => { + try { + const all: ModelDescriptor[] = Object.values(MODELS); + const filtered = + input.provider !== undefined ? all.filter((m) => m.provider === input.provider) : all; + const payload = { models: filtered }; + const result: ToolResult = { + content: [{ type: 'text', text: JSON.stringify(payload) }], + }; + return result; + } catch (err) { + return toMcpError(err); + } + }, +}; diff --git a/packages/mcp/src/tools/measure-latency.ts b/packages/mcp/src/tools/measure-latency.ts new file mode 100644 index 0000000..206f5a6 --- /dev/null +++ b/packages/mcp/src/tools/measure-latency.ts @@ -0,0 +1,58 @@ +import { getModel, measureLatency } from '@tokenometer/core'; +import { readEnv } from '../env.js'; +import { keyMissingError, toMcpError } from '../errors.js'; +import { MeasureLatencyInput } from '../schemas.js'; +import type { ToolDef, ToolResult } from './types.js'; + +const REQUIRED_KEY: Record = { + anthropic: 'ANTHROPIC_API_KEY', + cohere: 'COHERE_API_KEY', + google: 'GOOGLE_API_KEY', + mistral: 'MISTRAL_API_KEY', + openai: 'OPENAI_API_KEY', +}; + +const ENV_FIELD: Record< + string, + 'anthropicApiKey' | 'cohereApiKey' | 'googleApiKey' | 'mistralApiKey' | 'openaiApiKey' | undefined +> = { + anthropic: 'anthropicApiKey', + cohere: 'cohereApiKey', + google: 'googleApiKey', + mistral: 'mistralApiKey', + openai: 'openaiApiKey', +}; + +const DEFAULT_TRIALS = 3; + +export const measureLatencyTool: ToolDef = { + name: 'measure_latency', + description: + 'Run real metered streaming generations against the provider and report TTFT, total ms, and tokens/sec as p50/p95/mean. Each trial is a real (paid) chat completion. Requires the provider API key.', + schema: MeasureLatencyInput, + handler: async (input) => { + try { + const model = getModel(input.model); + const requiredVar = REQUIRED_KEY[model.provider]; + const fieldKey = ENV_FIELD[model.provider]; + const env = readEnv(); + if (!fieldKey || !env[fieldKey]) { + return keyMissingError(requiredVar ?? 'PROVIDER_API_KEY'); + } + const trials = input.trials ?? DEFAULT_TRIALS; + const latency = await measureLatency({ + env, + modelId: input.model, + prompt: input.prompt, + trials, + ...(input.maxTokens !== undefined ? { maxTokens: input.maxTokens } : {}), + }); + const result: ToolResult = { + content: [{ type: 'text', text: JSON.stringify(latency) }], + }; + return result; + } catch (err) { + return toMcpError(err); + } + }, +}; diff --git a/packages/mcp/src/tools/types.ts b/packages/mcp/src/tools/types.ts new file mode 100644 index 0000000..86e1be8 --- /dev/null +++ b/packages/mcp/src/tools/types.ts @@ -0,0 +1,36 @@ +import type { z } from 'zod'; + +export interface ToolResult { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +} + +/** + * One tool entry. `S` is the zod input schema type (inferred from each tool's + * file). The handler receives the parsed input narrowed by `z.infer`. + * + * Note on variance: arrays of `ToolDef` with heterogeneous schemas need to be + * typed as `ReadonlyArray` to satisfy TypeScript's strict variance + * check on the handler's parameter type. `AnyToolDef` erases the schema's + * input shape to `unknown`; the server narrows on the parsed value before + * dispatch, so the runtime contract is preserved. + */ +export interface ToolDef { + name: string; + description: string; + schema: S; + handler: (input: z.infer) => Promise; +} + +/** + * Erased-shape tool def for storing heterogeneous handlers in a single array. + * The handler accepts `unknown` because at the array level we've lost the + * specific input type. Callers must safeParse the schema before invoking + * `handler` (which the server already does). + */ +export interface AnyToolDef { + name: string; + description: string; + schema: z.ZodTypeAny; + handler: (input: unknown) => Promise; +} diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json new file mode 100644 index 0000000..c44ff6a --- /dev/null +++ b/packages/mcp/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "**/*.spec.ts", "dist"], + "references": [{ "path": "../core" }] +} diff --git a/tsconfig.json b/tsconfig.json index 58ea3c9..bf3c4e0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "references": [ { "path": "./packages/core" }, { "path": "./packages/cli" }, - { "path": "./packages/vscode" } + { "path": "./packages/vscode" }, + { "path": "./packages/mcp" } ] } From 595318aa92775a47f49e59f0df8a72592d8f0e04 Mon Sep 17 00:00:00 2001 From: Faraazuddin Mohammed Date: Mon, 11 May 2026 23:42:06 -0400 Subject: [PATCH 2/5] feat(react): add @tokenometer/react component library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initial release of @tokenometer/react@0.1.0 — drop-in React hooks and components for LLM cost dashboards. Hooks: useTokenCount, useTokenCountEmpirical, useCostMatrix, useBudget, useDebouncedTokenCount, useModelList, usePricing. Components: TokenCounter, ModelCostMatrix, BudgetMeter, CostBreakdown, ModelSelector, LiveTokenizer, PricingTable, VisionCostEstimator. Headless-first; opt-in styled wrappers via @tokenometer/react/styled (inline styles + CSS vars, no Tailwind leak). SSR / RSC compatible via "use client" banner. Peer deps: react >=18, react-dom >=18, @tokenometer/core >=1.0.1. Bundled with tsup (ESM + CJS + .d.ts). Tested with vitest + jsdom + @testing-library/react. Workspace-level vitest project so the jsdom env applies only to this package. The web playground gets a new /components route exercising every component as living docs and manual QA surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/add-react-components.md | 5 + package.json | 2 +- packages/react/README.md | 146 ++++++++++++++++++ packages/react/package.json | 85 ++++++++++ .../react/src/components/BudgetMeter.test.tsx | 27 ++++ packages/react/src/components/BudgetMeter.tsx | 39 +++++ .../react/src/components/CostBreakdown.tsx | 56 +++++++ .../react/src/components/LiveTokenizer.tsx | 60 +++++++ .../src/components/ModelCostMatrix.test.tsx | 26 ++++ .../react/src/components/ModelCostMatrix.tsx | 61 ++++++++ .../react/src/components/ModelSelector.tsx | 54 +++++++ .../react/src/components/PricingTable.tsx | 60 +++++++ .../src/components/TokenCounter.test.tsx | 38 +++++ .../react/src/components/TokenCounter.tsx | 51 ++++++ .../src/components/VisionCostEstimator.tsx | 131 ++++++++++++++++ packages/react/src/components/index.ts | 16 ++ packages/react/src/hooks/index.ts | 17 ++ packages/react/src/hooks/useBudget.test.ts | 35 +++++ packages/react/src/hooks/useBudget.ts | 54 +++++++ .../react/src/hooks/useCostMatrix.test.ts | 33 ++++ packages/react/src/hooks/useCostMatrix.ts | 30 ++++ .../react/src/hooks/useDebouncedTokenCount.ts | 44 ++++++ packages/react/src/hooks/useModelList.ts | 23 +++ packages/react/src/hooks/usePricing.ts | 38 +++++ .../react/src/hooks/useTokenCount.test.ts | 39 +++++ packages/react/src/hooks/useTokenCount.ts | 55 +++++++ .../react/src/hooks/useTokenCountEmpirical.ts | 55 +++++++ packages/react/src/index.ts | 51 ++++++ packages/react/src/styled/index.ts | 17 ++ .../react/src/styled/styled-components.tsx | 141 +++++++++++++++++ packages/react/src/styled/tokens.ts | 48 ++++++ packages/react/src/test-setup.ts | 7 + packages/react/src/utils/format.test.ts | 28 ++++ packages/react/src/utils/format.ts | 13 ++ packages/react/tsconfig.json | 18 +++ packages/react/tsup.config.ts | 18 +++ packages/react/vitest.config.ts | 10 ++ packages/web/package.json | 1 + packages/web/src/App.tsx | 2 + packages/web/src/pages/ComponentsPage.tsx | 113 ++++++++++++++ vitest.workspace.ts | 21 +++ 41 files changed, 1767 insertions(+), 1 deletion(-) create mode 100644 .changeset/add-react-components.md create mode 100644 packages/react/README.md create mode 100644 packages/react/package.json create mode 100644 packages/react/src/components/BudgetMeter.test.tsx create mode 100644 packages/react/src/components/BudgetMeter.tsx create mode 100644 packages/react/src/components/CostBreakdown.tsx create mode 100644 packages/react/src/components/LiveTokenizer.tsx create mode 100644 packages/react/src/components/ModelCostMatrix.test.tsx create mode 100644 packages/react/src/components/ModelCostMatrix.tsx create mode 100644 packages/react/src/components/ModelSelector.tsx create mode 100644 packages/react/src/components/PricingTable.tsx create mode 100644 packages/react/src/components/TokenCounter.test.tsx create mode 100644 packages/react/src/components/TokenCounter.tsx create mode 100644 packages/react/src/components/VisionCostEstimator.tsx create mode 100644 packages/react/src/components/index.ts create mode 100644 packages/react/src/hooks/index.ts create mode 100644 packages/react/src/hooks/useBudget.test.ts create mode 100644 packages/react/src/hooks/useBudget.ts create mode 100644 packages/react/src/hooks/useCostMatrix.test.ts create mode 100644 packages/react/src/hooks/useCostMatrix.ts create mode 100644 packages/react/src/hooks/useDebouncedTokenCount.ts create mode 100644 packages/react/src/hooks/useModelList.ts create mode 100644 packages/react/src/hooks/usePricing.ts create mode 100644 packages/react/src/hooks/useTokenCount.test.ts create mode 100644 packages/react/src/hooks/useTokenCount.ts create mode 100644 packages/react/src/hooks/useTokenCountEmpirical.ts create mode 100644 packages/react/src/index.ts create mode 100644 packages/react/src/styled/index.ts create mode 100644 packages/react/src/styled/styled-components.tsx create mode 100644 packages/react/src/styled/tokens.ts create mode 100644 packages/react/src/test-setup.ts create mode 100644 packages/react/src/utils/format.test.ts create mode 100644 packages/react/src/utils/format.ts create mode 100644 packages/react/tsconfig.json create mode 100644 packages/react/tsup.config.ts create mode 100644 packages/react/vitest.config.ts create mode 100644 packages/web/src/pages/ComponentsPage.tsx create mode 100644 vitest.workspace.ts diff --git a/.changeset/add-react-components.md b/.changeset/add-react-components.md new file mode 100644 index 0000000..f51b9a0 --- /dev/null +++ b/.changeset/add-react-components.md @@ -0,0 +1,5 @@ +--- +'@tokenometer/react': minor +--- + +Initial release of `@tokenometer/react` — drop-in React hooks and components for LLM token cost dashboards. Includes `useTokenCount`, `useCostMatrix`, `useBudget`, `useDebouncedTokenCount`, `useModelList`, `usePricing` hooks plus ``, ``, ``, ``, ``, ``, ``, `` components. Headless-first with opt-in `@tokenometer/react/styled` wrappers. SSR / RSC compatible via `"use client"` banner. Peer deps: react >=18, react-dom >=18, @tokenometer/core >=1.0.1. diff --git a/package.json b/package.json index 1685c9a..bf1677b 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ }, "workspaces": ["packages/*", "apps/*"], "scripts": { - "build": "tsc -b", + "build": "tsc -b && npm run build --workspaces --if-present", "clean": "tsc -b --clean && rm -rf packages/*/dist apps/*/dist", "test": "vitest run", "test:watch": "vitest", diff --git a/packages/react/README.md b/packages/react/README.md new file mode 100644 index 0000000..a80b633 --- /dev/null +++ b/packages/react/README.md @@ -0,0 +1,146 @@ +# @tokenometer/react + +React hooks and unstyled components for building LLM token-cost dashboards on +top of [`@tokenometer/core`](https://www.npmjs.com/package/@tokenometer/core). +Counters, cost matrices, budget meters, vision-token estimators and a live +tokenizer textarea for Claude, GPT-4o, Gemini, Mistral and Cohere — with a +headless API so you can bring your own UI. + +## Install + +```bash +npm i @tokenometer/react @tokenometer/core react react-dom +``` + +`react`, `react-dom` and `@tokenometer/core` are peer dependencies, so the +package adds nothing extra to your bundle beyond its own code. + +## Quickstart + +### Token counter + +```tsx +import { TokenCounter } from '@tokenometer/react'; + +export function Header() { + return ; +} +``` + +### Cost matrix across models and formats + +```tsx +import { ModelCostMatrix } from '@tokenometer/react'; + +export function Compare({ prompt }: { prompt: string }) { + return ( + + ); +} +``` + +### Live tokenizer textarea + +```tsx +import { LiveTokenizer } from '@tokenometer/react'; + +export function Playground() { + return ; +} +``` + +## Hooks API + +All hooks are tree-shakeable and exported from both the root entry and +`@tokenometer/react/hooks`. + +| Hook | Signature | Notes | +| --- | --- | --- | +| `useTokenCount` | `({ prompt, model, format? }) => { tokens, cost, tokenizer, approximate, error? }` | Synchronous. Memoized on inputs. | +| `useTokenCountEmpirical` | `({ prompt, model, env, format? }) => { data?, error?, isLoading }` | Async; provider API keys via `env`. | +| `useCostMatrix` | `({ prompt, models, formats? }) => TokenizeResult[]` | Flat cartesian product. | +| `useBudget` | `({ usedUsd, budgetUsd, warnAt? }) => { percent, remaining, state, formatted }` | `state` is `'ok' \| 'warn' \| 'over'`. | +| `useDebouncedTokenCount` | `({ prompt, model, delayMs?, format? }) => UseTokenCountResult & { isPending }` | Debounces the prompt before tokenizing. | +| `useModelList` | `({ providers? }) => ModelDescriptor[]` | Reads `MODELS` from core. | +| `usePricing` | `({ models?, providers? }) => { model, rate }[]` | Projects `RATES` into rows. | + +## Components API + +| Component | Required props | Optional props | +| --- | --- | --- | +| `TokenCounter` | `prompt`, `model` | `format`, `className`, `render` | +| `ModelCostMatrix` | `prompt`, `models` | `formats`, `className` | +| `BudgetMeter` | `usedUsd`, `budgetUsd` | `warnAt`, `label`, `className` | +| `CostBreakdown` | `items` | `showTotal`, `className` | +| `ModelSelector` | `value`, `onChange` | `providers`, `id`, `placeholder`, `className` | +| `LiveTokenizer` | `model` | `defaultPrompt`, `debounceMs`, `placeholder`, `onChange`, `className` | +| `PricingTable` | — | `models`, `providers`, `currency`, `className` | +| `VisionCostEstimator` | `provider`, `images` | `model`, `className` | + +Every component accepts `className` and forwards a ref to its root element. +Components emit `data-tk=""` attributes so consumers can target them +with their own CSS without ID gymnastics. + +## Headless pattern + +Every component is built on top of a hook. If you want full control over +the markup, skip the components and call the hook directly: + +```tsx +import { useTokenCount, formatUsd } from '@tokenometer/react'; + +export function CustomBadge({ prompt, model }: { prompt: string; model: string }) { + const { tokens, cost, approximate } = useTokenCount({ prompt, model }); + return ( +
+ {tokens} tokens · {formatUsd(cost)} + {approximate ? (approx.) : null} +
+ ); +} +``` + +## Styled variants + +If you want batteries-included visuals without writing CSS, import from +`@tokenometer/react/styled`: + +```tsx +import { StyledTokenCounter, StyledPricingTable } from '@tokenometer/react/styled'; +``` + +Styled wrappers apply minimal inline styles using CSS custom properties +(`--tk-bg`, `--tk-fg`, `--tk-border`, `--tk-warn`, `--tk-danger`, +`--tk-spacing`, `--tk-radius`, `--tk-font`). Override any of them on +`:root` or a parent element to theme. + +## SSR and React Server Components + +The build emits a `"use client";` banner so the package works inside React +Server Components without wrapping every import in `'use client'`. Hooks +that touch DOM APIs (debounce timers, `useState`) are intentionally lazy: +they only run on the client. Server-rendered output is deterministic and +matches the first client render for synchronous hooks. + +## Micro-frontend notes + +The package is ESM-first with a CommonJS fallback and ships separate +entry points for hooks, components, and styled wrappers. Federated builds +can import only what they use: + +```ts +import { useTokenCount } from '@tokenometer/react/hooks'; +import { TokenCounter } from '@tokenometer/react/components'; +``` + +`sideEffects: false` keeps bundlers happy with tree-shaking. React, +react-dom and `@tokenometer/core` are peer dependencies, so federated +hosts can dedupe them. + +## License + +MIT. diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 0000000..4cd856d --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,85 @@ +{ + "name": "@tokenometer/react", + "version": "0.1.0", + "description": "React hooks and components for LLM token cost dashboards — drop-in counters, cost matrices, budget meters for Claude, GPT-4o, Gemini, Mistral, Cohere.", + "license": "MIT", + "author": "Faraazuddin Mohammed ", + "homepage": "https://tokenometer.vercel.app", + "repository": { + "type": "git", + "url": "git+https://github.com/faraa2m/tokenometer.git", + "directory": "packages/react" + }, + "bugs": { + "url": "https://github.com/faraa2m/tokenometer/issues" + }, + "keywords": [ + "react", + "hooks", + "llm", + "tokens", + "cost", + "dashboard", + "ai", + "claude", + "gpt", + "gemini" + ], + "type": "module", + "sideEffects": false, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./hooks": { + "types": "./dist/hooks/index.d.ts", + "import": "./dist/hooks/index.js", + "require": "./dist/hooks/index.cjs" + }, + "./components": { + "types": "./dist/components/index.d.ts", + "import": "./dist/components/index.js", + "require": "./dist/components/index.cjs" + }, + "./styled": { + "types": "./dist/styled/index.d.ts", + "import": "./dist/styled/index.js", + "require": "./dist/styled/index.cjs" + }, + "./package.json": "./package.json" + }, + "files": ["dist", "README.md"], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "tsup", + "clean": "rm -rf dist" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0", + "@tokenometer/core": ">=1.0.1" + }, + "devDependencies": { + "@tokenometer/core": "1.0.1", + "@testing-library/jest-dom": "^6.5.0", + "@testing-library/react": "^16.0.1", + "@testing-library/user-event": "^14.5.2", + "@types/node": "^22.10.5", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "jsdom": "^25.0.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^3.0.0" + } +} diff --git a/packages/react/src/components/BudgetMeter.test.tsx b/packages/react/src/components/BudgetMeter.test.tsx new file mode 100644 index 0000000..2dc6ffe --- /dev/null +++ b/packages/react/src/components/BudgetMeter.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { BudgetMeter } from './BudgetMeter.js'; + +describe('BudgetMeter', () => { + it('renders a progress bar with the right max and value', () => { + render(); + const bar = screen.getByRole('progressbar', { name: 'month' }); + expect(bar).toHaveAttribute('max', '10'); + expect(bar).toHaveAttribute('value', '2.5'); + }); + + it('sets state attribute according to spend', () => { + const { container, rerender } = render(); + expect(container.firstChild).toHaveAttribute('data-tk-state', 'ok'); + rerender(); + expect(container.firstChild).toHaveAttribute('data-tk-state', 'warn'); + rerender(); + expect(container.firstChild).toHaveAttribute('data-tk-state', 'over'); + }); + + it('clamps the progress value to the max so the bar does not overflow', () => { + render(); + const bar = screen.getByRole('progressbar', { name: 'm' }); + expect(bar).toHaveAttribute('value', '10'); + }); +}); diff --git a/packages/react/src/components/BudgetMeter.tsx b/packages/react/src/components/BudgetMeter.tsx new file mode 100644 index 0000000..8cb906e --- /dev/null +++ b/packages/react/src/components/BudgetMeter.tsx @@ -0,0 +1,39 @@ +import { forwardRef } from 'react'; +import { useBudget } from '../hooks/useBudget.js'; + +export interface BudgetMeterProps { + usedUsd: number; + budgetUsd: number; + warnAt?: number; + className?: string; + label?: string; +} + +/** + * Progress bar visualizing spend against a USD budget. + * `` is intentionally unstyled — wrap with CSS or use the + * styled variant for opinionated visuals. + */ +export const BudgetMeter = forwardRef( + function BudgetMeter(props, ref) { + const { usedUsd, budgetUsd, warnAt, className, label = 'budget' } = props; + const budget = useBudget({ + usedUsd, + budgetUsd, + ...(warnAt !== undefined ? { warnAt } : {}), + }); + const max = budgetUsd > 0 ? budgetUsd : 1; + const value = Math.max(0, Math.min(usedUsd, max)); + return ( +
+
+ {label} + + {budget.formatted.used} / {budget.formatted.budget} ({budget.formatted.percent}) + +
+ +
+ ); + }, +); diff --git a/packages/react/src/components/CostBreakdown.tsx b/packages/react/src/components/CostBreakdown.tsx new file mode 100644 index 0000000..4510d36 --- /dev/null +++ b/packages/react/src/components/CostBreakdown.tsx @@ -0,0 +1,56 @@ +import { forwardRef } from 'react'; +import { formatUsd } from '../utils/format.js'; + +export interface CostBreakdownItem { + label: string; + tokens: number; + cost: number; +} + +export interface CostBreakdownProps { + items: readonly CostBreakdownItem[]; + showTotal?: boolean; + className?: string; +} + +/** + * Table of {label, tokens, cost} rows with an optional total row. + */ +export const CostBreakdown = forwardRef( + function CostBreakdown(props, ref) { + const { items, showTotal = true, className } = props; + const totals = items.reduce( + (acc, item) => ({ cost: acc.cost + item.cost, tokens: acc.tokens + item.tokens }), + { cost: 0, tokens: 0 }, + ); + return ( + + + + + + + + + + {items.map((item) => ( + + + + + + ))} + + {showTotal ? ( + + + + + + + + ) : null} +
labeltokenscost
{item.label}{item.tokens}{formatUsd(item.cost)}
total{totals.tokens}{formatUsd(totals.cost)}
+ ); + }, +); diff --git a/packages/react/src/components/LiveTokenizer.tsx b/packages/react/src/components/LiveTokenizer.tsx new file mode 100644 index 0000000..e1803cc --- /dev/null +++ b/packages/react/src/components/LiveTokenizer.tsx @@ -0,0 +1,60 @@ +import { type ChangeEvent, forwardRef, useCallback, useState } from 'react'; +import { useDebouncedTokenCount } from '../hooks/useDebouncedTokenCount.js'; +import { formatUsd } from '../utils/format.js'; + +export interface LiveTokenizerProps { + model: string; + defaultPrompt?: string; + debounceMs?: number; + placeholder?: string; + className?: string; + onChange?: (prompt: string) => void; +} + +/** + * Controlled textarea + debounced token count. Renders a live readout of + * tokens and input cost. + */ +export const LiveTokenizer = forwardRef( + function LiveTokenizer(props, ref) { + const { + model, + defaultPrompt = '', + debounceMs = 200, + placeholder = 'paste a prompt...', + className, + onChange, + } = props; + const [prompt, setPrompt] = useState(defaultPrompt); + const onTextareaChange = useCallback( + (e: ChangeEvent) => { + const next = e.target.value; + setPrompt(next); + onChange?.(next); + }, + [onChange], + ); + const result = useDebouncedTokenCount({ prompt, model, delayMs: debounceMs }); + return ( +
+