@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.
Files changed (49) hide show
  1. package/README.md +97 -6
  2. package/dist/__tests__/fake-context.d.ts +24 -0
  3. package/dist/__tests__/fake-context.js +35 -0
  4. package/dist/__tests__/helpers.d.ts +7 -0
  5. package/dist/__tests__/helpers.js +24 -0
  6. package/dist/__tests__/relay-harness.d.ts +26 -0
  7. package/dist/__tests__/relay-harness.js +73 -0
  8. package/dist/confirmation.d.ts +51 -0
  9. package/dist/confirmation.js +104 -0
  10. package/dist/hooks.d.ts +22 -1
  11. package/dist/hooks.js +34 -3
  12. package/dist/index.d.ts +5 -4
  13. package/dist/index.js +2 -1
  14. package/dist/mcp-b-optout.d.ts +1 -0
  15. package/dist/mcp-b-optout.js +23 -0
  16. package/dist/mcp-b.d.ts +11 -0
  17. package/dist/mcp-b.js +16 -0
  18. package/dist/provider.d.ts +58 -1
  19. package/dist/provider.js +79 -6
  20. package/dist/registration.d.ts +32 -2
  21. package/dist/registration.js +136 -38
  22. package/dist/types.d.ts +60 -2
  23. package/dist/types.js +40 -0
  24. package/dist/useRelaySession.d.ts +11 -5
  25. package/dist/useRelaySession.js +109 -61
  26. package/package.json +12 -5
  27. package/src/__tests__/fake-context.ts +58 -0
  28. package/src/__tests__/helpers.tsx +32 -0
  29. package/src/__tests__/host-autoinit.test.tsx +36 -0
  30. package/src/__tests__/host-options.test.tsx +46 -0
  31. package/src/__tests__/init-order.late-context.test.tsx +35 -0
  32. package/src/__tests__/init-order.strict.test.tsx +26 -0
  33. package/src/__tests__/insecure-context.test.tsx +31 -0
  34. package/src/__tests__/polyfill-execute.test.tsx +155 -0
  35. package/src/__tests__/relay-harness.ts +85 -0
  36. package/src/__tests__/transport.test.tsx +65 -0
  37. package/src/confirmation.test.tsx +175 -0
  38. package/src/confirmation.ts +162 -0
  39. package/src/hooks.ts +71 -4
  40. package/src/index.ts +8 -1
  41. package/src/mcp-b-optout.ts +25 -0
  42. package/src/mcp-b.ts +25 -0
  43. package/src/provider.tsx +160 -7
  44. package/src/registration.test.ts +274 -0
  45. package/src/registration.ts +145 -45
  46. package/src/types.test.ts +85 -0
  47. package/src/types.ts +105 -1
  48. package/src/useRelaySession.test.tsx +396 -0
  49. 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.com/webmcp-org) (`@mcp-b/global`) for registering UI tools that a connected agent harness can call. It exposes a fixed interface so the fast-moving `@mcp-b/*` packages can churn behind a single seam.
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 WebMCP polyfill and a tool registry
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 a local confirmation
79
- - Tool types (`ToolSpec`, `ToolHandler`, `PendingCallPrompt`, `ViewState`, ...) and the wire-protocol types re-exported from [`@r0hitsharma/mcp-relay`](../mcp-relay/README.md), the single source of truth shared with the Python relay
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 {};