Skip to content
Open
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "tampermonkey-mcp",
"mcpName": "io.github.Tampermonkey/tampermonkey-mcp",
"version": "0.0.5",
"version": "0.0.6",
"main": "index.js",
"scripts": {
"init": "",
Expand Down
79 changes: 78 additions & 1 deletion src/mcp/server/tampermonkey-ws-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,35 @@ import { logger as console } from '../../shared/logger';

const MIN_PORT_OFFSET = 1024;

/**
* Globally tracked list of actions allowed by the connected Tampermonkey instance.
* Populated when the browser extension connects (via 'options' query).
* Tampermonkey 5.5+ only allows: ['options', 'list', 'get', 'patch']
* Older versions also allow: 'put', 'delete'
*/
export let allowedActions: string[] = [];

/**
* Callbacks fired when allowed actions are discovered (after WebSocket connect).
* Used to notify MCP server instances so they can disable unsupported tools.
*/
const onAllowedActionsDiscovered: Array<(actions: string[]) => void> = [];

export function onAllowedActionsReady(cb: (actions: string[]) => void): void {
if (allowedActions.length > 0) {
// Already discovered — fire immediately
cb(allowedActions);
} else {
onAllowedActionsDiscovered.push(cb);
}
}

export interface OptionsExternalResponse {
messageId: string;
allow?: string[];
error?: { number: number; message: string };
}

export interface ListExternalResponse {
messageId: string;
list: Array<{
Expand Down Expand Up @@ -47,7 +76,7 @@ export interface DeleteExternalResponse {
error?: { number: number; message: string };
}

export type ExternalResponse = ListExternalResponse | GetExternalResponse | UpdateExternalResponse | PutExternalResponse | DeleteExternalResponse;
export type ExternalResponse = OptionsExternalResponse | ListExternalResponse | GetExternalResponse | UpdateExternalResponse | PutExternalResponse | DeleteExternalResponse;

export interface UserscriptRequest {
action: 'list' | 'get' | 'patch' | 'put' | 'delete' | 'options';
Expand Down Expand Up @@ -218,6 +247,9 @@ export class TampermonkeyWebSocketServer {
this._ws.close(4009, 'Connection superseded');
}

// Reset allowed actions for new connection — will be re-discovered below
allowedActions = [];

this._ws = ws;

// Start ping interval to keep connection alive
Expand Down Expand Up @@ -248,6 +280,12 @@ export class TampermonkeyWebSocketServer {

console.log('[TampermonkeyWS] Connection ready');
this._connected_cb();

// Discover allowed actions from Tampermonkey extension (non-blocking)
// Fire-and-forget: don't block connection resolution waiting for response
this._discoverAllowedActions().catch((e) => {
console.warn('[TampermonkeyWS] Failed to query allowed actions, assuming all allowed:', e);
});
} catch (e) {
console.error('[TampermonkeyWS] Auth error:', e);
ws.close(3003, 'Auth error');
Expand All @@ -256,6 +294,45 @@ export class TampermonkeyWebSocketServer {
.catch(e => console.error('[TampermonkeyWS] Auth error:', e));
}

/**
* Query allowed actions from Tampermonkey extension.
* Returns the list of actions the extension supports.
* Tampermonkey 5.5+ restricts to: ['options', 'list', 'get', 'patch']
*/
async options(): Promise<OptionsExternalResponse> {
const resp = await this._command({ action: 'options' });
return resp as OptionsExternalResponse;
}

/**
* Discover allowed actions and notify MCP server instances.
* Called as fire-and-forget after connection (doesn't block connected promise).
*/
private async _discoverAllowedActions(): Promise<void> {
const optionsResp = await this.options();
if (optionsResp.error) {
// 'options' not supported by this version — assume all tools are available
console.log(`[TampermonkeyWS] Options query returned error, assuming all actions allowed`);
return;
}
const allow = optionsResp.allow;
if (allow && allow.length > 0) {
allowedActions = allow;
console.log(`[TampermonkeyWS] Allowed actions: ${allowedActions.join(', ')}`);
} else {
// No allow list — assume all tools available (older extension versions)
console.log('[TampermonkeyWS] No allow list in options response, assuming all actions allowed');
}
// Notify all waiting MCP server instances
for (const cb of onAllowedActionsDiscovered) {
try {
cb(allowedActions);
} catch (e) {
console.error('[TampermonkeyWS] Error in allowed actions callback:', e);
}
}
}

/**
* List all userscripts
*/
Expand Down
83 changes: 79 additions & 4 deletions src/mcp/server/tampermonkey.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { TampermonkeyWebSocketServer } from './tampermonkey-ws-client';
import { TampermonkeyWebSocketServer, allowedActions, onAllowedActionsReady } from './tampermonkey-ws-client';
import { logger as console } from '../../shared/logger';

let serverPromise: Promise<TampermonkeyWebSocketServer> | null = null;
Expand Down Expand Up @@ -31,8 +31,52 @@ function ensureConnected(wsServer: TampermonkeyWebSocketServer): void {
}
}

/**
* Track registered tool handles so we can disable unsupported tools later.
* Keyed by MCP server instance to support both stdio (singleton) and HTTP (per-session).
*/
const toolHandlesByServer = new WeakMap<McpServer, {
put?: { disable: () => void };
delete?: { disable: () => void };
}>();

/**
* Disable tools that are not supported by the connected Tampermonkey version.
* Tampermonkey 5.5+ only allows: ['options', 'list', 'get', 'patch']
* Called both via callback (stdio) and at server creation time (HTTP).
*/
function applyAllowedActions(mcpServer: McpServer): void {
const handles = toolHandlesByServer.get(mcpServer);
if (!handles) return;

if (handles.put && !allowedActions.includes('put')) {
handles.put.disable();
console.log('[TampermonkeyWS] Tool tampermonkey_put disabled (not supported by connected Tampermonkey)');
}
if (handles.delete && !allowedActions.includes('delete')) {
handles.delete.disable();
console.log('[TampermonkeyWS] Tool tampermonkey_delete disabled (not supported by connected Tampermonkey)');
}
}

/**
* Check if an action is allowed, throw descriptive error if not.
* Runtime guard for HTTP sessions created before connection.
*/
function ensureActionAllowed(action: string): void {
if (allowedActions.length === 0) return; // Not yet discovered, allow
if (!allowedActions.includes(action)) {
throw new Error(
`Action '${action}' is not supported by the connected Tampermonkey version ` +
`(allowed: ${allowedActions.filter(a => a !== 'options').join(', ')}). ` +
`This tool is only available with Tampermonkey versions that support script ${action === 'put' ? 'creation' : 'deletion'} via external API.`
);
}
}

// eslint-disable-next-line @typescript-eslint/require-await
export const init = async (mcpServer: McpServer) => {
const handles: { put?: { disable: () => void }; delete?: { disable: () => void } } = {};

// Tool: tampermonkey_get_connection_code
mcpServer.tool(
Expand All @@ -50,12 +94,13 @@ export const init = async (mcpServer: McpServer) => {
2. Open Tampermonkey Editors extension in your browser
3. Enter the code in the extension popup
4. The extension connects to the MCP server's WebSocket
5. After connection, you can use tampermonkey_list, tampermonkey_get, tampermonkey_patch, tampermonkey_put, tampermonkey_delete
5. After connection, you can use tampermonkey_list, tampermonkey_get, tampermonkey_patch, and (if supported) tampermonkey_put, tampermonkey_delete

**Output:**
- \`code\`: The connection code to enter in Tampermonkey Editors
`,
{},
{ idempotentHint: true },
async () => {
const wsServer = await getServer();
const code = wsServer.code;
Expand Down Expand Up @@ -92,6 +137,7 @@ export const init = async (mcpServer: McpServer) => {
pattern: z.string().optional().describe('Filter scripts by name pattern'),
includePattern: z.array(z.string()).optional().describe('Filter scripts by include URL pattern'),
},
{ readOnlyHint: true },
async (args) => {
const wsServer = await getServer();
ensureConnected(wsServer);
Expand Down Expand Up @@ -149,6 +195,7 @@ export const init = async (mcpServer: McpServer) => {
path: z.string().describe('The script or resource path (<script-uuid>/source, <script-uuid>/storage or <script-uuid>/<external-resource-url>'),
ifNotModifiedSince: z.number().optional().describe('Unix timestamp - only return if script was modified after this time'),
},
{ readOnlyHint: true },
async (args) => {
const wsServer = await getServer();
ensureConnected(wsServer);
Expand Down Expand Up @@ -198,6 +245,7 @@ export const init = async (mcpServer: McpServer) => {
value: z.string().describe('The new script content'),
lastModified: z.number().optional().describe('Unix timestamp for optimistic locking'),
},
{ destructiveHint: true },
async (args) => {
const wsServer = await getServer();
ensureConnected(wsServer);
Expand Down Expand Up @@ -228,11 +276,15 @@ export const init = async (mcpServer: McpServer) => {
);

// Tool: tampermonkey_put
mcpServer.tool(
const putResult = mcpServer.tool(
'tampermonkey_put',
`
Create a new userscript.

**Note:** This tool requires Tampermonkey version 5.6+ With Tampermonkey 5.5+,
script creation via external API is not supported (returns 405 error).
Use browser automation as an alternative for newer versions.

**Input:**
- \`value\`: The script source code content
- \`lastModified\` (optional): Unix timestamp for optimistic locking
Expand All @@ -246,9 +298,11 @@ export const init = async (mcpServer: McpServer) => {
value: z.string().describe('The script source code content'),
lastModified: z.number().optional().describe('Unix timestamp for optimistic locking'),
},
{ destructiveHint: true },
async (args) => {
const wsServer = await getServer();
ensureConnected(wsServer);
ensureActionAllowed('put');

try {
const resp = await wsServer.put(args.value, args.lastModified);
Expand All @@ -274,13 +328,17 @@ export const init = async (mcpServer: McpServer) => {
}
}
);
handles.put = putResult;

// Tool: tampermonkey_delete
mcpServer.tool(
const deleteResult = mcpServer.tool(
'tampermonkey_delete',
`
Delete a userscript by path.

**Note:** This tool requires Tampermonkey version 5.6+ With Tampermonkey 5.5+,
script deletion via external API is not supported (returns 405 error).

**Input:**
- \`path\`: The script or resource path (<script-uuid>/source, <script-uuid>/storage or <script-uuid>/<external-resource-url>)

Expand All @@ -291,9 +349,11 @@ export const init = async (mcpServer: McpServer) => {
{
path: z.string().describe('The script or resource path (<script-uuid>/source, <script-uuid>/storage or <script-uuid>/<external-resource-url>)'),
},
{ destructiveHint: true },
async (args) => {
const wsServer = await getServer();
ensureConnected(wsServer);
ensureActionAllowed('delete');

try {
const resp = await wsServer.delete(args.path);
Expand All @@ -319,6 +379,21 @@ export const init = async (mcpServer: McpServer) => {
}
}
);
handles.delete = deleteResult;

// Track handles for this MCP server instance so we can disable tools later
toolHandlesByServer.set(mcpServer, handles);

// If allowed actions are already known (connection happened before this server was created),
// apply restrictions immediately (HTTP per-session case)
if (allowedActions.length > 0) {
applyAllowedActions(mcpServer);
} else {
// Otherwise, wait for connection to discover allowed actions (stdio case)
onAllowedActionsReady(() => {
applyAllowedActions(mcpServer);
});
}

console.log('[TampermonkeyWS] MCP tools initialized');
};
Loading