Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/script-invocation-results.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
137 changes: 129 additions & 8 deletions docs/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,20 @@ 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.
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
Expand All @@ -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.
Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -219,23 +228,135 @@ 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<Call | Report>;
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`.

## 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.
Expand Down
97 changes: 97 additions & 0 deletions packages/protocol/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<I extends z.ZodObject, O extends z.ZodObject> {
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading