Dr. Dominik Jain 8efbd9b5d3
🎉 Allow MCP to work with multiple files in parallel (#11927)
* 🎉 Add multi-file session routing to the MCP server

Session routing:
- Track each user's Penpot connections by session ID.
- Dispatch explicit sessions directly; otherwise discover the sole session.
- Return a retryable error when discovery fails or is incomplete.

Plugin and tools:
- Initialize connections with a fresh session ID and file metadata.
- Share the optional sessionId schema and place it last in tool inputs.

Validation:
- 84 tests, server type checking, and MCP formatting checks pass.
- Disable test logging to avoid the logging worker shutdown hang.

AI-assisted-by: gpt-6

* 🎉 Support independent MCP connections

- Require explicit connection intent for each integrated workspace.
- Derive short session IDs from the Penpot app instance and file.
- Show and copy session IDs in the MCP menu and standalone plugin UI.
- Stop stale callbacks and reconnect attempts after disconnect.

Validate with frontend and plugin tests, type checking, live multi-tab
checks, and standalone UI checks with a simulated connection. Server
tests pass with --test-force-exit; the normal runner can hang on shutdown.

AI-assisted-by: gpt-6

* 📚 Document multi-file MCP sessions

- Explain independent connections, session selection, and copying IDs.
- Describe session ID lifetimes for integrated and standalone plugins.
- Correct browser focus, connection, and recovery guidance.

Validate with the documentation site build and diff checks.

AI-assisted-by: gpt-6

* ✨ Change behavior on session ID duplication

* ✨ Open MCP toolbar menu on hover and match menu style

---------

Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
Co-authored-by: elhombretecla <delacruzgarciajuan@gmail.com>
2026-10-02 14:22:52 +02:00

139 lines
4.8 KiB
TypeScript

import { z } from "zod";
import "reflect-metadata";
import { TextResponse, ToolResponse } from "./ToolResponse";
import type { PenpotMcpServer, SessionContext } from "./PenpotMcpServer";
import { createLogger } from "./logger";
/**
* An empty arguments class for tools that do not require any parameters.
*/
export class EmptyToolArgs {
static schema = {};
}
/**
* Base class for type-safe tools with automatic schema generation and validation.
*
* This class provides type safety through automatic validation and strongly-typed
* protected methods. All tools should extend this class.
*
* @template TArgs - The strongly-typed arguments class for this tool
*/
export abstract class Tool<TArgs extends object> {
public static readonly SESSION_ID_SCHEMA = z
.string()
.min(1)
.max(128)
.optional()
.describe(
"Penpot session to use. Optional when exactly one session is connected. " +
"If several sessions are listed in an error, ask the user to select one and pass its sessionId."
);
private readonly logger = createLogger("Tool");
/** monotonically increasing counter for unique tool execution IDs */
private static executionCounter = 0;
protected constructor(
protected mcpServer: PenpotMcpServer,
private inputSchema: z.ZodRawShape
) {}
/**
* Executes the tool with automatic validation and type safety.
*
* This method handles the unknown args from the MCP protocol,
* delegating to the type-safe implementation.
*/
async execute(args: unknown): Promise<ToolResponse> {
const executionId = ++Tool.executionCounter;
try {
let argsInstance: TArgs = args as TArgs;
this.logger.info("Tool execution #%d starting: %s", executionId, this.getToolName());
if (this.logger.isLevelEnabled("debug")) {
this.logger.debug("Tool execution #%d arguments: %s", executionId, this.formatArgs(argsInstance));
}
// execute the actual tool logic
let result = await this.executeCore(argsInstance);
this.logger.info("Tool execution #%d complete: %s", executionId, this.getToolName());
return result;
} catch (error) {
this.logger.error("Tool execution #%d failed: %s; error: %s", executionId, this.getToolName(), error);
return new TextResponse(`Tool execution failed: ${String(error)}`);
}
}
/**
* Formats tool arguments for readable logging.
*
* Multi-line strings are preserved with proper indentation.
*/
protected formatArgs(args: TArgs): string {
const formatted: string[] = [];
for (const [key, value] of Object.entries(args)) {
if (typeof value === "string" && value.includes("\n")) {
// multi-line string - preserve formatting with indentation
const indentedValue = value
.split("\n")
.map((line, index) => (index === 0 ? line : " " + line))
.join("\n");
formatted.push(` ${key}: ${indentedValue}`);
} else if (typeof value === "string") {
// single-line string
formatted.push(` ${key}: "${value}"`);
} else if (value === null || value === undefined) {
formatted.push(` ${key}: ${value}`);
} else {
// other types (numbers, booleans, objects, arrays)
const stringified = JSON.stringify(value, null, 2);
if (stringified.includes("\n")) {
// multi-line JSON - indent it
const indented = stringified
.split("\n")
.map((line, index) => (index === 0 ? line : " " + line))
.join("\n");
formatted.push(` ${key}: ${indented}`);
} else {
formatted.push(` ${key}: ${stringified}`);
}
}
}
return formatted.length > 0 ? "\n" + formatted.join("\n") : "{}";
}
/**
* Retrieves the current session context.
*
* @returns The session context for the current request, or undefined if not in a request context
*/
protected getSessionContext(): SessionContext | undefined {
return this.mcpServer.getSessionContext();
}
public getInputSchema() {
return this.inputSchema;
}
/**
* Returns the tool's unique name.
*/
public abstract getToolName(): string;
/**
* Returns the tool's description.
*/
public abstract getToolDescription(): string;
/**
* Executes the tool's core logic.
*
* @param args - The (typed) tool arguments
*/
protected abstract executeCore(args: TArgs): Promise<ToolResponse>;
}