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.
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.
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:
pluginId: 'tanstack:query'
Correct:
pluginId: 'tanstack.query'
Root import in production
When the tools must stay in production, the root import is a no-op.
Wrong:
import { registerDevtoolsTools } from '@tanstack/devtools-webmcp'
Correct:
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.