@r0hitsharma/webmcp 0.12.0 → 0.13.0-rohit-charting-cursor-fixes.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -6
- package/dist/__tests__/fake-context.d.ts +24 -0
- package/dist/__tests__/fake-context.js +35 -0
- package/dist/__tests__/helpers.d.ts +7 -0
- package/dist/__tests__/helpers.js +24 -0
- package/dist/__tests__/relay-harness.d.ts +26 -0
- package/dist/__tests__/relay-harness.js +73 -0
- package/dist/confirmation.d.ts +51 -0
- package/dist/confirmation.js +104 -0
- package/dist/hooks.d.ts +22 -1
- package/dist/hooks.js +34 -3
- package/dist/index.d.ts +5 -4
- package/dist/index.js +2 -1
- package/dist/mcp-b-optout.d.ts +1 -0
- package/dist/mcp-b-optout.js +23 -0
- package/dist/mcp-b.d.ts +11 -0
- package/dist/mcp-b.js +16 -0
- package/dist/provider.d.ts +58 -1
- package/dist/provider.js +79 -6
- package/dist/registration.d.ts +32 -2
- package/dist/registration.js +136 -38
- package/dist/types.d.ts +60 -2
- package/dist/types.js +40 -0
- package/dist/useRelaySession.d.ts +11 -5
- package/dist/useRelaySession.js +109 -61
- package/package.json +12 -5
- package/src/__tests__/fake-context.ts +58 -0
- package/src/__tests__/helpers.tsx +32 -0
- package/src/__tests__/host-autoinit.test.tsx +36 -0
- package/src/__tests__/host-options.test.tsx +46 -0
- package/src/__tests__/init-order.late-context.test.tsx +35 -0
- package/src/__tests__/init-order.strict.test.tsx +26 -0
- package/src/__tests__/insecure-context.test.tsx +31 -0
- package/src/__tests__/polyfill-execute.test.tsx +155 -0
- package/src/__tests__/relay-harness.ts +85 -0
- package/src/__tests__/transport.test.tsx +65 -0
- package/src/confirmation.test.tsx +175 -0
- package/src/confirmation.ts +162 -0
- package/src/hooks.ts +71 -4
- package/src/index.ts +8 -1
- package/src/mcp-b-optout.ts +25 -0
- package/src/mcp-b.ts +25 -0
- package/src/provider.tsx +160 -7
- package/src/registration.test.ts +274 -0
- package/src/registration.ts +145 -45
- package/src/types.test.ts +85 -0
- package/src/types.ts +105 -1
- package/src/useRelaySession.test.tsx +396 -0
- package/src/useRelaySession.ts +130 -79
package/README.md
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
# @r0hitsharma/webmcp
|
|
2
2
|
|
|
3
|
-
A stable React wrapper over [WebMCP](https://github.
|
|
3
|
+
A stable React wrapper for registering UI tools that an AI agent can call, over [WebMCP](https://webmachinelearning.github.io/webmcp/) and the [MCP-B](https://docs.mcp-b.ai) polyfill (`@mcp-b/global`). It exposes a fixed interface so the fast-moving `@mcp-b/*` packages can churn behind a single seam.
|
|
4
4
|
|
|
5
5
|
Tools are registered into `document.modelContext`. A tool registered here runs in the consumer's own authenticated browser session, so it reuses the app's existing auth and APIs rather than requiring separate server credentials.
|
|
6
6
|
|
|
7
|
+
## Spec and browser status
|
|
8
|
+
|
|
9
|
+
- **WebMCP** is a draft of the W3C Web Machine Learning Community Group ([spec](https://webmachinelearning.github.io/webmcp/), [repository](https://github.com/webmachinelearning/webmcp)). It defines `document.modelContext.registerTool()`, tool annotations, and an `execute(input, { signal })` callback whose result the browser serializes as JSON. It is not a standard yet and still changes.
|
|
10
|
+
- **Chrome** exposes a native `document.modelContext` through an origin trial (Chrome 149 to 156 at the time of writing). Other browsers do not implement it.
|
|
11
|
+
- **MCP-B** ([docs](https://docs.mcp-b.ai), [source](https://github.com/WebMCP-org/npm-packages)) polyfills `document.modelContext` where it is missing and bridges the registered tools to MCP clients such as the MCP-B browser extension. `WebMCPProvider` installs it.
|
|
12
|
+
- A harness that cannot reach the page directly can call the same tools through the relay back-channel (`useRelaySession`, [`@r0hitsharma/mcp-relay`](../mcp-relay/README.md)).
|
|
13
|
+
|
|
7
14
|
## Installation
|
|
8
15
|
|
|
9
16
|
```bash
|
|
@@ -14,9 +21,12 @@ npm install @r0hitsharma/webmcp react
|
|
|
14
21
|
|
|
15
22
|
## Features
|
|
16
23
|
|
|
17
|
-
- Schema-first tool definitions (`defineTool`)
|
|
24
|
+
- Schema-first tool definitions (`defineTool`), validated against WebMCP's naming rules
|
|
25
|
+
- WebMCP annotations derived from `mutation`, with per-field overrides
|
|
26
|
+
- Human confirmation for every `mutation: true` call, on every invocation path
|
|
27
|
+
- An abort `signal` for every handler call, fired when the tool unregisters mid-call or the relay disconnects
|
|
18
28
|
- StrictMode-safe registration that mounts/unmounts cleanly
|
|
19
|
-
- A React provider that initializes the
|
|
29
|
+
- A React provider that initializes the polyfill with an explicit, same-origin transport
|
|
20
30
|
- Hooks to register tools, observe the registry, and contribute view state
|
|
21
31
|
- Wire-protocol types shared with the relay back-channel
|
|
22
32
|
|
|
@@ -66,17 +76,98 @@ function IdentityView({ onSelect }: { onSelect: (id: string) => void }) {
|
|
|
66
76
|
}
|
|
67
77
|
```
|
|
68
78
|
|
|
79
|
+
### Tool fields
|
|
80
|
+
|
|
81
|
+
| Field | Required | Notes |
|
|
82
|
+
| --- | --- | --- |
|
|
83
|
+
| `name` | yes | 1-128 characters of `A-Z a-z 0-9 _ . -`; `defineTool` throws otherwise. Chrome suggests 30 or fewer. |
|
|
84
|
+
| `description` | yes | What the tool does, for the agent. `defineTool` warns (once per tool) above 500 characters, Chrome's guidance; keep parameter descriptions under about 150 and results around 1.5K characters. |
|
|
85
|
+
| `schema` | yes | JSON Schema of the input object. |
|
|
86
|
+
| `handler` | yes | `(args, { signal }) => result`. Return plain data and throw on failure (see below). |
|
|
87
|
+
| `title` | no | Human-readable display name. |
|
|
88
|
+
| `annotations` | no | `readOnlyHint`, `consequentialHint`, `untrustedContentHint`. See below. |
|
|
89
|
+
| `outputSchema` | no | JSON Schema of the result object. Forwarded to MCP clients (MCP-B, relay); WebMCP has no output schema. |
|
|
90
|
+
| `mutation` | no | `true` for a tool that changes state: requires confirmation. |
|
|
91
|
+
| `confirmationSummary` | with `mutation` | `(args) => string` shown in the confirmation dialog. |
|
|
92
|
+
|
|
93
|
+
### Results, errors and cancellation
|
|
94
|
+
|
|
95
|
+
Return the result as plain data. On `document.modelContext` the browser serializes it as JSON for the agent; the MCP-B bridge and the relay turn it into an MCP result, with an object passed as `structuredContent`. Throw to report a failure: it reaches native agents as a failed call and MCP clients as `isError: true`.
|
|
96
|
+
|
|
97
|
+
The second handler argument carries an `AbortSignal`. It aborts when the tool is unregistered mid-call (its component unmounts, or the provider re-initializes) and, on the relay path, when the back-channel closes. An agent's own cancellation reaches it only on a browser whose `document.modelContext` passes a per-call signal: the MCP-B polyfill does not (as of 5.1.0), and the relay protocol has no cancel frame. Pass it to `fetch()` and other cancellable work. Handlers that take one argument keep working.
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
defineTool({
|
|
101
|
+
name: 'orders.search',
|
|
102
|
+
description: 'Search orders by customer name.',
|
|
103
|
+
schema: { type: 'object', properties: { q: { type: 'string' } }, required: ['q'] },
|
|
104
|
+
annotations: { untrustedContentHint: true }, // results contain customer-entered text
|
|
105
|
+
handler: async ({ q }: { q: string }, context) => {
|
|
106
|
+
const res = await fetch(`/api/orders?q=${encodeURIComponent(q)}`, {
|
|
107
|
+
signal: context?.signal,
|
|
108
|
+
});
|
|
109
|
+
if (!res.ok) throw new Error(`Search failed (HTTP ${res.status}).`);
|
|
110
|
+
return { orders: await res.json() };
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Annotations
|
|
116
|
+
|
|
117
|
+
Tools are registered with WebMCP's hints. `readOnlyHint` and `consequentialHint` come from `mutation` unless set explicitly: a mutation is `consequentialHint: true, readOnlyHint: false`; anything else is `readOnlyHint: true`. `untrustedContentHint` (default `false`) tells the agent the result may contain content the page does not control, so it should not follow instructions in it. WebMCP has no `destructiveHint`; MCP clients see a non-read-only tool as destructive (the relay sets `destructiveHint` from `consequentialHint`).
|
|
118
|
+
|
|
119
|
+
### Confirming mutations
|
|
120
|
+
|
|
121
|
+
Every call to a `mutation: true` tool waits for the user's approval, whether it came from a native agent, an MCP-B client, or the relay. The provider owns one queue for all of them. Render a dialog from `useToolConfirmation()` (or from `useRelaySession()`, which returns the same queue):
|
|
122
|
+
|
|
123
|
+
```tsx
|
|
124
|
+
import { ConfirmToolCallDialog } from '@r0hitsharma/mcp-connect';
|
|
125
|
+
import { useToolConfirmation } from '@r0hitsharma/webmcp';
|
|
126
|
+
|
|
127
|
+
function Confirmations() {
|
|
128
|
+
const { pendingConfirmation: p, pendingQueueLength, approve, deny } = useToolConfirmation();
|
|
129
|
+
return (
|
|
130
|
+
<ConfirmToolCallDialog
|
|
131
|
+
pendingCall={p && { callId: p.callId, sessionId: '', toolName: p.toolName, toolArgs: p.argsPreview,
|
|
132
|
+
summary: p.summary, createdAt: p.createdAt, expiresAt: p.expiresAt, status: 'pending' }}
|
|
133
|
+
queueLength={pendingQueueLength}
|
|
134
|
+
onApprove={approve}
|
|
135
|
+
onDeny={deny}
|
|
136
|
+
/>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
A denied call, or one nobody answers before the window closes (`confirmationWindowSeconds` on the provider, default 50 s, under the MCP SDK's 60 s request timeout; `useRelaySession` uses its own, default 25 s, under the relay's 30 s timeout), fails without running the handler: the agent gets an error (MCP `isError: true`) saying the user declined or did not answer. It is an error rather than a result so it never has to match the tool's `outputSchema`. With no dialog mounted, a mutation call therefore waits out the whole window and is then denied.
|
|
142
|
+
|
|
143
|
+
### Transport
|
|
144
|
+
|
|
145
|
+
`@mcp-b/global` starts itself on import and, unconfigured, accepts MCP connections from any origin. This package opts out of that and has `WebMCPProvider` initialize it with an explicit transport: by default the tab transport (used by the MCP-B extension) accepts the page's own origin only, and the iframe transport is off. Configure both with the `transport` prop:
|
|
146
|
+
|
|
147
|
+
```tsx
|
|
148
|
+
<WebMCPProvider
|
|
149
|
+
transport={{
|
|
150
|
+
tabServer: { allowedOrigins: [window.location.origin] },
|
|
151
|
+
// When the page is embedded, name the embedding origins:
|
|
152
|
+
iframeServer: { allowedOrigins: ['https://host.example'] },
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`false` disables a transport. The prop governs the MCP-B bridge only, not a browser's native `document.modelContext`. A host page can still set `window.__webModelContextOptions` before loading this package: the provider applies its `installTestingShim`, and its `transport` when the prop is not set. Only an explicit `autoInitialize: true` keeps @mcp-b/global's import-time start; the host then owns that instance, and the provider neither re-configures nor tears it down (its `transport` prop has no effect).
|
|
158
|
+
|
|
69
159
|
## Public surface
|
|
70
160
|
|
|
71
161
|
- `defineTool` — schema-first tool factory
|
|
72
|
-
- `WebMCPProvider` — provider that initializes the polyfill and registry
|
|
162
|
+
- `WebMCPProvider` — provider that initializes the polyfill (props: `transport`, `confirmationWindowSeconds`, `initPolyfill`) and the registry
|
|
73
163
|
- `useRegisterTool` — register a tool while a component is mounted
|
|
74
164
|
- `useTool` — observe a single tool's spec by name
|
|
75
165
|
- `useToolRegistry` / `useToolRegistryRef` — read the full registry
|
|
76
166
|
- `useContributeViewState` — contribute a partial view-state slice
|
|
167
|
+
- `useToolConfirmation` — drive a mutation-confirmation dialog from the provider's queue
|
|
77
168
|
- `listTools` / `getViewState` — imperative helpers for non-React callers
|
|
78
|
-
- `useRelaySession` — drive the relay back-channel from the registry: mint/reuse a session, advertise the registered tools, run incoming `invoke`s, and gate any `mutation: true` tool behind
|
|
79
|
-
- Tool types (`ToolSpec`, `ToolHandler`, `PendingCallPrompt`, `ViewState`, ...) and the wire-protocol types re-exported from [`@r0hitsharma/mcp-relay`](../mcp-relay/README.md),
|
|
169
|
+
- `useRelaySession` — drive the relay back-channel from the registry: mint/reuse a session, advertise the registered tools, run incoming `invoke`s, and gate any `mutation: true` tool behind the shared confirmation queue
|
|
170
|
+
- Tool types (`ToolSpec`, `ToolHandler`, `ToolHandlerContext`, `ToolAnnotations`, `ToolOutputSchema`, `PendingCallPrompt`, `ViewState`, `WebMCPTransportOptions`, ...) and the wire-protocol types re-exported from [`@r0hitsharma/mcp-relay`](../mcp-relay/README.md), their single source of truth
|
|
80
171
|
|
|
81
172
|
## Related
|
|
82
173
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal spec-shaped `document.modelContext` for unit tests: registerTool
|
|
3
|
+
* returns a Promise, rejects duplicates, and unregisters on signal abort.
|
|
4
|
+
*/
|
|
5
|
+
export type FakeTool = Record<string, unknown> & {
|
|
6
|
+
name: string;
|
|
7
|
+
execute: (input: unknown, options?: {
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
}) => unknown;
|
|
10
|
+
};
|
|
11
|
+
export type FakeContext = {
|
|
12
|
+
tools: Map<string, FakeTool>;
|
|
13
|
+
calls: FakeTool[];
|
|
14
|
+
registerTool: (tool: FakeTool, options?: {
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
}) => Promise<void>;
|
|
17
|
+
/** When set, the next registerTool waits for this before settling. */
|
|
18
|
+
gate?: Promise<void>;
|
|
19
|
+
/** When set, registerTool rejects with this error. */
|
|
20
|
+
failWith?: unknown;
|
|
21
|
+
};
|
|
22
|
+
export declare function installFakeContext(): FakeContext;
|
|
23
|
+
export declare function removeFakeContext(): void;
|
|
24
|
+
export declare function flushMicrotasks(): Promise<void>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function installFakeContext() {
|
|
2
|
+
const ctx = {
|
|
3
|
+
tools: new Map(),
|
|
4
|
+
calls: [],
|
|
5
|
+
async registerTool(tool, options) {
|
|
6
|
+
ctx.calls.push(tool);
|
|
7
|
+
if (ctx.gate)
|
|
8
|
+
await ctx.gate;
|
|
9
|
+
if (ctx.failWith)
|
|
10
|
+
throw ctx.failWith;
|
|
11
|
+
options?.signal?.throwIfAborted();
|
|
12
|
+
if (ctx.tools.has(tool.name)) {
|
|
13
|
+
throw new DOMException(`Tool already registered: ${tool.name}`, 'InvalidStateError');
|
|
14
|
+
}
|
|
15
|
+
ctx.tools.set(tool.name, tool);
|
|
16
|
+
options?.signal?.addEventListener('abort', () => {
|
|
17
|
+
if (ctx.tools.get(tool.name) === tool)
|
|
18
|
+
ctx.tools.delete(tool.name);
|
|
19
|
+
});
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
Object.defineProperty(document, 'modelContext', {
|
|
23
|
+
configurable: true,
|
|
24
|
+
value: ctx,
|
|
25
|
+
});
|
|
26
|
+
return ctx;
|
|
27
|
+
}
|
|
28
|
+
export function removeFakeContext() {
|
|
29
|
+
Reflect.deleteProperty(document, 'modelContext');
|
|
30
|
+
}
|
|
31
|
+
export async function flushMicrotasks() {
|
|
32
|
+
for (let i = 0; i < 5; i++)
|
|
33
|
+
await Promise.resolve();
|
|
34
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
35
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Tool names on the bridged `document.modelContext` (MCP-B's `listTools()`). */
|
|
2
|
+
export declare function modelContextToolNames(): string[];
|
|
3
|
+
/** Let deferred unregisters, polyfill syncs and timers settle. */
|
|
4
|
+
export declare function settle(ms?: number): Promise<void>;
|
|
5
|
+
export declare function ReadTool({ name }: {
|
|
6
|
+
name: string;
|
|
7
|
+
}): null;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { act } from '@testing-library/react';
|
|
2
|
+
import { useRegisterTool } from '../hooks.js';
|
|
3
|
+
import { defineTool } from '../types.js';
|
|
4
|
+
/** Tool names on the bridged `document.modelContext` (MCP-B's `listTools()`). */
|
|
5
|
+
export function modelContextToolNames() {
|
|
6
|
+
const ctx = document
|
|
7
|
+
.modelContext;
|
|
8
|
+
return ctx?.listTools?.().map((t) => t.name) ?? [];
|
|
9
|
+
}
|
|
10
|
+
/** Let deferred unregisters, polyfill syncs and timers settle. */
|
|
11
|
+
export async function settle(ms = 20) {
|
|
12
|
+
await act(async () => {
|
|
13
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export function ReadTool({ name }) {
|
|
17
|
+
useRegisterTool(defineTool({
|
|
18
|
+
name,
|
|
19
|
+
description: `Test tool ${name}.`,
|
|
20
|
+
schema: { type: 'object', properties: {} },
|
|
21
|
+
handler: () => ({ ok: true }),
|
|
22
|
+
}));
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare class FakeWebSocket {
|
|
2
|
+
static readonly CONNECTING = 0;
|
|
3
|
+
static readonly OPEN = 1;
|
|
4
|
+
static readonly CLOSING = 2;
|
|
5
|
+
static readonly CLOSED = 3;
|
|
6
|
+
static instances: FakeWebSocket[];
|
|
7
|
+
readonly url: string;
|
|
8
|
+
readyState: number;
|
|
9
|
+
readonly sent: Array<Record<string, unknown>>;
|
|
10
|
+
onopen: (() => void) | null;
|
|
11
|
+
onmessage: ((event: {
|
|
12
|
+
data: string;
|
|
13
|
+
}) => void) | null;
|
|
14
|
+
onerror: (() => void) | null;
|
|
15
|
+
onclose: (() => void) | null;
|
|
16
|
+
constructor(url: string);
|
|
17
|
+
send(data: string): void;
|
|
18
|
+
close(): void;
|
|
19
|
+
serverOpen(): Promise<void>;
|
|
20
|
+
serverSend(frame: Record<string, unknown>): Promise<void>;
|
|
21
|
+
/** Frames of `type` the browser sent. */
|
|
22
|
+
framesOf(type: string): Array<Record<string, unknown>>;
|
|
23
|
+
}
|
|
24
|
+
export declare function installRelayFakes(): void;
|
|
25
|
+
/** The socket the hook opened, once it has. */
|
|
26
|
+
export declare function openedSocket(): Promise<FakeWebSocket>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fakes for driving useRelaySession without a network: a scripted WebSocket
|
|
3
|
+
* the test plays the relay's side of, and a fetch that mints a session.
|
|
4
|
+
*/
|
|
5
|
+
import { act } from '@testing-library/react';
|
|
6
|
+
import { vi } from 'vitest';
|
|
7
|
+
export class FakeWebSocket {
|
|
8
|
+
static CONNECTING = 0;
|
|
9
|
+
static OPEN = 1;
|
|
10
|
+
static CLOSING = 2;
|
|
11
|
+
static CLOSED = 3;
|
|
12
|
+
static instances = [];
|
|
13
|
+
url;
|
|
14
|
+
readyState = FakeWebSocket.CONNECTING;
|
|
15
|
+
sent = [];
|
|
16
|
+
onopen = null;
|
|
17
|
+
onmessage = null;
|
|
18
|
+
onerror = null;
|
|
19
|
+
onclose = null;
|
|
20
|
+
constructor(url) {
|
|
21
|
+
this.url = url;
|
|
22
|
+
FakeWebSocket.instances.push(this);
|
|
23
|
+
}
|
|
24
|
+
send(data) {
|
|
25
|
+
this.sent.push(JSON.parse(data));
|
|
26
|
+
}
|
|
27
|
+
close() {
|
|
28
|
+
if (this.readyState === FakeWebSocket.CLOSED)
|
|
29
|
+
return;
|
|
30
|
+
this.readyState = FakeWebSocket.CLOSED;
|
|
31
|
+
this.onclose?.();
|
|
32
|
+
}
|
|
33
|
+
// --- the relay's side -----------------------------------------------------
|
|
34
|
+
async serverOpen() {
|
|
35
|
+
await act(async () => {
|
|
36
|
+
this.readyState = FakeWebSocket.OPEN;
|
|
37
|
+
this.onopen?.();
|
|
38
|
+
this.onmessage?.({
|
|
39
|
+
data: JSON.stringify({ type: 'hello/accepted', session_id: 's-1' }),
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
async serverSend(frame) {
|
|
44
|
+
await act(async () => {
|
|
45
|
+
this.onmessage?.({ data: JSON.stringify(frame) });
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/** Frames of `type` the browser sent. */
|
|
49
|
+
framesOf(type) {
|
|
50
|
+
return this.sent.filter((f) => f['type'] === type);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function installRelayFakes() {
|
|
54
|
+
FakeWebSocket.instances = [];
|
|
55
|
+
localStorage.clear();
|
|
56
|
+
vi.stubGlobal('WebSocket', FakeWebSocket);
|
|
57
|
+
vi.stubGlobal('fetch', vi.fn(async () => ({
|
|
58
|
+
ok: true,
|
|
59
|
+
status: 201,
|
|
60
|
+
json: async () => ({
|
|
61
|
+
connection_token: 'token-1',
|
|
62
|
+
ws_url: 'ws://relay.test/ws/sessions/s-1',
|
|
63
|
+
}),
|
|
64
|
+
})));
|
|
65
|
+
}
|
|
66
|
+
/** The socket the hook opened, once it has. */
|
|
67
|
+
export async function openedSocket() {
|
|
68
|
+
await vi.waitFor(() => {
|
|
69
|
+
if (FakeWebSocket.instances.length === 0)
|
|
70
|
+
throw new Error('no socket yet');
|
|
71
|
+
});
|
|
72
|
+
return FakeWebSocket.instances.at(-1);
|
|
73
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-in-the-loop confirmation for `mutation: true` tools.
|
|
3
|
+
*
|
|
4
|
+
* One queue per <WebMCPProvider>, shared by every path a tool can be invoked
|
|
5
|
+
* on (document.modelContext, the MCP-B bridge, the relay back-channel), so a
|
|
6
|
+
* mutation never runs without the user's approval whichever agent called it,
|
|
7
|
+
* and one dialog serves them all. Framework-free: React reads it through
|
|
8
|
+
* useSyncExternalStore.
|
|
9
|
+
*/
|
|
10
|
+
import type { PendingCallPrompt, ToolSpec } from './types.js';
|
|
11
|
+
/**
|
|
12
|
+
* How a confirmation ended. `cancelled` means the caller went away (its
|
|
13
|
+
* signal aborted, or the provider unmounted) before the user answered.
|
|
14
|
+
*/
|
|
15
|
+
export type ConfirmationDecision = 'approved' | 'denied' | 'expired' | 'cancelled';
|
|
16
|
+
export interface ConfirmationOptions {
|
|
17
|
+
/** Seconds before an unanswered prompt expires (and the call is denied). */
|
|
18
|
+
windowSeconds: number;
|
|
19
|
+
/** Reuse the caller's id (the relay's call_id); generated when omitted. */
|
|
20
|
+
callId?: string;
|
|
21
|
+
/** Aborting it withdraws the prompt with a `cancelled` decision. */
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
}
|
|
24
|
+
export declare class ConfirmationQueue {
|
|
25
|
+
private held;
|
|
26
|
+
private snapshot;
|
|
27
|
+
private readonly listeners;
|
|
28
|
+
private counter;
|
|
29
|
+
/** Queue a prompt for `spec` and resolve once the user (or the clock) decides. */
|
|
30
|
+
request(spec: Pick<ToolSpec, 'name' | 'confirmationSummary'>, args: Record<string, unknown>, { windowSeconds, callId, signal }: ConfirmationOptions): Promise<ConfirmationDecision>;
|
|
31
|
+
/** Answer the prompt at the head of the queue. */
|
|
32
|
+
resolveHead(decision: 'approved' | 'denied'): void;
|
|
33
|
+
/** Withdraw every prompt (e.g. the provider unmounted). */
|
|
34
|
+
cancelAll(): void;
|
|
35
|
+
getSnapshot: () => readonly PendingCallPrompt[];
|
|
36
|
+
subscribe: (listener: () => void) => (() => void);
|
|
37
|
+
private publish;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The error message an agent receives when a mutation was not approved.
|
|
41
|
+
*
|
|
42
|
+
* A denial is reported as a failed call (a rejected execute, an MCP
|
|
43
|
+
* `isError` result), not as a result object: a tool that declares an
|
|
44
|
+
* `outputSchema` would otherwise hand the agent an object that fails its own
|
|
45
|
+
* schema, and MCP servers and clients reject that as a broken tool. The
|
|
46
|
+
* message says the user declined, so an agent can tell it from a bug and does
|
|
47
|
+
* not retry blindly.
|
|
48
|
+
*/
|
|
49
|
+
export declare function denialMessage(decision: 'denied' | 'expired'): string;
|
|
50
|
+
/** Signature the registration layer uses to ask for approval. */
|
|
51
|
+
export type RequestConfirmation = (spec: Pick<ToolSpec, 'name' | 'confirmationSummary'>, args: Record<string, unknown>, options?: Partial<ConfirmationOptions>) => Promise<ConfirmationDecision>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// setTimeout fires at once for any delay above 2^31 - 1 ms (about 24.8 days).
|
|
2
|
+
const MAX_WINDOW_SECONDS = Math.floor((2 ** 31 - 1) / 1000);
|
|
3
|
+
function isValidWindow(seconds) {
|
|
4
|
+
return (typeof seconds === 'number' &&
|
|
5
|
+
Number.isFinite(seconds) &&
|
|
6
|
+
seconds > 0 &&
|
|
7
|
+
seconds <= MAX_WINDOW_SECONDS);
|
|
8
|
+
}
|
|
9
|
+
export class ConfirmationQueue {
|
|
10
|
+
held = [];
|
|
11
|
+
snapshot = [];
|
|
12
|
+
listeners = new Set();
|
|
13
|
+
counter = 0;
|
|
14
|
+
/** Queue a prompt for `spec` and resolve once the user (or the clock) decides. */
|
|
15
|
+
request(spec, args, { windowSeconds, callId, signal }) {
|
|
16
|
+
if (signal?.aborted)
|
|
17
|
+
return Promise.resolve('cancelled');
|
|
18
|
+
if (!isValidWindow(windowSeconds)) {
|
|
19
|
+
// A bad window must not open a prompt that can never expire (or that
|
|
20
|
+
// expires at once): fail the call and tell the developer why.
|
|
21
|
+
const error = new RangeError(`[webmcp] confirmationWindowSeconds must be a number of seconds above 0 and at most ${MAX_WINDOW_SECONDS}; got ${String(windowSeconds)}.`);
|
|
22
|
+
console.error(error.message);
|
|
23
|
+
return Promise.reject(error);
|
|
24
|
+
}
|
|
25
|
+
const id = callId ?? `local-${(this.counter += 1)}`;
|
|
26
|
+
// A re-delivered call is already on screen; the original prompt answers it.
|
|
27
|
+
if (this.held.some((h) => h.prompt.callId === id)) {
|
|
28
|
+
return Promise.resolve('cancelled');
|
|
29
|
+
}
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const now = new Date();
|
|
32
|
+
const prompt = {
|
|
33
|
+
callId: id,
|
|
34
|
+
toolName: spec.name,
|
|
35
|
+
summary: summarize(spec, args),
|
|
36
|
+
argsPreview: args,
|
|
37
|
+
createdAt: now.toISOString(),
|
|
38
|
+
expiresAt: new Date(now.getTime() + windowSeconds * 1000).toISOString(),
|
|
39
|
+
};
|
|
40
|
+
let done = false;
|
|
41
|
+
const onAbort = () => settle('cancelled');
|
|
42
|
+
const timer = setTimeout(() => settle('expired'), windowSeconds * 1000);
|
|
43
|
+
const settle = (decision) => {
|
|
44
|
+
if (done)
|
|
45
|
+
return;
|
|
46
|
+
done = true;
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
signal?.removeEventListener('abort', onAbort);
|
|
49
|
+
this.held = this.held.filter((h) => h.prompt !== prompt);
|
|
50
|
+
this.publish();
|
|
51
|
+
resolve(decision);
|
|
52
|
+
};
|
|
53
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
54
|
+
this.held.push({ prompt, settle });
|
|
55
|
+
this.publish();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** Answer the prompt at the head of the queue. */
|
|
59
|
+
resolveHead(decision) {
|
|
60
|
+
this.held[0]?.settle(decision);
|
|
61
|
+
}
|
|
62
|
+
/** Withdraw every prompt (e.g. the provider unmounted). */
|
|
63
|
+
cancelAll() {
|
|
64
|
+
for (const h of [...this.held])
|
|
65
|
+
h.settle('cancelled');
|
|
66
|
+
}
|
|
67
|
+
getSnapshot = () => this.snapshot;
|
|
68
|
+
subscribe = (listener) => {
|
|
69
|
+
this.listeners.add(listener);
|
|
70
|
+
return () => {
|
|
71
|
+
this.listeners.delete(listener);
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
publish() {
|
|
75
|
+
this.snapshot = this.held.map((h) => h.prompt);
|
|
76
|
+
for (const listener of this.listeners)
|
|
77
|
+
listener();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function summarize(spec, args) {
|
|
81
|
+
try {
|
|
82
|
+
return spec.confirmationSummary?.(args) ?? spec.name;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// A throwing summary must not bypass or wedge the gate: fall back to the
|
|
86
|
+
// tool name so the user can still decide.
|
|
87
|
+
return spec.name;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The error message an agent receives when a mutation was not approved.
|
|
92
|
+
*
|
|
93
|
+
* A denial is reported as a failed call (a rejected execute, an MCP
|
|
94
|
+
* `isError` result), not as a result object: a tool that declares an
|
|
95
|
+
* `outputSchema` would otherwise hand the agent an object that fails its own
|
|
96
|
+
* schema, and MCP servers and clients reject that as a broken tool. The
|
|
97
|
+
* message says the user declined, so an agent can tell it from a bug and does
|
|
98
|
+
* not retry blindly.
|
|
99
|
+
*/
|
|
100
|
+
export function denialMessage(decision) {
|
|
101
|
+
return decision === 'expired'
|
|
102
|
+
? 'The user did not approve this call before the confirmation request expired; it was not run.'
|
|
103
|
+
: 'The user denied this call; it was not run.';
|
|
104
|
+
}
|
package/dist/hooks.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* None of them import @mcp-b/* directly, that coupling lives in provider.tsx.
|
|
6
6
|
*/
|
|
7
7
|
import { type DependencyList } from 'react';
|
|
8
|
-
import type { ToolSpec, ViewState } from './types.js';
|
|
8
|
+
import type { PendingCallPrompt, ToolSpec, ViewState } from './types.js';
|
|
9
9
|
/**
|
|
10
10
|
* Register a tool while the calling component is mounted.
|
|
11
11
|
*
|
|
@@ -52,6 +52,27 @@ export declare function useRegisterTool<TArgs = Record<string, unknown>, TResult
|
|
|
52
52
|
* the registration themselves.
|
|
53
53
|
*/
|
|
54
54
|
export declare function useTool(toolName: string): ToolSpec | null;
|
|
55
|
+
/** The mutation-confirmation queue, as a confirmation dialog needs it. */
|
|
56
|
+
export interface ToolConfirmation {
|
|
57
|
+
/** The mutation awaiting approval (head of the queue), or null. */
|
|
58
|
+
pendingConfirmation: PendingCallPrompt | null;
|
|
59
|
+
/** Number of mutations waiting (including the active one). */
|
|
60
|
+
pendingQueueLength: number;
|
|
61
|
+
/** Approve the active mutation: its handler runs and returns the result. */
|
|
62
|
+
approve: () => void;
|
|
63
|
+
/** Deny the active mutation: the agent receives a denial result. */
|
|
64
|
+
deny: () => void;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Drive a confirmation dialog (e.g. `ConfirmToolCallDialog` from
|
|
68
|
+
* `@r0hitsharma/mcp-connect`) from the provider's queue.
|
|
69
|
+
*
|
|
70
|
+
* Every call to a `mutation: true` tool waits here for approval, whether it
|
|
71
|
+
* came through document.modelContext or the relay. Mount a dialog wired to
|
|
72
|
+
* this hook in any app that registers mutations: with nothing to answer
|
|
73
|
+
* them, calls are denied when the confirmation window expires.
|
|
74
|
+
*/
|
|
75
|
+
export declare function useToolConfirmation(): ToolConfirmation;
|
|
55
76
|
/**
|
|
56
77
|
* Read-only view of the whole tool registry.
|
|
57
78
|
*
|
package/dist/hooks.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* All hooks require <WebMCPProvider> in the component tree.
|
|
6
6
|
* None of them import @mcp-b/* directly, that coupling lives in provider.tsx.
|
|
7
7
|
*/
|
|
8
|
-
import { useEffect, useMemo, useRef } from 'react';
|
|
8
|
+
import { useEffect, useMemo, useRef, useSyncExternalStore, } from 'react';
|
|
9
9
|
import { useToolRegistryContext } from './provider.js';
|
|
10
10
|
import { acquireToolRegistration } from './registration.js';
|
|
11
11
|
// ---------------------------------------------------------------------------
|
|
@@ -73,19 +73,28 @@ export function useRegisterTool(spec, deps) {
|
|
|
73
73
|
get name() {
|
|
74
74
|
return specRef.current.name;
|
|
75
75
|
},
|
|
76
|
+
get title() {
|
|
77
|
+
return specRef.current.title;
|
|
78
|
+
},
|
|
76
79
|
get description() {
|
|
77
80
|
return specRef.current.description;
|
|
78
81
|
},
|
|
79
82
|
get schema() {
|
|
80
83
|
return specRef.current.schema;
|
|
81
84
|
},
|
|
85
|
+
get outputSchema() {
|
|
86
|
+
return specRef.current.outputSchema;
|
|
87
|
+
},
|
|
88
|
+
get annotations() {
|
|
89
|
+
return specRef.current.annotations;
|
|
90
|
+
},
|
|
82
91
|
get mutation() {
|
|
83
92
|
return specRef.current.mutation;
|
|
84
93
|
},
|
|
85
94
|
get confirmationSummary() {
|
|
86
95
|
return specRef.current.confirmationSummary;
|
|
87
96
|
},
|
|
88
|
-
handler: (args) => specRef.current.handler(args),
|
|
97
|
+
handler: (args, context) => specRef.current.handler(args, context),
|
|
89
98
|
}),
|
|
90
99
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
91
100
|
[spec.name]);
|
|
@@ -100,8 +109,11 @@ export function useRegisterTool(spec, deps) {
|
|
|
100
109
|
// microtask-synced McpServer and throws "Tool <name> is already registered".
|
|
101
110
|
// The manager registers each name once, refcounted, with AbortSignal-based
|
|
102
111
|
// unregister.
|
|
112
|
+
// Mutations are gated on the provider's confirmation queue (the same one the
|
|
113
|
+
// relay path uses), so every invocation path needs the user's approval.
|
|
114
|
+
const requestConfirmation = registry._requestConfirmation;
|
|
103
115
|
useEffect(() => {
|
|
104
|
-
return acquireToolRegistration(stableSpec);
|
|
116
|
+
return acquireToolRegistration(stableSpec, { requestConfirmation });
|
|
105
117
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
106
118
|
}, [spec.name]);
|
|
107
119
|
}
|
|
@@ -121,6 +133,25 @@ export function useTool(toolName) {
|
|
|
121
133
|
const registry = useToolRegistryContext();
|
|
122
134
|
return registry.tools.find((t) => t.name === toolName) ?? null;
|
|
123
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Drive a confirmation dialog (e.g. `ConfirmToolCallDialog` from
|
|
138
|
+
* `@r0hitsharma/mcp-connect`) from the provider's queue.
|
|
139
|
+
*
|
|
140
|
+
* Every call to a `mutation: true` tool waits here for approval, whether it
|
|
141
|
+
* came through document.modelContext or the relay. Mount a dialog wired to
|
|
142
|
+
* this hook in any app that registers mutations: with nothing to answer
|
|
143
|
+
* them, calls are denied when the confirmation window expires.
|
|
144
|
+
*/
|
|
145
|
+
export function useToolConfirmation() {
|
|
146
|
+
const queue = useToolRegistryContext()._confirmationQueue;
|
|
147
|
+
const pending = useSyncExternalStore(queue.subscribe, queue.getSnapshot, queue.getSnapshot);
|
|
148
|
+
return useMemo(() => ({
|
|
149
|
+
pendingConfirmation: pending[0] ?? null,
|
|
150
|
+
pendingQueueLength: pending.length,
|
|
151
|
+
approve: () => queue.resolveHead('approved'),
|
|
152
|
+
deny: () => queue.resolveHead('denied'),
|
|
153
|
+
}), [pending, queue]);
|
|
154
|
+
}
|
|
124
155
|
// ---------------------------------------------------------------------------
|
|
125
156
|
// useToolRegistry
|
|
126
157
|
// ---------------------------------------------------------------------------
|
package/dist/index.d.ts
CHANGED
|
@@ -11,17 +11,18 @@
|
|
|
11
11
|
* - useTool observe a single tool's spec by name
|
|
12
12
|
* - useToolRegistry read the full registry (listTools / getViewState)
|
|
13
13
|
* - useContributeViewState contribute a partial view-state slice
|
|
14
|
+
* - useToolConfirmation drive a mutation-confirmation dialog
|
|
14
15
|
* - useRelaySession drive a relay back-channel from the registry
|
|
15
16
|
* - listTools / getViewState imperative helpers for non-React callers
|
|
16
17
|
* - ToolSpec / ViewState / etc. shared types
|
|
17
18
|
* - wire-protocol types re-exported from @r0hitsharma/mcp-relay
|
|
18
19
|
*/
|
|
19
20
|
export { defineTool } from './types.js';
|
|
20
|
-
export type { PendingCallPrompt, ToolHandler, ToolRegistry, ToolSpec, ViewState, } from './types.js';
|
|
21
|
+
export type { PendingCallPrompt, ToolAnnotations, ToolHandler, ToolHandlerContext, ToolOutputSchema, ToolRegistry, ToolSpec, ViewState, } from './types.js';
|
|
21
22
|
export { WebMCPProvider } from './provider.js';
|
|
22
|
-
export type { WebMCPProviderProps, ToolRegistryContextValue, } from './provider.js';
|
|
23
|
-
export { useRegisterTool, useTool, useToolRegistry, useContributeViewState, listTools, getViewState, useToolRegistryRef, } from './hooks.js';
|
|
24
|
-
export type { ToolRegistryRef } from './hooks.js';
|
|
23
|
+
export type { WebMCPProviderProps, WebMCPTransportEndpoint, WebMCPTransportOptions, ToolRegistryContextValue, } from './provider.js';
|
|
24
|
+
export { useRegisterTool, useTool, useToolRegistry, useContributeViewState, useToolConfirmation, listTools, getViewState, useToolRegistryRef, } from './hooks.js';
|
|
25
|
+
export type { ToolConfirmation, ToolRegistryRef } from './hooks.js';
|
|
25
26
|
export { useRelaySession } from './useRelaySession.js';
|
|
26
27
|
export type { RelaySessionStatus, UseRelaySessionOptions, UseRelaySessionResult, } from './useRelaySession.js';
|
|
27
28
|
export type { BrowserToServerMessage, ConnectionTokenClaims, CreateSessionResponse, HarnessStatusMessage, HelloAcceptedMessage, HelloMessage, HelloRejectedMessage, InvokeMessage, PingMessage, PongMessage, ResultMessage, ServerToBrowserMessage, ToolActivityMessage, ToolDefinition, ToolInputSchema, ToolsChangedMessage, ToolsListMessage, } from './protocol.js';
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* - useTool observe a single tool's spec by name
|
|
12
12
|
* - useToolRegistry read the full registry (listTools / getViewState)
|
|
13
13
|
* - useContributeViewState contribute a partial view-state slice
|
|
14
|
+
* - useToolConfirmation drive a mutation-confirmation dialog
|
|
14
15
|
* - useRelaySession drive a relay back-channel from the registry
|
|
15
16
|
* - listTools / getViewState imperative helpers for non-React callers
|
|
16
17
|
* - ToolSpec / ViewState / etc. shared types
|
|
@@ -21,6 +22,6 @@ export { defineTool } from './types.js';
|
|
|
21
22
|
// Provider
|
|
22
23
|
export { WebMCPProvider } from './provider.js';
|
|
23
24
|
// Hooks
|
|
24
|
-
export { useRegisterTool, useTool, useToolRegistry, useContributeViewState, listTools, getViewState, useToolRegistryRef, } from './hooks.js';
|
|
25
|
+
export { useRegisterTool, useTool, useToolRegistry, useContributeViewState, useToolConfirmation, listTools, getViewState, useToolRegistryRef, } from './hooks.js';
|
|
25
26
|
// Relay session: connects the registry to a relay back-channel.
|
|
26
27
|
export { useRelaySession } from './useRelaySession.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|