diff --git a/.changeset/webmcp-tools.md b/.changeset/webmcp-tools.md new file mode 100644 index 000000000..14ea2b64f --- /dev/null +++ b/.changeset/webmcp-tools.md @@ -0,0 +1,5 @@ +--- +'@tanstack/devtools-webmcp': patch +--- + +Add `@tanstack/devtools-webmcp` so a library can register development-only WebMCP tools. diff --git a/.gitignore b/.gitignore index 45a04700a..189e6eb03 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,5 @@ vite.config.ts.timestamp-* .nitro .sonda *settings.local.json -.claude/worktrees \ No newline at end of file +.claude/worktrees +.agent/scratch/ \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md index 45fdc70f7..55065da6f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -231,6 +231,14 @@ Listens for `install-devtools` events from the devtools UI, runs the package man ### Connection injection (`@tanstack/devtools:connection-injection`) Replaces compile-time placeholders (`__TANSTACK_DEVTOOLS_PORT__`, `__TANSTACK_DEVTOOLS_HOST__`, `__TANSTACK_DEVTOOLS_PROTOCOL__`) in the event bus client code with the actual values from the running dev server, so the client automatically connects to the correct server. +## WebMCP Tools + +`@tanstack/devtools-webmcp` registers WebMCP tools on the page. A browser agent calls those tools during development. The package has no runtime dependencies. + +The helper uses the browser `modelContext`. The helper does not join the event bus. These tools do not appear in a devtools panel. + +The steps are in [WebMCP Tools](./webmcp-tools). + ## Data Flow To tie everything together, here is what happens when a plugin emits an event end-to-end: diff --git a/docs/config.json b/docs/config.json index 875de2227..25c948286 100644 --- a/docs/config.json +++ b/docs/config.json @@ -78,7 +78,8 @@ { "label": "Building Custom Plugins", "to": "building-custom-plugins" }, { "label": "Using devtools-utils", "to": "devtools-utils" }, { "label": "Bidirectional Communication", "to": "bidirectional-communication" }, - { "label": "Third-party Plugins", "to": "third-party-plugins" } + { "label": "Third-party Plugins", "to": "third-party-plugins" }, + { "label": "WebMCP Tools", "to": "webmcp-tools" } ], "frameworks": [ { diff --git a/docs/production.md b/docs/production.md index 70d6eb38b..125ce3f94 100644 --- a/docs/production.md +++ b/docs/production.md @@ -83,6 +83,32 @@ runtime behavior differs. This is independent of the Vite plugin's `removeDevtoolsOnBuild` option — the event client strips itself based on `NODE_ENV`, whether or not you use the Vite plugin. +## WebMCP tools in production + +You search a production build for registered library WebMCP tools. The root import does not register them. + +With the root import, libraries register those tools only in development. + +If the tools must stay registered in production, import `@tanstack/devtools-webmcp/production`. + +```ts +import { registerDevtoolsTools } from '@tanstack/devtools-webmcp/production' +``` + +The import `@tanstack/devtools-webmcp/production` is always the real helper. The register call is the same as in [WebMCP Tools](./webmcp-tools). + +Use the root import for development. + +```ts +import { registerDevtoolsTools } from '@tanstack/devtools-webmcp' +``` + +When `process.env.NODE_ENV` is `'development'`, the root import is the real helper. In every other environment, the root import is a no-op. An unset `NODE_ENV` is a no-op too. + +Bundlers remove the real helper. The tool objects stay in the library bundle. The no-op does not call the browser. The no-op does not keep the tools object. + +If you build an app, stop the search. The root import does not register these tools in that bundle. + ## Where to install the Devtools If you are using the devtools in development only, you can install them as a development dependency and only import them in development builds. This is the default recommended way to use the devtools. diff --git a/docs/webmcp-tools.md b/docs/webmcp-tools.md new file mode 100644 index 000000000..c8cd24e2d --- /dev/null +++ b/docs/webmcp-tools.md @@ -0,0 +1,155 @@ +--- +title: WebMCP Tools +id: webmcp-tools +--- + +An agent in the browser cannot see the internals of your library. WebMCP tools give that agent a way to read those internals during development. + +Call `registerDevtoolsTools` from `@tanstack/devtools-webmcp`. + +## Register the Tools + +1. Install `@tanstack/devtools-webmcp`. + +```sh +npm install @tanstack/devtools-webmcp +``` + +The package has no runtime dependencies. + +2. Paste this call into your library. + +```ts +import { registerDevtoolsTools } from '@tanstack/devtools-webmcp' + +const stop = registerDevtoolsTools({ + pluginId: 'tanstack.query', + instanceId: 'main', + tools: [ + { + name: 'getQueryCache', + description: 'Return a JSON summary of the query cache.', + inputSchema: { + type: 'object', + properties: { + queryHash: { type: 'string' }, + }, + }, + execute: (input) => summarize(client, input), + }, + ], +}) + +stop() +``` + +`summarize` and `client` are your own library code. + +3. If the tools must stay, do not call `stop()`. +4. When the tools must leave the page, call `stop()`. + +## Names + +`pluginId` is required. `instanceId` is optional. + +An empty `instanceId` means the same thing as no `instanceId`. An empty `instanceId` is legal. + +The browser tool name is `pluginId.name`. When `instanceId` is a non-empty string, the browser tool name is `pluginId.instanceId.name`. + +In the sample, the browser tool name is `tanstack.query.main.getQueryCache`. + +A part is `pluginId` or `name`. When `instanceId` is a non-empty string, that value is also a part. + +Each part is a non-empty string. Each part uses only ASCII letters (`A-Z` and `a-z`), digits, `_`, `-`, and `.`. + +The full tool name has 1 to 128 characters. + +## Tool Fields + +A tool needs `name`, `description`, and `execute`. + +- `name`: the last part of the browser tool name. +- `description`: the text that the agent reads. +- `title`: an optional string. +- `inputSchema`: an optional JSON Schema. +- `execute`: the function that returns the data. + +`TInput` defaults to `any`. This package does not read `inputSchema` into TypeScript types. + +`execute` receives the input and `{ signal }`. The `signal` is an `AbortSignal`. + +The return value is data that `JSON.stringify` can serialize. The helper sends that value to the browser with no change. + +The helper sets `annotations.debugging` to `true`. + +When you set one of these hints, the helper sends that hint with the tool. + +- `readOnlyHint` +- `untrustedContentHint` +- `consequentialHint` + +These hints do not change `debugging`. + +## Development and Production + +Use the root import for development. + +```ts +import { registerDevtoolsTools } from '@tanstack/devtools-webmcp' +``` + +When `process.env.NODE_ENV` is `'development'`, the root import is the real helper. In every other environment, the root import is a no-op. An unset `NODE_ENV` is a no-op too. + +Bundlers remove the real helper. The tool objects stay in the library bundle. The no-op does not call the browser. The no-op does not keep the tools object. + +If the tools must stay registered in production, import `@tanstack/devtools-webmcp/production`. + +```ts +import { registerDevtoolsTools } from '@tanstack/devtools-webmcp/production' +``` + +The import `@tanstack/devtools-webmcp/production` is always the real helper. The `registerDevtoolsTools` call stays the same. Read [Production](./production) for the production build. + +## Browser Lookup + +When `registerTool` is a function on `document.modelContext`, the helper uses `document.modelContext`. + +When that function is not on `document.modelContext`, and `navigator.modelContext.registerTool` is a function, the helper uses `navigator.modelContext`. + +When neither object has that function, the call returns a cleanup function. The call does not throw. The helper writes nothing to the console on this path. + +These environments take this path: + +- server render +- Node +- browser with no WebMCP + +## Replace and Remove + +One `AbortController` is for the whole list. The helper passes that signal to each `registerTool` call. The helper passes the same signal to `execute` as `{ signal }`. + +Cleanup aborts that `AbortController` and the `signal` in `execute`. Then the browser removes the tools. + +The call returns `stop` at once. The call does not return the `registerTool` promise. + +A second call with the same `pluginId` and `instanceId` replaces the previous list. That call aborts the previous controller. Then the previous `stop` function does nothing. + +A hot reload calls the helper a second time with the same `pluginId` and `instanceId`. + +An empty `tools` array removes the previous tools for that `pluginId` and `instanceId`. The helper registers no tools on that call. + +## Errors + +The helper writes one `console.error` for each error. The helper does not throw. The helper logs a rejected `registerTool` promise. When the helper aborts the signal, a rejection from that `registerTool` call writes no `console.error`. + +- If `pluginId` or `instanceId` is illegal, the helper registers nothing and the previous registration stays. An empty `instanceId` is legal. +- If a tool `name` is illegal, or the full name is longer than 128 characters, the helper skips that tool. Legal tools in the same list still register. +- If the list repeats a full name, the browser rejects the second tool. The helper logs that error, and the first tool stays registered. +- If other code owns that full name, the browser rejects that tool. The helper logs the error and skips that tool. +- If `JSON.stringify` cannot serialize `inputSchema`, the browser rejects that tool. The helper logs the error and continues with the other tools. + +## Result + +These tools do not appear in a devtools panel. + +The agent can call `tanstack.query.main.getQueryCache`. The tool returns the value from your `summarize` function. diff --git a/examples/react/basic/package.json b/examples/react/basic/package.json index 3addca1d7..32babc9d9 100644 --- a/examples/react/basic/package.json +++ b/examples/react/basic/package.json @@ -13,6 +13,7 @@ "@tanstack/devtools-a11y": "workspace:*", "@tanstack/devtools-client": "0.0.8", "@tanstack/devtools-event-client": "0.5.0", + "@tanstack/devtools-webmcp": "workspace:*", "@tanstack/react-devtools": "^0.10.12", "@tanstack/react-form": "^1.23.7", "@tanstack/react-query": "^5.90.1", diff --git a/examples/react/basic/src/example.css b/examples/react/basic/src/example.css index 11521b4a1..db9c45340 100644 --- a/examples/react/basic/src/example.css +++ b/examples/react/basic/src/example.css @@ -126,6 +126,13 @@ a { transform: scale(0.96); } +.example-shell button:disabled { + border-color: #eeebd4; + background: #eeebd4; + color: #3e3529; + cursor: not-allowed; +} + .example-shell :focus-visible { outline: 3px solid #61adbf; outline-offset: 3px; diff --git a/examples/react/basic/src/index.tsx b/examples/react/basic/src/index.tsx index 00ca13173..dd550baec 100644 --- a/examples/react/basic/src/index.tsx +++ b/examples/react/basic/src/index.tsx @@ -11,6 +11,7 @@ import Devtools from './setup' import { queryPlugin } from './plugin' import { Button } from './button' import { Feature } from './feature' +import { WebMcpProof } from './webmcp-proof' import './example.css' const queryClient = new QueryClient({ @@ -180,6 +181,7 @@ function App() { accessibility fixture in one focused sandbox.

+
unknown | Promise +} + +type ModelContextTool = { + name: string + description: string + annotations?: { + debugging?: boolean + } + execute: ToolRecord['execute'] +} + +type ModelContext = { + registerTool: ( + tool: ModelContextTool, + options?: { signal?: AbortSignal }, + ) => Promise +} + +type ActiveTool = { + stop: () => void + record: ToolRecord +} + +let recorderReady = false +let onRecord: (record: ToolRecord) => void = () => {} + +function ensureRecorder() { + if (recorderReady) { + return + } + + const doc = document as Document & { modelContext?: ModelContext } + const previous = doc.modelContext + + doc.modelContext = { + async registerTool(tool, options) { + const signal = options?.signal + if (signal) { + onRecord({ + name: tool.name, + debugging: tool.annotations?.debugging === true, + signal, + execute: tool.execute, + }) + } + if (previous) { + await previous.registerTool(tool, options) + } + }, + } + recorderReady = true +} + +export function WebMcpProof() { + const [status, setStatus] = useState('No tool is registered.') + const [active, setActive] = useState(null) + + function registerTool() { + ensureRecorder() + const previousSignal = active?.record.signal + let recorded: ToolRecord | null = null + onRecord = (record) => { + recorded = record + } + + const stop = registerDevtoolsTools({ + pluginId: 'tanstack.query', + instanceId: 'main', + tools: [ + { + name: 'getQueryCache', + description: 'Return a JSON summary of the query cache.', + execute: () => ({ status: 'ok' }), + }, + ], + }) + + if (!recorded) { + setActive(null) + setStatus('The helper did not register a tool.') + return + } + + const record: ToolRecord = recorded + const debuggingText = record.debugging ? 'true' : 'false' + const previousText = + previousSignal === undefined + ? '' + : previousSignal.aborted + ? ' The previous signal is aborted.' + : ' The previous signal is still active.' + + setActive({ stop, record }) + setStatus( + `Registered ${record.name}. debugging is ${debuggingText}.${previousText}`, + ) + } + + function runTool() { + if (!active) { + return + } + const result = active.record.execute({}, { signal: active.record.signal }) + setStatus(`Tool result is ${JSON.stringify(result)}.`) + } + + function stopTool() { + if (!active) { + return + } + active.stop() + const name = active.record.name + setActive(null) + setStatus( + active.record.signal.aborted + ? `Stopped ${name}. The signal is aborted.` + : `Stopped ${name}. The signal is still active.`, + ) + } + + return ( +
+

WebMCP tool

+

+ Register a development tool. Then read the name the browser receives. +

+
+ + + +
+

+ {status} +

+
+ ) +} diff --git a/package.json b/package.json index 5f45962cb..5a58e4184 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,14 @@ { "path": "packages/event-bus-client/dist/esm/plugin.js", "limit": "1.2 KB" + }, + { + "path": "packages/devtools-webmcp/dist/esm/register.js", + "limit": "1.5 KB" + }, + { + "path": "packages/devtools-webmcp/dist/esm/noop.js", + "limit": "250 B" } ], "devDependencies": { diff --git a/packages/devtools-webmcp/README.md b/packages/devtools-webmcp/README.md new file mode 100644 index 000000000..1d1529a10 --- /dev/null +++ b/packages/devtools-webmcp/README.md @@ -0,0 +1,152 @@ +# @tanstack/devtools-webmcp + +An agent in the browser cannot see the internals of your library. WebMCP tools give that agent a way to read those internals during development. + +Call `registerDevtoolsTools` from this package. + +## Register + +1. Install `@tanstack/devtools-webmcp`. + +```sh +npm install @tanstack/devtools-webmcp +``` + +The package has no runtime dependencies. + +2. Paste this call into your library. + +```ts +import { registerDevtoolsTools } from '@tanstack/devtools-webmcp' + +const stop = registerDevtoolsTools({ + pluginId: 'tanstack.query', + instanceId: 'main', + tools: [ + { + name: 'getQueryCache', + description: 'Return a JSON summary of the query cache.', + inputSchema: { + type: 'object', + properties: { + queryHash: { type: 'string' }, + }, + }, + execute: (input) => summarize(client, input), + }, + ], +}) + +stop() +``` + +`summarize` and `client` are your own library code. + +3. If the tools must stay, do not call `stop()`. +4. When the tools must leave the page, call `stop()`. + +## Names + +`pluginId` is required. `instanceId` is optional. + +An empty `instanceId` means the same thing as no `instanceId`. An empty `instanceId` is legal. + +The browser tool name is `pluginId.name`. When `instanceId` is a non-empty string, the browser tool name is `pluginId.instanceId.name`. + +In the sample, the browser tool name is `tanstack.query.main.getQueryCache`. + +A part is `pluginId` or `name`. When `instanceId` is a non-empty string, that value is also a part. + +Each part is a non-empty string. Each part uses only ASCII letters (`A-Z` and `a-z`), digits, `_`, `-`, and `.`. + +The full tool name has 1 to 128 characters. + +## Tool fields + +A tool needs `name`, `description`, and `execute`. + +- `name`: the last part of the browser tool name. +- `description`: the text that the agent reads. +- `title`: an optional string. +- `inputSchema`: an optional JSON Schema. +- `execute`: the function that returns the data. + +`TInput` defaults to `any`. This package does not read `inputSchema` into TypeScript types. + +`execute` receives the input and `{ signal }`. The `signal` is an `AbortSignal`. + +The return value is data that `JSON.stringify` can serialize. The helper sends that value to the browser with no change. + +The helper sets `annotations.debugging` to `true`. + +When you set one of these hints, the helper sends that hint with the tool. + +- `readOnlyHint` +- `untrustedContentHint` +- `consequentialHint` + +These hints do not change `debugging`. + +## Development and production + +Use the root import for development. + +```ts +import { registerDevtoolsTools } from '@tanstack/devtools-webmcp' +``` + +When `process.env.NODE_ENV` is `'development'`, the root import is the real helper. In every other environment, the root import is a no-op. An unset `NODE_ENV` is a no-op too. + +Bundlers remove the real helper. The tool objects stay in the library bundle. The no-op does not call the browser. The no-op does not keep the tools object. + +If the tools must stay registered in production, import `@tanstack/devtools-webmcp/production`. + +```ts +import { registerDevtoolsTools } from '@tanstack/devtools-webmcp/production' +``` + +The import `@tanstack/devtools-webmcp/production` is always the real helper. The `registerDevtoolsTools` call stays the same. + +## Browser lookup + +When `registerTool` is a function on `document.modelContext`, the helper uses `document.modelContext`. + +When that function is not on `document.modelContext`, and `navigator.modelContext.registerTool` is a function, the helper uses `navigator.modelContext`. + +When neither object has that function, the call returns a cleanup function. The call does not throw. The helper writes nothing to the console on this path. + +These environments take this path: + +- server render +- Node +- browser with no WebMCP + +## Replace and remove + +One `AbortController` is for the whole list. The helper passes that signal to each `registerTool` call. The helper passes the same signal to `execute` as `{ signal }`. + +Cleanup aborts that `AbortController` and the `signal` in `execute`. Then the browser removes the tools. + +The call returns `stop` at once. The call does not return the `registerTool` promise. + +A second call with the same `pluginId` and `instanceId` replaces the previous list. That call aborts the previous controller. Then the previous `stop` function does nothing. + +A hot reload calls the helper a second time with the same `pluginId` and `instanceId`. + +An empty `tools` array removes the previous tools for that `pluginId` and `instanceId`. The helper registers no tools on that call. + +## Errors + +The helper writes one `console.error` for each error. The helper does not throw. The helper logs a rejected `registerTool` promise. When the helper aborts the signal, a rejection from that `registerTool` call writes no `console.error`. + +- If `pluginId` or `instanceId` is illegal, the helper registers nothing and the previous registration stays. An empty `instanceId` is legal. +- If a tool `name` is illegal, or the full name is longer than 128 characters, the helper skips that tool. Legal tools in the same list still register. +- If the list repeats a full name, the browser rejects the second tool. The helper logs that error, and the first tool stays registered. +- If other code owns that full name, the browser rejects that tool. The helper logs the error and skips that tool. +- If `JSON.stringify` cannot serialize `inputSchema`, the browser rejects that tool. The helper logs the error and continues with the other tools. + +## Result + +These tools do not appear in a devtools panel. + +The agent can call `tanstack.query.main.getQueryCache`. The tool returns the value from your `summarize` function. diff --git a/packages/devtools-webmcp/bin/intent.js b/packages/devtools-webmcp/bin/intent.js new file mode 100644 index 000000000..2cf2efab4 --- /dev/null +++ b/packages/devtools-webmcp/bin/intent.js @@ -0,0 +1,20 @@ +#!/usr/bin/env node +// Auto-generated by @tanstack/intent setup +// Exposes the intent end-user CLI for consumers of this library. +// Commit this file, then add to your package.json: +// "bin": { "intent": "./bin/intent.js" } +try { + await import('@tanstack/intent/intent-library') +} catch (e) { + if (e?.code === 'ERR_MODULE_NOT_FOUND' || e?.code === 'MODULE_NOT_FOUND') { + console.error('@tanstack/intent is not installed.') + console.error('') + console.error('Install it as a dev dependency:') + console.error(' npm add -D @tanstack/intent') + console.error('') + console.error('Or run directly:') + console.error(' npx @tanstack/intent@latest list') + process.exit(1) + } + throw e +} diff --git a/packages/devtools-webmcp/eslint.config.js b/packages/devtools-webmcp/eslint.config.js new file mode 100644 index 000000000..e472c69e7 --- /dev/null +++ b/packages/devtools-webmcp/eslint.config.js @@ -0,0 +1,10 @@ +// @ts-check + +import rootConfig from '../../eslint.config.js' + +export default [ + ...rootConfig, + { + rules: {}, + }, +] diff --git a/packages/devtools-webmcp/package.json b/packages/devtools-webmcp/package.json new file mode 100644 index 000000000..894bbf9d8 --- /dev/null +++ b/packages/devtools-webmcp/package.json @@ -0,0 +1,73 @@ +{ + "name": "@tanstack/devtools-webmcp", + "version": "0.0.1", + "description": "A small helper a TanStack library uses to register development-only WebMCP tools.", + "author": "Tanner Linsley", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/devtools.git", + "directory": "packages/devtools-webmcp" + }, + "homepage": "https://tanstack.com/devtools", + "bugs": { + "url": "https://github.com/TanStack/devtools/issues" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "keywords": [ + "devtools" + ], + "type": "module", + "types": "dist/esm/index.d.ts", + "main": "dist/cjs/index.cjs", + "module": "dist/esm/index.js", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } + }, + "./production": { + "import": { + "types": "./dist/esm/production.d.ts", + "default": "./dist/esm/production.js" + }, + "require": { + "types": "./dist/cjs/production.d.cts", + "default": "./dist/cjs/production.cjs" + } + }, + "./package.json": "./package.json" + }, + "bin": { + "intent": "./bin/intent.js" + }, + "sideEffects": false, + "engines": { + "node": ">=18" + }, + "files": [ + "dist/", + "src", + "skills", + "bin" + ], + "scripts": { + "clean": "premove ./build ./dist", + "lint:fix": "eslint ./src --fix", + "test:eslint": "eslint ./src", + "test:lib": "vitest", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc", + "test:build": "publint --strict", + "build": "vite build" + } +} diff --git a/packages/devtools-webmcp/skills/devtools-webmcp/SKILL.md b/packages/devtools-webmcp/skills/devtools-webmcp/SKILL.md new file mode 100644 index 000000000..cacdd0c57 --- /dev/null +++ b/packages/devtools-webmcp/skills/devtools-webmcp/SKILL.md @@ -0,0 +1,194 @@ +--- +name: devtools-webmcp +description: 'Register WebMCP tools with registerDevtoolsTools from @tanstack/devtools-webmcp. Name rules, cleanup, replace behavior, root import, and the /production import.' +type: core +library: '@tanstack/devtools-webmcp' +library_version: '0.0.1' +sources: + - docs/webmcp-tools.md + - docs/production.md +--- + +# devtools-webmcp + +An agent in the browser cannot see the internals of your library. WebMCP tools give that agent a way to read those internals during development. + +The package is `@tanstack/devtools-webmcp`. It has no runtime dependencies. These tools do not appear in a devtools panel. + +## Register + +Paste this call into the library. + +```ts +import { registerDevtoolsTools } from '@tanstack/devtools-webmcp' + +const stop = registerDevtoolsTools({ + pluginId: 'tanstack.query', + instanceId: 'main', + tools: [ + { + name: 'getQueryCache', + description: 'Return a JSON summary of the query cache.', + inputSchema: { + type: 'object', + properties: { + queryHash: { type: 'string' }, + }, + }, + execute: (input) => summarize(client, input), + }, + ], +}) + +stop() +``` + +`summarize` and `client` are your own library code. This sample does not define them. + +If the tools must stay, do not call `stop()`. +When the tools must leave the page, call `stop()`. + +The call returns `stop` at once. The call does not return the `registerTool` promise. + +## Names + +`pluginId` is required. `instanceId` is optional. + +An empty `instanceId` means the same thing as no `instanceId`. An empty `instanceId` is legal. + +The browser tool name is `pluginId.name`. When `instanceId` is a non-empty string, the browser tool name is `pluginId.instanceId.name`. + +In the sample, the browser tool name is `tanstack.query.main.getQueryCache`. + +A part is `pluginId` or `name`. When `instanceId` is a non-empty string, that value is also a part. + +Each part is a non-empty string. Each part uses only ASCII letters (`A-Z` and `a-z`), digits, `_`, `-`, and `.`. + +The full tool name has 1 to 128 characters. + +## Tool fields + +A tool needs `name`, `description`, and `execute`. + +- `name`: the last part of the browser tool name. +- `description`: the text that the agent reads. +- `title`: an optional string. +- `inputSchema`: an optional JSON Schema. +- `execute`: the function that returns the data. + +`TInput` defaults to `any`. This package does not read `inputSchema` into TypeScript types. + +`execute` receives the input and `{ signal }`. The `signal` is an `AbortSignal`. + +The return value is data that `JSON.stringify` can serialize. The helper sends that value to the browser with no change. + +The helper sets `annotations.debugging` to `true`. + +When you set one of these hints, the helper sends that hint with the tool. + +- `readOnlyHint` +- `untrustedContentHint` +- `consequentialHint` + +These hints do not change `debugging`. + +## Cleanup and replace + +One `AbortController` is for the whole list. The helper passes that signal to each `registerTool` call. The helper passes the same signal to `execute` as `{ signal }`. + +Cleanup aborts that `AbortController` and the `signal` in `execute`. Then the browser removes the tools. + +A second call with the same `pluginId` and `instanceId` replaces the previous list. That call aborts the previous controller. Then the previous `stop` function does nothing. + +A hot reload calls the helper a second time with the same `pluginId` and `instanceId`. + +An empty `tools` array removes the previous tools for that `pluginId` and `instanceId`. The helper registers no tools on that call. + +## Errors + +The helper writes one `console.error` for each error. The helper does not throw. The helper logs a rejected `registerTool` promise. When the helper aborts the signal, a rejection from that `registerTool` call writes no `console.error`. + +- If `pluginId` or `instanceId` is illegal, the helper registers nothing and the previous registration stays. An empty `instanceId` is legal. +- If a tool `name` is illegal, or the full name is longer than 128 characters, the helper skips that tool. Legal tools in the same list still register. +- If the list repeats a full name, the browser rejects the second tool. The helper logs that error, and the first tool stays registered. +- If other code owns that full name, the browser rejects that tool. The helper logs the error and skips that tool. +- If `JSON.stringify` cannot serialize `inputSchema`, the browser rejects that tool. The helper logs the error and continues with the other tools. + +## Root import and `/production` + +Use the root import for development. + +```ts +import { registerDevtoolsTools } from '@tanstack/devtools-webmcp' +``` + +When `process.env.NODE_ENV` is `'development'`, the root import is the real helper. In every other environment, the root import is a no-op. An unset `NODE_ENV` is a no-op too. + +Bundlers remove the real helper. The tool objects stay in the library bundle. The no-op does not call the browser. The no-op does not keep the tools object. + +If the tools must stay registered in production, import `@tanstack/devtools-webmcp/production`. + +```ts +import { registerDevtoolsTools } from '@tanstack/devtools-webmcp/production' +``` + +The import `@tanstack/devtools-webmcp/production` is always the real helper. The `registerDevtoolsTools` call stays the same. + +## Browser lookup + +When `registerTool` is a function on `document.modelContext`, the helper uses `document.modelContext`. + +When that function is not on `document.modelContext`, and `navigator.modelContext.registerTool` is a function, the helper uses `navigator.modelContext`. + +When neither object has that function, the call returns a cleanup function. The call does not throw. The helper writes nothing to the console on this path. + +These environments take this path: + +- server render +- Node +- browser with no WebMCP + +## Common mistakes + +### Colon in a name + +A colon is not a legal character. The helper registers nothing, and the previous registration stays. + +Wrong: + +```ts +pluginId: 'tanstack:query' +``` + +Correct: + +```ts +pluginId: 'tanstack.query' +``` + +### Root import in production + +When the tools must stay in production, the root import is a no-op. + +Wrong: + +```ts +import { registerDevtoolsTools } from '@tanstack/devtools-webmcp' +``` + +Correct: + +```ts +import { registerDevtoolsTools } from '@tanstack/devtools-webmcp/production' +``` + +### A `stop()` call on the next line + +The sample includes `stop()`. That call removes the tools. + +If the tools must stay, do not call `stop()` on the next line. +When the tools must leave the page, call `stop()`. + +## Result + +The agent can call `tanstack.query.main.getQueryCache`. The tool returns the value from `summarize`. diff --git a/packages/devtools-webmcp/src/index.ts b/packages/devtools-webmcp/src/index.ts new file mode 100644 index 000000000..6610772e8 --- /dev/null +++ b/packages/devtools-webmcp/src/index.ts @@ -0,0 +1,36 @@ +import { registerDevtoolsTools as registerDevtoolsToolsImpl } from './register' +import { registerDevtoolsTools as registerDevtoolsToolsNoOp } from './noop' + +/** + * Register WebMCP tools for one plugin on the page. + * + * When `NODE_ENV` is `development`, this export is the real helper. + * This export is a no-op in every other environment. + * A production bundler replaces `process.env.NODE_ENV` with a literal. + * The bundler then keeps the no-op and removes `./register`. + * The caller imports `@tanstack/devtools-webmcp/production` to keep the real helper in production. + * + * `options.pluginId` is required. `options.instanceId` is optional. + * The function returns a cleanup function. The caller removes the tools with that function. + * + * @example + * ```ts + * const stop = registerDevtoolsTools({ + * pluginId: 'tanstack.query', + * tools: [], + * }) + * + * stop() + * ``` + */ +const registerDevtoolsTools = + process.env.NODE_ENV !== 'development' + ? registerDevtoolsToolsNoOp + : registerDevtoolsToolsImpl + +export { registerDevtoolsTools } +export type { + DevtoolsTool, + DevtoolsToolAnnotations, + RegisterDevtoolsToolsOptions, +} from './types' diff --git a/packages/devtools-webmcp/src/noop.ts b/packages/devtools-webmcp/src/noop.ts new file mode 100644 index 000000000..8d33838ab --- /dev/null +++ b/packages/devtools-webmcp/src/noop.ts @@ -0,0 +1,19 @@ +import type { RegisterDevtoolsToolsOptions } from './types' + +/** + * This function returns a cleanup function. This function does not register tools. + * This function does not keep the argument. The cleanup function does nothing. + * + * @example + * ```ts + * const stop = registerDevtoolsTools({ + * pluginId: 'tanstack.query', + * tools: [], + * }) + * + * stop() + * ``` + */ +export function registerDevtoolsTools(_options: RegisterDevtoolsToolsOptions) { + return () => {} +} diff --git a/packages/devtools-webmcp/src/production.ts b/packages/devtools-webmcp/src/production.ts new file mode 100644 index 000000000..2877619d4 --- /dev/null +++ b/packages/devtools-webmcp/src/production.ts @@ -0,0 +1,6 @@ +export { registerDevtoolsTools } from './register' +export type { + DevtoolsTool, + DevtoolsToolAnnotations, + RegisterDevtoolsToolsOptions, +} from './types' diff --git a/packages/devtools-webmcp/src/register.ts b/packages/devtools-webmcp/src/register.ts new file mode 100644 index 000000000..f9d526b8c --- /dev/null +++ b/packages/devtools-webmcp/src/register.ts @@ -0,0 +1,316 @@ +import type { DevtoolsTool, RegisterDevtoolsToolsOptions } from './types' + +const legalNamePattern = /^[A-Za-z0-9_.-]+$/ +const maxFullNameLength = 128 +const registrations = new Map() + +interface WebMcpToolAnnotations { + debugging: true + readOnlyHint?: boolean + untrustedContentHint?: boolean + consequentialHint?: boolean +} + +interface WebMcpToolRegistration { + name: string + title?: string + description: string + inputSchema?: Record + annotations: WebMcpToolAnnotations + execute: (input: unknown, browserArg?: unknown) => unknown | Promise +} + +interface RegisterableModelContext { + registerTool: ( + tool: WebMcpToolRegistration, + options: { signal: AbortSignal }, + ) => unknown +} + +function noopCleanup() {} + +function isLegalNamePart(value: string) { + return legalNamePattern.test(value) +} + +// An empty instanceId is the same as an omitted instanceId. +function normalizedInstanceId(instanceId: string | undefined) { + if (instanceId === undefined || instanceId === '') { + return undefined + } + return instanceId +} + +function registrationKey(pluginId: string, instanceId: string | undefined) { + if (instanceId === undefined) { + return pluginId + } + // `\0` is not a legal name character, so two pairs cannot share a key. + return `${pluginId}\0${instanceId}` +} + +function browserToolName( + pluginId: string, + instanceId: string | undefined, + name: string, +) { + if (instanceId === undefined) { + return `${pluginId}.${name}` + } + return `${pluginId}.${instanceId}.${name}` +} + +function hasModelContextKey(host: object): host is { modelContext?: unknown } { + return 'modelContext' in host +} + +function modelContextOf(host: object | undefined) { + if (host === undefined || !hasModelContextKey(host)) { + return undefined + } + return host.modelContext +} + +function globalHost(name: 'document' | 'navigator') { + if (name === 'document') { + if (typeof document === 'undefined') { + return undefined + } + return document + } + if (typeof navigator === 'undefined') { + return undefined + } + return navigator +} + +function hasRegisterTool( + context: unknown, +): context is RegisterableModelContext { + const isObject = typeof context === 'object' && context !== null + if (!isObject) { + return false + } + if (!('registerTool' in context)) { + return false + } + return typeof context.registerTool === 'function' +} + +function readModelContext() { + const documentContext = modelContextOf(globalHost('document')) + if (hasRegisterTool(documentContext)) { + return documentContext + } + const navigatorContext = modelContextOf(globalHost('navigator')) + if (hasRegisterTool(navigatorContext)) { + return navigatorContext + } + return undefined +} + +function logIllegalPart(part: 'pluginId' | 'instanceId', value: string) { + console.error( + `Devtools WebMCP skipped registration. ${part} "${value}" is not legal. Use letters, digits, "_", "-", and ".".`, + ) +} + +function idsAreLegal(pluginId: string, instanceId: string | undefined) { + const pluginIdIsLegal = isLegalNamePart(pluginId) + const instanceIdIsLegal = + instanceId === undefined || isLegalNamePart(instanceId) + + if (!pluginIdIsLegal) { + logIllegalPart('pluginId', pluginId) + } + if (instanceId !== undefined && !isLegalNamePart(instanceId)) { + logIllegalPart('instanceId', instanceId) + } + + return pluginIdIsLegal && instanceIdIsLegal +} + +function toolSkipReason(name: string, fullName: string) { + if (!isLegalNamePart(name)) { + return `Devtools WebMCP skipped "${name}". The tool name is not legal. Use letters, digits, "_", "-", and ".".` + } + if (fullName.length > maxFullNameLength) { + return `Devtools WebMCP skipped "${fullName}". The full name is longer than 128 characters.` + } + return undefined +} + +function signalFrom(value: unknown, fallback: AbortSignal) { + if (typeof value !== 'object' || value === null) { + return fallback + } + if (!('signal' in value)) { + return fallback + } + if (!(value.signal instanceof AbortSignal)) { + return fallback + } + return value.signal +} + +function wrapExecute(tool: DevtoolsTool, registrationSignal: AbortSignal) { + return (input: unknown, browserArg?: unknown) => + tool.execute(input, { + signal: signalFrom(browserArg, registrationSignal), + }) +} + +function toRegistration(tool: DevtoolsTool, name: string, signal: AbortSignal) { + const registration: WebMcpToolRegistration = { + name, + description: tool.description, + annotations: { + ...tool.annotations, + debugging: true, + }, + execute: wrapExecute(tool, signal), + } + if (tool.title !== undefined) { + registration.title = tool.title + } + if (tool.inputSchema !== undefined) { + registration.inputSchema = tool.inputSchema + } + return registration +} + +function isPromiseLike(value: unknown): value is PromiseLike { + if (typeof value !== 'object' && typeof value !== 'function') { + return false + } + if (value === null) { + return false + } + if (!('then' in value)) { + return false + } + return typeof value.then === 'function' +} + +function watchRegistration( + result: unknown, + signal: AbortSignal, + fullName: string, +) { + if (!isPromiseLike(result)) { + return + } + void Promise.resolve(result).then( + () => undefined, + (error: unknown) => { + // The browser can reject an in-flight registerTool call after abort. + if (signal.aborted) { + return + } + console.error(`Devtools WebMCP failed to register "${fullName}".`, error) + }, + ) +} + +function registerOne( + modelContext: RegisterableModelContext, + tool: DevtoolsTool, + fullName: string, + signal: AbortSignal, +) { + try { + const result = modelContext.registerTool( + toRegistration(tool, fullName, signal), + { + signal, + }, + ) + watchRegistration(result, signal, fullName) + } catch (error) { + if (signal.aborted) { + return + } + console.error(`Devtools WebMCP failed to register "${fullName}".`, error) + } +} + +function releaseRegistration(key: string, controller: AbortController) { + if (registrations.get(key) !== controller) { + return + } + registrations.delete(key) + controller.abort() +} + +function replaceRegistration(key: string) { + const previous = registrations.get(key) + if (previous !== undefined) { + registrations.delete(key) + previous.abort() + } + const controller = new AbortController() + registrations.set(key, controller) + return controller +} + +/** + * Register WebMCP tools for one plugin on the page. + * + * `options.pluginId` is required. `options.instanceId` is optional. + * An empty `instanceId` means the same as an omitted `instanceId`. + * When `instanceId` is omitted, the browser tool name is `pluginId.name`. + * When `instanceId` is set, the browser tool name is `pluginId.instanceId.name`. + * The helper sets `annotations.debugging` to `true`. + * + * The function returns a cleanup function. The caller removes the tools with that function. + * A second call with the same `pluginId` and `instanceId` replaces the previous tools. + * The previous cleanup function then does nothing. + * + * This function does not throw. It writes one `console.error` for each failure. + * + * @example + * ```ts + * const stop = registerDevtoolsTools({ + * pluginId: 'tanstack.query', + * instanceId: 'main', + * tools: [ + * { + * name: 'getQueryCache', + * description: 'Return a JSON summary of the query cache.', + * execute: (input) => summarize(input), + * }, + * ], + * }) + * + * stop() + * ``` + */ +export function registerDevtoolsTools(options: RegisterDevtoolsToolsOptions) { + const modelContext = readModelContext() + if (modelContext === undefined) { + return noopCleanup + } + + const instanceId = normalizedInstanceId(options.instanceId) + if (!idsAreLegal(options.pluginId, instanceId)) { + return noopCleanup + } + + const key = registrationKey(options.pluginId, instanceId) + const controller = replaceRegistration(key) + const tools = options.tools + + for (const tool of tools) { + const fullName = browserToolName(options.pluginId, instanceId, tool.name) + const skipReason = toolSkipReason(tool.name, fullName) + if (skipReason !== undefined) { + console.error(skipReason) + continue + } + registerOne(modelContext, tool, fullName, controller.signal) + } + + return () => { + releaseRegistration(key, controller) + } +} diff --git a/packages/devtools-webmcp/src/types.ts b/packages/devtools-webmcp/src/types.ts new file mode 100644 index 000000000..e679a87ed --- /dev/null +++ b/packages/devtools-webmcp/src/types.ts @@ -0,0 +1,23 @@ +export interface DevtoolsToolAnnotations { + readOnlyHint?: boolean + untrustedContentHint?: boolean + consequentialHint?: boolean +} + +export interface DevtoolsTool { + name: string + title?: string + description: string + inputSchema?: Record + annotations?: DevtoolsToolAnnotations + execute: ( + input: TInput, + options: { signal: AbortSignal }, + ) => unknown | Promise +} + +export interface RegisterDevtoolsToolsOptions { + pluginId: string + instanceId?: string + tools: Array +} diff --git a/packages/devtools-webmcp/tests/index.test.ts b/packages/devtools-webmcp/tests/index.test.ts new file mode 100644 index 000000000..ab0816072 --- /dev/null +++ b/packages/devtools-webmcp/tests/index.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { registerDevtoolsTools } from '../src' +import { registerDevtoolsTools as registerDevtoolsToolsProduction } from '../src/production' + +interface SeenTool { + name: string + description: string + annotations?: { + debugging?: boolean + } +} + +function createTool(name: string) { + return { + name, + description: 'Return a JSON summary of the query cache.', + execute: () => ({ status: 'ok' }), + } +} + +function installDocumentContext() { + const calls: Array = [] + const registerTool = (tool: SeenTool): void => { + calls.push(tool) + } + document.modelContext = { registerTool } + return calls +} + +function expectRegistered(calls: Array, name: string) { + expect(calls).toHaveLength(1) + expect(calls[0]?.name).toBe(name) + expect(calls[0]?.annotations?.debugging).toBe(true) +} + +afterEach(() => { + vi.unstubAllEnvs() + vi.resetModules() + delete document.modelContext +}) + +describe('registerDevtoolsTools', () => { + it('returns a cleanup function and does not call registerTool when NODE_ENV is test', () => { + const calls = installDocumentContext() + + const stop = registerDevtoolsTools({ + pluginId: 'tanstack.query.noop', + tools: [createTool('getQueryCache')], + }) + + expect(stop).toBeTypeOf('function') + stop() + expect(calls).toEqual([]) + }) + + it('calls registerTool from the root import when NODE_ENV is development', async () => { + const calls = installDocumentContext() + vi.stubEnv('NODE_ENV', 'development') + vi.resetModules() + // The re-import is required because NODE_ENV is read at module load. + const { registerDevtoolsTools: registerInDevelopment } = + await import('../src') + + const stop = registerInDevelopment({ + pluginId: 'tanstack.query', + tools: [createTool('getQueryCache')], + }) + + expectRegistered(calls, 'tanstack.query.getQueryCache') + stop() + }) + + it('calls registerTool from the production import when NODE_ENV is test', () => { + const calls = installDocumentContext() + + const stop = registerDevtoolsToolsProduction({ + pluginId: 'tanstack.query.production', + tools: [createTool('getQueryCache')], + }) + + expectRegistered(calls, 'tanstack.query.production.getQueryCache') + stop() + }) +}) diff --git a/packages/devtools-webmcp/tests/noop.test.ts b/packages/devtools-webmcp/tests/noop.test.ts new file mode 100644 index 000000000..b211898d9 --- /dev/null +++ b/packages/devtools-webmcp/tests/noop.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { registerDevtoolsTools } from '../src/noop' + +interface RegisteredTool { + name: string + description: string +} + +interface ModelContext { + registerTool: (tool: RegisteredTool) => void +} + +declare global { + interface Document { + modelContext?: ModelContext + } +} + +describe('registerDevtoolsTools', () => { + it('returns a cleanup function and does not call registerTool', () => { + const calls: Array = [] + + const registerTool = (tool: RegisteredTool): void => { + calls.push(tool) + } + + document.modelContext = { registerTool } + + try { + const stop = registerDevtoolsTools({ + pluginId: 'tanstack.query', + instanceId: 'main', + tools: [ + { + name: 'getQueryCache', + description: 'Return a JSON summary of the query cache.', + execute: () => ({ status: 'ok' }), + }, + ], + }) + + expect(stop).toBeTypeOf('function') + expect(() => { + stop() + }).not.toThrow() + expect(calls).toEqual([]) + } finally { + delete document.modelContext + } + }) +}) diff --git a/packages/devtools-webmcp/tests/register.test.ts b/packages/devtools-webmcp/tests/register.test.ts new file mode 100644 index 000000000..54363e62a --- /dev/null +++ b/packages/devtools-webmcp/tests/register.test.ts @@ -0,0 +1,523 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { registerDevtoolsTools } from '../src/register' +import type { MockInstance } from 'vitest' + +declare global { + interface Navigator { + modelContext?: NonNullable + } +} + +interface SeenTool { + name: string + description: string + title?: string + inputSchema?: Record + annotations?: { + debugging?: boolean + readOnlyHint?: boolean + untrustedContentHint?: boolean + consequentialHint?: boolean + } + execute?: (input: unknown, options?: unknown) => unknown +} + +interface RegisterCall { + tool: SeenTool + signal: AbortSignal | undefined +} + +const stops: Array<() => void> = [] + +let consoleError: MockInstance + +function createTool( + name: string, + execute: (input: unknown, options: { signal: AbortSignal }) => unknown = () => + undefined, +) { + return { + name, + description: 'Read the library state.', + execute, + } +} + +function isSignalOptions(value: unknown): value is { signal: AbortSignal } { + if (typeof value !== 'object' || value === null) { + return false + } + if (!('signal' in value)) { + return false + } + return value.signal instanceof AbortSignal +} + +function watchRegisterTool( + impl: (tool: SeenTool) => unknown = () => undefined, +) { + const calls: Array = [] + function registerTool(tool: SeenTool, ...rest: Array) { + const options = rest[0] + const signal = isSignalOptions(options) ? options.signal : undefined + calls.push({ tool, signal }) + return impl(tool) + } + return { calls, registerTool } +} + +function installDocumentContext(impl?: (tool: SeenTool) => unknown) { + const watched = watchRegisterTool(impl) + document.modelContext = { registerTool: watched.registerTool } + return watched +} + +function installNavigatorContext(impl?: (tool: SeenTool) => unknown) { + const watched = watchRegisterTool(impl) + navigator.modelContext = { registerTool: watched.registerTool } + return watched +} + +function register(options: Parameters[0]) { + const stop = registerDevtoolsTools(options) + stops.push(stop) + return stop +} + +function signalAt(calls: Array, index: number) { + const signal = calls[index]?.signal + expect(signal).toBeInstanceOf(AbortSignal) + if (!(signal instanceof AbortSignal)) { + throw new Error(`registerTool call ${index} has no AbortSignal`) + } + return signal +} + +function expectRegistered( + calls: Array, + index: number, + name: string, +) { + const call = calls[index] + expect(call?.tool.name).toBe(name) + expect(call?.tool.annotations).toEqual({ debugging: true }) + expect(call?.signal).toBeInstanceOf(AbortSignal) +} + +function clearModelContext() { + delete document.modelContext + delete navigator.modelContext +} + +async function flushPromises() { + await Promise.resolve() + await Promise.resolve() +} + +beforeEach(() => { + consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + clearModelContext() +}) + +afterEach(() => { + for (const stop of stops) { + stop() + } + stops.length = 0 + consoleError.mockRestore() + clearModelContext() +}) + +describe('registerDevtoolsTools', () => { + it('returns a cleanup without logging when modelContext is missing', () => { + const watched = installDocumentContext() + register({ + pluginId: 'tanstack.query.missing', + tools: [createTool('getQueryCache')], + }) + const signal = signalAt(watched.calls, 0) + clearModelContext() + consoleError.mockClear() + + const stop = register({ + pluginId: 'tanstack.query.missing', + tools: [createTool('listQueries')], + }) + + expect(stop).toBeTypeOf('function') + expect(() => { + stop() + }).not.toThrow() + expect(consoleError).not.toHaveBeenCalled() + expect(signal.aborted).toBe(false) + }) + + it('registers the prefixed name on document.modelContext with debugging true', () => { + const receivedSignals: Array = [] + const execute = (_input: unknown, options: { signal: AbortSignal }) => { + receivedSignals.push(options.signal) + return { queries: [] } + } + const inputSchema = { + type: 'object', + properties: { + queryHash: { type: 'string' }, + }, + } + const documentContext = installDocumentContext() + const navigatorContext = installNavigatorContext() + + register({ + pluginId: 'tanstack.query', + instanceId: 'main', + tools: [ + { + name: 'getQueryCache', + title: 'Query cache', + description: 'Return a JSON summary of the query cache.', + inputSchema, + annotations: { + readOnlyHint: true, + untrustedContentHint: false, + consequentialHint: true, + }, + execute, + }, + ], + }) + + expect(documentContext.calls).toEqual([ + { + tool: { + name: 'tanstack.query.main.getQueryCache', + title: 'Query cache', + description: 'Return a JSON summary of the query cache.', + inputSchema, + annotations: { + readOnlyHint: true, + untrustedContentHint: false, + consequentialHint: true, + debugging: true, + }, + execute: expect.any(Function), + }, + signal: expect.any(AbortSignal), + }, + ]) + const registeredExecute = documentContext.calls[0]?.tool.execute + const registrationSignal = documentContext.calls[0]?.signal + expect(registeredExecute).not.toBe(execute) + registeredExecute?.({ queryHash: 'abc' }) + expect(receivedSignals[0]).toBe(registrationSignal) + const invoke = new AbortController() + registeredExecute?.({ queryHash: 'def' }, { signal: invoke.signal }) + expect(receivedSignals[1]).toBe(invoke.signal) + expect(documentContext.calls[0]?.tool.inputSchema).toBe(inputSchema) + expect(navigatorContext.calls).toEqual([]) + }) + + it('uses navigator.modelContext when document.modelContext.registerTool is missing', () => { + const navigatorContext = installNavigatorContext() + // The DOM type requires registerTool. This object omits it at runtime. + Object.defineProperty(document, 'modelContext', { + configurable: true, + writable: true, + value: {}, + }) + + register({ + pluginId: 'tanstack.query.nav', + tools: [createTool('getQueryCache')], + }) + + expect(navigatorContext.calls).toHaveLength(1) + expectRegistered( + navigatorContext.calls, + 0, + 'tanstack.query.nav.getQueryCache', + ) + expect(document.modelContext?.registerTool).toBeUndefined() + expect(consoleError).not.toHaveBeenCalled() + }) + + it('aborts the first signal and registers the new tool on a second call', () => { + const watched = installDocumentContext() + register({ + pluginId: 'tanstack.query.replace', + instanceId: 'main', + tools: [createTool('getQueryCache')], + }) + const firstSignal = signalAt(watched.calls, 0) + + register({ + pluginId: 'tanstack.query.replace', + instanceId: 'main', + tools: [createTool('listQueries')], + }) + + expect(firstSignal.aborted).toBe(true) + expectRegistered( + watched.calls, + 1, + 'tanstack.query.replace.main.listQueries', + ) + }) + + it('does nothing when the first cleanup runs after a replacement', () => { + const watched = installDocumentContext() + const firstStop = register({ + pluginId: 'tanstack.query.oldstop', + tools: [createTool('getQueryCache')], + }) + register({ + pluginId: 'tanstack.query.oldstop', + tools: [createTool('listQueries')], + }) + const secondSignal = signalAt(watched.calls, 1) + + firstStop() + + expect(secondSignal.aborted).toBe(false) + expect(watched.calls).toHaveLength(2) + }) + + it('aborts the previous signal and registers no tool when tools is empty', () => { + const watched = installDocumentContext() + register({ + pluginId: 'tanstack.query.empty', + tools: [createTool('getQueryCache')], + }) + const firstSignal = signalAt(watched.calls, 0) + watched.calls.length = 0 + + register({ + pluginId: 'tanstack.query.empty', + tools: [], + }) + + expect(firstSignal.aborted).toBe(true) + expect(watched.calls).toEqual([]) + }) + + it('logs once and keeps the previous registration when pluginId is illegal', () => { + const watched = installDocumentContext() + register({ + pluginId: 'tanstack.query.keep', + tools: [createTool('getQueryCache')], + }) + const firstSignal = signalAt(watched.calls, 0) + watched.calls.length = 0 + consoleError.mockClear() + + const stop = register({ + pluginId: 'tanstack query', + tools: [createTool('getQueryCache')], + }) + + expect(consoleError).toHaveBeenCalledTimes(1) + expect(consoleError).toHaveBeenCalledWith( + 'Devtools WebMCP skipped registration. pluginId "tanstack query" is not legal. Use letters, digits, "_", "-", and ".".', + ) + expect(watched.calls).toEqual([]) + expect(firstSignal.aborted).toBe(false) + stop() + expect(firstSignal.aborted).toBe(false) + }) + + it('skips an illegal tool name and registers the legal tool', () => { + const watched = installDocumentContext() + register({ + pluginId: 'tanstack.query.skip', + tools: [createTool('getQueryCache')], + }) + const previousSignal = signalAt(watched.calls, 0) + watched.calls.length = 0 + consoleError.mockClear() + + register({ + pluginId: 'tanstack.query.skip', + tools: [createTool('bad name'), createTool('listQueries')], + }) + + expect(previousSignal.aborted).toBe(true) + expect(consoleError).toHaveBeenCalledTimes(1) + expect(consoleError).toHaveBeenCalledWith( + 'Devtools WebMCP skipped "bad name". The tool name is not legal. Use letters, digits, "_", "-", and ".".', + ) + expect(watched.calls).toHaveLength(1) + expectRegistered(watched.calls, 0, 'tanstack.query.skip.listQueries') + }) + + it('logs a rejected registerTool promise without rejecting the caller', async () => { + const watched = installDocumentContext((tool) => { + if (tool.name === 'tanstack.query.reject.getQueryCache') { + return Promise.reject(new Error('register failed')) + } + return undefined + }) + + const stop = register({ + pluginId: 'tanstack.query.reject', + tools: [createTool('getQueryCache'), createTool('listQueries')], + }) + + expect(stop).toBeTypeOf('function') + expect(watched.calls).toHaveLength(2) + expectRegistered(watched.calls, 1, 'tanstack.query.reject.listQueries') + await flushPromises() + expect(consoleError).toHaveBeenCalledTimes(1) + expect(consoleError).toHaveBeenCalledWith( + 'Devtools WebMCP failed to register "tanstack.query.reject.getQueryCache".', + expect.objectContaining({ message: 'register failed' }), + ) + }) + + it('does not log a registerTool rejection after abort', async () => { + let rejectRegistration: ((reason: unknown) => void) | undefined + const watched = installDocumentContext( + () => + new Promise((_resolve, reject) => { + rejectRegistration = reject + }), + ) + + const stop = register({ + pluginId: 'tanstack.query.abortlog', + tools: [createTool('getQueryCache')], + }) + stop() + expect(rejectRegistration).toBeTypeOf('function') + if (rejectRegistration === undefined) { + throw new Error('registerTool did not return a promise') + } + rejectRegistration(new Error('aborted')) + await flushPromises() + + expect(watched.calls).toHaveLength(1) + expect(consoleError).not.toHaveBeenCalled() + }) + + it('aborts the signal passed to registerTool on cleanup', () => { + const watched = installDocumentContext() + const stop = register({ + pluginId: 'tanstack.query.stop', + tools: [createTool('getQueryCache')], + }) + const signal = signalAt(watched.calls, 0) + + expect(signal.aborted).toBe(false) + stop() + expect(signal.aborted).toBe(true) + }) + + it('treats an empty instanceId like an omitted instanceId', () => { + const watched = installDocumentContext() + register({ + pluginId: 'tanstack.query.blank', + tools: [createTool('getQueryCache')], + }) + const firstSignal = signalAt(watched.calls, 0) + + register({ + pluginId: 'tanstack.query.blank', + instanceId: '', + tools: [createTool('listQueries')], + }) + + expect(firstSignal.aborted).toBe(true) + expectRegistered(watched.calls, 1, 'tanstack.query.blank.listQueries') + }) + + it('does not abort a different instanceId', () => { + const watched = installDocumentContext() + register({ + pluginId: 'tanstack.query.pair', + instanceId: 'main', + tools: [createTool('getQueryCache')], + }) + const mainSignal = signalAt(watched.calls, 0) + + register({ + pluginId: 'tanstack.query.pair', + instanceId: 'other', + tools: [createTool('getQueryCache')], + }) + + expect(mainSignal.aborted).toBe(false) + expectRegistered( + watched.calls, + 1, + 'tanstack.query.pair.other.getQueryCache', + ) + }) + + it('skips a tool when the full name is longer than 128 characters', () => { + const watched = installDocumentContext() + // pluginId "tanstack.query.long" is 19 characters. 19 + 1 + 109 = 129. + const longName = 'n'.repeat(109) + + register({ + pluginId: 'tanstack.query.long', + tools: [createTool(longName), createTool('getQueryCache')], + }) + + expect(consoleError).toHaveBeenCalledTimes(1) + const logged = consoleError.mock.calls[0]?.[0] + expect(logged).toEqual( + expect.stringContaining('The full name is longer than 128 characters.'), + ) + expect(logged).toEqual(expect.stringContaining(longName)) + expect(watched.calls).toHaveLength(1) + expectRegistered(watched.calls, 0, 'tanstack.query.long.getQueryCache') + }) + + it('logs once and keeps the previous registration when instanceId is illegal', () => { + const watched = installDocumentContext() + register({ + pluginId: 'tanstack.query.badinstance', + instanceId: 'main', + tools: [createTool('getQueryCache')], + }) + const firstSignal = signalAt(watched.calls, 0) + watched.calls.length = 0 + consoleError.mockClear() + + const stop = register({ + pluginId: 'tanstack.query.badinstance', + instanceId: 'bad id', + tools: [createTool('getQueryCache')], + }) + + expect(consoleError).toHaveBeenCalledTimes(1) + expect(consoleError).toHaveBeenCalledWith( + 'Devtools WebMCP skipped registration. instanceId "bad id" is not legal. Use letters, digits, "_", "-", and ".".', + ) + expect(watched.calls).toEqual([]) + expect(firstSignal.aborted).toBe(false) + stop() + expect(firstSignal.aborted).toBe(false) + }) + + it('logs a thrown registerTool error and registers the next tool', () => { + const watched = installDocumentContext((tool) => { + if (tool.name === 'tanstack.query.throw.badTool') { + throw new Error('register failed') + } + return undefined + }) + + const stop = register({ + pluginId: 'tanstack.query.throw', + tools: [createTool('badTool'), createTool('getQueryCache')], + }) + + expect(stop).toBeTypeOf('function') + expect(consoleError).toHaveBeenCalledTimes(1) + expect(consoleError).toHaveBeenCalledWith( + 'Devtools WebMCP failed to register "tanstack.query.throw.badTool".', + expect.objectContaining({ message: 'register failed' }), + ) + expect(watched.calls).toHaveLength(2) + expectRegistered(watched.calls, 1, 'tanstack.query.throw.getQueryCache') + }) +}) diff --git a/packages/devtools-webmcp/tests/test-setup.ts b/packages/devtools-webmcp/tests/test-setup.ts new file mode 100644 index 000000000..a9d0dd31a --- /dev/null +++ b/packages/devtools-webmcp/tests/test-setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest' diff --git a/packages/devtools-webmcp/tsconfig.docs.json b/packages/devtools-webmcp/tsconfig.docs.json new file mode 100644 index 000000000..2880b4dfa --- /dev/null +++ b/packages/devtools-webmcp/tsconfig.docs.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["tests", "src"] +} diff --git a/packages/devtools-webmcp/tsconfig.json b/packages/devtools-webmcp/tsconfig.json new file mode 100644 index 000000000..44533f71c --- /dev/null +++ b/packages/devtools-webmcp/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "eslint.config.js", "vite.config.ts", "tests"] +} diff --git a/packages/devtools-webmcp/vite.config.ts b/packages/devtools-webmcp/vite.config.ts new file mode 100644 index 000000000..6dba626d4 --- /dev/null +++ b/packages/devtools-webmcp/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + plugins: [], + test: { + name: packageJson.name, + dir: './', + watch: false, + environment: 'jsdom', + setupFiles: ['./tests/test-setup.ts'], + globals: true, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts', './src/production.ts'], + srcDir: './src', + }), +) diff --git a/packages/devtools/skills/devtools-production/SKILL.md b/packages/devtools/skills/devtools-production/SKILL.md index 415b1e3fa..bdd8643f6 100644 --- a/packages/devtools/skills/devtools-production/SKILL.md +++ b/packages/devtools/skills/devtools-production/SKILL.md @@ -328,6 +328,28 @@ All return `readonly [Plugin, NoOpPlugin]`. The `NoOpPlugin` always has the same See the **devtools-framework-adapters** skill for the full factory API details. +## @tanstack/devtools-webmcp root import + +When `process.env.NODE_ENV` is `'development'`, the root import is the real helper. + +```ts +import { registerDevtoolsTools } from '@tanstack/devtools-webmcp' +``` + +In every other environment, the root import is a no-op. An unset `NODE_ENV` is a no-op too. + +Bundlers remove the real helper. The tool objects stay in the library bundle. The no-op does not call the browser. The no-op does not keep the tools object. + +If the tools must stay registered in production, import `@tanstack/devtools-webmcp/production`. + +```ts +import { registerDevtoolsTools } from '@tanstack/devtools-webmcp/production' +``` + +The import `@tanstack/devtools-webmcp/production` is always the real helper. + +The `registerDevtoolsTools` call stays the same. The call is in `docs/webmcp-tools.md`. + ## Common Mistakes ### HIGH: Keeping devtools in production without disabling stripping diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4794396aa..11a9bd88f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -792,6 +792,9 @@ importers: '@tanstack/devtools-event-client': specifier: 0.5.0 version: link:../../../packages/event-bus-client + '@tanstack/devtools-webmcp': + specifier: workspace:* + version: link:../../../packages/devtools-webmcp '@tanstack/react-devtools': specifier: ^0.10.12 version: link:../../../packages/react-devtools @@ -1738,6 +1741,8 @@ importers: specifier: ^20.0.0 version: 20.9.0 + packages/devtools-webmcp: {} + packages/event-bus: dependencies: ws: @@ -1837,7 +1842,7 @@ importers: version: 5.55.1(@typescript-eslint/types@8.58.0) svelte-check: specifier: ^4.2.0 - version: 4.7.6(picomatch@4.0.4)(svelte@5.55.1(@typescript-eslint/types@8.58.0))(typescript@5.9.3) + version: 4.7.6(picomatch@4.0.5)(svelte@5.55.1(@typescript-eslint/types@8.58.0))(typescript@5.9.3) packages/vue-devtools: dependencies: @@ -19015,6 +19020,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + fetch-blob@3.2.0: dependencies: node-domexception: 1.0.0 @@ -22852,12 +22861,12 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-check@4.7.6(picomatch@4.0.4)(svelte@5.55.1(@typescript-eslint/types@8.58.0))(typescript@5.9.3): + svelte-check@4.7.6(picomatch@4.0.5)(svelte@5.55.1(@typescript-eslint/types@8.58.0))(typescript@5.9.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 '@sveltejs/load-config': 0.2.3 chokidar: 4.0.3 - fdir: 6.5.0(picomatch@4.0.4) + fdir: 6.5.0(picomatch@4.0.5) picocolors: 1.1.1 sade: 1.8.1 svelte: 5.55.1(@typescript-eslint/types@8.58.0)