From 969b523ba507401a58051dea5efc02a3c0559de1 Mon Sep 17 00:00:00 2001 From: ZZZZJJJ0928 <290019641+ZZZZJJJ0928@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:36:20 +0800 Subject: [PATCH 1/7] docs: propose opt-in userscript invocation and structured results --- README.md | 2 + docs/script-execution.md | 85 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 docs/script-execution.md diff --git a/README.md b/README.md index 2a3036a..6653b0c 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,8 @@ The four tab tools (`tabs_list`, `tab_scripts`, `tab_reload`, `script_logs`) als Exact input/output schemas live in [`packages/protocol`](packages/protocol) and are the single source of truth. +The proposed `script_handlers`, `script_call` and `script_results` tools extend this workflow with explicitly registered callbacks and structured results. They require matching extension support and are omitted for existing extensions. See the [script invocation proposal](docs/script-execution.md) for the API, authorization boundary and examples. File saving is outside this proposal. + In `--readonly` mode only read-only tools are exposed, which excludes `scripts_write`, `scripts_set_enabled`, `scripts_delete` and `tab_reload`. Only tools the connected extension reports in its handshake are exposed. Before the extension connects, `tools/list` contains `vm_status` alone; the list is refreshed when the connection changes. Calling a tool that is not exposed returns an error result. A call that races a disconnect still fails with code `503` and the message `Violentmonkey is not connected`. Over the HTTP transport this is a tool result, not an HTTP status, since the HTTP request itself succeeded. diff --git a/docs/script-execution.md b/docs/script-execution.md new file mode 100644 index 0000000..7d86711 --- /dev/null +++ b/docs/script-execution.md @@ -0,0 +1,85 @@ +# Proposal: opt-in userscript callbacks and structured results + +An agent developing a userscript can currently install or update it, reload a granted tab, and inspect `GM_log` output. Testing a function with several inputs requires editing the script or adding an application-specific trigger. Log strings also require an application-specific convention to associate an asynchronous result with the request that produced it. + +This proposal adds one explicitly registered callback per script and frame, plus a bounded channel for structured results. An agent could call a script's validation or calculation function with JSON input and receive its return value or error without rerunning the script's top-level initialization. A script could also deliberately report a result from an event it observes. + +## Scope decision + +The current [development contract](contract.md#non-goals) excludes externally driving userscripts and treats `GM_log` as the feedback boundary. Callable handlers would intentionally extend that scope. This proposal asks whether that extension is desirable before requesting a release; it does not assume that callback registration alone resolves the product or permission decision. + +Invocation requires a connected MCP session, an explicitly granted tab, an enabled installed script and a handler registered in that document. Scripts opt in using `@grant GM_registerMcpHandler` and an explicit registration call. Results contain only what the script returns or reports. No generic evaluation, DOM query, navigation, console capture or network capture tool is added. Registration is an application-level opt-in, not a new security sandbox: an agent already allowed to edit scripts can add a handler, and a handler can perform the same actions as its userscript. + +File saving, download directories, local filesystem access and application-specific integrations are deferred to a separate proposal. Existing `GM_download` behavior is unchanged. + +## Userscript API + +```js +// ==UserScript== +// @name MCP calculation example +// @namespace mcp-example +// @version 1.0.0 +// @match https://example.com/* +// @grant GM_registerMcpHandler +// @grant GM_reportResult +// ==/UserScript== + +GM_registerMcpHandler(async (params, { callId }) => { + if (!params || typeof params.a !== 'number' || typeof params.b !== 'number') { + throw new Error('Expected numeric a and b'); + } + return { sum: params.a + params.b, callId }; +}); + +// Optional unsolicited report; does not register a callable handler. +GM_reportResult({ event: 'ready' }); +``` + +`GM_registerMcpHandler(handler)` resolves after registration is acknowledged. A later registration replaces the callback for that script in that frame. The callback receives JSON input and `{ callId }`, and may return synchronously or asynchronously. `undefined` becomes `null`; other values must be JSON serializable. Thrown errors, rejected promises and invalid return values become failed execution records. + +`GM_reportResult(value, { callId?, error? }?)` returns `{ accepted, callId }`. Without a call ID it creates an unsolicited record. With a call ID it completes only a matching call from the same script, frame and document channel. The first terminal reply wins; duplicate replies return `accepted: false`. Callbacks normally return their value directly. `GM.registerMcpHandler` and `GM.reportResult` provide the corresponding namespaced APIs. + +## MCP tools + +| Tool | Input | Output | +| --- | --- | --- | +| `script_handlers` | `tabId` | `handlers: [{ scriptId, frameId }]` | +| `script_call` | `tabId`, `scriptId`, optional `frameId`, `params`, `callId`, `wait`, `timeoutMs` | `result: Execution` | +| `script_results` | `tabId`, optional `scriptId`, `callId`, `afterSeq`, `limit` | `entries: Execution[]`, `nextSeq`, `dropped` | + +All three tools require tab authorization. `script_call` defaults to frame 0, `params: null`, `wait: true` and a 10-second execution timeout; the permitted timeout is 1–25,000 ms. The server's default 30-second transport timeout leaves time to return an execution timeout record. Deployments overriding that transport timeout should keep it above the requested execution timeout. + +An execution record contains `callId`, `scriptId`, `frameId`, an opaque `documentId`, `seq`, `startedAt`, `status`, and optional `finishedAt`, `value` or `error`. Status is `pending`, `completed`, `failed`, `timed_out` or `cancelled`. Timestamps are milliseconds since the epoch. Call IDs are nonempty strings of at most 128 characters, generated by the extension when omitted. + +For example, after discovering the installed script and granting and reloading its tab: + +```json +{"tabId": 7, "scriptId": 1, "params": {"a": 2, "b": 3}, "callId": "sum-1"} +``` + +Pass this to `script_call`; a successful execution returns `result.status: "completed"` and `result.value.sum: 5`. For long-running work, use `wait: false` and poll `script_results` with the returned call ID. Reads default to 50 entries and allow 1–100; `afterSeq` is an exclusive cursor and `nextSeq` can be used for the next read. Reads do not consume records, and `dropped` counts evictions cumulatively for the tab's session. + +## Errors, limits and lifecycle + +Protocol errors cover invalid arguments, missing handlers and denied access. Script errors are returned in a valid execution record; a successful MCP request does not imply a successful script. Read-only mode exposes handler discovery and result reads, but excludes `script_call`, which is marked potentially destructive and open-world. + +A timeout ends the wait, not the script or its side effects. A late reply can update a retained `timed_out` record to `completed` or `failed`. Reusing a retained call ID with the same script, frame and serialized parameters returns the existing record without invoking the callback again. Different arguments with that ID fail. Eviction, disconnect or restart removes this deduplication guarantee; there is no durable exactly-once execution. Clients should not automatically retry side effects with a fresh ID. + +The extension limits serialized input and output to 256 Ki characters each, errors to 8,192 characters, and pending calls to 32 per tab. Terminal records expire after five minutes; count and byte pressure may evict them earlier. Retention is capped at 100 records and approximately 2 MiB per tab, with pending calls protected from eviction and bounded separately by the per-call and concurrency limits. + +Navigation or reload cancels unfinished calls for the replaced document. Closing a tab, revoking its grant or disconnecting clears its channels and retained results. These operations do not undo effects already performed by a script. Back/forward cache restoration renews the channel and restores registered handlers without rerunning script initialization, provided the original authorization session still exists. Old calls are not replayed and old channel replies remain invalid. Main frames and child frames can restore in either order. A revoked or replaced session requires explicit authorization and a reload. + +## Implementation and compatibility + +The MCP repository owns the three tool schemas, typed client interfaces, capability negotiation and transport tests. The companion extension change owns the GM APIs, authorization checks, per-document dispatch, result retention and page lifecycle. Tools are exposed only when both server and extension support them; existing extensions retain their current tool list. The existing JavaScript client transport can advertise the new names, while a protocol/client package release makes them available to typed consumers. + +No package version or minimum extension release is assigned by this draft. Coordinate the MCP package release and extension rollout if the proposal is accepted. The extension can be tested against a local build of this repository before publishing. + +## Questions for maintainers + +1. Is explicit callback invocation an acceptable extension of the current development-only scope, or should the initial feature be limited to unsolicited structured reports? +2. Are two new GM APIs appropriate, and should the proposed names or registration model change? +3. Is the existing per-tab grant sufficient, or should invocation require an additional user-facing permission? +4. Are the timeout, retention, deduplication and back/forward cache semantics suitable for an initial version? + +The companion tests cover correlation, failures, expiry, authorization, navigation and restoration. Before requesting merge, browser verification should cover Chrome and Firefox, both script realms, child frames, and actual persisted `pageshow` events. From 4fbc64eb1ac2d5347c8628730b06a4c3d97fd27a Mon Sep 17 00:00:00 2001 From: ZZZZJJJ0928 <290019641+ZZZZJJJ0928@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:36:20 +0800 Subject: [PATCH 2/7] feat: define script invocation and result tools Add capability-negotiated handler discovery, JSON invocation and result polling. Cover structured responses, input validation, authorization errors and read-only filtering with transport tests. --- .changeset/script-invocation-results.md | 7 ++ packages/protocol/src/tools.ts | 68 +++++++++++++ packages/server/test/e2e.test.ts | 122 +++++++++++++++++++++++- 3 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 .changeset/script-invocation-results.md diff --git a/.changeset/script-invocation-results.md b/.changeset/script-invocation-results.md new file mode 100644 index 0000000..5d05fd8 --- /dev/null +++ b/.changeset/script-invocation-results.md @@ -0,0 +1,7 @@ +--- +"@violentmonkey/mcp": minor +"@violentmonkey/mcp-client": minor +"@violentmonkey/mcp-protocol": minor +--- + +Add capability-negotiated `script_handlers`, `script_call` and `script_results` tools for explicitly registered userscript callbacks and structured results. Read-only sessions expose discovery and result reads while excluding invocation. Requires matching extension support. diff --git a/packages/protocol/src/tools.ts b/packages/protocol/src/tools.ts index 0365c95..8a09280 100644 --- a/packages/protocol/src/tools.ts +++ b/packages/protocol/src/tools.ts @@ -39,10 +39,29 @@ export const logEntry = z.object({ count: z.number().int().optional().describe('Number of identical consecutive lines collapsed'), }); +const executionScriptId = z.number().int().positive(); +const executionTabId = z.number().int().nonnegative(); +const frameId = z.number().int().nonnegative(); +const callId = z.string().min(1).max(128); + +export const scriptExecution = z.object({ + callId, + scriptId: executionScriptId, + frameId, + documentId: z.string().describe('Opaque identity of the execution channel'), + seq: z.number().int().nonnegative(), + status: z.enum(['pending', 'completed', 'failed', 'timed_out', 'cancelled']), + startedAt: z.number().describe('Milliseconds since the epoch'), + finishedAt: z.number().optional(), + value: z.json().optional(), + error: z.string().optional(), +}); + export interface ToolAnnotations { readOnlyHint?: boolean; destructiveHint?: boolean; idempotentHint?: boolean; + openWorldHint?: boolean; } export interface ToolDefinition { @@ -163,6 +182,55 @@ export const tools = { }), annotations: { readOnlyHint: true }, }), + script_handlers: defineTool({ + title: 'List callable script handlers', + description: + 'List scripts that registered GM_registerMcpHandler in a granted tab, with their frame ids. ' + + 'The script must declare @grant GM_registerMcpHandler. Grant and reload the tab first.', + input: z.object({ tabId: executionTabId }), + output: z.object({ handlers: z.array(z.object({ scriptId: executionScriptId, frameId })) }), + annotations: { readOnlyHint: true }, + }), + script_call: defineTool({ + title: 'Call a userscript', + description: + 'Invoke a registered userscript handler in a granted tab with JSON params. It may have side effects. ' + + 'Inspect result.status for script success or failure. wait=false returns immediately; poll script_results afterwards. ' + + 'A timeout does not stop execution. Reusing a retained callId with the same script, frame and params does not execute again; ' + + 'deduplication ends when the result is evicted or the session ends. Do not automatically retry side effects with a new callId.', + input: z.object({ + tabId: executionTabId, + scriptId: executionScriptId, + frameId: frameId.default(0), + params: z.json().default(null), + callId: callId.optional(), + wait: z.boolean().default(true), + timeoutMs: z.number().int().min(1).max(25000).default(10000), + }), + output: z.object({ result: scriptExecution }), + annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }, + }), + script_results: defineTool({ + title: 'Read script results', + description: + 'Read retained structured results in a granted tab, including explicit GM_reportResult reports. ' + + 'The script must declare @grant GM_reportResult for explicit reports. Filter by callId or scriptId, or poll with afterSeq. ' + + 'Results are held in memory for up to five minutes with entry and size limits; dropped is cumulative. ' + + 'Revocation, tab close or disconnect clears them. Reading does not consume results.', + input: z.object({ + tabId: executionTabId, + scriptId: executionScriptId.optional(), + callId: callId.optional(), + afterSeq: z.number().int().nonnegative().default(0), + limit: z.number().int().min(1).max(100).default(50), + }), + output: z.object({ + entries: z.array(scriptExecution), + nextSeq: z.number().int().nonnegative(), + dropped: z.number().int().nonnegative(), + }), + annotations: { readOnlyHint: true }, + }), scripts_test_match: defineTool({ title: 'Test script match', description: diff --git a/packages/server/test/e2e.test.ts b/packages/server/test/e2e.test.ts index f76e0cc..a93f52f 100644 --- a/packages/server/test/e2e.test.ts +++ b/packages/server/test/e2e.test.ts @@ -3,7 +3,8 @@ import { createServer } from 'node:net'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { createClient, ToolCallError, type McpClient } from '@violentmonkey/mcp-client'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { ToolOutput } from '@violentmonkey/mcp-protocol'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { startServer, type RunningServer } from '../src/index'; const TOKEN = 'test-token'; @@ -218,6 +219,97 @@ describe('violentmonkey mcp', () => { expect(JSON.stringify(res.content)).toContain('500'); }); + it('discovers registered handlers and preserves correlated structured results', async () => { + const ext = extension(); + const records: Array['result']> = []; + const inputs: unknown[] = []; + ext.handle('script_handlers', () => ({ handlers: [{ scriptId: 1, frameId: 0 }] })); + ext.handle('script_call', async (input) => { + inputs.push(input); + // Complete concurrent requests in the opposite order to their dispatch. + if (input.callId === 'first') await new Promise(resolve => setTimeout(resolve, 30)); + const result = { + callId: input.callId!, scriptId: input.scriptId, frameId: input.frameId, + documentId: 'document-one', seq: records.length + 1, + status: 'completed' as const, startedAt: 1, value: input.params, + }; + records.push(result); + return { result }; + }); + ext.handle('script_results', ({ callId, afterSeq, limit }) => { + const entries = records.filter(r => (!callId || r.callId === callId) && r.seq > afterSeq).slice(0, limit); + return { entries, nextSeq: entries.at(-1)?.seq ?? afterSeq, dropped: 0 }; + }); + ext.connect(); + await waitFor(() => ext.status === 'open'); + expect((await mcp.listTools()).tools.map(t => t.name).sort()).toEqual([ + 'script_call', 'script_handlers', 'script_results', 'vm_status', + ]); + expect((await mcp.callTool({ name: 'script_handlers', arguments: { tabId: 7 } })).structuredContent) + .toEqual({ handlers: [{ scriptId: 1, frameId: 0 }] }); + + const values = [{ rows: [false, 0, '', null] }, false]; + const responses = await Promise.all(['first', 'second'].map((callId, i) => mcp.callTool({ + name: 'script_call', arguments: { tabId: 7, scriptId: 1, callId, params: values[i] }, + }))); + expect(responses[0]?.structuredContent).toMatchObject({ result: { callId: 'first', value: values[0] } }); + expect(responses[1]?.structuredContent).toMatchObject({ result: { callId: 'second', value: false } }); + expect(inputs[0]).toEqual({ + tabId: 7, scriptId: 1, callId: 'first', frameId: 0, params: values[0], wait: true, timeoutMs: 10000, + }); + const page = await mcp.callTool({ name: 'script_results', arguments: { tabId: 7, limit: 1 } }); + expect(page.structuredContent).toEqual({ entries: [records[0]], nextSeq: 1, dropped: 0 }); + const next = await mcp.callTool({ name: 'script_results', arguments: { tabId: 7, afterSeq: 1 } }); + expect(next.structuredContent).toEqual({ entries: [records[1]], nextSeq: 2, dropped: 0 }); + }); + + it.each(['pending', 'failed', 'timed_out', 'cancelled'] as const)( + 'returns an execution status of %s as structured data, not a transport error', async status => { + const ext = extension(); + ext.handle('script_call', ({ scriptId, frameId }) => ({ result: { + callId: 'call-one', scriptId, frameId, documentId: 'doc', seq: 1, + status, startedAt: 1, ...(status !== 'pending' && { finishedAt: 2, error: 'Script did not complete' }), + } })); + ext.connect(); + await waitFor(() => ext.status === 'open'); + const result = await mcp.callTool({ + name: 'script_call', arguments: { tabId: 7, scriptId: 1, wait: false }, + }); + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toMatchObject({ result: { status } }); + }, + ); + + it('validates execution arguments before dispatch and preserves authorization errors', async () => { + const ext = extension(); + const invoke = vi.fn(() => { throw new ToolCallError(403, 'Tab is not authorized for MCP'); }); + ext.handle('script_call', invoke); + ext.connect(); + await waitFor(() => ext.status === 'open'); + for (const invalid of [ + { timeoutMs: 25001 }, { timeoutMs: 0 }, { scriptId: 0 }, { frameId: -1 }, + { callId: '' }, { callId: 'x'.repeat(129) }, { wait: 'yes' }, + ]) { + const result = await mcp.callTool({ + name: 'script_call', arguments: { tabId: 7, scriptId: 1, ...invalid }, + }); + expect(result.isError).toBe(true); + } + expect(invoke).not.toHaveBeenCalled(); + const denied = await mcp.callTool({ name: 'script_call', arguments: { tabId: 7, scriptId: 1 } }); + expect(denied).toMatchObject({ isError: true, content: [{ type: 'text', text: '[403] Tab is not authorized for MCP' }] }); + }); + + it('rejects malformed execution results from an extension', async () => { + const ext = extension(); + ext.handle('script_call', () => ({ result: { status: 'unknown' } }) as never); + ext.connect(); + await waitFor(() => ext.status === 'open'); + const result = await mcp.callTool({ name: 'script_call', arguments: { tabId: 7, scriptId: 1 } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain('[502]'); + }); + it('propagates handler errors and validates params', async () => { const ext = extension(); ext.handle('scripts_get', () => { @@ -330,6 +422,34 @@ describe('origin check', () => { }); describe('read-only mode', () => { + it('allows execution discovery and results but never forwards script_call', async () => { + const port = await freePort(); + const server = await startServer({ port, token: TOKEN, transport: 'http', allowNoOrigin: true, readonly: true }); + const mcp = new Client({ name: 'agent', version: '1' }); + const ext = createClient({ port, token: TOKEN, info: { name: 'test', version: '1' } }); + const invoke = vi.fn(() => { throw new Error('Must never be called'); }); + ext.handle('script_call', invoke); + ext.handle('script_handlers', () => ({ handlers: [{ scriptId: 1, frameId: 0 }] })); + ext.handle('script_results', () => ({ entries: [], nextSeq: 0, dropped: 0 })); + try { + await mcp.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`), { + requestInit: { headers: { Authorization: `Bearer ${TOKEN}` } }, + })); + ext.connect(); + await waitFor(() => ext.status === 'open'); + expect((await mcp.listTools()).tools.map(t => t.name).sort()) + .toEqual(['script_handlers', 'script_results', 'vm_status']); + expect((await mcp.callTool({ name: 'script_results', arguments: { tabId: 7 } })).structuredContent) + .toEqual({ entries: [], nextSeq: 0, dropped: 0 }); + expect((await mcp.callTool({ name: 'script_call', arguments: { tabId: 7, scriptId: 1 } })).isError).toBe(true); + expect(invoke).not.toHaveBeenCalled(); + } finally { + ext.close(); + await mcp.close(); + await server.close(); + } + }); + it('hides write tools and never forwards them', async () => { const port = await freePort(); const server = await startServer({ From 1689565219bd4be4006c0ec188f7f3fd90607200 Mon Sep 17 00:00:00 2001 From: ZZZZJJJ0928 <290019641+ZZZZJJJ0928@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:07:57 +0800 Subject: [PATCH 3/7] docs: record Chrome validation for script execution proposal --- docs/script-execution.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/script-execution.md b/docs/script-execution.md index 7d86711..b67dad7 100644 --- a/docs/script-execution.md +++ b/docs/script-execution.md @@ -4,6 +4,8 @@ An agent developing a userscript can currently install or update it, reload a gr This proposal adds one explicitly registered callback per script and frame, plus a bounded channel for structured results. An agent could call a script's validation or calculation function with JSON input and receive its return value or error without rerunning the script's top-level initialization. A script could also deliberately report a result from an event it observes. +Status as of 2026-11-02: the paired MCP and extension implementation passed all 38 real-browser regression cases in Chrome 156 (MV3). Scope and API acceptance remain open for maintainer discussion. Firefox and Chrome MV2 runtime validation are still pending; see [Validation](#validation). + ## Scope decision The current [development contract](contract.md#non-goals) excludes externally driving userscripts and treats `GM_log` as the feedback boundary. Callable handlers would intentionally extend that scope. This proposal asks whether that extension is desirable before requesting a release; it does not assume that callback registration alone resolves the product or permission decision. @@ -75,6 +77,26 @@ The MCP repository owns the three tool schemas, typed client interfaces, capabil No package version or minimum extension release is assigned by this draft. Coordinate the MCP package release and extension rollout if the proposal is accepted. The extension can be tested against a local build of this repository before publishing. +## Validation + +The paired implementation was tested on 2026-11-02 with Google Chrome 156.0.8078.79 on macOS arm64, using a headed browser, an isolated profile and the unpacked MV3 extension build (2.49.6). The tested revisions were MCP `4fbc64eb1ac2d5347c8628730b06a4c3d97fd27a` and extension `f376fc5e7c13e24f026c0088640473c78ac88f8c`. + +The tests used an MCP SDK client over Streamable HTTP, the actual MCP server, its WebSocket connection to the extension, and installed userscripts. No simulated extension backend was used. The `GM_*` APIs were exercised in `@inject-into page` and the corresponding `GM.*` APIs in `@inject-into content`. Both scripts ran in the top document, a same-origin iframe and a cross-origin iframe. + +All 38 browser regression cases passed: + +| Area | Verified behavior | +| --- | --- | +| Calls and results | Synchronous JSON and falsy values, asynchronous returns, concurrent response correlation, unsolicited reports and exclusive result cursors | +| Failures and limits | Thrown errors, rejected promises, circular values, BigInt, oversized inputs and outputs, missing frame handlers and disabled scripts | +| Call lifecycle | Retained call-ID deduplication, conflicting reuse, `wait: false`, execution timeout and late completion | +| Page restoration | Real back/forward cache restoration, renewed document channels, callable handlers in both realms and all three frame contexts, no repeated script initialization | +| Authorization and cleanup | Revocation and regrant, rejection of cached old authorization sessions, reload, disconnect/reconnect, read-only mode and tab closure | + +Back and forward navigation produced trusted native `pageshow` events with `persisted === true`; the check did not substitute a reload for restoration. Script closure instances survived with an initialization count of one, while MCP document tokens changed. Navigation cancelled unfinished calls, and late replies could not overwrite those cancelled records. Restored scripts could also report results. No unexpected page JavaScript errors were observed. + +The MCP automated suite (25 tests), typecheck and build passed. The companion extension suite (141 tests), lint, and MV2/MV3 builds also passed. A successful MV2 build does not establish MV2 runtime behavior. Firefox and Chrome MV2 still need real-browser checks before claiming cross-browser validation. File saving remains outside this first round. + ## Questions for maintainers 1. Is explicit callback invocation an acceptable extension of the current development-only scope, or should the initial feature be limited to unsolicited structured reports? @@ -82,4 +104,4 @@ No package version or minimum extension release is assigned by this draft. Coord 3. Is the existing per-tab grant sufficient, or should invocation require an additional user-facing permission? 4. Are the timeout, retention, deduplication and back/forward cache semantics suitable for an initial version? -The companion tests cover correlation, failures, expiry, authorization, navigation and restoration. Before requesting merge, browser verification should cover Chrome and Firefox, both script realms, child frames, and actual persisted `pageshow` events. +The Chrome results support an initial review of the paired changes. They do not settle the scope or permission decisions above. This proposal intentionally identifies an extension to the existing development contract rather than treating that extension as already accepted. From 604a6f9f5f024fb996a3895705ceaa6bc9bbcdfc Mon Sep 17 00:00:00 2001 From: ZZZZJJJ0928 <290019641+ZZZZJJJ0928@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:27:32 +0800 Subject: [PATCH 4/7] docs: clarify current Chrome and pending Firefox validation --- docs/script-execution.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/script-execution.md b/docs/script-execution.md index b67dad7..4599d1e 100644 --- a/docs/script-execution.md +++ b/docs/script-execution.md @@ -4,7 +4,7 @@ An agent developing a userscript can currently install or update it, reload a gr This proposal adds one explicitly registered callback per script and frame, plus a bounded channel for structured results. An agent could call a script's validation or calculation function with JSON input and receive its return value or error without rerunning the script's top-level initialization. A script could also deliberately report a result from an event it observes. -Status as of 2026-11-02: the paired MCP and extension implementation passed all 38 real-browser regression cases in Chrome 156 (MV3). Scope and API acceptance remain open for maintainer discussion. Firefox and Chrome MV2 runtime validation are still pending; see [Validation](#validation). +Status as of 2026-11-02: the paired MCP and extension implementation passed all 38 real-browser regression cases in Chrome 156 (MV3). Scope and API acceptance remain open for maintainer discussion. Firefox runtime validation is still pending; see [Validation](#validation). ## Scope decision @@ -95,7 +95,9 @@ All 38 browser regression cases passed: Back and forward navigation produced trusted native `pageshow` events with `persisted === true`; the check did not substitute a reload for restoration. Script closure instances survived with an initialization count of one, while MCP document tokens changed. Navigation cancelled unfinished calls, and late replies could not overwrite those cancelled records. Restored scripts could also report results. No unexpected page JavaScript errors were observed. -The MCP automated suite (25 tests), typecheck and build passed. The companion extension suite (141 tests), lint, and MV2/MV3 builds also passed. A successful MV2 build does not establish MV2 runtime behavior. Firefox and Chrome MV2 still need real-browser checks before claiming cross-browser validation. File saving remains outside this first round. +The MCP automated suite (25 tests), typecheck and build passed. The companion extension suite (141 tests), lint, and MV2/MV3 builds also passed. Successful builds do not establish runtime compatibility. Firefox still needs real-browser checks before claiming cross-browser validation. + +Chrome removed Manifest V2 support starting with version 139, as documented in the [official support timeline](https://developer.chrome.com/docs/extensions/develop/migrate/mv2-deprecation-timeline). Current Chrome verification therefore targets MV3. Legacy Chrome MV2 runtime checks would require an explicitly agreed older-browser compatibility target; they are not part of current Chrome validation. File saving remains outside this first round. ## Questions for maintainers From 162a229bd103e811673b6a0684bce28e26c78f13 Mon Sep 17 00:00:00 2001 From: ZZZZJJJ0928 <290019641+ZZZZJJJ0928@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:21:06 +0800 Subject: [PATCH 5/7] feat: named script handlers and call-bound reports Follow the API agreed in violentmonkey/violentmonkey#2726: - script_handlers lists named handlers per script and frame, with an optional description and an inputSchema hint that is not validated. - script_call requires a handler name. - script_results returns calls and reports as a discriminated union on `kind`; reports carry the callId of their call, if any. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/script-invocation-results.md | 2 +- README.md | 2 +- docs/script-execution.md | 82 +++++++++++++++---------- packages/protocol/src/tools.ts | 61 +++++++++++++----- packages/server/test/e2e.test.ts | 74 ++++++++++++++-------- 5 files changed, 144 insertions(+), 77 deletions(-) diff --git a/.changeset/script-invocation-results.md b/.changeset/script-invocation-results.md index 5d05fd8..9c9d80c 100644 --- a/.changeset/script-invocation-results.md +++ b/.changeset/script-invocation-results.md @@ -4,4 +4,4 @@ "@violentmonkey/mcp-protocol": minor --- -Add capability-negotiated `script_handlers`, `script_call` and `script_results` tools for explicitly registered userscript callbacks and structured results. Read-only sessions expose discovery and result reads while excluding invocation. Requires matching extension support. +Add capability-negotiated `script_handlers`, `script_call` and `script_results` tools for named userscript handlers registered with `GM.mcp.register`, and for structured calls and reports. Read-only sessions expose discovery and result reads while excluding invocation. Requires matching extension support. diff --git a/README.md b/README.md index 6653b0c..e80c715 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ The four tab tools (`tabs_list`, `tab_scripts`, `tab_reload`, `script_logs`) als Exact input/output schemas live in [`packages/protocol`](packages/protocol) and are the single source of truth. -The proposed `script_handlers`, `script_call` and `script_results` tools extend this workflow with explicitly registered callbacks and structured results. They require matching extension support and are omitted for existing extensions. See the [script invocation proposal](docs/script-execution.md) for the API, authorization boundary and examples. File saving is outside this proposal. +The proposed `script_handlers`, `script_call` and `script_results` tools extend this workflow with explicitly registered, named userscript handlers and structured results. They require matching extension support and are omitted for existing extensions. See the [script invocation proposal](docs/script-execution.md) for the API, authorization boundary and examples. File saving is outside this proposal. In `--readonly` mode only read-only tools are exposed, which excludes `scripts_write`, `scripts_set_enabled`, `scripts_delete` and `tab_reload`. diff --git a/docs/script-execution.md b/docs/script-execution.md index 4599d1e..518361b 100644 --- a/docs/script-execution.md +++ b/docs/script-execution.md @@ -1,16 +1,16 @@ -# Proposal: opt-in userscript callbacks and structured results +# Proposal: opt-in userscript handlers and structured results An agent developing a userscript can currently install or update it, reload a granted tab, and inspect `GM_log` output. Testing a function with several inputs requires editing the script or adding an application-specific trigger. Log strings also require an application-specific convention to associate an asynchronous result with the request that produced it. -This proposal adds one explicitly registered callback per script and frame, plus a bounded channel for structured results. An agent could call a script's validation or calculation function with JSON input and receive its return value or error without rerunning the script's top-level initialization. A script could also deliberately report a result from an event it observes. +This proposal lets a script explicitly register named handlers, plus a bounded channel for structured results. An agent could discover what each handler does and expects, call a script's validation or calculation function with JSON input, and receive its return value or error without rerunning the script's top-level initialization. A script could also deliberately report results from events it observes, on its own or attached to a call. -Status as of 2026-11-02: the paired MCP and extension implementation passed all 38 real-browser regression cases in Chrome 156 (MV3). Scope and API acceptance remain open for maintainer discussion. Firefox runtime validation is still pending; see [Validation](#validation). +Status: the API follows the shape agreed in the extension review (`GM.mcp = { register, report }`, named handlers with descriptions and input schemas, a call-bound `report`). As of 2026-11-03 the paired implementation passes all 46 real-browser regression cases in Chrome 156 (MV3). Firefox runtime validation is still pending; see [Validation](#validation). ## Scope decision The current [development contract](contract.md#non-goals) excludes externally driving userscripts and treats `GM_log` as the feedback boundary. Callable handlers would intentionally extend that scope. This proposal asks whether that extension is desirable before requesting a release; it does not assume that callback registration alone resolves the product or permission decision. -Invocation requires a connected MCP session, an explicitly granted tab, an enabled installed script and a handler registered in that document. Scripts opt in using `@grant GM_registerMcpHandler` and an explicit registration call. Results contain only what the script returns or reports. No generic evaluation, DOM query, navigation, console capture or network capture tool is added. Registration is an application-level opt-in, not a new security sandbox: an agent already allowed to edit scripts can add a handler, and a handler can perform the same actions as its userscript. +Invocation requires a connected MCP session, an explicitly granted tab, an enabled installed script and a handler registered in that document. Scripts opt in using `@grant GM.mcp` (or `GM_mcp`) and an explicit registration call. Results contain only what the script returns or reports. No generic evaluation, DOM query, navigation, console capture or network capture tool is added. Registration is an application-level opt-in, not a new security sandbox: an agent already allowed to edit scripts can add a handler, and a handler can perform the same actions as its userscript. File saving, download directories, local filesystem access and application-specific integrations are deferred to a separate proposal. Existing `GM_download` behavior is unchanged. @@ -22,54 +22,67 @@ File saving, download directories, local filesystem access and application-speci // @namespace mcp-example // @version 1.0.0 // @match https://example.com/* -// @grant GM_registerMcpHandler -// @grant GM_reportResult +// @grant GM.mcp // ==/UserScript== -GM_registerMcpHandler(async (params, { callId }) => { - if (!params || typeof params.a !== 'number' || typeof params.b !== 'number') { - throw new Error('Expected numeric a and b'); - } - return { sum: params.a + params.b, callId }; +GM.mcp.register({ + sum: { + description: 'Adds two numbers', + inputSchema: { + type: 'object', + properties: { a: { type: 'number' }, b: { type: 'number' } }, + required: ['a', 'b'], + }, + handle({ params, report }) { + if (typeof params?.a !== 'number' || typeof params?.b !== 'number') { + throw new Error('Expected numeric a and b'); + } + return { sum: params.a + params.b }; + }, + }, }); // Optional unsolicited report; does not register a callable handler. -GM_reportResult({ event: 'ready' }); +GM.mcp.report({ event: 'ready' }); ``` -`GM_registerMcpHandler(handler)` resolves after registration is acknowledged. A later registration replaces the callback for that script in that frame. The callback receives JSON input and `{ callId }`, and may return synchronously or asynchronously. `undefined` becomes `null`; other values must be JSON serializable. Thrown errors, rejected promises and invalid return values become failed execution records. +`GM.mcp.register(handlers)` registers handlers by name and resolves to an `unregister()` function once the extension accepted them. Names are 1–64 letters, digits, `_`, `.` or `-`. Registering an existing name replaces it and keeps the script's other handlers; a script may have up to 32 handlers per frame. `unregister()` removes only the handlers that call added and that were not replaced since. `GM_mcp` is the same object for `@grant GM_mcp`. -`GM_reportResult(value, { callId?, error? }?)` returns `{ accepted, callId }`. Without a call ID it creates an unsolicited record. With a call ID it completes only a matching call from the same script, frame and document channel. The first terminal reply wins; duplicate replies return `accepted: false`. Callbacks normally return their value directly. `GM.registerMcpHandler` and `GM.reportResult` provide the corresponding namespaced APIs. +`description` and `inputSchema` are shown to the agent. The schema is a hint: the extension does not validate params against it, so the handler validates its own input. + +`handle({ params, report })` may return synchronously or asynchronously; its result settles the call. `undefined` becomes `null`; other values must be JSON serializable. Thrown errors, rejected promises and invalid return values settle the call as failed. Scripts never handle call IDs: `report(value, { error }?)` is bound to its call, adds a report attached to it, may be called any number of times, and stays usable after the call settled or timed out. It rejects once the document was replaced, access was revoked or the call is no longer retained. + +`GM.mcp.report(value, { error }?)` adds a report that is not tied to a call. ## MCP tools | Tool | Input | Output | | --- | --- | --- | -| `script_handlers` | `tabId` | `handlers: [{ scriptId, frameId }]` | -| `script_call` | `tabId`, `scriptId`, optional `frameId`, `params`, `callId`, `wait`, `timeoutMs` | `result: Execution` | -| `script_results` | `tabId`, optional `scriptId`, `callId`, `afterSeq`, `limit` | `entries: Execution[]`, `nextSeq`, `dropped` | +| `script_handlers` | `tabId` | `scripts: [{ scriptId, frameId, handlers: [{ name, description?, inputSchema? }] }]` | +| `script_call` | `tabId`, `scriptId`, `handler`, optional `frameId`, `params`, `callId`, `wait`, `timeoutMs` | `result: Call` | +| `script_results` | `tabId`, optional `scriptId`, `callId`, `afterSeq`, `limit` | `entries: (Call \| Report)[]`, `nextSeq`, `dropped` | All three tools require tab authorization. `script_call` defaults to frame 0, `params: null`, `wait: true` and a 10-second execution timeout; the permitted timeout is 1–25,000 ms. The server's default 30-second transport timeout leaves time to return an execution timeout record. Deployments overriding that transport timeout should keep it above the requested execution timeout. -An execution record contains `callId`, `scriptId`, `frameId`, an opaque `documentId`, `seq`, `startedAt`, `status`, and optional `finishedAt`, `value` or `error`. Status is `pending`, `completed`, `failed`, `timed_out` or `cancelled`. Timestamps are milliseconds since the epoch. Call IDs are nonempty strings of at most 128 characters, generated by the extension when omitted. +Both entry kinds contain `scriptId`, `frameId`, an opaque `documentId`, `seq` and optional `value` or `error`. A call (`kind: "call"`) also has `callId`, `handler`, `startedAt`, `status` and optional `finishedAt`. Status is `pending`, `completed`, `failed`, `timed_out` or `cancelled`. A report (`kind: "report"`) has `reportedAt` and, when it belongs to a call, that call's `callId`. Timestamps are milliseconds since the epoch. Call IDs are nonempty strings of at most 128 characters, generated by the extension when omitted. -For example, after discovering the installed script and granting and reloading its tab: +For example, after granting and reloading the tab and reading `script_handlers`: ```json -{"tabId": 7, "scriptId": 1, "params": {"a": 2, "b": 3}, "callId": "sum-1"} +{"tabId": 7, "scriptId": 1, "handler": "sum", "params": {"a": 2, "b": 3}, "callId": "sum-1"} ``` -Pass this to `script_call`; a successful execution returns `result.status: "completed"` and `result.value.sum: 5`. For long-running work, use `wait: false` and poll `script_results` with the returned call ID. Reads default to 50 entries and allow 1–100; `afterSeq` is an exclusive cursor and `nextSeq` can be used for the next read. Reads do not consume records, and `dropped` counts evictions cumulatively for the tab's session. +Pass this to `script_call`; a successful execution returns `result.status: "completed"` and `result.value.sum: 5`. For long-running work, use `wait: false` and poll `script_results` with the returned call ID, which also returns the call's reports. Reads default to 50 entries and allow 1–100; `afterSeq` is an exclusive cursor and `nextSeq` can be used for the next read. A call's `seq` changes when its status changes. Reads do not consume records, and `dropped` counts evictions cumulatively for the tab's session. ## Errors, limits and lifecycle Protocol errors cover invalid arguments, missing handlers and denied access. Script errors are returned in a valid execution record; a successful MCP request does not imply a successful script. Read-only mode exposes handler discovery and result reads, but excludes `script_call`, which is marked potentially destructive and open-world. -A timeout ends the wait, not the script or its side effects. A late reply can update a retained `timed_out` record to `completed` or `failed`. Reusing a retained call ID with the same script, frame and serialized parameters returns the existing record without invoking the callback again. Different arguments with that ID fail. Eviction, disconnect or restart removes this deduplication guarantee; there is no durable exactly-once execution. Clients should not automatically retry side effects with a fresh ID. +A timeout ends the wait, not the script or its side effects. A late return value can update a retained `timed_out` record to `completed` or `failed`. Reusing a retained call ID with the same script, frame, handler and serialized parameters returns the existing record without invoking the handler again. Different arguments with that ID fail. Eviction, disconnect or restart removes this deduplication guarantee, and reports left from an evicted call are dropped when its ID is reused; there is no durable exactly-once execution. Clients should not automatically retry side effects with a fresh ID. -The extension limits serialized input and output to 256 Ki characters each, errors to 8,192 characters, and pending calls to 32 per tab. Terminal records expire after five minutes; count and byte pressure may evict them earlier. Retention is capped at 100 records and approximately 2 MiB per tab, with pending calls protected from eviction and bounded separately by the per-call and concurrency limits. +The extension limits serialized input and output to 256 Ki characters each, errors to 8,192 characters, and pending calls to 32 per tab. Settled calls and reports expire after five minutes, and reporting keeps a call retained; count and byte pressure may evict them earlier. Retention is capped at 100 entries and approximately 2 MiB per tab, with pending calls protected from eviction and bounded separately by the per-call and concurrency limits. -Navigation or reload cancels unfinished calls for the replaced document. Closing a tab, revoking its grant or disconnecting clears its channels and retained results. These operations do not undo effects already performed by a script. Back/forward cache restoration renews the channel and restores registered handlers without rerunning script initialization, provided the original authorization session still exists. Old calls are not replayed and old channel replies remain invalid. Main frames and child frames can restore in either order. A revoked or replaced session requires explicit authorization and a reload. +Navigation or reload cancels unfinished calls for the replaced document. Closing a tab, revoking its grant or disconnecting clears its channels and retained results. These operations do not undo effects already performed by a script. Back/forward cache restoration renews the channel and restores registered handlers with their metadata without rerunning script initialization, provided the original authorization session still exists. Old calls are not replayed and their return values and reports remain invalid. Main frames and child frames can restore in either order. A revoked or replaced session requires explicit authorization and a reload. ## Implementation and compatibility @@ -79,30 +92,31 @@ No package version or minimum extension release is assigned by this draft. Coord ## Validation -The paired implementation was tested on 2026-11-02 with Google Chrome 156.0.8078.79 on macOS arm64, using a headed browser, an isolated profile and the unpacked MV3 extension build (2.49.6). The tested revisions were MCP `4fbc64eb1ac2d5347c8628730b06a4c3d97fd27a` and extension `f376fc5e7c13e24f026c0088640473c78ac88f8c`. +The paired implementation was tested on 2026-11-03 with Google Chrome 156.0.8078.80 on macOS arm64, using a headed browser, an isolated profile, the unpacked MV3 extension build and the per-extension "Allow User Scripts" switch. -The tests used an MCP SDK client over Streamable HTTP, the actual MCP server, its WebSocket connection to the extension, and installed userscripts. No simulated extension backend was used. The `GM_*` APIs were exercised in `@inject-into page` and the corresponding `GM.*` APIs in `@inject-into content`. Both scripts ran in the top document, a same-origin iframe and a cross-origin iframe. +The tests used an MCP SDK client over Streamable HTTP, the actual MCP server, its WebSocket connection to the extension, and installed userscripts. No simulated extension backend was used. `GM_mcp` was exercised in `@inject-into page` and `GM.mcp` in `@inject-into content`. Both scripts ran in the top document, a same-origin iframe and a cross-origin iframe, with 12 named handlers each. -All 38 browser regression cases passed: +All 46 browser regression cases passed: | Area | Verified behavior | | --- | --- | -| Calls and results | Synchronous JSON and falsy values, asynchronous returns, concurrent response correlation, unsolicited reports and exclusive result cursors | -| Failures and limits | Thrown errors, rejected promises, circular values, BigInt, oversized inputs and outputs, missing frame handlers and disabled scripts | -| Call lifecycle | Retained call-ID deduplication, conflicting reuse, `wait: false`, execution timeout and late completion | -| Page restoration | Real back/forward cache restoration, renewed document channels, callable handlers in both realms and all three frame contexts, no repeated script initialization | +| Registration | Named handlers with descriptions and input schemas in both realms and all frame contexts; `unregister()` removes only its own handlers | +| Calls and results | Synchronous JSON and falsy values, asynchronous returns, concurrent response correlation, call-bound reports before and after settling, unsolicited reports and exclusive result cursors | +| Failures and limits | Thrown errors, rejected promises, circular values, BigInt, oversized inputs and outputs, unknown or missing handler names, missing frame handlers and disabled scripts | +| Call lifecycle | Retained call-ID deduplication, conflicting reuse with other params or handler, `wait: false`, execution timeout and late completion | +| Page restoration | Real back/forward cache restoration, renewed document channels, handlers and their metadata restored in both realms and all three frame contexts, no repeated script initialization | | Authorization and cleanup | Revocation and regrant, rejection of cached old authorization sessions, reload, disconnect/reconnect, read-only mode and tab closure | -Back and forward navigation produced trusted native `pageshow` events with `persisted === true`; the check did not substitute a reload for restoration. Script closure instances survived with an initialization count of one, while MCP document tokens changed. Navigation cancelled unfinished calls, and late replies could not overwrite those cancelled records. Restored scripts could also report results. No unexpected page JavaScript errors were observed. +Back and forward navigation produced trusted native `pageshow` events with `persisted === true`; the check did not substitute a reload for restoration. Script closure instances survived with an initialization count of one, while document IDs changed. Navigation cancelled unfinished calls, and late replies could not overwrite those cancelled records. Restored handlers could still be called and report results. No unexpected page JavaScript errors were observed. -The MCP automated suite (25 tests), typecheck and build passed. The companion extension suite (141 tests), lint, and MV2/MV3 builds also passed. Successful builds do not establish runtime compatibility. Firefox still needs real-browser checks before claiming cross-browser validation. +The MCP automated suite (28 tests), typecheck and build pass, as do the companion extension suite (172 tests), lint, and MV2/MV3 builds. Successful builds do not establish runtime compatibility. Firefox still needs real-browser checks before claiming cross-browser validation. Chrome removed Manifest V2 support starting with version 139, as documented in the [official support timeline](https://developer.chrome.com/docs/extensions/develop/migrate/mv2-deprecation-timeline). Current Chrome verification therefore targets MV3. Legacy Chrome MV2 runtime checks would require an explicitly agreed older-browser compatibility target; they are not part of current Chrome validation. File saving remains outside this first round. ## Questions for maintainers 1. Is explicit callback invocation an acceptable extension of the current development-only scope, or should the initial feature be limited to unsolicited structured reports? -2. Are two new GM APIs appropriate, and should the proposed names or registration model change? +2. ~~Are two new GM APIs appropriate, and should the proposed names or registration model change?~~ Agreed in review: one `GM.mcp` namespace with named handlers and a call-bound `report`. 3. Is the existing per-tab grant sufficient, or should invocation require an additional user-facing permission? 4. Are the timeout, retention, deduplication and back/forward cache semantics suitable for an initial version? diff --git a/packages/protocol/src/tools.ts b/packages/protocol/src/tools.ts index 8a09280..5ce1eb1 100644 --- a/packages/protocol/src/tools.ts +++ b/packages/protocol/src/tools.ts @@ -44,17 +44,41 @@ const executionTabId = z.number().int().nonnegative(); const frameId = z.number().int().nonnegative(); const callId = z.string().min(1).max(128); -export const scriptExecution = z.object({ - callId, +const handlerName = z.string().regex(/^[\w.-]{1,64}$/).describe('Handler name from script_handlers'); + +const executionEntry = { scriptId: executionScriptId, frameId, - documentId: z.string().describe('Opaque identity of the execution channel'), + documentId: z.string().describe('Opaque identity of the document the script ran in'), seq: z.number().int().nonnegative(), + value: z.json().optional(), + error: z.string().optional(), +}; + +export const scriptCall = z.object({ + kind: z.literal('call'), + callId, + handler: handlerName, + ...executionEntry, status: z.enum(['pending', 'completed', 'failed', 'timed_out', 'cancelled']), startedAt: z.number().describe('Milliseconds since the epoch'), finishedAt: z.number().optional(), - value: z.json().optional(), - error: z.string().optional(), +}); + +export const scriptReport = z.object({ + kind: z.literal('report'), + callId: callId.optional().describe('The call this report belongs to, if any'), + ...executionEntry, + reportedAt: z.number().describe('Milliseconds since the epoch'), +}); + +export const scriptResultEntry = z.discriminatedUnion('kind', [scriptCall, scriptReport]); + +export const scriptHandler = z.object({ + name: handlerName, + description: z.string().optional(), + inputSchema: z.record(z.string(), z.json()).optional() + .describe('JSON Schema provided by the script as a hint; it is not validated by the extension'), }); export interface ToolAnnotations { @@ -185,37 +209,42 @@ export const tools = { script_handlers: defineTool({ title: 'List callable script handlers', description: - 'List scripts that registered GM_registerMcpHandler in a granted tab, with their frame ids. ' + - 'The script must declare @grant GM_registerMcpHandler. Grant and reload the tab first.', + 'List named handlers that userscripts registered with GM.mcp.register in a granted tab, per script and frame, ' + + 'with their descriptions and input schemas. The script must declare @grant GM.mcp or GM_mcp. Grant and reload the tab first.', input: z.object({ tabId: executionTabId }), - output: z.object({ handlers: z.array(z.object({ scriptId: executionScriptId, frameId })) }), + output: z.object({ + scripts: z.array(z.object({ scriptId: executionScriptId, frameId, handlers: z.array(scriptHandler) })), + }), annotations: { readOnlyHint: true }, }), script_call: defineTool({ - title: 'Call a userscript', + title: 'Call a userscript handler', description: - 'Invoke a registered userscript handler in a granted tab with JSON params. It may have side effects. ' + + 'Invoke a named userscript handler in a granted tab with JSON params. It may have side effects. ' + + 'The handler validates params itself; inputSchema is only a hint. ' + 'Inspect result.status for script success or failure. wait=false returns immediately; poll script_results afterwards. ' + - 'A timeout does not stop execution. Reusing a retained callId with the same script, frame and params does not execute again; ' + + 'The handler may add reports to the call, also after it settled; read them with script_results and the callId. ' + + 'A timeout does not stop execution. Reusing a retained callId with the same script, frame, handler and params does not execute again; ' + 'deduplication ends when the result is evicted or the session ends. Do not automatically retry side effects with a new callId.', input: z.object({ tabId: executionTabId, scriptId: executionScriptId, frameId: frameId.default(0), + handler: handlerName, params: z.json().default(null), callId: callId.optional(), wait: z.boolean().default(true), timeoutMs: z.number().int().min(1).max(25000).default(10000), }), - output: z.object({ result: scriptExecution }), + output: z.object({ result: scriptCall }), annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }, }), script_results: defineTool({ title: 'Read script results', description: - 'Read retained structured results in a granted tab, including explicit GM_reportResult reports. ' + - 'The script must declare @grant GM_reportResult for explicit reports. Filter by callId or scriptId, or poll with afterSeq. ' + - 'Results are held in memory for up to five minutes with entry and size limits; dropped is cumulative. ' + + 'Read retained calls and reports in a granted tab, ordered by seq. Reports come from GM.mcp.report or a handler\'s ' + + 'call-bound report and carry the callId of their call, if any. Filter by callId (the call and its reports) or scriptId, ' + + 'or poll with afterSeq. Results are held in memory for up to five minutes with entry and size limits; dropped is cumulative. ' + 'Revocation, tab close or disconnect clears them. Reading does not consume results.', input: z.object({ tabId: executionTabId, @@ -225,7 +254,7 @@ export const tools = { limit: z.number().int().min(1).max(100).default(50), }), output: z.object({ - entries: z.array(scriptExecution), + entries: z.array(scriptResultEntry), nextSeq: z.number().int().nonnegative(), dropped: z.number().int().nonnegative(), }), diff --git a/packages/server/test/e2e.test.ts b/packages/server/test/e2e.test.ts index a93f52f..30a3b42 100644 --- a/packages/server/test/e2e.test.ts +++ b/packages/server/test/e2e.test.ts @@ -219,21 +219,33 @@ describe('violentmonkey mcp', () => { expect(JSON.stringify(res.content)).toContain('500'); }); - it('discovers registered handlers and preserves correlated structured results', async () => { + it('discovers named handlers and preserves correlated calls and reports', async () => { const ext = extension(); - const records: Array['result']> = []; + const records: Array['entries'][number]> = []; const inputs: unknown[] = []; - ext.handle('script_handlers', () => ({ handlers: [{ scriptId: 1, frameId: 0 }] })); + const scripts = [{ + scriptId: 1, + frameId: 0, + handlers: [ + { name: 'echo', description: 'Echoes params', inputSchema: { type: 'object' } }, + { name: 'ping' }, + ], + }]; + ext.handle('script_handlers', () => ({ scripts })); ext.handle('script_call', async (input) => { inputs.push(input); // Complete concurrent requests in the opposite order to their dispatch. if (input.callId === 'first') await new Promise(resolve => setTimeout(resolve, 30)); const result = { - callId: input.callId!, scriptId: input.scriptId, frameId: input.frameId, - documentId: 'document-one', seq: records.length + 1, - status: 'completed' as const, startedAt: 1, value: input.params, + kind: 'call' as const, callId: input.callId!, handler: input.handler, + scriptId: input.scriptId, frameId: input.frameId, documentId: 'document-one', seq: records.length + 1, + status: 'completed' as const, startedAt: 1, finishedAt: 2, value: input.params, }; records.push(result); + records.push({ + kind: 'report', callId: input.callId!, scriptId: input.scriptId, frameId: input.frameId, + documentId: 'document-one', seq: records.length + 1, reportedAt: 3, value: { progress: 1 }, + }); return { result }; }); ext.handle('script_results', ({ callId, afterSeq, limit }) => { @@ -246,34 +258,38 @@ describe('violentmonkey mcp', () => { 'script_call', 'script_handlers', 'script_results', 'vm_status', ]); expect((await mcp.callTool({ name: 'script_handlers', arguments: { tabId: 7 } })).structuredContent) - .toEqual({ handlers: [{ scriptId: 1, frameId: 0 }] }); + .toEqual({ scripts }); const values = [{ rows: [false, 0, '', null] }, false]; const responses = await Promise.all(['first', 'second'].map((callId, i) => mcp.callTool({ - name: 'script_call', arguments: { tabId: 7, scriptId: 1, callId, params: values[i] }, + name: 'script_call', arguments: { tabId: 7, scriptId: 1, handler: 'echo', callId, params: values[i] }, }))); - expect(responses[0]?.structuredContent).toMatchObject({ result: { callId: 'first', value: values[0] } }); - expect(responses[1]?.structuredContent).toMatchObject({ result: { callId: 'second', value: false } }); + expect(responses[0]?.structuredContent).toMatchObject({ result: { kind: 'call', callId: 'first', value: values[0] } }); + expect(responses[1]?.structuredContent).toMatchObject({ result: { callId: 'second', handler: 'echo', value: false } }); expect(inputs[0]).toEqual({ - tabId: 7, scriptId: 1, callId: 'first', frameId: 0, params: values[0], wait: true, timeoutMs: 10000, + tabId: 7, scriptId: 1, handler: 'echo', callId: 'first', frameId: 0, params: values[0], wait: true, timeoutMs: 10000, }); - const page = await mcp.callTool({ name: 'script_results', arguments: { tabId: 7, limit: 1 } }); - expect(page.structuredContent).toEqual({ entries: [records[0]], nextSeq: 1, dropped: 0 }); - const next = await mcp.callTool({ name: 'script_results', arguments: { tabId: 7, afterSeq: 1 } }); - expect(next.structuredContent).toEqual({ entries: [records[1]], nextSeq: 2, dropped: 0 }); + const page = await mcp.callTool({ name: 'script_results', arguments: { tabId: 7, limit: 2 } }); + expect(page.structuredContent).toEqual({ entries: records.slice(0, 2), nextSeq: 2, dropped: 0 }); + const next = await mcp.callTool({ name: 'script_results', arguments: { tabId: 7, afterSeq: 2 } }); + expect(next.structuredContent).toEqual({ entries: records.slice(2), nextSeq: 4, dropped: 0 }); + const unsolicited = { kind: 'report' as const, scriptId: 1, frameId: 0, documentId: 'd', seq: 5, reportedAt: 4, value: 'ready' }; + records.push(unsolicited); + expect((await mcp.callTool({ name: 'script_results', arguments: { tabId: 7, afterSeq: 4 } })).structuredContent) + .toEqual({ entries: [unsolicited], nextSeq: 5, dropped: 0 }); }); it.each(['pending', 'failed', 'timed_out', 'cancelled'] as const)( 'returns an execution status of %s as structured data, not a transport error', async status => { const ext = extension(); - ext.handle('script_call', ({ scriptId, frameId }) => ({ result: { - callId: 'call-one', scriptId, frameId, documentId: 'doc', seq: 1, + ext.handle('script_call', ({ scriptId, frameId, handler }) => ({ result: { + kind: 'call', callId: 'call-one', handler, scriptId, frameId, documentId: 'doc', seq: 1, status, startedAt: 1, ...(status !== 'pending' && { finishedAt: 2, error: 'Script did not complete' }), } })); ext.connect(); await waitFor(() => ext.status === 'open'); const result = await mcp.callTool({ - name: 'script_call', arguments: { tabId: 7, scriptId: 1, wait: false }, + name: 'script_call', arguments: { tabId: 7, scriptId: 1, handler: 'echo', wait: false }, }); expect(result.isError).not.toBe(true); expect(result.structuredContent).toMatchObject({ result: { status } }); @@ -289,23 +305,29 @@ describe('violentmonkey mcp', () => { for (const invalid of [ { timeoutMs: 25001 }, { timeoutMs: 0 }, { scriptId: 0 }, { frameId: -1 }, { callId: '' }, { callId: 'x'.repeat(129) }, { wait: 'yes' }, + { handler: undefined }, { handler: '' }, { handler: 'has space' }, { handler: 'x'.repeat(65) }, ]) { const result = await mcp.callTool({ - name: 'script_call', arguments: { tabId: 7, scriptId: 1, ...invalid }, + name: 'script_call', arguments: { tabId: 7, scriptId: 1, handler: 'echo', ...invalid }, }); expect(result.isError).toBe(true); } expect(invoke).not.toHaveBeenCalled(); - const denied = await mcp.callTool({ name: 'script_call', arguments: { tabId: 7, scriptId: 1 } }); + const denied = await mcp.callTool({ name: 'script_call', arguments: { tabId: 7, scriptId: 1, handler: 'echo' } }); expect(denied).toMatchObject({ isError: true, content: [{ type: 'text', text: '[403] Tab is not authorized for MCP' }] }); }); - it('rejects malformed execution results from an extension', async () => { + it.each([ + ['script_call', { result: { status: 'unknown' } }], + ['script_call', { result: { kind: 'report', scriptId: 1, frameId: 0, documentId: 'd', seq: 1, reportedAt: 1 } }], + ['script_results', { entries: [{ kind: 'other' }], nextSeq: 0, dropped: 0 }], + ['script_handlers', { scripts: [{ scriptId: 1, frameId: 0, handlers: [{ name: 'bad name' }] }] }], + ] as const)('rejects malformed %s output from an extension', async (name, output) => { const ext = extension(); - ext.handle('script_call', () => ({ result: { status: 'unknown' } }) as never); + ext.handle(name, () => output as never); ext.connect(); await waitFor(() => ext.status === 'open'); - const result = await mcp.callTool({ name: 'script_call', arguments: { tabId: 7, scriptId: 1 } }); + const result = await mcp.callTool({ name, arguments: { tabId: 7, scriptId: 1, handler: 'echo' } }); expect(result.isError).toBe(true); expect(JSON.stringify(result.content)).toContain('[502]'); }); @@ -429,7 +451,7 @@ describe('read-only mode', () => { const ext = createClient({ port, token: TOKEN, info: { name: 'test', version: '1' } }); const invoke = vi.fn(() => { throw new Error('Must never be called'); }); ext.handle('script_call', invoke); - ext.handle('script_handlers', () => ({ handlers: [{ scriptId: 1, frameId: 0 }] })); + ext.handle('script_handlers', () => ({ scripts: [{ scriptId: 1, frameId: 0, handlers: [{ name: 'echo' }] }] })); ext.handle('script_results', () => ({ entries: [], nextSeq: 0, dropped: 0 })); try { await mcp.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`), { @@ -441,7 +463,9 @@ describe('read-only mode', () => { .toEqual(['script_handlers', 'script_results', 'vm_status']); expect((await mcp.callTool({ name: 'script_results', arguments: { tabId: 7 } })).structuredContent) .toEqual({ entries: [], nextSeq: 0, dropped: 0 }); - expect((await mcp.callTool({ name: 'script_call', arguments: { tabId: 7, scriptId: 1 } })).isError).toBe(true); + expect((await mcp.callTool({ + name: 'script_call', arguments: { tabId: 7, scriptId: 1, handler: 'echo' }, + })).isError).toBe(true); expect(invoke).not.toHaveBeenCalled(); } finally { ext.close(); From 4c9a23968d8cea8dc0b7793e97cb06ff2e596f59 Mon Sep 17 00:00:00 2001 From: ZZZZJJJ0928 <290019641+ZZZZJJJ0928@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:06:07 +0800 Subject: [PATCH 6/7] docs: write the result size limit as 256k Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/script-execution.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/script-execution.md b/docs/script-execution.md index 518361b..f3525c3 100644 --- a/docs/script-execution.md +++ b/docs/script-execution.md @@ -80,7 +80,7 @@ Protocol errors cover invalid arguments, missing handlers and denied access. Scr A timeout ends the wait, not the script or its side effects. A late return value can update a retained `timed_out` record to `completed` or `failed`. Reusing a retained call ID with the same script, frame, handler and serialized parameters returns the existing record without invoking the handler again. Different arguments with that ID fail. Eviction, disconnect or restart removes this deduplication guarantee, and reports left from an evicted call are dropped when its ID is reused; there is no durable exactly-once execution. Clients should not automatically retry side effects with a fresh ID. -The extension limits serialized input and output to 256 Ki characters each, errors to 8,192 characters, and pending calls to 32 per tab. Settled calls and reports expire after five minutes, and reporting keeps a call retained; count and byte pressure may evict them earlier. Retention is capped at 100 entries and approximately 2 MiB per tab, with pending calls protected from eviction and bounded separately by the per-call and concurrency limits. +The extension limits serialized input and output to 256k characters each, errors to 8,192 characters, and pending calls to 32 per tab. Settled calls and reports expire after five minutes, and reporting keeps a call retained; count and byte pressure may evict them earlier. Retention is capped at 100 entries and approximately 2 MiB per tab, with pending calls protected from eviction and bounded separately by the per-call and concurrency limits. Navigation or reload cancels unfinished calls for the replaced document. Closing a tab, revoking its grant or disconnecting clears its channels and retained results. These operations do not undo effects already performed by a script. Back/forward cache restoration renews the channel and restores registered handlers with their metadata without rerunning script initialization, provided the original authorization session still exists. Old calls are not replayed and their return values and reports remain invalid. Main frames and child frames can restore in either order. A revoked or replaced session requires explicit authorization and a reload. From a735d6fec7a0fa9bd104d31eba6ef3208289ab07 Mon Sep 17 00:00:00 2001 From: ZZZZJJJ0928 <290019641+ZZZZJJJ0928@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:19:15 +0800 Subject: [PATCH 7/7] docs: document script tools in the README and contract As requested in review: - Remove docs/script-execution.md; its proposal and validation notes move to the PR description, and the GM.mcp API reference stays with the extension docs. - List script_handlers, script_call and script_results in the README Tools table, with an empty Violentmonkey version for now. - Add the three tools to docs/contract.md and update the Non-goals entry about driving userscripts from outside. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 9 +-- docs/contract.md | 137 ++++++++++++++++++++++++++++++++++++--- docs/script-execution.md | 123 ----------------------------------- 3 files changed, 134 insertions(+), 135 deletions(-) delete mode 100644 docs/script-execution.md diff --git a/README.md b/README.md index e80c715..4c73f3d 100644 --- a/README.md +++ b/README.md @@ -99,16 +99,17 @@ Exposing the server beyond localhost (`--host 0.0.0.0`) is your responsibility; | `tab_scripts` | What the extension attempted to run on a granted tab, including failures. | read-only | >=2.49.6 | | `tab_reload` | Reload a granted tab so scripts are re-injected. Returns the new `loadId`. | idempotent | >=2.49.6 | | `script_logs` | Read `GM_log` output for a granted tab, paged by a `seq` cursor. | read-only | >=2.49.6 | +| `script_handlers` | Named handlers that userscripts registered with `GM.mcp` in a granted tab, with their descriptions and input schemas. | read-only | | +| `script_call` | Call a registered handler with JSON params and return its result. | destructive, open-world | | +| `script_results` | Read retained calls and reports for a granted tab, paged by a `seq` cursor. | read-only | | `—` means the tool is handled by the server and needs no Violentmonkey support. The extension reports which tools it implements when it connects, and the server exposes only those, so an unimplemented tool is never listed — it does not appear and then fail. -The four tab tools (`tabs_list`, `tab_scripts`, `tab_reload`, `script_logs`) also need the user to grant a tab first. A grant is created explicitly in the Violentmonkey dashboard, lives in extension memory, and is revoked when its tab closes. Calling a tab tool on a tab that is not granted fails with `403`. `tab_reload` is a plain reload, not navigation. See [docs/contract.md](docs/contract.md) for the full contract. +The tab tools (`tabs_list`, `tab_scripts`, `tab_reload`, `script_logs`, `script_handlers`, `script_call`, `script_results`) also need the user to grant a tab first. A grant is created explicitly in the Violentmonkey dashboard, lives in extension memory, and is revoked when its tab closes. Calling a tab tool on a tab that is not granted fails with `403`. `tab_reload` is a plain reload, not navigation. See [docs/contract.md](docs/contract.md) for the full contract. Exact input/output schemas live in [`packages/protocol`](packages/protocol) and are the single source of truth. -The proposed `script_handlers`, `script_call` and `script_results` tools extend this workflow with explicitly registered, named userscript handlers and structured results. They require matching extension support and are omitted for existing extensions. See the [script invocation proposal](docs/script-execution.md) for the API, authorization boundary and examples. File saving is outside this proposal. - -In `--readonly` mode only read-only tools are exposed, which excludes `scripts_write`, `scripts_set_enabled`, `scripts_delete` and `tab_reload`. +In `--readonly` mode only read-only tools are exposed, which excludes `scripts_write`, `scripts_set_enabled`, `scripts_delete`, `tab_reload` and `script_call`. Only tools the connected extension reports in its handshake are exposed. Before the extension connects, `tools/list` contains `vm_status` alone; the list is refreshed when the connection changes. Calling a tool that is not exposed returns an error result. A call that races a disconnect still fails with code `503` and the message `Violentmonkey is not connected`. Over the HTTP transport this is a tool result, not an HTTP status, since the HTTP request itself succeeded. diff --git a/docs/contract.md b/docs/contract.md index 0339ff0..bf0e6b5 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -20,6 +20,10 @@ scripts_write ──► tab_reload ──► tab_scripts ──► script_logs Three of them read, one acts, and one answers a question about a URL without touching the browser at all. +`script_handlers`, `script_call` and `script_results` shorten the loop for code a script +chooses to expose: instead of reloading to rerun the whole script, the agent calls one of +its handlers with new input and reads the structured result. + ## Scope This document defines the tool schemas and the behavior the MCP server guarantees. @@ -27,8 +31,9 @@ Implementing the tools inside the extension is out of scope here. ## Observation boundary -The agent sees only what a userscript deliberately emits through `GM_log`. The extension -does not capture `console`, page content, network traffic or DOM state. +The agent sees only what a userscript deliberately emits through `GM_log`, or returns and +reports through `GM.mcp`. The extension does not capture `console`, page content, network +traffic or DOM state. This is a deliberate limit rather than an implementation gap. Capturing `console` would give the agent read access to arbitrary pages the user visits, which is a different @@ -55,8 +60,9 @@ array until the user grants a tab. a recycled id cannot inherit a stale grant. - A call against a tab that is not granted fails with `403`. -Three tools require a grant: `tab_scripts`, `tab_reload` and `script_logs`. `tabs_list` -enumerates grants, and `scripts_test_match` touches no page, so both work with none. +Six tools require a grant: `tab_scripts`, `tab_reload`, `script_logs`, `script_handlers`, +`script_call` and `script_results`. `tabs_list` enumerates grants, and `scripts_test_match` +touches no page, so both work with none. `tab_reload` carries no `destructiveHint`. Consent is the grant, not a per-call prompt: confirming every reload would make the write/reload/inspect loop unusable. @@ -85,6 +91,9 @@ never listed. Compatibility with older extensions needs no special handling. | `tab_reload` | tab | idempotent | | `script_logs` | tab | read-only | | `scripts_test_match` | — | read-only | +| `script_handlers` | tab | read-only | +| `script_call` | tab | destructive, open-world | +| `script_results` | tab | read-only | ### `tabs_list` @@ -219,14 +228,124 @@ agent tests exactly what it is about to write with `scripts_write`. Stateless: no tab, no grant, no stored script. When the metadata block cannot be parsed, the call fails with `400` and the extension's message, the same as `scripts_write`. +### `script_handlers` + +List the handlers userscripts registered with `GM.mcp.register` in a tab, per script and +frame. A script opts in with `@grant GM.mcp` (or `GM_mcp`) and an explicit registration; +see the extension's [`GM.mcp` reference](https://github.com/violentmonkey/violentmonkey/blob/master/docs/mcp-script-results.md) for the userscript API. + +```ts +input: { tabId: number } +output: { + scripts: Array<{ + scriptId: number; + frameId: number; + handlers: Array<{ + name: string; + description?: string; + inputSchema?: object; // JSON Schema from the script; a hint, not validated + }>; + }>; +} +``` + +### `script_call` + +Call a registered handler with JSON params. + +```ts +input: { + tabId: number; + scriptId: number; + handler: string; + frameId?: number; // default 0 + params?: JSON; // default null + callId?: string; // 1–128 chars, generated when omitted + wait?: boolean; // default true + timeoutMs?: number; // default 10000, 1–25000 +} +output: { result: Call } + +type Call = { + kind: 'call'; + callId: string; + handler: string; + scriptId: number; + frameId: number; + documentId: string; // opaque, changes with each document + seq: number; + status: 'pending' | 'completed' | 'failed' | 'timed_out' | 'cancelled'; + startedAt: number; // ms since epoch + finishedAt?: number; + value?: JSON; + error?: string; +}; +``` + +- A script failure is a result, not a protocol error: check `status`. +- A timeout ends the wait, not the script or its side effects. A late return still settles + a retained `timed_out` call. +- Reusing a retained `callId` with the same arguments returns the existing call without + running the handler again; different arguments fail with `400`. This lasts only while + the call is retained, so the agent should not retry side effects with a new `callId`. +- Navigation or reload cancels unfinished calls. A page restored from the back/forward + cache keeps its handlers, but its old calls stay cancelled. +- `destructive` and `open-world`: a handler can do anything its userscript can. Like + `tab_reload`, consent is the grant; `--readonly` excludes the tool. + +### `script_results` + +Read the calls and reports retained for a tab. A script adds reports with `GM.mcp.report` +or with the `report` function bound to a call. + +```ts +input: { + tabId: number; + scriptId?: number; + callId?: string; // the call and its reports + afterSeq?: number; + limit?: number; // default 50, max 100 +} +output: { + entries: Array; + nextSeq: number; // pass as afterSeq to poll + dropped: number; // entries evicted since the grant +} + +type Report = { + kind: 'report'; + callId?: string; // present when bound to a call + scriptId: number; + frameId: number; + documentId: string; + seq: number; + reportedAt: number; // ms since epoch + value?: JSON; + error?: string; +}; +``` + +A call's `seq` changes when its status changes, so polling with `afterSeq` sees updates. +Reading does not consume entries. + +Caps, enforced by the extension: + +- 256k characters per serialized `params`, return value or report; 8,192 per error. +- 32 pending calls per tab. +- 100 entries and about 2 MB per tab. Settled calls and reports expire after five minutes, + and count or size pressure evicts the oldest first. + +Revoking the grant, closing the tab or disconnecting clears handlers and results. + ## Errors -Existing codes apply unchanged. Two are worth restating: +Existing codes apply unchanged. A few are worth restating: | Code | Meaning here | | --- | --- | | `403` | Tab not granted, or the user revoked the grant | -| `400` | `scripts_test_match` received a `code` whose metadata block cannot be parsed | +| `400` | `scripts_test_match` received a `code` whose metadata block cannot be parsed, or `script_call` reused a `callId` with different arguments | +| `404` | `script_call` named a handler that is not registered in that frame | `503` still means the extension is not connected, which is also why the tool list is empty apart from `vm_status`. @@ -234,8 +353,10 @@ empty apart from `vm_status`. ## Non-goals - Capturing `console`, DOM state or network traffic. See "Observation boundary". -- `tab_eval` or `tab_query`. Userscripts do not work by being driven from outside; an - agent that needs to operate a page should use an extension built for it. +- `tab_eval` or `tab_query`. The agent cannot run arbitrary code in a page or query it. + It can only call handlers that a userscript explicitly registers with `GM.mcp`, which + run with that script's own permissions. An agent that needs to operate a page generally + should use an extension built for it. - `scripts_for_url` / `getMatchedScripts(url)`. It only works on installed scripts, so using it requires installing the thing you are unsure about, and `tab_scripts` already answers the same question for the page the user is on. diff --git a/docs/script-execution.md b/docs/script-execution.md deleted file mode 100644 index f3525c3..0000000 --- a/docs/script-execution.md +++ /dev/null @@ -1,123 +0,0 @@ -# Proposal: opt-in userscript handlers and structured results - -An agent developing a userscript can currently install or update it, reload a granted tab, and inspect `GM_log` output. Testing a function with several inputs requires editing the script or adding an application-specific trigger. Log strings also require an application-specific convention to associate an asynchronous result with the request that produced it. - -This proposal lets a script explicitly register named handlers, plus a bounded channel for structured results. An agent could discover what each handler does and expects, call a script's validation or calculation function with JSON input, and receive its return value or error without rerunning the script's top-level initialization. A script could also deliberately report results from events it observes, on its own or attached to a call. - -Status: the API follows the shape agreed in the extension review (`GM.mcp = { register, report }`, named handlers with descriptions and input schemas, a call-bound `report`). As of 2026-11-03 the paired implementation passes all 46 real-browser regression cases in Chrome 156 (MV3). Firefox runtime validation is still pending; see [Validation](#validation). - -## Scope decision - -The current [development contract](contract.md#non-goals) excludes externally driving userscripts and treats `GM_log` as the feedback boundary. Callable handlers would intentionally extend that scope. This proposal asks whether that extension is desirable before requesting a release; it does not assume that callback registration alone resolves the product or permission decision. - -Invocation requires a connected MCP session, an explicitly granted tab, an enabled installed script and a handler registered in that document. Scripts opt in using `@grant GM.mcp` (or `GM_mcp`) and an explicit registration call. Results contain only what the script returns or reports. No generic evaluation, DOM query, navigation, console capture or network capture tool is added. Registration is an application-level opt-in, not a new security sandbox: an agent already allowed to edit scripts can add a handler, and a handler can perform the same actions as its userscript. - -File saving, download directories, local filesystem access and application-specific integrations are deferred to a separate proposal. Existing `GM_download` behavior is unchanged. - -## Userscript API - -```js -// ==UserScript== -// @name MCP calculation example -// @namespace mcp-example -// @version 1.0.0 -// @match https://example.com/* -// @grant GM.mcp -// ==/UserScript== - -GM.mcp.register({ - sum: { - description: 'Adds two numbers', - inputSchema: { - type: 'object', - properties: { a: { type: 'number' }, b: { type: 'number' } }, - required: ['a', 'b'], - }, - handle({ params, report }) { - if (typeof params?.a !== 'number' || typeof params?.b !== 'number') { - throw new Error('Expected numeric a and b'); - } - return { sum: params.a + params.b }; - }, - }, -}); - -// Optional unsolicited report; does not register a callable handler. -GM.mcp.report({ event: 'ready' }); -``` - -`GM.mcp.register(handlers)` registers handlers by name and resolves to an `unregister()` function once the extension accepted them. Names are 1–64 letters, digits, `_`, `.` or `-`. Registering an existing name replaces it and keeps the script's other handlers; a script may have up to 32 handlers per frame. `unregister()` removes only the handlers that call added and that were not replaced since. `GM_mcp` is the same object for `@grant GM_mcp`. - -`description` and `inputSchema` are shown to the agent. The schema is a hint: the extension does not validate params against it, so the handler validates its own input. - -`handle({ params, report })` may return synchronously or asynchronously; its result settles the call. `undefined` becomes `null`; other values must be JSON serializable. Thrown errors, rejected promises and invalid return values settle the call as failed. Scripts never handle call IDs: `report(value, { error }?)` is bound to its call, adds a report attached to it, may be called any number of times, and stays usable after the call settled or timed out. It rejects once the document was replaced, access was revoked or the call is no longer retained. - -`GM.mcp.report(value, { error }?)` adds a report that is not tied to a call. - -## MCP tools - -| Tool | Input | Output | -| --- | --- | --- | -| `script_handlers` | `tabId` | `scripts: [{ scriptId, frameId, handlers: [{ name, description?, inputSchema? }] }]` | -| `script_call` | `tabId`, `scriptId`, `handler`, optional `frameId`, `params`, `callId`, `wait`, `timeoutMs` | `result: Call` | -| `script_results` | `tabId`, optional `scriptId`, `callId`, `afterSeq`, `limit` | `entries: (Call \| Report)[]`, `nextSeq`, `dropped` | - -All three tools require tab authorization. `script_call` defaults to frame 0, `params: null`, `wait: true` and a 10-second execution timeout; the permitted timeout is 1–25,000 ms. The server's default 30-second transport timeout leaves time to return an execution timeout record. Deployments overriding that transport timeout should keep it above the requested execution timeout. - -Both entry kinds contain `scriptId`, `frameId`, an opaque `documentId`, `seq` and optional `value` or `error`. A call (`kind: "call"`) also has `callId`, `handler`, `startedAt`, `status` and optional `finishedAt`. Status is `pending`, `completed`, `failed`, `timed_out` or `cancelled`. A report (`kind: "report"`) has `reportedAt` and, when it belongs to a call, that call's `callId`. Timestamps are milliseconds since the epoch. Call IDs are nonempty strings of at most 128 characters, generated by the extension when omitted. - -For example, after granting and reloading the tab and reading `script_handlers`: - -```json -{"tabId": 7, "scriptId": 1, "handler": "sum", "params": {"a": 2, "b": 3}, "callId": "sum-1"} -``` - -Pass this to `script_call`; a successful execution returns `result.status: "completed"` and `result.value.sum: 5`. For long-running work, use `wait: false` and poll `script_results` with the returned call ID, which also returns the call's reports. Reads default to 50 entries and allow 1–100; `afterSeq` is an exclusive cursor and `nextSeq` can be used for the next read. A call's `seq` changes when its status changes. Reads do not consume records, and `dropped` counts evictions cumulatively for the tab's session. - -## Errors, limits and lifecycle - -Protocol errors cover invalid arguments, missing handlers and denied access. Script errors are returned in a valid execution record; a successful MCP request does not imply a successful script. Read-only mode exposes handler discovery and result reads, but excludes `script_call`, which is marked potentially destructive and open-world. - -A timeout ends the wait, not the script or its side effects. A late return value can update a retained `timed_out` record to `completed` or `failed`. Reusing a retained call ID with the same script, frame, handler and serialized parameters returns the existing record without invoking the handler again. Different arguments with that ID fail. Eviction, disconnect or restart removes this deduplication guarantee, and reports left from an evicted call are dropped when its ID is reused; there is no durable exactly-once execution. Clients should not automatically retry side effects with a fresh ID. - -The extension limits serialized input and output to 256k characters each, errors to 8,192 characters, and pending calls to 32 per tab. Settled calls and reports expire after five minutes, and reporting keeps a call retained; count and byte pressure may evict them earlier. Retention is capped at 100 entries and approximately 2 MiB per tab, with pending calls protected from eviction and bounded separately by the per-call and concurrency limits. - -Navigation or reload cancels unfinished calls for the replaced document. Closing a tab, revoking its grant or disconnecting clears its channels and retained results. These operations do not undo effects already performed by a script. Back/forward cache restoration renews the channel and restores registered handlers with their metadata without rerunning script initialization, provided the original authorization session still exists. Old calls are not replayed and their return values and reports remain invalid. Main frames and child frames can restore in either order. A revoked or replaced session requires explicit authorization and a reload. - -## Implementation and compatibility - -The MCP repository owns the three tool schemas, typed client interfaces, capability negotiation and transport tests. The companion extension change owns the GM APIs, authorization checks, per-document dispatch, result retention and page lifecycle. Tools are exposed only when both server and extension support them; existing extensions retain their current tool list. The existing JavaScript client transport can advertise the new names, while a protocol/client package release makes them available to typed consumers. - -No package version or minimum extension release is assigned by this draft. Coordinate the MCP package release and extension rollout if the proposal is accepted. The extension can be tested against a local build of this repository before publishing. - -## Validation - -The paired implementation was tested on 2026-11-03 with Google Chrome 156.0.8078.80 on macOS arm64, using a headed browser, an isolated profile, the unpacked MV3 extension build and the per-extension "Allow User Scripts" switch. - -The tests used an MCP SDK client over Streamable HTTP, the actual MCP server, its WebSocket connection to the extension, and installed userscripts. No simulated extension backend was used. `GM_mcp` was exercised in `@inject-into page` and `GM.mcp` in `@inject-into content`. Both scripts ran in the top document, a same-origin iframe and a cross-origin iframe, with 12 named handlers each. - -All 46 browser regression cases passed: - -| Area | Verified behavior | -| --- | --- | -| Registration | Named handlers with descriptions and input schemas in both realms and all frame contexts; `unregister()` removes only its own handlers | -| Calls and results | Synchronous JSON and falsy values, asynchronous returns, concurrent response correlation, call-bound reports before and after settling, unsolicited reports and exclusive result cursors | -| Failures and limits | Thrown errors, rejected promises, circular values, BigInt, oversized inputs and outputs, unknown or missing handler names, missing frame handlers and disabled scripts | -| Call lifecycle | Retained call-ID deduplication, conflicting reuse with other params or handler, `wait: false`, execution timeout and late completion | -| Page restoration | Real back/forward cache restoration, renewed document channels, handlers and their metadata restored in both realms and all three frame contexts, no repeated script initialization | -| Authorization and cleanup | Revocation and regrant, rejection of cached old authorization sessions, reload, disconnect/reconnect, read-only mode and tab closure | - -Back and forward navigation produced trusted native `pageshow` events with `persisted === true`; the check did not substitute a reload for restoration. Script closure instances survived with an initialization count of one, while document IDs changed. Navigation cancelled unfinished calls, and late replies could not overwrite those cancelled records. Restored handlers could still be called and report results. No unexpected page JavaScript errors were observed. - -The MCP automated suite (28 tests), typecheck and build pass, as do the companion extension suite (172 tests), lint, and MV2/MV3 builds. Successful builds do not establish runtime compatibility. Firefox still needs real-browser checks before claiming cross-browser validation. - -Chrome removed Manifest V2 support starting with version 139, as documented in the [official support timeline](https://developer.chrome.com/docs/extensions/develop/migrate/mv2-deprecation-timeline). Current Chrome verification therefore targets MV3. Legacy Chrome MV2 runtime checks would require an explicitly agreed older-browser compatibility target; they are not part of current Chrome validation. File saving remains outside this first round. - -## Questions for maintainers - -1. Is explicit callback invocation an acceptable extension of the current development-only scope, or should the initial feature be limited to unsolicited structured reports? -2. ~~Are two new GM APIs appropriate, and should the proposed names or registration model change?~~ Agreed in review: one `GM.mcp` namespace with named handlers and a call-bound `report`. -3. Is the existing per-tab grant sufficient, or should invocation require an additional user-facing permission? -4. Are the timeout, retention, deduplication and back/forward cache semantics suitable for an initial version? - -The Chrome results support an initial review of the paired changes. They do not settle the scope or permission decisions above. This proposal intentionally identifies an extension to the existing development contract rather than treating that extension as already accepted.