diff --git a/.changeset/script-invocation-results.md b/.changeset/script-invocation-results.md new file mode 100644 index 0000000..9c9d80c --- /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 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 2a3036a..4c73f3d 100644 --- a/README.md +++ b/README.md @@ -99,14 +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. -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/packages/protocol/src/tools.ts b/packages/protocol/src/tools.ts index 0365c95..5ce1eb1 100644 --- a/packages/protocol/src/tools.ts +++ b/packages/protocol/src/tools.ts @@ -39,10 +39,53 @@ 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); + +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 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(), +}); + +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 { readOnlyHint?: boolean; destructiveHint?: boolean; idempotentHint?: boolean; + openWorldHint?: boolean; } export interface ToolDefinition { @@ -163,6 +206,60 @@ export const tools = { }), annotations: { readOnlyHint: true }, }), + script_handlers: defineTool({ + title: 'List callable script handlers', + description: + '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({ + scripts: z.array(z.object({ scriptId: executionScriptId, frameId, handlers: z.array(scriptHandler) })), + }), + annotations: { readOnlyHint: true }, + }), + script_call: defineTool({ + title: 'Call a userscript handler', + description: + '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. ' + + '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: scriptCall }), + annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }, + }), + script_results: defineTool({ + title: 'Read script results', + description: + '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, + 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(scriptResultEntry), + 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..30a3b42 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,119 @@ describe('violentmonkey mcp', () => { expect(JSON.stringify(res.content)).toContain('500'); }); + it('discovers named handlers and preserves correlated calls and reports', async () => { + const ext = extension(); + const records: Array['entries'][number]> = []; + const inputs: unknown[] = []; + 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 = { + 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 }) => { + 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({ 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, handler: 'echo', callId, params: values[i] }, + }))); + 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, 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: 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, 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, handler: 'echo', 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' }, + { handler: undefined }, { handler: '' }, { handler: 'has space' }, { handler: 'x'.repeat(65) }, + ]) { + const result = await mcp.callTool({ + 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, handler: 'echo' } }); + expect(denied).toMatchObject({ isError: true, content: [{ type: 'text', text: '[403] Tab is not authorized for MCP' }] }); + }); + + 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(name, () => output as never); + ext.connect(); + await waitFor(() => ext.status === 'open'); + 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]'); + }); + it('propagates handler errors and validates params', async () => { const ext = extension(); ext.handle('scripts_get', () => { @@ -330,6 +444,36 @@ 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', () => ({ 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`), { + 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, handler: 'echo' }, + })).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({