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)