diff --git a/examples/workflow-server/src/common/graph-extension.ts b/examples/workflow-server/src/common/graph-extension.ts index 2b684e8..0097c48 100644 --- a/examples/workflow-server/src/common/graph-extension.ts +++ b/examples/workflow-server/src/common/graph-extension.ts @@ -86,7 +86,7 @@ export class TaskNodeBuilder extends GNodeBuilder protected createCompartmentHeader(): GLabel { return new GLabelBuilder(GLabel) .type(ModelTypes.LABEL_HEADING) - .id(this.proxy.id + '_classname') + .id(this.proxy.id + '_label') .text(this.proxy.name) .build(); } @@ -151,7 +151,7 @@ export class CategoryNodeBuilder extends Activity protected createCompartmentHeader(): GLabel { return new GLabelBuilder(GLabel) .type(ModelTypes.LABEL_HEADING) - .id(this.proxy.id + '_classname') + .id(this.proxy.id + '_label') .text(this.proxy.name) .build(); } diff --git a/examples/workflow-server/src/common/handler/create-decision-node-handler.ts b/examples/workflow-server/src/common/handler/create-decision-node-handler.ts index 1bfa574..1dca59b 100644 --- a/examples/workflow-server/src/common/handler/create-decision-node-handler.ts +++ b/examples/workflow-server/src/common/handler/create-decision-node-handler.ts @@ -25,6 +25,6 @@ export class CreateDecisionNodeHandler extends CreateActivityNodeHandler { label = 'Decision Node'; protected override builder(point?: Point): ActivityNodeBuilder { - return super.builder(point).addCssClass('decision').resizeLocations(GResizeLocation.CROSS); + return super.builder(point).addCssClass('decision').resizeLocations(GResizeLocation.CROSS).size(32, 32); } } diff --git a/examples/workflow-server/src/common/handler/create-merge-node-handler.ts b/examples/workflow-server/src/common/handler/create-merge-node-handler.ts index 9330d4b..fe41a0a 100644 --- a/examples/workflow-server/src/common/handler/create-merge-node-handler.ts +++ b/examples/workflow-server/src/common/handler/create-merge-node-handler.ts @@ -25,6 +25,6 @@ export class CreateMergeNodeHandler extends CreateActivityNodeHandler { label = 'Merge Node'; protected override builder(point: Point | undefined): ActivityNodeBuilder { - return super.builder(point).addCssClass('merge').resizeLocations(GResizeLocation.CROSS); + return super.builder(point).addCssClass('merge').resizeLocations(GResizeLocation.CROSS).size(32, 32); } } diff --git a/examples/workflow-server/src/common/mcp/workflow-create-nodes-handler.ts b/examples/workflow-server/src/common/mcp/workflow-create-nodes-handler.ts new file mode 100644 index 0000000..68a587f --- /dev/null +++ b/examples/workflow-server/src/common/mcp/workflow-create-nodes-handler.ts @@ -0,0 +1,34 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { GLabel, GModelElement } from '@eclipse-glsp/server'; +import { CreateNodesMcpToolHandler } from '@eclipse-glsp/server-mcp'; +import { injectable } from 'inversify'; +import { ModelTypes } from '../util/model-types'; + +@injectable() +export class WorkflowCreateNodesMcpToolHandler extends CreateNodesMcpToolHandler { + override getCorrespondingLabelId(element: GModelElement): string | undefined { + // Category labels are nested in a header component + if (element.type === ModelTypes.CATEGORY) { + return element.children.find(child => child.type === ModelTypes.COMP_HEADER)?.children.find(child => child instanceof GLabel) + ?.id; + } + + // Assume that generally, labelled nodes have those labels as direct children + return element.children.find(child => child instanceof GLabel)?.id; + } +} diff --git a/examples/workflow-server/src/common/mcp/workflow-element-types-handler.ts b/examples/workflow-server/src/common/mcp/workflow-element-types-handler.ts new file mode 100644 index 0000000..3a416dd --- /dev/null +++ b/examples/workflow-server/src/common/mcp/workflow-element-types-handler.ts @@ -0,0 +1,155 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { DefaultTypes } from '@eclipse-glsp/server'; +import { + createResourceToolResult, + ElementTypesMcpResourceHandler, + GLSPMcpServer, + objectArrayToMarkdownTable, + ResourceHandlerResult +} from '@eclipse-glsp/server-mcp'; +import { injectable } from 'inversify'; +import { ModelTypes } from '../util/model-types'; +import * as z from 'zod/v4'; + +interface ElementType { + id: string; + label: string; + description: string; + hasLabel: boolean; +} + +const WORKFLOW_NODE_ELEMENT_TYPES: ElementType[] = [ + { + id: ModelTypes.AUTOMATED_TASK, + label: 'Automated Task', + description: 'Task without human input', + hasLabel: true + }, + { + id: ModelTypes.MANUAL_TASK, + label: 'Manual Task', + description: 'Task done by a human', + hasLabel: true + }, + { + id: ModelTypes.JOIN_NODE, + label: 'Join Node', + description: 'Gateway that merges parallel flows', + hasLabel: false + }, + { + id: ModelTypes.FORK_NODE, + label: 'Fork Node', + description: 'Gateway that splits into parallel flows', + hasLabel: false + }, + { + id: ModelTypes.MERGE_NODE, + label: 'Merge Node', + description: 'Gateway that merges alternative flows', + hasLabel: false + }, + { + id: ModelTypes.DECISION_NODE, + label: 'Decision Node', + description: 'Gateway that splits into alternative flows', + hasLabel: false + }, + { + id: ModelTypes.CATEGORY, + label: 'Category', + description: 'Container node that groups other elements', + hasLabel: true + } +]; +const WORKFLOW_EDGE_ELEMENT_TYPES: ElementType[] = [ + { + id: DefaultTypes.EDGE, + label: 'Edge', + description: 'Standard control flow edge', + hasLabel: false + }, + { + id: ModelTypes.WEIGHTED_EDGE, + label: 'Weighted Edge', + description: 'Edge that indicates a weighted probability. Typically used with a Decision Node.', + hasLabel: false + } +]; + +const WORKFLOW_ELEMENT_TYPES_STRING = [ + '# Creatable element types for diagram type "workflow-diagram"', + '## Node Types', + objectArrayToMarkdownTable(WORKFLOW_NODE_ELEMENT_TYPES), + '## Edge Types', + objectArrayToMarkdownTable(WORKFLOW_EDGE_ELEMENT_TYPES) +].join('\n'); + +/** + * The default {@link ElementTypesMcpResourceHandler} extracts a list of operations generically from + * the `OperationHandlerRegistry`, because it can't know the details of a specific GLSP implementation. + * This is naturally quite limited in expression and relies on semantically meaningful model types to be + * able to inform an MCP client reliably. + * + * However, when overriding this for a specific implementation, we don't have those limitations. Rather, + * since the available element types do not change dynamically, we can simply provide a statically generated + * string. + */ +@injectable() +export class WorkflowElementTypesMcpResourceHandler extends ElementTypesMcpResourceHandler { + override registerTool(server: GLSPMcpServer): void { + server.registerTool( + 'element-types', + { + title: 'Creatable Element Types', + description: + 'List all element types (nodes and edges) that can be created for a specific diagram type. ' + + 'Use this to discover valid elementTypeId values for creation tools.', + inputSchema: { + diagramType: z.string().describe('Diagram type whose elements should be discovered') + } + }, + async params => createResourceToolResult(await this.handle(params)) + ); + } + + override async handle({ diagramType }: { diagramType?: string }): Promise { + this.logger.info(`'element-types' invoked for diagram type '${diagramType}'`); + + // In this specifc GLSP implementation, only 'workflow-diagram' is valid + if (diagramType !== 'workflow-diagram') { + return { + content: { + uri: `glsp://types/${diagramType}/elements`, + mimeType: 'text/plain', + text: 'Invalid diagram type.' + }, + isError: true + }; + } + + return { + content: { + uri: `glsp://types/${diagramType}/elements`, + mimeType: 'text/markdown', + text: WORKFLOW_ELEMENT_TYPES_STRING + }, + isError: false + }; + } +} diff --git a/examples/workflow-server/src/common/mcp/workflow-mcp-model-serializer.ts b/examples/workflow-server/src/common/mcp/workflow-mcp-model-serializer.ts new file mode 100644 index 0000000..2d72af4 --- /dev/null +++ b/examples/workflow-server/src/common/mcp/workflow-mcp-model-serializer.ts @@ -0,0 +1,153 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { GModelElement } from '@eclipse-glsp/graph'; +import { DefaultTypes } from '@eclipse-glsp/server'; +import { DefaultMcpModelSerializer } from '@eclipse-glsp/server-mcp'; +import { injectable } from 'inversify'; +import { ModelTypes } from '../util/model-types'; + +/** + * As compared to the {@link DefaultMcpModelSerializer}, this is a specific implementation and we + * know not only the structure of our graph but also each relevant attribute. This enables us to + * order them semantically so the produced serialization makes more sense if read with semantics + * mind. As LLMs (i.e., the MCP clients) work semantically, this is superior to a random ordering. + * Furthermore, including only the relevant information without redundancies decreases context size. + */ +@injectable() +export class WorkflowMcpModelSerializer extends DefaultMcpModelSerializer { + override prepareElement(element: GModelElement): Record[]> { + const elements = this.flattenStructure(element); + + // Define the order of keys + const result: Record[]> = { + [DefaultTypes.GRAPH]: [], + [ModelTypes.CATEGORY]: [], + [ModelTypes.AUTOMATED_TASK]: [], + [ModelTypes.MANUAL_TASK]: [], + [ModelTypes.FORK_NODE]: [], + [ModelTypes.JOIN_NODE]: [], + [ModelTypes.DECISION_NODE]: [], + [ModelTypes.MERGE_NODE]: [], + [DefaultTypes.EDGE]: [], + [ModelTypes.WEIGHTED_EDGE]: [] + }; + elements.forEach(element => { + this.combinePositionAndSize(element); + + const adjustedElement = this.adjustElement(element); + if (!adjustedElement) { + return; + } + + result[element.type].push(adjustedElement); + }); + + return result; + } + + private adjustElement(element: Record): Record | undefined { + switch (element.type) { + case ModelTypes.AUTOMATED_TASK: + case ModelTypes.MANUAL_TASK: { + const label = element.children.find((child: { type: string }) => child.type === ModelTypes.LABEL_HEADING); + + // For tasks, the only content with impact on element size is the label + // Therefore, all other factors get integrated into the label size for the AI to do proper resizing operations + const labelSize = { + // 10px padding right, 31px padding left (incl. icon) + width: Math.trunc(label.size.width + 10 + 31), + // 7px padding top and bottom each + height: Math.trunc(label.size.height + 14) + }; + + return { + id: element.id, + position: element.position, + size: element.size, + bounds: element.bounds, + label: label.text, + labelSize: labelSize, + parentId: element.parent.type === ModelTypes.STRUCTURE ? element.parent.parent.id : element.parentId + }; + } + case ModelTypes.CATEGORY: { + const label = element.children + .find((child: { type: string }) => child.type === ModelTypes.COMP_HEADER) + ?.children.find((child: { type: string }) => child.type === ModelTypes.LABEL_HEADING); + + const labelSize = { + width: Math.trunc(label.size.width + 20), + height: Math.trunc(label.size.height + 20) + }; + + const usableSpaceSize = { + width: Math.trunc(Math.max(0, element.size.width - 10)), + height: Math.trunc(Math.max(0, element.size.height - labelSize.height - 10)) + }; + + return { + id: element.id, + isContainer: true, + position: element.position, + size: element.size, + bounds: element.bounds, + label: label.text, + labelSize: labelSize, + usableSpaceSize: usableSpaceSize, + parentId: element.parentId + }; + } + case ModelTypes.JOIN_NODE: + case ModelTypes.MERGE_NODE: + case ModelTypes.DECISION_NODE: + case ModelTypes.FORK_NODE: { + return { + id: element.id, + position: element.position, + size: element.size, + bounds: element.bounds, + parentId: element.parentId + }; + } + case DefaultTypes.EDGE: { + return { + id: element.id, + sourceId: element.sourceId, + targetId: element.targetId, + parentId: element.parentId + }; + } + case ModelTypes.WEIGHTED_EDGE: { + return { + id: element.id, + sourceId: element.sourceId, + targetId: element.targetId, + probability: element.probability, + parentId: element.parentId + }; + } + case DefaultTypes.GRAPH: { + return { + id: element.id, + isContainer: true + }; + } + default: + return undefined; + } + } +} diff --git a/examples/workflow-server/src/common/mcp/workflow-mcp-module.ts b/examples/workflow-server/src/common/mcp/workflow-mcp-module.ts new file mode 100644 index 0000000..34b0134 --- /dev/null +++ b/examples/workflow-server/src/common/mcp/workflow-mcp-module.ts @@ -0,0 +1,40 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { bindAsService } from '@eclipse-glsp/server'; +import { + CreateNodesMcpToolHandler, + ElementTypesMcpResourceHandler, + McpModelSerializer, + McpToolHandler, + ModifyNodesMcpToolHandler, + RequestLayoutMcpToolHandler +} from '@eclipse-glsp/server-mcp'; +import { ContainerModule } from 'inversify'; +import { WorkflowCreateNodesMcpToolHandler } from './workflow-create-nodes-handler'; +import { WorkflowElementTypesMcpResourceHandler } from './workflow-element-types-handler'; +import { WorkflowMcpModelSerializer } from './workflow-mcp-model-serializer'; +import { WorkflowModifyNodesMcpToolHandler } from './workflow-modify-nodes-handler'; + +export function configureWorfklowMcpModule(): ContainerModule { + return new ContainerModule((bind, unbind, isBound, rebind) => { + rebind(McpModelSerializer).to(WorkflowMcpModelSerializer).inSingletonScope(); + rebind(ElementTypesMcpResourceHandler).to(WorkflowElementTypesMcpResourceHandler).inSingletonScope(); + rebind(CreateNodesMcpToolHandler).to(WorkflowCreateNodesMcpToolHandler).inSingletonScope(); + rebind(ModifyNodesMcpToolHandler).to(WorkflowModifyNodesMcpToolHandler).inSingletonScope(); + bindAsService(bind, McpToolHandler, RequestLayoutMcpToolHandler); + }); +} diff --git a/examples/workflow-server/src/common/mcp/workflow-modify-nodes-handler.ts b/examples/workflow-server/src/common/mcp/workflow-modify-nodes-handler.ts new file mode 100644 index 0000000..c8684f5 --- /dev/null +++ b/examples/workflow-server/src/common/mcp/workflow-modify-nodes-handler.ts @@ -0,0 +1,34 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { GLabel, GShapeElement } from '@eclipse-glsp/server'; +import { ModifyNodesMcpToolHandler } from '@eclipse-glsp/server-mcp'; +import { injectable } from 'inversify'; +import { ModelTypes } from '../util/model-types'; + +@injectable() +export class WorkflowModifyNodesMcpToolHandler extends ModifyNodesMcpToolHandler { + override getCorrespondingLabelId(element: GShapeElement): string | undefined { + // Category labels are nested in a header component + if (element.type === ModelTypes.CATEGORY) { + return element.children.find(child => child.type === ModelTypes.COMP_HEADER)?.children.find(child => child instanceof GLabel) + ?.id; + } + + // Assume that generally, labelled nodes have those labels as direct children + return element.children.find(child => child instanceof GLabel)?.id; + } +} diff --git a/examples/workflow-server/src/node/app.ts b/examples/workflow-server/src/node/app.ts index 3a659bc..24fb002 100644 --- a/examples/workflow-server/src/node/app.ts +++ b/examples/workflow-server/src/node/app.ts @@ -19,8 +19,9 @@ import { configureELKLayoutModule } from '@eclipse-glsp/layout-elk'; import { GModelStorage, Logger, SocketServerLauncher, WebSocketServerLauncher, createAppModule } from '@eclipse-glsp/server/node'; import { Container } from 'inversify'; -import { configureMcpModule } from '@eclipse-glsp/server-mcp'; +import { configureMcpInitModule, configureMcpServerModule } from '@eclipse-glsp/server-mcp'; import { WorkflowLayoutConfigurator } from '../common/layout/workflow-layout-configurator'; +import { configureWorfklowMcpModule } from '../common/mcp/workflow-mcp-module'; import { WorkflowDiagramModule, WorkflowServerModule } from '../common/workflow-diagram-module'; import { createWorkflowCliParser } from './workflow-cli-parser'; @@ -40,15 +41,19 @@ async function launch(argv?: string[]): Promise { }); const elkLayoutModule = configureELKLayoutModule({ algorithms: ['layered'], layoutConfigurator: WorkflowLayoutConfigurator }); - const serverModule = new WorkflowServerModule().configureDiagramModule(new WorkflowDiagramModule(() => GModelStorage), elkLayoutModule); - const mcpModule = configureMcpModule(); + const serverModule = new WorkflowServerModule().configureDiagramModule( + new WorkflowDiagramModule(() => GModelStorage), + elkLayoutModule, + configureMcpInitModule() // needs to be part of `configureDiagramModule` to ensure correct initialization + ); + const mcpModule = configureMcpServerModule(); // must not be part of `configureDiagramModule` to ensure MCP server launch if (options.webSocket) { const launcher = appContainer.resolve(WebSocketServerLauncher); - launcher.configure(serverModule, mcpModule); + launcher.configure(serverModule, mcpModule, configureWorfklowMcpModule()); await launcher.start({ port: options.port, host: options.host, path: 'workflow' }); } else { const launcher = appContainer.resolve(SocketServerLauncher); - launcher.configure(serverModule, mcpModule); + launcher.configure(serverModule, mcpModule, configureWorfklowMcpModule()); await launcher.start({ port: options.port, host: options.host }); } } diff --git a/packages/server-mcp/README.md b/packages/server-mcp/README.md new file mode 100644 index 0000000..a7d7165 --- /dev/null +++ b/packages/server-mcp/README.md @@ -0,0 +1,474 @@ +# @eclipse-glsp/server-mcp + +An extension of the [GLSP Node Server](https://github.com/eclipse-glsp/glsp-server-node) that exposes a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server alongside the existing GLSP server. This allows AI agents and LLM-based tools to interact with graphical diagram models using the standardized MCP interface. + +## Table of Contents + +- [Purpose](#purpose) +- [Usage and Extension](#usage-and-extension) + - [Installation](#installation) + - [Integrating into a GLSP Server](#integrating-into-a-glsp-server) + - [Server Configuration](#server-configuration) + - [Extending with Custom Handlers](#extending-with-custom-handlers) + - [Adding a Custom Tool](#adding-a-custom-tool) + - [Adding a Custom Resource](#adding-a-custom-resource) + - [Overriding an Existing Handler](#overriding-an-existing-handler) + - [Resource vs. Tool Mode](#resource-vs-tool-mode) + - [ID Aliasing](#id-aliasing) +- [Tools and Resources Reference](#tools-and-resources-reference) + - [Resources](#resources) + - [Tools](#tools) + +--- + +## Purpose + +This package bridges GLSP-based graphical modeling environments with AI agents through the Model Context Protocol. Once integrated into a GLSP Node server, it starts an HTTP-based MCP server that provides: + +- **Resources** — Read-only structured data about active sessions, diagram models, element types, and diagram screenshots. +- **Tools** — Read/write operations to create, modify, delete, validate, and navigate diagram elements. + +However, depending on the used parameters those resources are exposed as MCP tools as well, because not every client is able to deal with MCP resources properly. + +The MCP server is initialized as part of the GLSP server startup sequence and creates a new MCP session for each connecting MCP client. Each session runs a preconfigured AI agent persona (the _GLSP Modeling Agent_) that guides AI clients toward correct and safe usage of the modeling tools. It should be noted that the server startup sequence does not mean simply starting a server process, but rather that some kind of GLSP client starts the initialization. + +--- + +## Usage and Extension + +### Installation + +```bash +yarn add @eclipse-glsp/server-mcp +# or +npm install @eclipse-glsp/server-mcp +``` + +### Integrating into a GLSP Server + +Load the MCP container module in your GLSP server's DI configuration: + +```typescript +import { GModelStorage, WebSocketServerLauncher, createAppModule } from '@eclipse-glsp/server/node'; +import { Container } from 'inversify'; +import { configureMcpInitModule, configureMcpServerModule } from '@eclipse-glsp/server-mcp'; + +const appContainer = new Container(); +appContainer.load(createAppModule(options)); + +const mcpInitModule = configureMcpInitModule(); // needs to be part of `configureDiagramModule` to ensure correct initialization +const serverModule = new MyServerModule().configureDiagramModule(new MyDiagramModule(() => GModelStorage), mcpInitModule); + +const launcher = appContainer.resolve(WebSocketServerLauncher); +const mcpModule = configureMcpServerModule(); // must not be part of `configureDiagramModule` to ensure MCP server launch +launcher.configure(serverModule, mcpModule); +``` + +The `configureInitModule()` is required to wire up the server-to-client action handlers that enable the `diagram-png` and `get-selection` features. + +### Server Configuration + +The MCP server is configured through the GLSP `InitializeParameters`. The client (e.g., a Theia or VS Code extension) must include an `McpServerConfiguration` object in the initialize request parameters under the key `mcpServer`. + +The following options are supported with their defaults: + +| Option | Type | Default | Description | +| ------------------- | --------- | ----------------- | -------------------------------------------------------------------------------- | +| `port` | `number` | `60000` | Port the MCP HTTP server listens on | +| `host` | `string` | `'127.0.0.1'` | Host/interface the server binds to | +| `route` | `string` | `'/glsp-mcp'` | HTTP route path for the MCP endpoint | +| `name` | `string` | `'glspMcpServer'` | Name reported in the MCP server handshake | +| `options.resources` | `boolean` | `false` | Whether to expose data handlers as MCP **resources** (true) or **tools** (false) | +| `options.aliasIds` | `boolean` | `true` | Whether to replace raw IDs with shorter integer aliases in all responses | + +Once started, the server URL is reported back in the `InitializeResult` under `result.mcpServer.url`, e.g. `http://127.0.0.1:60000/glsp-mcp`. + +The MCP server uses the **Streamable HTTP transport** and supports session resumability via `mcp-session-id` headers (GET for SSE, POST for RPC, DELETE for session termination). + +### Extending with Custom Handlers + +All handlers are registered through InversifyJS DI. The two extension points are `McpToolHandler` and `McpResourceHandler`. When bound against the symbols of the same name, then they will be automatically discovered and registered. + +#### Adding a Custom Tool + +```typescript +import { inject, injectable } from 'inversify'; +import { McpToolHandler, GLSPMcpServer } from '@eclipse-glsp/server-mcp'; +import { ClientSessionManager, ModelState } from '@eclipse-glsp/server'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types'; +import * as z from 'zod/v4'; + +@injectable() +export class MyCustomMcpToolHandler implements McpToolHandler { + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + registerTool(server: GLSPMcpServer): void { + server.registerTool( + 'my-tool', + { + description: 'Description of what this tool does.', + inputSchema: { + sessionId: z.string().describe('Session ID'), + myParam: z.string().describe('Some parameter') + } + }, + params => this.handle(params) + ); + } + + async handle(params: { sessionId: string; myParam: string }): Promise { + // access relevant services via the session container + const session = this.clientSessionManager.getSession(sessionId); + const modelState = session.container.get(ModelState); + + // ... implement tool logic ... + return { isError: false, content: [{ type: 'text', text: 'Done' }] }; + } +} + +// In your ContainerModule: +import { bindAsService } from '@eclipse-glsp/server'; + +bindAsService(bind, McpToolHandler, MyCustomMcpToolHandler); +``` + +#### Adding a Custom Resource + +```typescript +import { inject, injectable } from 'inversify'; +import { + McpResourceHandler, + GLSPMcpServer, + ResourceHandlerResult, + createResourceResult, + createResourceToolResult +} from '@eclipse-glsp/server-mcp'; + +@injectable() +export class MyCustomMcpResourceHandler implements McpResourceHandler { + registerResource(server: GLSPMcpServer): void { + server.registerResource( + 'my-resource', + 'glsp://my-resource', + { title: 'My Resource', description: 'A custom resource', mimeType: 'text/markdown' }, + async () => createResourceResult(await this.handle({})) + ); + } + + registerTool(server: GLSPMcpServer): void { + server.registerTool('my-resource', { description: '...' }, async () => createResourceToolResult(await this.handle({}))); + } + + async handle(params: Record): Promise { + return { + content: { uri: 'glsp://my-resource', mimeType: 'text/markdown', text: '# My Resource' }, + isError: false + }; + } +} + +// In your ContainerModule: +import { bindAsService } from '@eclipse-glsp/server'; + +bindAsService(bind, McpResourceHandler, MyCustomMcpResourceHandler); +``` + +#### Overriding an Existing Handler + +Use InversifyJS `rebind` to replace a built-in handler with your own subclass: + +```typescript +// Override a tool handler +rebind(CreateNodesMcpToolHandler).to(MyCreateNodesMcpToolHandler).inSingletonScope(); + +// Override a resource handler +rebind(SessionsListMcpResourceHandler).to(MySessionsListMcpResourceHandler).inSingletonScope(); +``` + +### Resource vs. Tool Mode + +By default (`options.resources: false`), all data handlers (sessions list, element types, diagram model, diagram PNG) are registered as **tools**. This maximizes compatibility with MCP clients that do not yet support the resources protocol. + +Set `options.resources: true` to register these handlers as proper MCP **resources** instead, which enables URI-based access patterns such as `glsp://sessions` or `glsp://diagrams/{sessionId}/model`. + +### ID Aliasing + +When `options.aliasIds: true` (default), the server replaces verbose element IDs (e.g., UUIDs) with short integer strings (e.g., `"1"`, `"2"`) in all tool and resource responses. This reduces token consumption when working with large models. The aliases are scoped per session and are resolved back to real IDs transparently before any operation is dispatched. + +However, this means that there is certain caution required when implementing MCP handlers, as it is the responsibility of the developer to ensure that the IDs are correctly resolved. In general, each received ID is an alias that needs to be mapped back to the real ID, while for each ID that is part of the response, an alias has to be created first. + +```typescript +// Get the `McpIdAliasService` from the session container +const mcpIdAliasService = session.container.get(McpIdAliasService); + +// Look-up the real ID +const realId = mcpIdAliasService.lookup(sessionId, aliasId); + +// Alias a real ID +const aliasId = mcpIdAliasService.alias(sessionId, realId); +``` + +--- + +## Tools and Resources Reference + +### Resources + +Resources are URI-addressable read-only data endpoints. When `options.resources` is `false` (default), they are exposed as tools instead — see [Resource vs. Tool Mode](#resource-vs-tool-mode). + +| Name | URI | MIME Type | Description | +| --------------- | ------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `sessions-list` | `glsp://sessions` | `text/markdown` | Lists all active GLSP client sessions with their session ID, diagram type, source URI, and read-only status. | +| `element-types` | `glsp://types/{diagramType}/elements` | `text/markdown` | Lists all creatable node and edge types for a given diagram type. Requires at least one active session of that type. Returns a Markdown table of type IDs and labels. | +| `diagram-model` | `glsp://diagrams/{sessionId}/model` | `text/markdown` | Returns the complete serialized GLSP model for a session as a Markdown structure, including all nodes, edges, and their properties. | +| `diagram-png` | `glsp://diagrams/{sessionId}/png` | `image/png` | Returns a base64-encoded PNG screenshot of the current diagram state. Requires a connected frontend client. Times out after 5 seconds if no response is received. | + +--- + +### Tools + +All tools that modify the diagram require an explicit user approval step (as noted in their descriptions). Tools that query state are read-only. + +#### `sessions-list` + +Lists all active GLSP client sessions. + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| _(none)_ | | | | + +**Returns:** Markdown table with columns `sessionId`, `diagramType`, `sourceUri`, `readOnly`. + +--- + +#### `element-types` + +Discovers all creatable element type IDs for a diagram type. + +| Parameter | Type | Required | Description | +| ------------- | -------- | -------- | ---------------------------------------------- | +| `diagramType` | `string` | Yes | The diagram type to query (e.g., `"workflow"`) | + +**Returns:** Markdown document with separate tables for node types and edge types, each with columns `id` and `label`. + +--- + +#### `diagram-model` + +Retrieves the complete serialized model for a session. + +| Parameter | Type | Required | Description | +| ----------- | -------- | -------- | ------------------- | +| `sessionId` | `string` | Yes | Session ID to query | + +**Returns:** Markdown-formatted model tree including all element IDs, types, bounds, and properties. + +--- + +#### `diagram-png` + +Renders and returns a PNG screenshot of the diagram. + +| Parameter | Type | Required | Description | +| ----------- | -------- | -------- | -------------------- | +| `sessionId` | `string` | Yes | Session ID to render | + +**Returns:** PNG image data. Times out after 5 seconds if the frontend does not respond. + +--- + +#### `diagram-elements` + +Retrieves serialized details for specific diagram elements. + +| Parameter | Type | Required | Description | +| ------------ | ---------- | -------- | ---------------------------------- | +| `sessionId` | `string` | Yes | Session ID containing the elements | +| `elementIds` | `string[]` | Yes | One or more element IDs to query | + +**Returns:** Markdown-formatted representation of the requested elements and their properties. + +--- + +#### `create-nodes` + +Creates one or more new nodes in the diagram. + +| Parameter | Type | Required | Description | +| ----------------------- | -------------------------- | -------- | ----------------------------------------------------------------------- | +| `sessionId` | `string` | Yes | Session ID where nodes should be created | +| `nodes` | `object[]` | Yes | Array of node descriptors (minimum 1) | +| `nodes[].elementTypeId` | `string` | Yes | Element type ID (use `element-types` to discover valid IDs) | +| `nodes[].position` | `{ x: number, y: number }` | Yes | Absolute diagram coordinates | +| `nodes[].text` | `string` | No | Initial label text (if the type supports labels) | +| `nodes[].containerId` | `string` | No | ID of a container/parent element; uses the diagram root if not provided | +| `nodes[].args` | `Record` | No | Additional type-specific creation arguments | + +**Returns:** List of newly created element IDs and any errors for failed creations. + +--- + +#### `create-edges` + +Creates one or more new edges connecting diagram elements. + +| Parameter | Type | Required | Description | +| ------------------------- | ---------------------------- | -------- | -------------------------------------------------------- | +| `sessionId` | `string` | Yes | Session ID where edges should be created | +| `edges` | `object[]` | Yes | Array of edge descriptors (minimum 1) | +| `edges[].elementTypeId` | `string` | Yes | Edge type ID (use `element-types` to discover valid IDs) | +| `edges[].sourceElementId` | `string` | Yes | ID of the source element | +| `edges[].targetElementId` | `string` | Yes | ID of the target element | +| `edges[].routingPoints` | `{ x: number, y: number }[]` | No | Optional intermediate routing points | +| `edges[].args` | `Record` | No | Additional type-specific creation arguments | + +**Returns:** List of newly created edge IDs and any errors for failed creations. + +--- + +#### `delete-elements` + +Deletes one or more elements (nodes or edges) from the diagram. Dependent elements (e.g., edges connected to a deleted node) are removed automatically. + +| Parameter | Type | Required | Description | +| ------------ | ---------- | -------- | ------------------------------------------- | +| `sessionId` | `string` | Yes | Session ID where elements should be deleted | +| `elementIds` | `string[]` | Yes | Array of element IDs to delete (minimum 1) | + +**Returns:** Count of deleted elements including automatically removed dependents. + +--- + +#### `modify-nodes` + +Modifies position, size, and/or label of one or more existing nodes. + +| Parameter | Type | Required | Description | +| --------------------- | ----------------------------------- | -------- | ----------------------------------------------- | +| `sessionId` | `string` | Yes | Session ID | +| `changes` | `object[]` | Yes | Array of change descriptors (minimum 1) | +| `changes[].elementId` | `string` | Yes | ID of the node to modify | +| `changes[].position` | `{ x: number, y: number }` | No | New absolute position | +| `changes[].size` | `{ width: number, height: number }` | No | New size | +| `changes[].text` | `string` | No | New label text (if the element supports labels) | + +**Returns:** Number of modified nodes and commands dispatched. + +--- + +#### `modify-edges` + +Modifies the source/target connection or routing points of one or more existing edges. + +| Parameter | Type | Required | Description | +| --------------------------- | ---------------------------- | -------- | ------------------------------------------------------------------------ | +| `sessionId` | `string` | Yes | Session ID | +| `changes` | `object[]` | Yes | Array of change descriptors (minimum 1) | +| `changes[].elementId` | `string` | Yes | ID of the edge to modify | +| `changes[].sourceElementId` | `string` | No | New source element ID (must be provided together with `targetElementId`) | +| `changes[].targetElementId` | `string` | No | New target element ID (must be provided together with `sourceElementId`) | +| `changes[].routingPoints` | `{ x: number, y: number }[]` | No | New routing points; an empty array removes all routing points | + +**Note:** Reconnection (`sourceElementId`/`targetElementId`) and routing point changes are mutually exclusive per edge change entry. + +**Returns:** Number of successfully modified edges and any errors. + +--- + +#### `save-model` + +Saves the diagram model to persistent storage. + +| Parameter | Type | Required | Description | +| ----------- | -------- | -------- | --------------------------------------------------------------------------- | +| `sessionId` | `string` | Yes | Session ID where the model should be saved | +| `fileUri` | `string` | No | Optional destination URI; if omitted, saves to the original source location | + +**Returns:** Success message or `"No changes to save"` if the model is not dirty. + +--- + +#### `validate-diagram` + +Runs validation on specific elements or the entire model and returns markers. + +| Parameter | Type | Required | Description | +| ------------ | ------------------- | -------- | ------------------------------------------------------------------------------------- | +| `sessionId` | `string` | Yes | Session ID to validate | +| `elementIds` | `string[]` | No | Element IDs to validate; if omitted, validates the entire model from the root | +| `reason` | `"batch" \| "live"` | No | Validation mode: `"batch"` for thorough, `"live"` for incremental (default: `"live"`) | + +**Returns:** Markdown table of validation markers with severity (error/warning/info), element ID, and message. + +**Note:** Returns an error if no `ModelValidator` is configured for the diagram type. + +--- + +#### `undo` + +Undoes one or more recent commands on the command stack. + +| Parameter | Type | Required | Description | +| ---------------- | -------- | -------- | -------------------------------------- | +| `sessionId` | `string` | Yes | Session ID | +| `commandsToUndo` | `number` | Yes | Number of commands to undo (minimum 1) | + +**Returns:** `"Undo successful"` or an error if nothing can be undone. + +--- + +#### `redo` + +Reapplies one or more previously undone commands. + +| Parameter | Type | Required | Description | +| ---------------- | -------- | -------- | -------------------------------------- | +| `sessionId` | `string` | Yes | Session ID | +| `commandsToRedo` | `number` | Yes | Number of commands to redo (minimum 1) | + +**Returns:** `"Redo successful"` or an error if nothing can be redone. + +--- + +#### `get-selection` + +Queries the element IDs of all currently selected elements in the connected UI. + +| Parameter | Type | Required | Description | +| ----------- | -------- | -------- | ------------------- | +| `sessionId` | `string` | Yes | Session ID to query | + +**Returns:** List of selected element IDs. Times out after 5 seconds if the frontend does not respond. + +--- + +#### `change-view` + +Changes the viewport of the session's associated UI client. + +| Parameter | Type | Required | Description | +| ---------------- | ------------------------------------------------------------- | -------- | -------------------------------------------------------------------- | +| `sessionId` | `string` | Yes | Session ID | +| `viewportAction` | `"fit-to-screen" \| "center-on-elements" \| "reset-viewport"` | Yes | The viewport change to apply | +| `elementIds` | `string[]` | No | Elements to center on or fit; if omitted, the entire diagram is used | + +**Returns:** `"Viewport successfully changed"` or an error. + +--- + +### Not Registered by Default + +#### `request-layout` _(optional)_ + +Triggers automatic layout computation for the diagram. This tool is **not registered by default** because it requires a `LayoutEngine` to be present in the specific GLSP implementation. To enable it, bind `RequestLayoutMcpToolHandler` as an `McpToolHandler` in your container module. + +```typescript +bindAsService(bind, McpToolHandler, RequestLayoutMcpToolHandler); +``` + +--- + +## License + +EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 diff --git a/packages/server-mcp/src/default-mcp-resource-contribution.ts b/packages/server-mcp/src/default-mcp-resource-contribution.ts deleted file mode 100644 index 83c723e..0000000 --- a/packages/server-mcp/src/default-mcp-resource-contribution.ts +++ /dev/null @@ -1,333 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2025 EclipseSource and others. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * This Source Code may also be made available under the following Secondary - * Licenses when the conditions for such availability set forth in the Eclipse - * Public License v. 2.0 are satisfied: GNU General Public License, version 2 - * with the GNU Classpath Exception which is available at - * https://www.gnu.org/software/classpath/license.html. - * - * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 - ********************************************************************************/ - -import { - ClientSessionManager, - CreateOperationHandler, - DiagramModules, - GModelSerializer, - Logger, - ModelState, - OperationHandlerRegistry -} from '@eclipse-glsp/server'; -import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp'; -import { ReadResourceResult } from '@modelcontextprotocol/sdk/types'; -import { ContainerModule, inject, injectable } from 'inversify'; -import { McpServerContribution } from './mcp-server-contribution'; -import { GLSPMcpServer } from './mcp-server-manager'; -import { extractParam } from './mcp-util'; - -/** - * Default MCP server contribution that provides read-only resources for accessing - * GLSP server state, including sessions, diagram types, element types, - * diagram models, and validation markers. - * - * This contribution can be overridden to customize or extend resource functionality. - */ -@injectable() -export class DefaultMcpResourceContribution implements McpServerContribution { - @inject(Logger) protected logger: Logger; - @inject(DiagramModules) protected diagramModules: Map; - @inject(ClientSessionManager) protected clientSessionManager: ClientSessionManager; - - configure(server: GLSPMcpServer): void { - this.registerSessionsListResource(server); - this.registerSessionInfoResource(server); - this.registerDiagramTypesResource(server); - this.registerElementTypesResource(server); - this.registerDiagramModelResource(server); - } - - protected registerSessionsListResource(server: GLSPMcpServer): void { - server.registerResource( - 'sessions-list', - 'glsp://sessions', - { - title: 'GLSP Sessions List', - description: 'List all active GLSP client sessions across all diagram types', - mimeType: 'application/json' - }, - async () => this.getAllSessions() - ); - } - - protected registerSessionInfoResource(server: GLSPMcpServer): void { - server.registerResource( - 'session-info', - new ResourceTemplate('glsp://sessions/{sessionId}', { - list: async () => { - const sessions = this.clientSessionManager.getSessions(); - return { - resources: sessions.map(session => ({ - uri: `glsp://sessions/${session.id}`, - name: `Session: ${session.id}`, - description: `GLSP session for diagram type: ${session.diagramType}`, - mimeType: 'application/json' - })) - }; - }, - complete: { - sessionId: async () => this.getSessionIds() - } - }), - { - title: 'Session Information', - description: - 'Get detailed metadata for a specific GLSP client session including diagram type, source URI, and edit permissions', - mimeType: 'application/json' - }, - async (_uri, params) => this.getSessionInfo(params) - ); - } - - protected registerDiagramTypesResource(server: GLSPMcpServer): void { - server.registerResource( - 'diagram-types', - 'glsp://types', - { - title: 'Available Diagram Types', - description: 'List all registered GLSP diagram types (e.g., workflow, class-diagram, state-machine)', - mimeType: 'application/json' - }, - async () => this.getDiagramTypesList() - ); - } - - protected registerElementTypesResource(server: GLSPMcpServer): void { - server.registerResource( - 'element-types', - new ResourceTemplate('glsp://types/{diagramType}/elements', { - list: async () => { - const diagramTypes = Array.from(this.diagramModules.keys()); - return { - resources: diagramTypes.map(type => ({ - uri: `glsp://types/${type}/elements`, - name: `Element Types: ${type}`, - description: `Creatable element types for ${type} diagrams`, - mimeType: 'application/json' - })) - }; - }, - complete: { - diagramType: async () => this.getDiagramTypes() - } - }), - { - title: 'Creatable Element Types', - description: - 'List all element types (nodes and edges) that can be created for a specific diagram type. ' + - 'Use this to discover valid elementTypeId values for creation tools.', - mimeType: 'application/json' - }, - async (_uri, params) => this.getElementTypes(params) - ); - } - - protected registerDiagramModelResource(server: GLSPMcpServer): void { - server.registerResource( - 'diagram-model', - new ResourceTemplate('glsp://diagrams/{sessionId}/model', { - list: async () => { - const sessions = this.clientSessionManager.getSessions(); - return { - resources: sessions.map(session => ({ - uri: `glsp://diagrams/${session.id}/model`, - name: `Diagram Model: ${session.id}`, - description: `Complete GLSP model structure for session ${session.id}`, - mimeType: 'application/json' - })) - }; - }, - complete: { - sessionId: async () => this.getSessionIds() - } - }), - { - title: 'Diagram Model Structure', - description: - 'Get the complete GLSP model (GModelRoot) for a session as a JSON structure. ' + - 'Includes all nodes, edges, and their properties.', - mimeType: 'application/json' - }, - async (_uri, params) => this.getDiagramModel(params) - ); - } - - // --- Resource Handlers --- - - protected async getAllSessions(): Promise { - const sessions = this.clientSessionManager.getSessions(); - const sessionsList = sessions.map(session => { - const modelState = session.container.get(ModelState); - return { - sessionId: session.id, - diagramType: session.diagramType, - sourceUri: modelState.sourceUri, - readOnly: modelState.isReadonly - }; - }); - - return { - contents: [ - { - uri: 'glsp://sessions', - mimeType: 'application/json', - text: JSON.stringify({ sessions: sessionsList }, undefined, 2) - } - ] - }; - } - - protected async getSessionInfo(params: Record): Promise { - const sessionId = extractParam(params, 'sessionId'); - if (!sessionId) { - return { contents: [] }; - } - - const session = this.clientSessionManager.getSession(sessionId); - if (!session) { - return { contents: [] }; - } - - const modelState = session.container.get(ModelState); - const sessionInfo = { - sessionId: session.id, - diagramType: session.diagramType, - sourceUri: modelState.sourceUri, - readOnly: modelState.isReadonly - }; - - return { - contents: [ - { - uri: `glsp://sessions/${sessionId}`, - mimeType: 'application/json', - text: JSON.stringify(sessionInfo, undefined, 2) - } - ] - }; - } - - protected async getDiagramTypesList(): Promise { - const diagramTypes = Array.from(this.diagramModules.keys()); - - return { - contents: [ - { - uri: 'glsp://types', - mimeType: 'application/json', - text: JSON.stringify({ diagramTypes }, undefined, 2) - } - ] - }; - } - - protected async getElementTypes(params: Record): Promise { - const diagramType = extractParam(params, 'diagramType'); - if (!diagramType) { - return { contents: [] }; - } - - // Try to get a session of this diagram type to access the operation handler registry - const sessions = this.clientSessionManager.getSessionsByType(diagramType); - if (sessions.length === 0) { - return { - contents: [ - { - uri: `glsp://types/${diagramType}/elements`, - mimeType: 'application/json', - text: JSON.stringify( - { - diagramType, - nodeTypes: [], - edgeTypes: [], - message: 'No active session found for this diagram type. Create a session first to discover element types.' - }, - undefined, - 2 - ) - } - ] - }; - } - - const session = sessions[0]; - const registry = session.container.get(OperationHandlerRegistry); - - const nodeTypes: Array<{ id: string; label: string }> = []; - const edgeTypes: Array<{ id: string; label: string }> = []; - - for (const key of registry.keys()) { - const handler = registry.get(key); - if (handler && CreateOperationHandler.is(handler)) { - if (key.startsWith('createNode_')) { - const elementTypeId = key.substring('createNode_'.length); - nodeTypes.push({ id: elementTypeId, label: elementTypeId }); - } else if (key.startsWith('createEdge_')) { - const elementTypeId = key.substring('createEdge_'.length); - edgeTypes.push({ id: elementTypeId, label: elementTypeId }); - } - } - } - - return { - contents: [ - { - uri: `glsp://types/${diagramType}/elements`, - mimeType: 'application/json', - text: JSON.stringify({ diagramType, nodeTypes, edgeTypes }, undefined, 2) - } - ] - }; - } - - protected async getDiagramModel(params: Record): Promise { - const sessionId = extractParam(params, 'sessionId'); - if (!sessionId) { - return { contents: [] }; - } - - const session = this.clientSessionManager.getSession(sessionId); - if (!session) { - return { contents: [] }; - } - - const modelState = session.container.get(ModelState); - const serializer = session.container.get(GModelSerializer); - - const schema = serializer.createSchema(modelState.root); - - return { - contents: [ - { - uri: `glsp://diagrams/${sessionId}/model`, - mimeType: 'application/json', - text: JSON.stringify(schema, undefined, 2) - } - ] - }; - } - - // --- Utility Methods --- - - protected async getSessionIds(): Promise { - return this.clientSessionManager.getSessions().map(s => s.id); - } - - protected async getDiagramTypes(): Promise { - return Array.from(this.diagramModules.keys()); - } -} diff --git a/packages/server-mcp/src/default-mcp-tool-contribution.ts b/packages/server-mcp/src/default-mcp-tool-contribution.ts deleted file mode 100644 index 6ae5453..0000000 --- a/packages/server-mcp/src/default-mcp-tool-contribution.ts +++ /dev/null @@ -1,562 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2025 EclipseSource and others. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * This Source Code may also be made available under the following Secondary - * Licenses when the conditions for such availability set forth in the Eclipse - * Public License v. 2.0 are satisfied: GNU General Public License, version 2 - * with the GNU Classpath Exception which is available at - * https://www.gnu.org/software/classpath/license.html. - * - * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 - ********************************************************************************/ - -import { DeleteElementOperation, MarkersReason, RedoAction, SaveModelAction, UndoAction } from '@eclipse-glsp/protocol'; -import { - ClientSessionManager, - CommandStack, - CreateEdgeOperation, - CreateNodeOperation, - Logger, - ModelState, - ModelValidator -} from '@eclipse-glsp/server'; -import { CallToolResult } from '@modelcontextprotocol/sdk/types'; -import { inject, injectable } from 'inversify'; -import * as z from 'zod/v4'; -import { McpServerContribution } from './mcp-server-contribution'; -import { GLSPMcpServer } from './mcp-server-manager'; -import { createToolError, createToolSuccess } from './mcp-util'; - -/** - * Default MCP server contribution that provides tools for performing actions on - * GLSP diagrams, including validation and element creation. - * - * This contribution can be overridden to customize or extend tool functionality. - */ -@injectable() -export class DefaultMcpToolContribution implements McpServerContribution { - @inject(Logger) protected logger: Logger; - @inject(ClientSessionManager) protected clientSessionManager: ClientSessionManager; - - configure(server: GLSPMcpServer): void { - this.registerValidateDiagramTool(server); - this.registerCreateNodeTool(server); - this.registerCreateEdgeTool(server); - this.registerDeleteElementTool(server); - this.registerUndoTool(server); - this.registerRedoTool(server); - this.registerSaveTool(server); - } - - protected registerValidateDiagramTool(server: GLSPMcpServer): void { - server.registerTool( - 'validate-diagram', - { - description: - 'Validate diagram elements and return validation markers (errors, warnings, info). ' + - 'Triggers active validation computation. Use elementIds parameter to validate specific elements, ' + - 'or omit to validate the entire model.', - inputSchema: { - sessionId: z.string().describe('Session ID to validate'), - elementIds: z - .array(z.string()) - .optional() - .describe('Array of element IDs to validate. If not provided, validates entire model starting from root.'), - reason: z - .enum([MarkersReason.BATCH, MarkersReason.LIVE]) - .optional() - .default(MarkersReason.LIVE) - .describe('Validation reason: "batch" for thorough validation, "live" for quick incremental checks') - }, - outputSchema: z.object({ - success: z.boolean().describe('Whether validation completed successfully'), - markers: z - .array( - z.object({ - label: z.string().describe('Short label for the validation issue'), - description: z.string().describe('Full description of the validation issue'), - elementId: z.string().describe('ID of the element with the issue'), - kind: z.enum(['info', 'warning', 'error']).describe('Severity of the validation issue') - }) - ) - .describe('Array of validation markers found'), - message: z.string().optional().describe('Additional information (e.g., "No validator configured")') - }) - }, - params => this.validateDiagram(params) - ); - } - - protected registerCreateNodeTool(server: GLSPMcpServer): void { - server.registerTool( - 'create-node', - { - description: - 'Create a new node element in the diagram at a specified location. ' + - 'This operation modifies the diagram state and requires user approval. ' + - 'Query glsp://types/{diagramType}/elements resource to discover valid element type IDs.', - inputSchema: { - sessionId: z.string().describe('Session ID where the node should be created'), - elementTypeId: z - .string() - .describe( - 'Element type ID (e.g., "task:manual", "task:automated"). ' + - 'Use element-types resource to discover valid IDs.' - ), - location: z - .object({ - x: z.number().describe('X coordinate in diagram space'), - y: z.number().describe('Y coordinate in diagram space') - }) - .describe('Position where the node should be created (absolute diagram coordinates)'), - containerId: z.string().optional().describe('ID of the container element. If not provided, node is added to the root.'), - args: z - .record(z.string(), z.any()) - .optional() - .describe('Additional type-specific arguments for node creation (varies by element type)') - }, - outputSchema: z.object({ - success: z.boolean().describe('Whether node creation succeeded'), - elementId: z.string().optional().describe('ID of the newly created node'), - message: z.string().describe('Success or error message'), - error: z.string().optional().describe('Error message if operation failed'), - details: z.any().optional().describe('Additional error details') - }) - }, - params => this.createNode(params) - ); - } - - protected registerCreateEdgeTool(server: GLSPMcpServer): void { - server.registerTool( - 'create-edge', - { - description: - 'Create a new edge connecting two elements in the diagram. ' + - 'This operation modifies the diagram state and requires user approval. ' + - 'Query glsp://types/{diagramType}/elements resource to discover valid edge type IDs.', - inputSchema: { - sessionId: z.string().describe('Session ID where the edge should be created'), - elementTypeId: z - .string() - .describe('Edge type ID (e.g., "edge", "transition"). Use element-types resource to discover valid IDs.'), - sourceElementId: z.string().describe('ID of the source element (must exist in the diagram)'), - targetElementId: z.string().describe('ID of the target element (must exist in the diagram)'), - args: z - .record(z.string(), z.any()) - .optional() - .describe('Additional type-specific arguments for edge creation (varies by edge type)') - }, - outputSchema: z.object({ - success: z.boolean().describe('Whether edge creation succeeded'), - elementId: z.string().optional().describe('ID of the newly created edge'), - message: z.string().describe('Success or error message'), - error: z.string().optional().describe('Error message if operation failed'), - details: z.any().optional().describe('Additional error details') - }) - }, - params => this.createEdge(params) - ); - } - - // --- Tool Handlers --- - - protected async validateDiagram(params: any): Promise { - const { sessionId, elementIds, reason } = params; - - try { - const session = this.clientSessionManager.getSession(sessionId); - if (!session) { - return createToolError('Session not found', { sessionId }); - } - - const modelState = session.container.get(ModelState); - - // Try to get ModelValidator (it's optional) - let validator: ModelValidator | undefined; - try { - validator = session.container.get(ModelValidator); - } catch (error) { - // No validator bound - this is acceptable - } - - if (!validator) { - return createToolSuccess({ - markers: [], - message: 'No validator configured for this diagram type' - }); - } - - // Determine which elements to validate - const idsToValidate = elementIds && elementIds.length > 0 ? elementIds : [modelState.root.id]; - - // Get elements from index - const elements = modelState.index.getAll(idsToValidate); - - // Run validation - const markers = await validator.validate(elements, reason ?? MarkersReason.BATCH); - - return createToolSuccess({ markers }); - } catch (error) { - this.logger.error('Validation failed', error); - return createToolError('Validation failed', { message: error instanceof Error ? error.message : String(error) }); - } - } - - protected async createNode(params: any): Promise { - const { sessionId, elementTypeId, location, containerId, args } = params; - - try { - const session = this.clientSessionManager.getSession(sessionId); - if (!session) { - return createToolError('Session not found', { sessionId }); - } - - const modelState = session.container.get(ModelState); - - // Check if model is readonly - if (modelState.isReadonly) { - return createToolError('Model is read-only', { sessionId }); - } - - // Snapshot element IDs before operation using index.allIds() - const beforeIds = new Set(modelState.index.allIds()); - - // Create operation - const operation = CreateNodeOperation.create(elementTypeId, { location, containerId, args }); - - // Dispatch operation - await session.actionDispatcher.dispatch(operation); - - // Snapshot element IDs after operation - const afterIds = modelState.index.allIds(); - - // Find new element ID - const newIds = afterIds.filter(id => !beforeIds.has(id)); - const newElementId = newIds.length > 0 ? newIds[0] : undefined; - - if (!newElementId) { - return createToolError('Node creation succeeded but could not determine element ID'); - } - - return createToolSuccess({ - elementId: newElementId, - message: 'Node created successfully' - }); - } catch (error) { - this.logger.error('Node creation failed', error); - return createToolError('Node creation failed', { message: error instanceof Error ? error.message : String(error) }); - } - } - - protected async createEdge(params: any): Promise { - const { sessionId, elementTypeId, sourceElementId, targetElementId, args } = params; - - try { - const session = this.clientSessionManager.getSession(sessionId); - if (!session) { - return createToolError('Session not found', { sessionId }); - } - - const modelState = session.container.get(ModelState); - - // Check if model is readonly - if (modelState.isReadonly) { - return createToolError('Model is read-only', { sessionId }); - } - - // Validate source and target exist - const source = modelState.index.find(sourceElementId); - if (!source) { - return createToolError('Source element not found', { sourceElementId }); - } - - const target = modelState.index.find(targetElementId); - if (!target) { - return createToolError('Target element not found', { targetElementId }); - } - - // Snapshot element IDs before operation using index.allIds() - const beforeIds = new Set(modelState.index.allIds()); - - // Create operation - const operation = CreateEdgeOperation.create({ elementTypeId, sourceElementId, targetElementId, args }); - - // Dispatch operation - await session.actionDispatcher.dispatch(operation); - - // Snapshot element IDs after operation - const afterIds = modelState.index.allIds(); - - // Find new element ID - const newIds = afterIds.filter(id => !beforeIds.has(id)); - const newElementId = newIds.length > 0 ? newIds[0] : undefined; - - if (!newElementId) { - return createToolError('Edge creation succeeded but could not determine element ID'); - } - - return createToolSuccess({ - elementId: newElementId, - message: 'Edge created successfully' - }); - } catch (error) { - this.logger.error('Edge creation failed', error); - return createToolError('Edge creation failed', { message: error instanceof Error ? error.message : String(error) }); - } - } - - protected registerDeleteElementTool(server: GLSPMcpServer): void { - server.registerTool( - 'delete-element', - { - description: - 'Delete one or more elements (nodes or edges) from the diagram. ' + - 'This operation modifies the diagram state and requires user approval. ' + - 'Automatically handles dependent elements (e.g., deleting a node also deletes connected edges).', - inputSchema: { - sessionId: z.string().describe('Session ID where the elements should be deleted'), - elementIds: z.array(z.string()).min(1).describe('Array of element IDs to delete. Must include at least one element ID.') - }, - outputSchema: z.object({ - success: z.boolean().describe('Whether element deletion succeeded'), - deletedCount: z.number().optional().describe('Number of elements deleted (including dependents)'), - message: z.string().describe('Success or error message'), - error: z.string().optional().describe('Error message if operation failed'), - details: z.any().optional().describe('Additional error details') - }) - }, - params => this.deleteElement(params) - ); - } - - protected async deleteElement(params: any): Promise { - const { sessionId, elementIds } = params; - - try { - const session = this.clientSessionManager.getSession(sessionId); - if (!session) { - return createToolError('Session not found', { sessionId }); - } - - const modelState = session.container.get(ModelState); - - // Check if model is readonly - if (modelState.isReadonly) { - return createToolError('Model is read-only', { sessionId }); - } - - // Validate elements exist - const missingIds: string[] = []; - for (const elementId of elementIds) { - const element = modelState.index.find(elementId); - if (!element) { - missingIds.push(elementId); - } - } - - if (missingIds.length > 0) { - return createToolError('Some elements not found', { missingIds }); - } - - // Snapshot element count before operation - const beforeCount = modelState.index.allIds().length; - - // Create and dispatch delete operation - const operation = DeleteElementOperation.create(elementIds); - await session.actionDispatcher.dispatch(operation); - - // Calculate how many elements were deleted (including dependents) - const afterCount = modelState.index.allIds().length; - const deletedCount = beforeCount - afterCount; - - return createToolSuccess({ - deletedCount, - message: `Successfully deleted ${deletedCount} element(s) (including dependents)` - }); - } catch (error) { - this.logger.error('Element deletion failed', error); - return createToolError('Element deletion failed', { message: error instanceof Error ? error.message : String(error) }); - } - } - - protected registerUndoTool(server: GLSPMcpServer): void { - server.registerTool( - 'undo', - { - description: 'Undo the last executed command in the diagram. Reverts the most recent change made to the model.', - inputSchema: { - sessionId: z.string().describe('Session ID where undo should be performed') - }, - outputSchema: z.object({ - success: z.boolean().describe('Whether undo succeeded'), - canUndo: z.boolean().describe('Whether there are more commands to undo'), - canRedo: z.boolean().describe('Whether there are commands that can be redone'), - message: z.string().describe('Success or error message'), - error: z.string().optional().describe('Error message if operation failed'), - details: z.any().optional().describe('Additional error details') - }) - }, - params => this.undo(params) - ); - } - - protected async undo(params: any): Promise { - const { sessionId } = params; - - try { - const session = this.clientSessionManager.getSession(sessionId); - if (!session) { - return createToolError('Session not found', { sessionId }); - } - - const modelState = session.container.get(ModelState); - - // Check if model is readonly - if (modelState.isReadonly) { - return createToolError('Model is read-only', { sessionId }); - } - - const commandStack = session.container.get(CommandStack); - - if (!commandStack.canUndo()) { - return createToolError('Nothing to undo', { canUndo: false, canRedo: commandStack.canRedo() }); - } - - // Dispatch undo action - const action = UndoAction.create(); - await session.actionDispatcher.dispatch(action); - - return createToolSuccess({ - canUndo: commandStack.canUndo(), - canRedo: commandStack.canRedo(), - message: 'Undo successful' - }); - } catch (error) { - this.logger.error('Undo failed', error); - return createToolError('Undo failed', { message: error instanceof Error ? error.message : String(error) }); - } - } - - protected registerRedoTool(server: GLSPMcpServer): void { - server.registerTool( - 'redo', - { - description: 'Redo the last undone command in the diagram. Re-applies the most recently undone change.', - inputSchema: { - sessionId: z.string().describe('Session ID where redo should be performed') - }, - outputSchema: z.object({ - success: z.boolean().describe('Whether redo succeeded'), - canUndo: z.boolean().describe('Whether there are commands to undo'), - canRedo: z.boolean().describe('Whether there are more commands that can be redone'), - message: z.string().describe('Success or error message'), - error: z.string().optional().describe('Error message if operation failed'), - details: z.any().optional().describe('Additional error details') - }) - }, - params => this.redo(params) - ); - } - - protected async redo(params: any): Promise { - const { sessionId } = params; - - try { - const session = this.clientSessionManager.getSession(sessionId); - if (!session) { - return createToolError('Session not found', { sessionId }); - } - - const modelState = session.container.get(ModelState); - - // Check if model is readonly - if (modelState.isReadonly) { - return createToolError('Model is read-only', { sessionId }); - } - - const commandStack = session.container.get(CommandStack); - - if (!commandStack.canRedo()) { - return createToolError('Nothing to redo', { canUndo: commandStack.canUndo(), canRedo: false }); - } - - // Dispatch redo action - const action = RedoAction.create(); - await session.actionDispatcher.dispatch(action); - - return createToolSuccess({ - canUndo: commandStack.canUndo(), - canRedo: commandStack.canRedo(), - message: 'Redo successful' - }); - } catch (error) { - this.logger.error('Redo failed', error); - return createToolError('Redo failed', { message: error instanceof Error ? error.message : String(error) }); - } - } - - protected registerSaveTool(server: GLSPMcpServer): void { - server.registerTool( - 'save-model', - { - description: - 'Save the current diagram model to persistent storage. ' + - 'This operation persists all changes back to the source model. ' + - 'Optionally specify a new fileUri to save to a different location.', - inputSchema: { - sessionId: z.string().describe('Session ID where the model should be saved'), - fileUri: z - .string() - .optional() - .describe('Optional destination file URI. If not provided, saves to the original source model location.') - }, - outputSchema: z.object({ - success: z.boolean().describe('Whether save succeeded'), - isDirty: z.boolean().describe('Whether the model is still dirty after save (should be false on success)'), - message: z.string().describe('Success or error message'), - error: z.string().optional().describe('Error message if operation failed'), - details: z.any().optional().describe('Additional error details') - }) - }, - params => this.saveModel(params) - ); - } - - protected async saveModel(params: any): Promise { - const { sessionId, fileUri } = params; - - try { - const session = this.clientSessionManager.getSession(sessionId); - if (!session) { - return createToolError('Session not found', { sessionId }); - } - - const commandStack = session.container.get(CommandStack); - - // Check if there are unsaved changes - if (!commandStack.isDirty) { - return createToolSuccess({ - isDirty: false, - message: 'No changes to save' - }); - } - - // Dispatch save action - const action = SaveModelAction.create({ fileUri }); - await session.actionDispatcher.dispatch(action); - - return createToolSuccess({ - isDirty: commandStack.isDirty, - message: 'Model saved successfully' - }); - } catch (error) { - this.logger.error('Save failed', error); - return createToolError('Save failed', { message: error instanceof Error ? error.message : String(error) }); - } - } -} diff --git a/packages/server-mcp/src/di.config.ts b/packages/server-mcp/src/di.config.ts index a8a7426..c745513 100644 --- a/packages/server-mcp/src/di.config.ts +++ b/packages/server-mcp/src/di.config.ts @@ -13,21 +13,79 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { GLSPServerInitContribution, GLSPServerListener, bindAsService } from '@eclipse-glsp/server'; +import { bindAsService, GLSPServerInitContribution, GLSPServerListener } from '@eclipse-glsp/server'; import { ContainerModule } from 'inversify'; -import { DefaultMcpResourceContribution } from './default-mcp-resource-contribution'; -import { DefaultMcpToolContribution } from './default-mcp-tool-contribution'; -import { McpServerContribution } from './mcp-server-contribution'; -import { McpServerManager } from './mcp-server-manager'; +import { + DefaultMcpModelSerializer, + DiagramModelMcpResourceHandler, + DiagramPngMcpResourceHandler, + ElementTypesMcpResourceHandler, + McpModelSerializer, + McpResourceContribution, + SessionsListMcpResourceHandler +} from './resources'; +import { + DefaultMcpIdAliasService, + DefaultMcpOptionService, + McpIdAliasService, + McpOptionService, + McpOptionServiceContribution, + McpResourceHandler, + McpServerContribution, + McpServerManager, + McpToolHandler +} from './server'; +import { + ChangeViewMcpToolHandler, + CreateEdgesMcpToolHandler, + CreateNodesMcpToolHandler, + DeleteElementsMcpToolHandler, + DiagramElementsMcpToolHandler, + GetSelectionMcpToolHandler, + McpToolContribution, + ModifyEdgesMcpToolHandler, + ModifyNodesMcpToolHandler, + RedoMcpToolHandler, + SaveModelMcpToolHandler, + UndoMcpToolHandler, + ValidateDiagramMcpToolHandler +} from './tools'; -export function configureMcpModule(): ContainerModule { +export function configureMcpServerModule(): ContainerModule { return new ContainerModule(bind => { bind(McpServerManager).toSelf().inSingletonScope(); bind(GLSPServerInitContribution).toService(McpServerManager); bind(GLSPServerListener).toService(McpServerManager); - // Register default MCP contributions for resources and tools - bindAsService(bind, McpServerContribution, DefaultMcpResourceContribution); - bindAsService(bind, McpServerContribution, DefaultMcpToolContribution); + bindAsService(bind, McpOptionService, DefaultMcpOptionService); + bindAsService(bind, McpServerContribution, McpOptionServiceContribution); + + bind(McpIdAliasService).to(DefaultMcpIdAliasService).inSingletonScope(); + + bind(McpModelSerializer).to(DefaultMcpModelSerializer).inSingletonScope(); + + // Resources + bindAsService(bind, McpResourceHandler, SessionsListMcpResourceHandler); + bindAsService(bind, McpResourceHandler, ElementTypesMcpResourceHandler); + bindAsService(bind, McpResourceHandler, DiagramModelMcpResourceHandler); + bindAsService(bind, McpResourceHandler, DiagramPngMcpResourceHandler); + + bindAsService(bind, McpServerContribution, McpResourceContribution); + + // Tools + bindAsService(bind, McpToolHandler, CreateNodesMcpToolHandler); + bindAsService(bind, McpToolHandler, CreateEdgesMcpToolHandler); + bindAsService(bind, McpToolHandler, DeleteElementsMcpToolHandler); + bindAsService(bind, McpToolHandler, SaveModelMcpToolHandler); + bindAsService(bind, McpToolHandler, ValidateDiagramMcpToolHandler); + bindAsService(bind, McpToolHandler, DiagramElementsMcpToolHandler); + bindAsService(bind, McpToolHandler, ModifyNodesMcpToolHandler); + bindAsService(bind, McpToolHandler, ModifyEdgesMcpToolHandler); + bindAsService(bind, McpToolHandler, UndoMcpToolHandler); + bindAsService(bind, McpToolHandler, RedoMcpToolHandler); + bindAsService(bind, McpToolHandler, GetSelectionMcpToolHandler); + bindAsService(bind, McpToolHandler, ChangeViewMcpToolHandler); + + bindAsService(bind, McpServerContribution, McpToolContribution); }); } diff --git a/packages/server-mcp/src/index.ts b/packages/server-mcp/src/index.ts index 174d0b8..206a050 100644 --- a/packages/server-mcp/src/index.ts +++ b/packages/server-mcp/src/index.ts @@ -13,10 +13,9 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -export * from './default-mcp-resource-contribution'; -export * from './default-mcp-tool-contribution'; export * from './di.config'; -export * from './http-server-with-sessions'; -export * from './mcp-server-contribution'; -export * from './mcp-server-manager'; -export * from './mcp-util'; +export * from './init'; +export * from './resources'; +export * from './server'; +export * from './tools'; +export * from './util'; diff --git a/packages/server-mcp/src/init/export-png-action-handler-contribution.ts b/packages/server-mcp/src/init/export-png-action-handler-contribution.ts new file mode 100644 index 0000000..a79b678 --- /dev/null +++ b/packages/server-mcp/src/init/export-png-action-handler-contribution.ts @@ -0,0 +1,38 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { ActionHandlerFactory, ActionHandlerRegistry, Args, ClientSessionInitializer } from '@eclipse-glsp/server'; +import { inject, injectable } from 'inversify'; +import { DiagramPngMcpResourceHandler } from '../resources'; + +/** + * This `ClientSessionInitializer` serves to register an additional `ActionHandler` without needing to extend `ServerModule`. + * + * See `ActionHandlerRegistryInitializer` + */ +@injectable() +export class ExportMcpPngActionHandlerInitContribution implements ClientSessionInitializer { + @inject(ActionHandlerFactory) + protected factory: ActionHandlerFactory; + @inject(ActionHandlerRegistry) + protected registry: ActionHandlerRegistry; + @inject(DiagramPngMcpResourceHandler) + protected pngHandler: DiagramPngMcpResourceHandler; + + initialize(args?: Args): void { + this.registry.registerHandler(this.pngHandler); + } +} diff --git a/packages/server-mcp/src/init/get-selection-action-handler-contribution.ts b/packages/server-mcp/src/init/get-selection-action-handler-contribution.ts new file mode 100644 index 0000000..b321cd8 --- /dev/null +++ b/packages/server-mcp/src/init/get-selection-action-handler-contribution.ts @@ -0,0 +1,38 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { ActionHandlerFactory, ActionHandlerRegistry, Args, ClientSessionInitializer } from '@eclipse-glsp/server'; +import { inject, injectable } from 'inversify'; +import { GetSelectionMcpToolHandler } from '../tools'; + +/** + * This `ClientSessionInitializer` serves to register an additional `ActionHandler` without needing to extend `ServerModule`. + * + * See `ActionHandlerRegistryInitializer` + */ +@injectable() +export class GetSelectionActionHandlerInitContribution implements ClientSessionInitializer { + @inject(ActionHandlerFactory) + protected factory: ActionHandlerFactory; + @inject(ActionHandlerRegistry) + protected registry: ActionHandlerRegistry; + @inject(GetSelectionMcpToolHandler) + protected handler: GetSelectionMcpToolHandler; + + initialize(args?: Args): void { + this.registry.registerHandler(this.handler); + } +} diff --git a/packages/server-mcp/src/mcp-server-contribution.ts b/packages/server-mcp/src/init/index.ts similarity index 58% rename from packages/server-mcp/src/mcp-server-contribution.ts rename to packages/server-mcp/src/init/index.ts index 159166d..5587ae4 100644 --- a/packages/server-mcp/src/mcp-server-contribution.ts +++ b/packages/server-mcp/src/init/index.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2025 EclipseSource and others. + * Copyright (c) 2026 EclipseSource and others. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0 which is available at @@ -14,14 +14,6 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { MaybePromise } from '@eclipse-glsp/server'; -import { CallToolResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types'; -import { GLSPMcpServer } from './mcp-server-manager'; - -export interface McpServerContribution { - configure(server: GLSPMcpServer): MaybePromise; -} -export const McpServerContribution = Symbol('McpServerContribution'); - -export type ToolResultContent = CallToolResult['content'][number]; -export type ResourceResultContent = ReadResourceResult['contents'][number]; +export * from './export-png-action-handler-contribution'; +export * from './get-selection-action-handler-contribution'; +export * from './init-module.config'; diff --git a/packages/server-mcp/src/init/init-module.config.ts b/packages/server-mcp/src/init/init-module.config.ts new file mode 100644 index 0000000..ee96358 --- /dev/null +++ b/packages/server-mcp/src/init/init-module.config.ts @@ -0,0 +1,31 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { bindAsService, ClientSessionInitializer } from '@eclipse-glsp/server'; +import { ContainerModule } from 'inversify'; +import { ExportMcpPngActionHandlerInitContribution } from './export-png-action-handler-contribution'; +import { GetSelectionActionHandlerInitContribution } from './get-selection-action-handler-contribution'; + +/** + * This only exists to inject additional action handlers without interfering too much with the given module hierarchy. + */ +export function configureMcpInitModule(): ContainerModule { + return new ContainerModule(bind => { + bind(ExportMcpPngActionHandlerInitContribution).toSelf().inSingletonScope(); + bind(ClientSessionInitializer).toService(ExportMcpPngActionHandlerInitContribution); + bindAsService(bind, ClientSessionInitializer, GetSelectionActionHandlerInitContribution); + }); +} diff --git a/packages/server-mcp/src/mcp-util.ts b/packages/server-mcp/src/mcp-util.ts deleted file mode 100644 index 1344da6..0000000 --- a/packages/server-mcp/src/mcp-util.ts +++ /dev/null @@ -1,72 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2025 EclipseSource and others. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * This Source Code may also be made available under the following Secondary - * Licenses when the conditions for such availability set forth in the Eclipse - * Public License v. 2.0 are satisfied: GNU General Public License, version 2 - * with the GNU Classpath Exception which is available at - * https://www.gnu.org/software/classpath/license.html. - * - * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 - ********************************************************************************/ - -import { CallToolResult } from '@modelcontextprotocol/sdk/types'; - -/** - * Extracts a single parameter value from MCP resource template parameters. - * Parameters can be either a string or an array of strings. - * - * @param params The parameter record from the resource template - * @param key The parameter key to extract - * @returns The first value if it's an array, or the value directly if it's a string - */ -export function extractParam(params: Record, key: string): string | undefined { - const value = params[key]; - return Array.isArray(value) ? value[0] : value; -} - -/** - * Creates a tool result with both text and structured content. - * This generic function handles both success and error cases consistently. - * - * @param data The data to include in the response - * @returns A CallToolResult with the provided data in both text and structured form - */ -export function createToolResult>(data: T): CallToolResult { - return { - content: [ - { - type: 'text', - text: JSON.stringify(data, undefined, 2) - } - ], - structuredContent: data - }; -} - -/** - * Creates a successful tool result with both text and structured content. - * Includes a `success: true` flag and spreads the provided data. - * - * @param data Additional data to include in the success response - * @returns A CallToolResult with success status and the provided data - */ -export function createToolSuccess = Record>(data: T): CallToolResult { - return createToolResult({ success: true, ...data }); -} - -/** - * Creates an error tool result with both text and structured content. - * Includes a `success: false` flag, error message, and optional details. - * - * @param message The error message - * @param details Optional additional error details - * @returns A CallToolResult with error status and details - */ -export function createToolError = Record>(message: string, details?: T): CallToolResult { - return createToolResult({ success: false, message, error: message, ...(details && { details }) }); -} diff --git a/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts b/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts new file mode 100644 index 0000000..895fbf6 --- /dev/null +++ b/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts @@ -0,0 +1,129 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { ClientSessionManager, Logger, ModelState } from '@eclipse-glsp/server'; +import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { GLSPMcpServer, McpIdAliasService, McpResourceHandler, ResourceHandlerResult } from '../../server'; +import { createResourceResult, createResourceToolResult, extractResourceParam } from '../../util'; +import { McpModelSerializer } from '../services/mcp-model-serializer'; + +/** + * Creates a serialized representation of a given session's model state. + */ +@injectable() +export class DiagramModelMcpResourceHandler implements McpResourceHandler { + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + registerResource(server: GLSPMcpServer): void { + server.registerResource( + 'diagram-model', + new ResourceTemplate('glsp://diagrams/{sessionId}/model', { + list: () => { + const sessionIds = this.getSessionIds(); + return { + resources: sessionIds.map(sessionId => ({ + uri: `glsp://diagrams/${sessionId}/model`, + name: `Diagram Model: ${sessionId}`, + description: `Complete GLSP model structure for session ${sessionId}`, + mimeType: 'text/markdown' + })) + }; + }, + complete: { + sessionId: () => this.getSessionIds() + } + }), + { + title: 'Diagram Model Structure', + description: + 'Get the complete GLSP model for a session as a markdown structure. ' + + 'Includes all nodes, edges, and their relevant properties.', + mimeType: 'text/markdown' + }, + async (_uri, params) => createResourceResult(await this.handle({ sessionId: extractResourceParam(params, 'sessionId') })) + ); + } + + registerTool(server: GLSPMcpServer): void { + server.registerTool( + 'diagram-model', + { + title: 'Diagram Model Structure', + description: + 'Get the complete GLSP model for a session as a markdown structure. ' + + 'Includes all nodes, edges, and their relevant properties.', + inputSchema: { + sessionId: z.string().describe('Session ID for which to query the model.') + } + }, + async params => createResourceToolResult(await this.handle(params)) + ); + } + + async handle({ sessionId }: { sessionId?: string }): Promise { + this.logger.info(`'diagram-model' invoked for session '${sessionId}'`); + + if (!sessionId) { + return { + content: { + uri: `glsp://diagrams/${sessionId}/model`, + mimeType: 'text/plain', + text: 'No session id provided.' + }, + isError: true + }; + } + + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + return { + content: { + uri: `glsp://diagrams/${sessionId}/model`, + mimeType: 'text/plain', + text: 'No active session found for this session id.' + }, + isError: true + }; + } + + const modelState = session.container.get(ModelState); + + const mcpIdAliasService = session.container.get(McpIdAliasService); + const aliasFn = mcpIdAliasService.alias.bind(mcpIdAliasService, sessionId); + + const mcpSerializer = session.container.get(McpModelSerializer); + const [mcpString] = mcpSerializer.serialize(modelState.root, aliasFn); + + return { + content: { + uri: `glsp://diagrams/${sessionId}/model`, + mimeType: 'text/markdown', + text: mcpString + }, + isError: false + }; + } + + protected getSessionIds(): string[] { + return this.clientSessionManager.getSessions().map(s => s.id); + } +} diff --git a/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts b/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts new file mode 100644 index 0000000..2d205c4 --- /dev/null +++ b/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts @@ -0,0 +1,185 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { Action, ActionHandler, ClientSessionManager, ExportPngMcpAction, ExportPngMcpActionResult, Logger } from '@eclipse-glsp/server'; +import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { GLSPMcpServer, McpResourceHandler, ResourceHandlerResult } from '../../server'; +import { createResourceResult, createToolResult, extractResourceParam } from '../../util'; + +/** + * Creates a base64-encoded PNG of the given session's model state. + * + * Since visual logic is not only much easier on the frontend, but also already implemented there + * with little reason to engineer the feature on the backend, communication with the frontend is necessary. + * However, GLSP's architecture is client-driven, i.e., the server is passive and not the driver of events. + * This means that we have to somewhat circumvent this by making this handler simultaneously an `ActionHandler`. + * + * We trigger the frontend PNG creation using {@link ExportPngMcpAction} and register this class as an + * `ActionHandler` for the response action {@link ExportPngMcpActionResult}. This is necessary, because we can't just + * wait for the result of a dispatched action (at least on the server side). Instead, we make use of the class + * to carry the promise resolver for the initial request (by the MCP client) to use when receiving the response action. + * However, it is unclear whether this works in all circumstances, as it introduces impure functions. + */ +@injectable() +export class DiagramPngMcpResourceHandler implements McpResourceHandler, ActionHandler { + actionKinds = [ExportPngMcpActionResult.KIND]; + + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + protected resolvers: Record< + string, + { sessionId: string; resolve: (value: ResourceHandlerResult | PromiseLike) => void } + > = {}; + + registerResource(server: GLSPMcpServer): void { + server.registerResource( + 'diagram-png', + new ResourceTemplate('glsp://diagrams/{sessionId}/png', { + list: () => { + const sessionIds = this.getSessionIds(); + return { + resources: sessionIds.map(sessionId => ({ + uri: `glsp://diagrams/${sessionId}/png`, + name: `Diagram PNG: ${sessionId}`, + description: `Complete PNG of the model for session ${sessionId}`, + mimeType: 'image/png' + })) + }; + }, + complete: { + sessionId: () => this.getSessionIds() + } + }), + { + title: 'Diagram Model PNG', + description: + 'Get the complete image of the model for a session as a PNG. ' + + 'Includes all nodes and edges to help with visually relevant tasks.', + mimeType: 'image/png' + }, + async (_uri, params) => createResourceResult(await this.handle({ sessionId: extractResourceParam(params, 'sessionId') })) + ); + } + + registerTool(server: GLSPMcpServer): void { + server.registerTool( + 'diagram-png', + { + title: 'Diagram Model PNG', + description: + 'Get the complete image of the model for a session as a PNG. ' + + 'Includes all nodes and edges to help with visually relevant tasks.', + inputSchema: { + sessionId: z.string().describe('Session ID for which the image should be created') + } + }, + async params => { + const result = await this.handle(params); + if (result.isError) { + return createToolResult((result.content as any).text, true); + } + + return { + isError: result.isError, + content: [ + { + type: 'image', + data: (result.content as any).blob, + mimeType: 'image/png' + } + ] + }; + } + ); + } + + async handle({ sessionId }: { sessionId?: string }): Promise { + this.logger.info(`'diagram-png' invoked for session ${sessionId}`); + + if (!sessionId) { + return { + content: { + uri: `glsp://diagrams/${sessionId}/png`, + mimeType: 'text/plain', + text: 'No session id provided.' + }, + isError: true + }; + } + + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + return { + content: { + uri: `glsp://diagrams/${sessionId}/png`, + mimeType: 'text/plain', + text: 'No active session found for this session id.' + }, + isError: true + }; + } + + const requestId = Math.trunc(Math.random() * 1000).toString(); + this.logger.info(`ExportPngMcpAction dispatched with request ID '${requestId}'`); + session.actionDispatcher.dispatch(ExportPngMcpAction.create(requestId)); + + // Start a promise and save the resolve function to the class + return new Promise(resolve => { + this.resolvers[requestId] = { sessionId, resolve }; + setTimeout( + () => + resolve({ + content: { + uri: `glsp://diagrams/${sessionId}/png`, + mimeType: 'text/plain', + text: 'The generation of the PNG timed out.' + }, + isError: true + }), + 5000 + ); + }); + } + + async execute(action: ExportPngMcpActionResult): Promise { + const requestId = action.mcpRequestId; + this.logger.info(`ExportPngMcpActionResult received with request ID '${requestId}'`); + + // Resolve the previously started promise + const { sessionId, resolve } = this.resolvers[requestId]; + resolve?.({ + content: { + uri: `glsp://diagrams/${sessionId}/png`, + mimeType: 'image/png', + blob: action.png + }, + isError: false + }); + delete this.resolvers[requestId]; + + return []; + } + + protected getSessionIds(): string[] { + return this.clientSessionManager.getSessions().map(s => s.id); + } +} diff --git a/packages/server-mcp/src/resources/handlers/element-types-handler.ts b/packages/server-mcp/src/resources/handlers/element-types-handler.ts new file mode 100644 index 0000000..bf3e03e --- /dev/null +++ b/packages/server-mcp/src/resources/handlers/element-types-handler.ts @@ -0,0 +1,153 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { ClientSessionManager, CreateOperationHandler, DiagramModules, Logger, OperationHandlerRegistry } from '@eclipse-glsp/server'; +import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ContainerModule, inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { GLSPMcpServer, McpResourceHandler, ResourceHandlerResult } from '../../server'; +import { createResourceResult, createResourceToolResult, extractResourceParam, objectArrayToMarkdownTable } from '../../util'; + +/** + * Lists the available element types for a given diagram type. This should likely include not only their id but also some description. + * Additionally, some element types may not be creatable and should be omitted or annotated as such. + */ +@injectable() +export class ElementTypesMcpResourceHandler implements McpResourceHandler { + @inject(Logger) + protected logger: Logger; + + @inject(DiagramModules) + protected diagramModules: Map; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + registerResource(server: GLSPMcpServer): void { + server.registerResource( + 'element-types', + new ResourceTemplate('glsp://types/{diagramType}/elements', { + list: () => { + const diagramTypes = this.getDiagramTypes(); + return { + resources: diagramTypes.map(type => ({ + uri: `glsp://types/${type}/elements`, + name: `Element Types: ${type}`, + description: `Creatable element types for ${type} diagrams`, + mimeType: 'text/markdown' + })) + }; + }, + complete: { + diagramType: () => this.getDiagramTypes() + } + }), + { + title: 'Creatable Element Types', + description: + 'List all element types (nodes and edges) that can be created for a specific diagram type. ' + + 'Use this to discover valid elementTypeId values for creation tools.', + mimeType: 'text/markdown' + }, + async (_uri, params) => createResourceResult(await this.handle({ diagramType: extractResourceParam(params, 'diagramType') })) + ); + } + + registerTool(server: GLSPMcpServer): void { + server.registerTool( + 'element-types', + { + title: 'Creatable Element Types', + description: + 'List all element types (nodes and edges) that can be created for a specific diagram type. ' + + 'Use this to discover valid elementTypeId values for creation tools.', + inputSchema: { + diagramType: z.string().describe('Diagram type whose elements should be discovered') + } + }, + async params => createResourceToolResult(await this.handle(params)) + ); + } + + async handle({ diagramType }: { diagramType?: string }): Promise { + this.logger.info(`'element-types' invoked for diagram type '${diagramType}'`); + + if (!diagramType) { + return { + content: { + uri: `glsp://types/${diagramType}/elements`, + mimeType: 'text/plain', + text: 'No diagram type provided.' + }, + isError: true + }; + } + + // Try to get a session of this diagram type to access the operation handler registry + const sessions = this.clientSessionManager.getSessionsByType(diagramType); + if (sessions.length === 0) { + return { + content: { + uri: `glsp://types/${diagramType}/elements`, + mimeType: 'text/plain', + text: 'No active session found for this diagram type. Create a session first to discover element types.' + }, + isError: true + }; + } + + const session = sessions[0]; + const registry = session.container.get(OperationHandlerRegistry); + + const nodeTypes: Array<{ id: string; label: string }> = []; + const edgeTypes: Array<{ id: string; label: string }> = []; + + // Extract the node and edge operations by the systematic the registry stores them + for (const key of registry.keys()) { + const handler = registry.get(key); + if (handler && CreateOperationHandler.is(handler)) { + if (key.startsWith('createNode_')) { + const elementTypeId = key.substring('createNode_'.length); + nodeTypes.push({ id: elementTypeId, label: handler.label }); + } else if (key.startsWith('createEdge_')) { + const elementTypeId = key.substring('createEdge_'.length); + edgeTypes.push({ id: elementTypeId, label: handler.label }); + } + } + } + + const result = [ + `# Creatable element types for diagram type "${diagramType}"`, + '## Node Types', + objectArrayToMarkdownTable(nodeTypes), + '## Edge Types', + objectArrayToMarkdownTable(edgeTypes) + ].join('\n'); + + return { + content: { + uri: `glsp://types/${diagramType}/elements`, + mimeType: 'text/markdown', + text: result + }, + isError: false + }; + } + + protected getDiagramTypes(): string[] { + return Array.from(this.diagramModules.keys()); + } +} diff --git a/packages/server-mcp/src/resources/handlers/sessions-list-handler.ts b/packages/server-mcp/src/resources/handlers/sessions-list-handler.ts new file mode 100644 index 0000000..1673a69 --- /dev/null +++ b/packages/server-mcp/src/resources/handlers/sessions-list-handler.ts @@ -0,0 +1,81 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { ClientSessionManager, Logger, ModelState } from '@eclipse-glsp/server'; +import { inject, injectable } from 'inversify'; +import { GLSPMcpServer, McpResourceHandler, ResourceHandlerResult } from '../../server'; +import { createResourceResult, createResourceToolResult, objectArrayToMarkdownTable } from '../../util'; + +/** + * Lists the current sessions according to the {@link ClientSessionManager}. This includes not only + * their id but also diagram type, source uri, and read-only status. + */ +@injectable() +export class SessionsListMcpResourceHandler implements McpResourceHandler { + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + registerResource(server: GLSPMcpServer): void { + server.registerResource( + 'sessions-list', + 'glsp://sessions', + { + title: 'GLSP Sessions List', + description: 'List all active GLSP client sessions across all diagram types', + mimeType: 'text/markdown' + }, + async () => createResourceResult(await this.handle({})) + ); + } + + registerTool(server: GLSPMcpServer): void { + server.registerTool( + 'sessions-list', + { + title: 'GLSP Sessions List', + description: 'List all active GLSP client sessions across all diagram types' + }, + async () => createResourceToolResult(await this.handle({})) + ); + } + + async handle(params: Record): Promise { + this.logger.info("'sessions-list' invoked"); + + const sessions = this.clientSessionManager.getSessions(); + const sessionsList = sessions.map(session => { + const modelState = session.container.get(ModelState); + return { + sessionId: session.id, + diagramType: session.diagramType, + sourceUri: modelState.sourceUri, + readOnly: modelState.isReadonly + }; + }); + + return { + content: { + uri: 'glsp://sessions', + mimeType: 'text/markdown', + text: objectArrayToMarkdownTable(sessionsList) + }, + isError: false + }; + } +} diff --git a/packages/server-mcp/src/resources/index.ts b/packages/server-mcp/src/resources/index.ts new file mode 100644 index 0000000..13de476 --- /dev/null +++ b/packages/server-mcp/src/resources/index.ts @@ -0,0 +1,22 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +export * from './handlers/diagram-model-handler'; +export * from './handlers/diagram-png-handler'; +export * from './handlers/element-types-handler'; +export * from './handlers/sessions-list-handler'; +export * from './mcp-resource-contribution'; +export * from './services/mcp-model-serializer'; diff --git a/packages/server-mcp/src/resources/mcp-resource-contribution.ts b/packages/server-mcp/src/resources/mcp-resource-contribution.ts new file mode 100644 index 0000000..f18fd68 --- /dev/null +++ b/packages/server-mcp/src/resources/mcp-resource-contribution.ts @@ -0,0 +1,41 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { injectable, multiInject } from 'inversify'; +import { GLSPMcpServer, McpResourceHandler, McpServerContribution } from '../server'; + +/** + * MCP server contribution that provides read-only resources for accessing + * GLSP server state, including sessions, element types, and diagram models. + * + * This contribution should not be overriden or extended if another resource is required. + * Instead, a new {@link McpResourceHandler} should be registered like: + * @example + * bindAsService(bind, McpResourceHandler, SessionsListMcpResourceHandler); + */ +@injectable() +export class McpResourceContribution implements McpServerContribution { + @multiInject(McpResourceHandler) + protected mcpResourceHandlers: McpResourceHandler[]; + + configure(server: GLSPMcpServer): void { + if (server.options.resources) { + this.mcpResourceHandlers.forEach(handler => handler.registerResource(server)); + } else { + this.mcpResourceHandlers.forEach(handler => handler.registerTool(server)); + } + } +} diff --git a/packages/server-mcp/src/resources/services/mcp-model-serializer.ts b/packages/server-mcp/src/resources/services/mcp-model-serializer.ts new file mode 100644 index 0000000..460743c --- /dev/null +++ b/packages/server-mcp/src/resources/services/mcp-model-serializer.ts @@ -0,0 +1,181 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { GModelElement } from '@eclipse-glsp/graph'; +import { GModelSerializer } from '@eclipse-glsp/server'; +import { inject, injectable } from 'inversify'; +import { objectArrayToMarkdownTable } from '../../util'; + +export const McpModelSerializer = Symbol('McpModelSerializer'); + +/** + * The `McpModelSerializer` is used to transform a graphical model into an appropriately formatted string + * for communicating with an LLM. It is recommended to use Markdown or simply JSON for this purpose. + */ +export interface McpModelSerializer { + /** + * Transforms the given {@link GModelElement} into a string representation. + * @param element The element that should be serialized. + * @param aliasFn Optional function to alias an ID according to `McpIdAliasService` + * @returns The transformed string and the underlying flattened graph object. + */ + serialize(element: GModelElement, aliasFn?: (id: string) => string): [string, Record[]>]; + + /** + * Transforms the given {@link GModelElement} items into a string representation. + * It is assumed that they represent a subgraph of the total graph and duplicate elements, e.g, + * by hierarchy, are removed. + * @param elements The elements that should be serialized. + * @param aliasFn Optional function to alias an ID according to `McpIdAliasService` + * @returns The transformed string and the underlying flattened graph object. + */ + serializeArray(elements: GModelElement[], aliasFn?: (id: string) => string): [string, Record[]>]; +} + +/** + * The `DefaultMcpModelSerializer` transforms the graph into a canonically serializable + * format (as produced by `GModelSerializer`), flattens the graph structure into a list of elements, + * removes unnecessary information, and finally adds some derived visual information. + * + * It can only do so in a generic manner without control of the order of elements or element attributes, + * since no details of a specific GLSP implementation are known. + */ +@injectable() +export class DefaultMcpModelSerializer implements McpModelSerializer { + @inject(GModelSerializer) + protected gModelSerialzer: GModelSerializer; + + protected keysToRemove: string[] = [ + 'cssClasses', + 'revision', + 'layout', + 'args', + 'layoutOptions', + 'alignment', + 'children', + 'routingPoints', + 'resizeLocations', + 'parent' + ]; + + serialize(element: GModelElement, aliasFn?: (id: string) => string): [string, Record[]>] { + return this.serializeArray([element], aliasFn); + } + + serializeArray(elements: GModelElement[], aliasFn?: (id: string) => string): [string, Record[]>] { + const elementsByTypeArray = elements.map(element => this.prepareElement(element)); + + const result: Record[]> = {}; + + const allKeys = new Set(elementsByTypeArray.flatMap(obj => Object.keys(obj))); + + allKeys.forEach(key => { + const combined = elementsByTypeArray.flatMap(obj => obj[key] || []); + + result[key] = Array.from(new Map(combined.map(item => [item.id, item])).values()).map(item => this.applyAlias(item, aliasFn)); + }); + + return [ + Object.entries(result) + .flatMap(([type, elements]) => [`# ${type}`, objectArrayToMarkdownTable(elements)]) + .join('\n'), + result + ]; + } + + protected applyAlias(element: Record, aliasFn?: (id: string) => string): Record { + if (element.id) { + element.id = aliasFn?.(element.id); + } + if (element.sourceId) { + element.sourceId = aliasFn?.(element.sourceId); + } + if (element.targetId) { + element.targetId = aliasFn?.(element.targetId); + } + if (element.parentId) { + element.parentId = aliasFn?.(element.parentId); + } + return element; + } + + protected prepareElement(element: GModelElement): Record[]> { + const schema = this.gModelSerialzer.createSchema(element); + + const elements = this.flattenStructure(schema, element.parent?.id); + + const result: Record[]> = {}; + elements.forEach(element => { + this.removeKeys(element); + this.combinePositionAndSize(element); + if (result[element.type] === undefined) { + result[element.type] = []; + } + result[element.type].push(element); + }); + + return result; + } + + protected flattenStructure(element: Record, parentId?: string): Record[] { + const newElement = { ...element }; + + const result: Record[] = []; + + result.push(newElement); + if (newElement.children !== undefined) { + newElement.children + .flatMap((child: Record) => this.flattenStructure(child, newElement.id)) + .forEach((element: Record) => result.push(element)); + } + newElement.parentId = parentId; + + return result; + } + + protected removeKeys(element: Record): void { + for (const key in element) { + if (this.keysToRemove.includes(key)) { + delete element[key]; + } + } + } + + protected combinePositionAndSize(element: Record): void { + const position = element.position; + if (position) { + // Not all positioned elements necessarily possess a size + const size = element.size ?? { width: 0, height: 0 }; + + const x = Math.trunc(position.x); + const y = Math.trunc(position.y); + const width = Math.trunc(size.width); + const height = Math.trunc(size.height); + + // Only expose the truncated sizes for smaller context size at irrelevant precision loss + element['position'] = { x, y }; + element['size'] = { width, height }; + + // Add bounds in addition to position and size to reduce derived calculations + element['bounds'] = { + left: x, + right: x + width, + top: y, + bottom: y + height + }; + } + } +} diff --git a/packages/server-mcp/src/http-server-with-sessions.ts b/packages/server-mcp/src/server/http-server-with-sessions.ts similarity index 98% rename from packages/server-mcp/src/http-server-with-sessions.ts rename to packages/server-mcp/src/server/http-server-with-sessions.ts index e851b9b..5bd570c 100644 --- a/packages/server-mcp/src/http-server-with-sessions.ts +++ b/packages/server-mcp/src/server/http-server-with-sessions.ts @@ -15,10 +15,10 @@ ********************************************************************************/ import { Deferred, Disposable, Emitter, Logger } from '@eclipse-glsp/server'; -import { InMemoryEventStore } from '@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore'; -import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp'; -import { isInitializeRequest } from '@modelcontextprotocol/sdk/types'; +import { InMemoryEventStore } from '@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js'; +import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import type { Express } from 'express'; import * as express from 'express'; import * as http from 'http'; diff --git a/packages/server-mcp/src/server/index.ts b/packages/server-mcp/src/server/index.ts new file mode 100644 index 0000000..6187dc6 --- /dev/null +++ b/packages/server-mcp/src/server/index.ts @@ -0,0 +1,21 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +export * from './http-server-with-sessions'; +export * from './mcp-server-contribution'; +export * from './mcp-server-manager'; +export * from './mcp-id-alias-service'; +export * from './mcp-option-service'; diff --git a/packages/server-mcp/src/server/mcp-id-alias-service.ts b/packages/server-mcp/src/server/mcp-id-alias-service.ts new file mode 100644 index 0000000..3573205 --- /dev/null +++ b/packages/server-mcp/src/server/mcp-id-alias-service.ts @@ -0,0 +1,97 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { inject, injectable } from 'inversify'; +import { McpOptionService } from './mcp-option-service'; + +export const McpIdAliasService = Symbol('McpIdAliasService'); + +/** + * A Service that allows to alias IDs with an integer string. Since those are much + * shorter and not random, it is much more efficient in terms of tokens. + * + * For the tools and resources this generally means that if the output contains an + * element ID, it needs to first call `alias`. If the input contains an element ID + * it needs to first `lookup` the real ID. + */ +export interface McpIdAliasService { + /** + * Maps an ID to an integer alias within a specific session. + * If the ID has been seen before in this session, it returns the existing alias. + */ + alias(sessionId: string, id: string): string; + /** + * Retrieves the original ID associated with an alias for a specific session. + * @throws Error if the alias does not exist. + */ + lookup(sessionId: string, alias: string): string; +} + +@injectable() +export class DefaultMcpIdAliasService implements McpIdAliasService { + @inject(McpOptionService) + protected mcpOptionService: McpOptionService; + + // Map> + protected idAliasMap = new Map>(); + // Map> + protected aliasIdMap = new Map>(); + + protected counter = 0; + + alias(sessionId: string, uuid: string): string { + if (!this.mcpOptionService.get('aliasIds')) { + return uuid; + } + + let idToAlias = this.idAliasMap.get(sessionId); + let aliasToId = this.aliasIdMap.get(sessionId); + + if (!idToAlias || !aliasToId) { + idToAlias = new Map(); + aliasToId = new Map(); + this.idAliasMap.set(sessionId, idToAlias); + this.aliasIdMap.set(sessionId, aliasToId); + } + + const existingAlias = idToAlias.get(uuid); + if (existingAlias) { + return existingAlias; + } + + const newAlias = (++this.counter).toString(); + + idToAlias.set(uuid, newAlias); + aliasToId.set(newAlias, uuid); + + return newAlias; + } + + lookup(sessionId: string, alias: string): string { + if (!this.mcpOptionService.get('aliasIds')) { + return alias; + } + + const aliasToUuid = this.aliasIdMap.get(sessionId); + const uuid = aliasToUuid?.get(alias); + + if (!uuid) { + throw new Error(`Mapping not found for alias "${alias}" in session "${sessionId}"`); + } + + return uuid; + } +} diff --git a/packages/server-mcp/src/server/mcp-option-service.ts b/packages/server-mcp/src/server/mcp-option-service.ts new file mode 100644 index 0000000..7f0250a --- /dev/null +++ b/packages/server-mcp/src/server/mcp-option-service.ts @@ -0,0 +1,53 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { McpServerOptions } from '@eclipse-glsp/protocol'; +import { inject, injectable } from 'inversify'; +import { McpServerContribution } from './mcp-server-contribution'; +import { GLSPMcpServer } from './mcp-server-manager'; + +export const McpOptionService = Symbol('McpOptionService'); + +export interface McpOptionService { + set(options: McpServerOptions): void; + get(key: keyof McpServerOptions): boolean | undefined; +} + +@injectable() +export class DefaultMcpOptionService implements McpOptionService { + protected options: McpServerOptions; + + set(options: McpServerOptions): void { + this.options = options; + } + + get(key: keyof McpServerOptions): boolean | undefined { + if (!this.options) { + return undefined; + } + return this.options[key]; + } +} + +@injectable() +export class McpOptionServiceContribution implements McpServerContribution { + @inject(McpOptionService) + protected mcpOptionService: McpOptionService; + + configure(server: GLSPMcpServer): void { + this.mcpOptionService.set(server.options); + } +} diff --git a/packages/server-mcp/src/server/mcp-server-contribution.ts b/packages/server-mcp/src/server/mcp-server-contribution.ts new file mode 100644 index 0000000..395e394 --- /dev/null +++ b/packages/server-mcp/src/server/mcp-server-contribution.ts @@ -0,0 +1,74 @@ +/******************************************************************************** + * Copyright (c) 2025 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { MaybePromise } from '@eclipse-glsp/server'; +import { CallToolResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types'; +import { GLSPMcpServer } from './mcp-server-manager'; + +export interface McpServerContribution { + configure(server: GLSPMcpServer): MaybePromise; +} +export const McpServerContribution = Symbol('McpServerContribution'); + +export type ToolResultContent = CallToolResult['content'][number]; +export type ResourceResultContent = ReadResourceResult['contents'][number]; + +export interface ResourceHandlerResult { + content: ResourceResultContent; + isError: boolean; +} + +/** + * An `McpResourceHandler` defines a resource for the MCP server. This includes + * not only the logic to execute but also the definition of the endpoint. As + * it may be the case that resources should be offered as tools for compatibility + * purposes, both kinds of endpoints need to be defined. + * + * The following code shows how to register a new handler or override an existing one: + * @example + * // Register a new handler + * bindAsService(bind, McpResourceHandler, SessionsListMcpResourceHandler); + * // Override an existing handler; necessitates inheritance + * rebind(SessionsListMcpResourceHandler).to(MySessionsListMcpResourceHandler).inSingletonScope(); + */ +export interface McpResourceHandler { + /** Defines the endpoint and registers the resource with the given MCP server as a resource*/ + registerResource(server: GLSPMcpServer): void; + /** Defines the endpoint and registers the resource with the given MCP server as a tool */ + registerTool(server: GLSPMcpServer): void; + /** Executes the logic given the endpoints input and provides corresponding output */ + handle(params: Record): Promise; +} +export const McpResourceHandler = Symbol('McpResourceHandler'); + +/** + * An `McpToolHandler` defines a tools for the MCP server. This includes + * not only the logic to execute but also the definition of the endpoint. + * + * The following code shows how to register a new handler or override an existing one: + * @example + * // Register a new handler + * bindAsService(bind, McpToolHandler, CreateNodeMcpToolHandler); + * // Override an existing handler; necessitates inheritance + * rebind(CreateNodeMcpToolHandler).to(MyCreateNodeMcpToolHandler).inSingletonScope(); + */ +export interface McpToolHandler { + /** Defines the endpoint and registers the tool with the given MCP server */ + registerTool(server: GLSPMcpServer): void; + /** Executes the logic given the endpoints input and provides corresponding output */ + handle(params: Record): Promise; +} +export const McpToolHandler = Symbol('McpToolHandler'); diff --git a/packages/server-mcp/src/mcp-server-manager.ts b/packages/server-mcp/src/server/mcp-server-manager.ts similarity index 68% rename from packages/server-mcp/src/mcp-server-manager.ts rename to packages/server-mcp/src/server/mcp-server-manager.ts index 8cbeba8..fadf75f 100644 --- a/packages/server-mcp/src/mcp-server-manager.ts +++ b/packages/server-mcp/src/server/mcp-server-manager.ts @@ -25,6 +25,7 @@ import { Logger, McpInitializeResult, McpServerConfiguration, + McpServerOptions, getMcpServerConfig } from '@eclipse-glsp/server'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; @@ -35,7 +36,23 @@ import { McpServerContribution } from './mcp-server-contribution'; export type FullMcpServerConfiguration = Required; -export interface GLSPMcpServer extends Pick {} +export interface GLSPMcpServer extends Pick { + options: McpServerOptions; +} + +const AGENT_PERSONA = ` +You are the GLSP Modeling Agent. Your primary goal is to assist in the creation and modification of graphical models using the +CLSP MCP server. You have to adhere to the following principles: +- MCP-Interaction: Any modeling related activity has to occur using the MCP server. +- Real Data: The diagram model is the ground truth regarding the existing graphical model. Always query it before modifying the diagram. +- Real Creation: Consult the available element types before creating elements. +- Visual Proof: An image of the graphical model can be created, if you deem it useful for calculating or verifying layout decisions. +- Precision: All IDs and types must be exact. +- Visualization: When creating nodes, suggest sensible default positions and avoid visual overlapping. +- Careful: Under no circumstances save the model without explicit instruction. If you deem it sensible, you may ask the user for permission. + The same goes for Undo/Redo operations. +- Layouting: If available, make use of automatic layouting when not given explicit custom layouting requirements. +`; @injectable() export class McpServerManager implements GLSPServerInitContribution, GLSPServerListener, Disposable { @@ -52,8 +69,11 @@ export class McpServerManager implements GLSPServerInitContribution, GLSPServerL return result; } - const { port = 0, host = '127.0.0.1', route = '/glsp-mcp', name = 'glspMcpServer' } = mcpServerParam; - const mcpServerConfig: FullMcpServerConfiguration = { port, host, route, name }; + // use a fixed default port instead of 0 so that the MCP server need only be registered once + // using 0, i.e., a random port, would require re-registering the MCP server each time + const { port = 60000, host = '127.0.0.1', route = '/glsp-mcp', name = 'glspMcpServer', options = {} } = mcpServerParam; + const optionsWithDefaults = { resources: options.resources ?? false, aliasIds: options.aliasIds ?? true }; + const mcpServerConfig: FullMcpServerConfiguration = { port, host, route, name, options: optionsWithDefaults }; const httpServer = new McpHttpServerWithSessions(this.logger); httpServer.onSessionInitialized(client => this.onSessionInitialized(client, mcpServerConfig)); @@ -79,12 +99,13 @@ export class McpServerManager implements GLSPServerInitContribution, GLSPServerL this.toDispose.push(Disposable.create(() => server.close())); } - protected createMcpServer({ name }: FullMcpServerConfiguration): McpServer { - const server = new McpServer({ name, version: '1.0.0' }, { capabilities: { logging: {} } }); + protected createMcpServer({ name, options }: FullMcpServerConfiguration): McpServer { + const server = new McpServer({ name, version: '1.0.0' }, { capabilities: { logging: {} }, instructions: AGENT_PERSONA }); const glspMcpServer: GLSPMcpServer = { registerPrompt: server.registerPrompt.bind(server), registerResource: server.registerResource.bind(server), - registerTool: server.registerTool.bind(server) + registerTool: server.registerTool.bind(server), + options }; this.contributions.forEach(contribution => contribution.configure(glspMcpServer)); return server; diff --git a/packages/server-mcp/src/tools/handlers/change-view-handler.ts b/packages/server-mcp/src/tools/handlers/change-view-handler.ts new file mode 100644 index 0000000..8c64bb7 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/change-view-handler.ts @@ -0,0 +1,110 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { Action, CenterAction, ClientSessionManager, FitToScreenAction, Logger, ModelState } from '@eclipse-glsp/server'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { GLSPMcpServer, McpIdAliasService, McpToolHandler } from '../../server'; +import { createToolResult } from '../../util'; + +/** + * Changes the given session's viewport. + */ +@injectable() +export class ChangeViewMcpToolHandler implements McpToolHandler { + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + protected viewportActions: string[] = ['fit-to-screen', 'center-on-elements', 'reset-viewport']; + + registerTool(server: GLSPMcpServer): void { + server.registerTool( + 'change-view', + { + description: "Change the viewport of the session's associated UI. " + 'This is only relevent on explicit user request.', + inputSchema: { + sessionId: z.string().describe('Session ID where the model should be saved'), + viewportAction: z.enum(this.viewportActions).describe('The type of viewport change action to be undertaken.'), + elementIds: z + .array(z.string()) + .optional() + .describe( + "Elements to center on or fit. Relevant for actions 'center-on-elements' and 'fit-to-screen'. " + + 'If omitted, the entire diagram is taken instead.' + ) + } + }, + params => this.handle(params) + ); + } + + async handle({ + sessionId, + viewportAction, + elementIds + }: { + sessionId: string; + viewportAction: string; + elementIds?: string[]; + }): Promise { + this.logger.info(`'change-view' invoked for session '${sessionId}'`); + + if (!sessionId) { + return createToolResult('No session id provided.', true); + } + + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + return createToolResult('Session not found', true); + } + + if (!elementIds) { + const modelState = session.container.get(ModelState); + elementIds = modelState.index.allIds(); + } else { + // The input consists of ID aliases + const mcpIdAliasService = session.container.get(McpIdAliasService); + elementIds = elementIds.map(id => mcpIdAliasService.lookup(sessionId, id)); + } + + let action: Action | undefined = undefined; + switch (viewportAction) { + case 'fit-to-screen': + action = FitToScreenAction.create(elementIds, { animate: true, padding: 20 }); + break; + case 'center-on-elements': + action = CenterAction.create(elementIds, { animate: true, retainZoom: true }); + break; + case 'reset-viewport': + // `OriginViewportAction` is not available, because it lives in feature space, not protocol space + // should this be removed? + action = { kind: 'originViewport', animate: true } as Action; + break; + } + + if (!action) { + return createToolResult('Invalid viewport action', true); + } + + await session.actionDispatcher.dispatch(action); + + return createToolResult('Viewport successfully changed', false); + } +} diff --git a/packages/server-mcp/src/tools/handlers/create-edges-handler.ts b/packages/server-mcp/src/tools/handlers/create-edges-handler.ts new file mode 100644 index 0000000..a422a2e --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/create-edges-handler.ts @@ -0,0 +1,194 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { ChangeRoutingPointsOperation, ClientSessionManager, CreateEdgeOperation, Logger, ModelState } from '@eclipse-glsp/server'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { GLSPMcpServer, McpIdAliasService, McpToolHandler } from '../../server'; +import { createToolResult } from '../../util'; + +/** + * Creates one or multiple new edges in the given session's model. + */ +@injectable() +export class CreateEdgesMcpToolHandler implements McpToolHandler { + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + registerTool(server: GLSPMcpServer): void { + server.registerTool( + 'create-edges', + { + description: + 'Create one or multiple new edges connecting two elements in the diagram. ' + + 'This operation modifies the diagram state and requires user approval. ' + + 'Query glsp://types/{diagramType}/elements resource to discover valid edge type IDs.', + inputSchema: { + sessionId: z.string().describe('Session ID where the edge should be created'), + edges: z + .array( + z.object({ + elementTypeId: z + .string() + .describe( + 'Edge type ID (e.g., "edge", "transition"). Use element-types resource to discover valid IDs.' + ), + sourceElementId: z.string().describe('ID of the source element (must exist in the diagram)'), + targetElementId: z.string().describe('ID of the target element (must exist in the diagram)'), + routingPoints: z + .array( + z.object({ + x: z.number().describe('Routing point x coordinate'), + y: z.number().describe('Routing point y coordinate') + }) + ) + .optional() + .describe('Optional array of routing point coordinates that allow for a complex edge path.'), + args: z + .record(z.string(), z.any()) + .optional() + .describe('Additional type-specific arguments for edge creation (varies by edge type)') + }) + ) + .min(1) + .describe('Array of edges to create. Must include at least one node.') + } + }, + params => this.handle(params) + ); + } + + async handle({ + sessionId, + edges + }: { + sessionId: string; + edges: { + elementTypeId: string; + sourceElementId: string; + targetElementId: string; + routingPoints?: { x: number; y: number }[]; + args?: Record; + }[]; + }): Promise { + this.logger.info(`'create-edges' invoked for session '${sessionId}' with ${edges.length} edges`); + + if (!sessionId) { + return createToolResult('No session id provided.', true); + } + if (!edges || !edges.length) { + return createToolResult('No edges provided.', true); + } + + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + return createToolResult('Session not found', true); + } + + const modelState = session.container.get(ModelState); + if (modelState.isReadonly) { + return createToolResult('Model is read-only', true); + } + + const mcpIdAliasService = session.container.get(McpIdAliasService); + + // Snapshot element IDs before operation + let beforeIds = modelState.index.allIds(); + + const errors: string[] = []; + const successIds: string[] = []; + let dispatchedOperations = 0; + // Since we need sequential handling of the created elements, we can't call all in parallel + for (const edge of edges) { + const { elementTypeId, routingPoints, args } = edge; + const sourceElementId = mcpIdAliasService.lookup(sessionId, edge.sourceElementId); + const targetElementId = mcpIdAliasService.lookup(sessionId, edge.targetElementId); + + // Validate source and target exist + const source = modelState.index.find(sourceElementId); + if (!source) { + errors.push(`Source element not found: ${sourceElementId}`); + continue; + } + const target = modelState.index.find(targetElementId); + if (!target) { + errors.push(`Target element not found: ${targetElementId}`); + continue; + } + + // Create & dispatch the operation + const operation = CreateEdgeOperation.create({ elementTypeId, sourceElementId, targetElementId, args }); + // Wait for the operation to be handled so the new ID is present + await session.actionDispatcher.dispatch(operation); + dispatchedOperations++; + + // Snapshot element IDs after operation + const afterIds = modelState.index.allIds(); + + // Find new element ID by filtering only the newly added ones,... + const newIds = afterIds.filter(id => !beforeIds.includes(id)); + // ...find the new elements that are of the same type as the created element, + const newElements = newIds.map(id => modelState.index.find(id)).filter(element => element?.type === elementTypeId); + // ...and in case that multiple exist (which should never be the case), + // assume that the first new element represents the actually relevant element + const newElementId = newElements.length > 0 ? newElements[0]?.id : undefined; + // Log a warning in case that multiple elements of the same type were created + if (newElements.length > 1) { + this.logger.warn('More than 1 new element created'); + } + + // For the next iteration, use the new snapshot as the baseline + beforeIds = afterIds; + + // We can't directly know whether an operation failed, because there are no + // direct responses, but if we see no new ID, we can assume it failed + if (!newElementId) { + errors.push(`Edge creation likely failed because no new element ID was found for input: ${JSON.stringify(edge)}`); + continue; + } + + if (routingPoints) { + const routingPointsOperation = ChangeRoutingPointsOperation.create([ + { elementId: newElementId, newRoutingPoints: routingPoints } + ]); + await session.actionDispatcher.dispatch(routingPointsOperation); + dispatchedOperations++; + } + + successIds.push(mcpIdAliasService.alias(sessionId, newElementId)); + } + + // Create a failure string if any errors occurred + let failureStr = ''; + if (errors.length) { + const failureListStr = errors.map(error => `- ${error}\n`); + failureStr = `\nThe following errors occured:\n${failureListStr}`; + } + + const successListStr = successIds.map(successId => `- ${successId}`).join('\n'); + // Even if every input given yields an error, the MCP call was still successful technically (even if not semantically) + // Otherwise, we would need some kind of transaction to rollback successful creations, which would be a great technical challenge + return createToolResult( + `Sucessfully created ${successIds.length} edge(s) (in ${dispatchedOperations} commands) ` + + `with element IDs:\n${successListStr}${failureStr}`, + false + ); + } +} diff --git a/packages/server-mcp/src/tools/handlers/create-nodes-handler.ts b/packages/server-mcp/src/tools/handlers/create-nodes-handler.ts new file mode 100644 index 0000000..2b7b812 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/create-nodes-handler.ts @@ -0,0 +1,207 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { + ApplyLabelEditOperation, + ClientSessionManager, + CreateNodeOperation, + GLabel, + GModelElement, + Logger, + ModelState +} from '@eclipse-glsp/server'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { GLSPMcpServer, McpIdAliasService, McpToolHandler } from '../../server'; +import { createToolResult } from '../../util'; + +/** + * Creates one or multiple new nodes in the given session's model. + */ +@injectable() +export class CreateNodesMcpToolHandler implements McpToolHandler { + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + registerTool(server: GLSPMcpServer): void { + server.registerTool( + 'create-nodes', + { + description: + 'Create one or multiple new nodes element in the diagram at a specified position. ' + + 'When creating new nodes absolutely consider the visual alignment with existing nodes. ' + + 'This operation modifies the diagram state and requires user approval. ' + + 'Query glsp://types/{diagramType}/elements resource to discover valid element type IDs.', + inputSchema: { + sessionId: z.string().describe('Session ID where the node should be created'), + nodes: z + .array( + z.object({ + elementTypeId: z + .string() + .describe( + 'Element type ID (e.g., "task:manual", "task:automated"). ' + + 'Use element-types resource to discover valid IDs.' + ), + position: z + .object({ + x: z.number().describe('X coordinate in diagram space'), + y: z.number().describe('Y coordinate in diagram space') + }) + .describe('Position where the node should be created (absolute diagram coordinates)'), + text: z.string().optional().describe('Label text to use in case the given element type allows for labels.'), + containerId: z + .string() + .optional() + .describe('ID of the container element. If not provided, node is added to the root.'), + args: z + .record(z.string(), z.any()) + .optional() + .describe('Additional type-specific arguments for node creation (varies by element type)') + }) + ) + .min(1) + .describe('Array of nodes to create. Must include at least one node.') + } + }, + params => this.handle(params) + ); + } + + async handle({ + sessionId, + nodes + }: { + sessionId: string; + nodes: { + elementTypeId: string; + position: { x: number; y: number }; + text?: string; + containerId?: string; + args?: Record; + }[]; + }): Promise { + this.logger.info(`'create-nodes' invoked for session '${sessionId}' with ${nodes.length} nodes`); + + if (!sessionId) { + return createToolResult('No session id provided.', true); + } + if (!nodes || !nodes.length) { + return createToolResult('No nodes provided.', true); + } + + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + return createToolResult('Session not found', true); + } + + const modelState = session.container.get(ModelState); + if (modelState.isReadonly) { + return createToolResult('Model is read-only', true); + } + + const mcpIdAliasService = session.container.get(McpIdAliasService); + + // Snapshot element IDs before operation + let beforeIds = modelState.index.allIds(); + + const errors: string[] = []; + const successIds: string[] = []; + let dispatchedOperations = 0; + // Since we need sequential handling of the created elements, we can't call all in parallel + for (const node of nodes) { + const { elementTypeId, position, text, args } = node; + const containerId = node.containerId ? mcpIdAliasService.lookup(sessionId, node.containerId) : undefined; + + // Using the name "position" instead of "location", as this is the name in the element's properties + // This just ensures that the AI sees a coherent API with common naming + // Here in the code, we can just reassign anyway + const operation = CreateNodeOperation.create(elementTypeId, { location: position, containerId, args }); + // Wait for the operation to be handled so the new ID is present + await session.actionDispatcher.dispatch(operation); + dispatchedOperations++; + + // Snapshot element IDs after operation + const afterIds = modelState.index.allIds(); + + // Find new element ID by filtering only the newly added ones,... + const newIds = afterIds.filter(id => !beforeIds.includes(id)); + // ...find the new elements that are of the same type as the created element, + const newElements = newIds.map(id => modelState.index.find(id)).filter(element => element?.type === elementTypeId); + // ...and in case that multiple exist (which should never be the case), + // assume that the first new element represents the actually relevant element + const newElementId = newElements.length > 0 ? newElements[0]?.id : undefined; + // Log a warning in case that multiple elements of the same type were created + if (newElements.length > 1) { + this.logger.warn('More than 1 new element created'); + } + + // For the next iteration, use the new snapshot as the baseline + beforeIds = afterIds; + + // We can't directly know whether an operation failed, because there are no + // direct responses, but if we see no new ID, we can assume it failed + if (!newElementId) { + errors.push(`Node creation likely failed because no new element ID was found for input: ${JSON.stringify(node)}`); + continue; + } + + const newElementLabelId = this.getCorrespondingLabelId(modelState.index.get(newElementId)); + // If it is indeed labeled (and we actually want to set the label)... + if (newElementLabelId && text) { + // ...then use an already existing operation to set the label + const editLabelOperation = ApplyLabelEditOperation.create({ labelId: newElementLabelId, text }); + await session.actionDispatcher.dispatch(editLabelOperation); + dispatchedOperations++; + } + + successIds.push(mcpIdAliasService.alias(sessionId, newElementId)); + } + + // Create a failure string if any errors occurred + let failureStr = ''; + if (errors.length) { + const failureListStr = errors.map(error => `- ${error}\n`); + failureStr = `\nThe following errors occured:\n${failureListStr}`; + } + + const successListStr = successIds.map(successId => `- ${successId}`).join('\n'); + // Even if every input given yields an error, the MCP call was still successful technically (even if not semantically) + // Otherwise, we would need some kind of transaction to rollback successful creations, which would be a great technical challenge + return createToolResult( + `Successfully created ${successIds.length} node(s) (in ${dispatchedOperations} commands) ` + + `with the element IDs:\n${successListStr}${failureStr}`, + false + ); + } + + /** + * This method provides the label ID for a labelled node's label. + * + * While it can be generally assumed that labelled nodes contain those labels + * as direct children, some custom elements may wrap their labels in intermediary + * container nodes. However, in the likely scenario that a specific GLSP implementation + * requires no further changes to this handler except extracting nested labels, this + * function serves as an easy entrypoint without a full override. + */ + protected getCorrespondingLabelId(element: GModelElement): string | undefined { + return element.children.find(child => child instanceof GLabel)?.id; + } +} diff --git a/packages/server-mcp/src/tools/handlers/delete-elements-handler.ts b/packages/server-mcp/src/tools/handlers/delete-elements-handler.ts new file mode 100644 index 0000000..5cbfaa4 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/delete-elements-handler.ts @@ -0,0 +1,104 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { ClientSessionManager, DeleteElementOperation, Logger, ModelState } from '@eclipse-glsp/server'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { GLSPMcpServer, McpIdAliasService, McpToolHandler } from '../../server'; +import { createToolResult } from '../../util'; + +/** + * Deletes one or more element using their element ID from the given session's model. + */ +@injectable() +export class DeleteElementsMcpToolHandler implements McpToolHandler { + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + registerTool(server: GLSPMcpServer): void { + server.registerTool( + 'delete-elements', + { + description: + 'Delete one or more elements (nodes or edges) from the diagram. ' + + 'This operation modifies the diagram state and requires user approval. ' + + 'Automatically handles dependent elements (e.g., deleting a node also deletes connected edges).', + inputSchema: { + sessionId: z.string().describe('Session ID where the elements should be deleted'), + elementIds: z.array(z.string()).min(1).describe('Array of element IDs to delete. Must include at least one element ID.') + } + }, + params => this.handle(params) + ); + } + + async handle({ sessionId, elementIds }: { sessionId: string; elementIds: string[] }): Promise { + this.logger.info(`'delete-elements' invoked for session '${sessionId}' with ${elementIds.length} elements`); + + if (!sessionId) { + return createToolResult('No session id provided.', true); + } + if (!elementIds || !elementIds.length) { + return createToolResult('No elementIds provided.', true); + } + + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + return createToolResult('Session not found', true); + } + + const modelState = session.container.get(ModelState); + if (modelState.isReadonly) { + return createToolResult('Model is read-only', true); + } + + const mcpIdAliasService = session.container.get(McpIdAliasService); + + // Validate elements exist + const missingIds: string[] = []; + const realIds: string[] = []; + for (const elementId of elementIds) { + const realId = mcpIdAliasService.lookup(sessionId, elementId); + const element = modelState.index.find(realId); + if (element) { + realIds.push(realId); + } else { + missingIds.push(elementId); + } + } + + if (missingIds.length > 0) { + return createToolResult(`Some elements not found: ${missingIds}`, true); + } + + // Snapshot element count before operation + const beforeCount = modelState.index.allIds().length; + + // Create and dispatch delete operation + const operation = DeleteElementOperation.create(realIds); + await session.actionDispatcher.dispatch(operation); + + // Calculate how many elements were deleted (including dependents) + const afterCount = modelState.index.allIds().length; + const deletedCount = beforeCount - afterCount; + + return createToolResult(`Successfully deleted ${deletedCount} element(s) (including dependents)`, false); + } +} diff --git a/packages/server-mcp/src/tools/handlers/diagram-elements-handler.ts b/packages/server-mcp/src/tools/handlers/diagram-elements-handler.ts new file mode 100644 index 0000000..f74b4e5 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/diagram-elements-handler.ts @@ -0,0 +1,86 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { ClientSessionManager, GModelElement, Logger, ModelState } from '@eclipse-glsp/server'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { McpModelSerializer } from '../../resources/services/mcp-model-serializer'; +import { GLSPMcpServer, McpIdAliasService, McpToolHandler } from '../../server'; +import { createToolResult } from '../../util'; + +/** + * Creates a serialized representation of one or more specific elements of a given session's model. + */ +@injectable() +export class DiagramElementsMcpToolHandler implements McpToolHandler { + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + registerTool(server: GLSPMcpServer): void { + server.registerTool( + 'diagram-elements', + { + title: 'Diagram Model Elements', + description: + 'Get one or more elements of a GLSP model for a session as a markdown structure. ' + + 'This is a more specific query than diagram-model to use if not the entire model is relevant.', + inputSchema: { + sessionId: z.string().describe('Session ID containing the relevant model.'), + elementIds: z.array(z.string()).min(1).describe('Element IDs that should be queried.') + } + }, + params => this.handle(params) + ); + } + + async handle({ sessionId, elementIds }: { sessionId: string; elementIds: string[] }): Promise { + this.logger.info(`'diagram-element' invoked for session '${sessionId}' and '${elementIds.length}' elements`); + + if (!sessionId) { + return createToolResult('No session id provided.', true); + } + if (!elementIds || !elementIds.length) { + return createToolResult('No element ids provided.', true); + } + + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + return createToolResult('No active session found for this session id.', true); + } + + const modelState = session.container.get(ModelState); + + const mcpIdAliasService = session.container.get(McpIdAliasService); + + const elements: GModelElement[] = []; + for (const elementId of elementIds) { + const element = modelState.index.find(mcpIdAliasService.lookup(sessionId, elementId)); + if (!element) { + return createToolResult('No element found for this element id.', true); + } + elements.push(element); + } + + const mcpSerializer = session.container.get(McpModelSerializer); + const [mcpString] = mcpSerializer.serializeArray(elements); + + return createToolResult(mcpString, false); + } +} diff --git a/packages/server-mcp/src/tools/handlers/get-selection-handler.ts b/packages/server-mcp/src/tools/handlers/get-selection-handler.ts new file mode 100644 index 0000000..4bebba1 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/get-selection-handler.ts @@ -0,0 +1,109 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { + Action, + ActionHandler, + ClientSessionManager, + GetSelectionMcpAction, + GetSelectionMcpResultAction, + Logger +} from '@eclipse-glsp/server'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { GLSPMcpServer, McpIdAliasService, McpToolHandler } from '../../server'; +import { createToolResult } from '../../util'; + +/** + * Queries the currently selected elements for a given session's diagram. + */ +@injectable() +export class GetSelectionMcpToolHandler implements McpToolHandler, ActionHandler { + actionKinds = [GetSelectionMcpResultAction.KIND]; + + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + protected resolvers: Record) => void }> = {}; + + registerTool(server: GLSPMcpServer): void { + server.registerTool( + 'get-selection', + { + title: 'Get Selected Diagram Elements', + description: + 'Get the element IDs of all currently selected elements in the UI. ' + + 'This is usually only relevant when a user directly references their selection.', + inputSchema: { + sessionId: z.string().describe('Session ID for which the selection should be queried') + } + }, + params => this.handle(params) + ); + } + + async handle({ sessionId }: { sessionId?: string }): Promise { + this.logger.info(`'get-selection' invoked for session '${sessionId}'`); + + if (!sessionId) { + return createToolResult('No session id provided.', true); + } + + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + return createToolResult('No active session found for this session id.', true); + } + + const requestId = Math.trunc(Math.random() * 1000).toString(); + this.logger.info(`GetSelectionMcpAction dispatched with request ID '${requestId}'`); + session.actionDispatcher.dispatch(GetSelectionMcpAction.create(requestId)); + + // Start a promise and save the resolve function to the class + return new Promise(resolve => { + this.resolvers[requestId] = { sessionId, resolve }; + setTimeout(() => resolve(createToolResult('The request timed out.', true)), 5000); + }); + } + + async execute(action: GetSelectionMcpResultAction): Promise { + const requestId = action.mcpRequestId; + this.logger.info(`GetSelectionMcpResultAction received with request ID '${requestId}'`); + + const { sessionId, resolve } = this.resolvers[requestId]; + + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + this.logger.warn(`No session '${sessionId}' for request ID '${requestId}'`); + return []; + } + + const mcpIdAliasService = session.container.get(McpIdAliasService); + + const selectedIds = action.selectedElementsIDs.map(id => mcpIdAliasService.alias(sessionId, id)); + + // Resolve the previously started promise + const selectedIdsStr = selectedIds.map(id => `- ${id}`).join('\n'); + resolve?.(createToolResult(`Following element IDs are selected:\n${selectedIdsStr}`, false)); + + delete this.resolvers[requestId]; + + return []; + } +} diff --git a/packages/server-mcp/src/tools/handlers/modify-edges-handler.ts b/packages/server-mcp/src/tools/handlers/modify-edges-handler.ts new file mode 100644 index 0000000..9421dd9 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/modify-edges-handler.ts @@ -0,0 +1,178 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { + ChangeRoutingPointsOperation, + ClientSessionManager, + GShapeElement, + Logger, + ModelState, + ReconnectEdgeOperation +} from '@eclipse-glsp/server'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { GLSPMcpServer, McpIdAliasService, McpToolHandler } from '../../server'; +import { createToolResult } from '../../util'; + +/** + * Modifies onr or more edges in the given session's model. + */ +@injectable() +export class ModifyEdgesMcpToolHandler implements McpToolHandler { + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + registerTool(server: GLSPMcpServer): void { + server.registerTool( + 'modify-edges', + { + description: + 'Modify one or more edge elements in the diagram. ' + + 'This operation modifies the diagram state and requires user approval.', + inputSchema: { + sessionId: z.string().describe('Session ID in which the node should be modified'), + changes: z + .array( + z.object({ + elementId: z.string().describe('Element ID that should be modified.'), + sourceElementId: z.string().optional().describe('ID of the source element (must exist in the diagram)'), + targetElementId: z.string().optional().describe('ID of the target element (must exist in the diagram)'), + routingPoints: z + .array( + z.object({ + x: z.number().describe('Routing point x coordinate'), + y: z.number().describe('Routing point y coordinate') + }) + ) + .optional() + .describe( + 'Optional array of routing point coordinates that allow for a complex edge path. ' + + 'Using an empty array removes all routing points.' + ) + }) + ) + .min(1) + .describe( + 'Array of change objects containing an element ID and their intended changes. Must include at least one change.' + ) + } + }, + params => this.handle(params) + ); + } + + async handle({ + sessionId, + changes + }: { + sessionId: string; + changes: { elementId: string; sourceElementId?: string; targetElementId?: string; routingPoints?: { x: number; y: number }[] }[]; + }): Promise { + this.logger.info(`'modify-nodes' invoked for session '${sessionId}' with ${changes.length} changes`); + + if (!sessionId) { + return createToolResult('No session id provided.', true); + } + if (!changes || !changes.length) { + return createToolResult('No changes provided.', true); + } + + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + return createToolResult('No active session found for this session id.', true); + } + + const modelState = session.container.get(ModelState); + if (modelState.isReadonly) { + return createToolResult('Model is read-only', true); + } + + const mcpIdAliasService = session.container.get(McpIdAliasService); + + // Map the list of changes to their underlying element + const elements: [(typeof changes)[number], GShapeElement][] = changes.map(change => [ + change, + modelState.index.find(mcpIdAliasService.lookup(sessionId, change.elementId)) as GShapeElement + ]); + + // If any element could not be resolved, do not proceed + // As compared to the create operations, changes can be done in bulk, i.e., in a single transaction + const undefinedElements = elements.filter(([change, element]) => !element).map(([change]) => change.elementId); + if (undefinedElements.length) { + return createToolResult(`No edges found for the following element ids: ${undefinedElements}`, true); + } + + // Do all dispatches in parallel, as they should not interfere with each other + const promises: Promise[] = []; + const errors: string[] = []; + elements.forEach(([change, element]) => { + const { routingPoints } = change; + const elementId = mcpIdAliasService.lookup(sessionId, change.elementId); + const sourceElementId = change.sourceElementId ? mcpIdAliasService.lookup(sessionId, change.sourceElementId) : undefined; + const targetElementId = change.targetElementId ? mcpIdAliasService.lookup(sessionId, change.targetElementId) : undefined; + + // Filter incomplete change requests + if ((sourceElementId && !targetElementId) || (!sourceElementId && targetElementId)) { + errors.push(`Both source and target ID are required for input: ${JSON.stringify(change)}`); + return; + } + + // Reconnect an edge if required + if (sourceElementId && targetElementId) { + const source = modelState.index.find(sourceElementId); + if (!source) { + errors.push(`Source element not found: ${sourceElementId}`); + return; + } + const target = modelState.index.find(targetElementId); + if (!target) { + errors.push(`Target element not found: ${targetElementId}`); + return; + } + + const operation = ReconnectEdgeOperation.create({ edgeElementId: elementId, sourceElementId, targetElementId }); + promises.push(session.actionDispatcher.dispatch(operation)); + // It doesn't make much sense to add routing points while reconnecting an edge + return; + } + + // Change routing points if required + if (routingPoints) { + const operation = ChangeRoutingPointsOperation.create([{ elementId, newRoutingPoints: routingPoints }]); + promises.push(session.actionDispatcher.dispatch(operation)); + } + }); + + // Wait for all dispatches to finish before notifying the caller + await Promise.all(promises); + + // Create a failure string if any errors occurred + let failureStr = ''; + if (errors.length) { + const failureListStr = errors.map(error => `- ${error}\n`); + failureStr = `\nThe following errors occured:\n${failureListStr}`; + } + + return createToolResult( + `Succesfully modified ${changes.length - errors.length} edge(s) (in ${promises.length} commands)${failureStr}`, + false + ); + } +} diff --git a/packages/server-mcp/src/tools/handlers/modify-nodes-handler.ts b/packages/server-mcp/src/tools/handlers/modify-nodes-handler.ts new file mode 100644 index 0000000..454e0b7 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/modify-nodes-handler.ts @@ -0,0 +1,169 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { + ApplyLabelEditOperation, + ChangeBoundsOperation, + ClientSessionManager, + GLabel, + GShapeElement, + Logger, + ModelState +} from '@eclipse-glsp/server'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { GLSPMcpServer, McpIdAliasService, McpToolHandler } from '../../server'; +import { createToolResult } from '../../util'; + +/** + * Modifies onr or more nodes in the given session's model. + */ +@injectable() +export class ModifyNodesMcpToolHandler implements McpToolHandler { + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + registerTool(server: GLSPMcpServer): void { + server.registerTool( + 'modify-nodes', + { + description: + 'Modify one or more node elements in the diagram. ' + + "When modifying the node's position or size, absolutely consider the visual alignment with other nodes. " + + 'This operation modifies the diagram state and requires user approval.', + inputSchema: { + sessionId: z.string().describe('Session ID in which the node should be modified'), + changes: z + .array( + z.object({ + elementId: z.string().describe('Element ID that should be modified.'), + position: z + .object({ + x: z.number().describe('X coordinate in diagram space'), + y: z.number().describe('Y coordinate in diagram space') + }) + .optional() + .describe('Position where the node should be moved to (absolute diagram coordinates)'), + size: z + .object({ + width: z.number().describe('Width of the element in diagram space'), + height: z.number().describe('Height of the element in diagram space') + }) + .optional() + .describe('New size of the node'), + text: z + .string() + .optional() + .describe("Label text to use instead (given that the element's type allows for labels).") + }) + ) + .min(1) + .describe( + 'Array of change objects containing an element ID and their intended changes. Must include at least one change.' + ) + } + }, + params => this.handle(params) + ); + } + + async handle({ + sessionId, + changes + }: { + sessionId: string; + changes: { elementId: string; position?: { x: number; y: number }; size?: { width: number; height: number }; text?: string }[]; + }): Promise { + this.logger.info(`'modify-nodes' invoked for session '${sessionId}' with ${changes.length} changes`); + + if (!sessionId) { + return createToolResult('No session id provided.', true); + } + if (!changes || !changes.length) { + return createToolResult('No changes provided.', true); + } + + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + return createToolResult('No active session found for this session id.', true); + } + + const modelState = session.container.get(ModelState); + if (modelState.isReadonly) { + return createToolResult('Model is read-only', true); + } + + const mcpIdAliasService = session.container.get(McpIdAliasService); + + // Map the list of changes to their underlying element + const elements: [(typeof changes)[number], GShapeElement][] = changes.map(change => [ + change, + modelState.index.find(mcpIdAliasService.lookup(sessionId, change.elementId)) as GShapeElement + ]); + + // If any element could not be resolved, do not proceed + // As compared to the create operations, changes can be done in bulk, i.e., in a single transaction + const undefinedElements = elements.filter(([change, element]) => !element).map(([change]) => change.elementId); + if (undefinedElements.length) { + return createToolResult(`No nodes found for the following element ids: ${undefinedElements}`, true); + } + + // Do all dispatches in parallel, as they should not interfere with each other + const promises: Promise[] = []; + elements.forEach(([change, element]) => { + const { size, position, text } = change; + const elementId = mcpIdAliasService.lookup(sessionId, change.elementId); + + // Resize and/or move the affected node if applicable + if (size || position) { + const newSize = size ?? element.size; + const newPosition = position ?? element.position; + + const operation = ChangeBoundsOperation.create([{ elementId, newSize, newPosition }]); + promises.push(session.actionDispatcher.dispatch(operation)); + } + + // Change the label if applicable + const newElementLabelId = this.getCorrespondingLabelId(element); + if (newElementLabelId && text) { + const editLabelOperation = ApplyLabelEditOperation.create({ labelId: newElementLabelId, text }); + promises.push(session.actionDispatcher.dispatch(editLabelOperation)); + } + }); + + // Wait for all dispatches to finish before notifying the caller + await Promise.all(promises); + + return createToolResult(`Succesfully modified ${changes.length} node(s) (in ${promises.length} commands)`, false); + } + + /** + * This method provides the label ID for a labelled node's label. + * + * While it can be generally assumed that labelled nodes contain those labels + * as direct children, some custom elements may wrap their labels in intermediary + * container nodes. However, in the likely scenario that a specific GLSP implementation + * requires no further changes to this handler except extracting nested labels, this + * function serves as an easy entrypoint without a full override. + */ + protected getCorrespondingLabelId(element: GShapeElement): string | undefined { + return element.children.find(child => child instanceof GLabel)?.id; + } +} diff --git a/packages/server-mcp/src/tools/handlers/redo-handler.ts b/packages/server-mcp/src/tools/handlers/redo-handler.ts new file mode 100644 index 0000000..2246de8 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/redo-handler.ts @@ -0,0 +1,74 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { ClientSessionManager, CommandStack, Logger, RedoAction } from '@eclipse-glsp/server'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { GLSPMcpServer, McpToolHandler } from '../../server'; +import { createToolResult } from '../../util'; + +/** + * Redo a given number of the most recent undone actions on the command stack. + */ +@injectable() +export class RedoMcpToolHandler implements McpToolHandler { + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + registerTool(server: GLSPMcpServer): void { + server.registerTool( + 'redo', + { + description: 'Redo a given number of the last undone commands in the diagram and re-applies their effect.', + inputSchema: { + sessionId: z.string().describe('Session ID where redo should be performed'), + commandsToRedo: z.number().min(1).describe('Number of commands to redo') + } + }, + params => this.handle(params) + ); + } + + async handle({ sessionId, commandsToRedo }: { sessionId: string; commandsToRedo: number }): Promise { + this.logger.info(`'redo' invoked for session '${sessionId}'`); + + if (!sessionId) { + return createToolResult('No session id provided.', true); + } + + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + return createToolResult('Session not found', true); + } + + const commandStack = session.container.get(CommandStack); + + if (!commandStack.canRedo()) { + return createToolResult('Nothing to redo', true); + } + + for (let i = 0; i < commandsToRedo; i++) { + const action = RedoAction.create(); + await session.actionDispatcher.dispatch(action); + } + + return createToolResult('Redo successful', false); + } +} diff --git a/packages/server-mcp/src/tools/handlers/request-layout-handler.ts b/packages/server-mcp/src/tools/handlers/request-layout-handler.ts new file mode 100644 index 0000000..54c970d --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/request-layout-handler.ts @@ -0,0 +1,72 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { ClientSessionManager, LayoutOperation, Logger } from '@eclipse-glsp/server'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { GLSPMcpServer, McpToolHandler } from '../../server'; +import { createToolResult } from '../../util'; + +/** + * Requests an automatic layout of a given session's diagram. + * + * This tool is not registered by default, since the implementation of a `LayoutEngine` + * depends on a specific GLSP implementation and cannot be assumed to generally exist. + * Thus, it must be registered manually if layouting is required. + */ +@injectable() +export class RequestLayoutMcpToolHandler implements McpToolHandler { + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + registerTool(server: GLSPMcpServer): void { + server.registerTool( + 'request-layout', + { + description: + "Requests an automatic layout for the given session's model. " + + 'This should only be used if the user demands some unspecified layout. ' + + "In case of a custom layout, refer to the 'modify-nodes' and 'modify-edges' tools instead.", + inputSchema: { + sessionId: z.string().describe('Session ID of the model to layout') + } + }, + params => this.handle(params) + ); + } + + async handle({ sessionId }: { sessionId: string }): Promise { + this.logger.info(`'request-layout' invoked for session '${sessionId}'`); + + if (!sessionId) { + return createToolResult('No session id provided.', true); + } + + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + return createToolResult('Session not found', true); + } + + const operation = LayoutOperation.create(); + await session.actionDispatcher.dispatch(operation); + + return createToolResult('Automatic layout applied', false); + } +} diff --git a/packages/server-mcp/src/tools/handlers/save-model-handler.ts b/packages/server-mcp/src/tools/handlers/save-model-handler.ts new file mode 100644 index 0000000..150af53 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/save-model-handler.ts @@ -0,0 +1,81 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { ClientSessionManager, CommandStack, Logger, SaveModelAction } from '@eclipse-glsp/server'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { GLSPMcpServer, McpToolHandler } from '../../server'; +import { createToolResult } from '../../util'; + +/** + * Saves the given session's model. + */ +@injectable() +export class SaveModelMcpToolHandler implements McpToolHandler { + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + registerTool(server: GLSPMcpServer): void { + server.registerTool( + 'save-model', + { + description: + 'Save the current diagram model to persistent storage. ' + + 'This operation persists all changes back to the source model. ' + + 'Only do this on an explicit user request and not as part of other tasks. ' + + 'Optionally specify a new fileUri to save to a different location.', + inputSchema: { + sessionId: z.string().describe('Session ID where the model should be saved'), + fileUri: z + .string() + .optional() + .describe('Optional destination file URI. If not provided, saves to the original source model location.') + } + }, + params => this.handle(params) + ); + } + + async handle({ sessionId, fileUri }: { sessionId: string; fileUri?: string }): Promise { + this.logger.info(`'save-model' invoked for session '${sessionId}'`); + + if (!sessionId) { + return createToolResult('No session id provided.', true); + } + + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + return createToolResult('Session not found', true); + } + + const commandStack = session.container.get(CommandStack); + + // Check if there are unsaved changes + if (!commandStack.isDirty) { + return createToolResult('No changes to save', false); + } + + // Dispatch save action + const action = SaveModelAction.create({ fileUri }); + await session.actionDispatcher.dispatch(action); + + return createToolResult('Model saved successfully', false); + } +} diff --git a/packages/server-mcp/src/tools/handlers/undo-handler.ts b/packages/server-mcp/src/tools/handlers/undo-handler.ts new file mode 100644 index 0000000..b9f84fe --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/undo-handler.ts @@ -0,0 +1,74 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { ClientSessionManager, CommandStack, Logger, UndoAction } from '@eclipse-glsp/server'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { GLSPMcpServer, McpToolHandler } from '../../server'; +import { createToolResult } from '../../util'; + +/** + * Undo a given number of the most recent actions on the command stack. + */ +@injectable() +export class UndoMcpToolHandler implements McpToolHandler { + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + registerTool(server: GLSPMcpServer): void { + server.registerTool( + 'undo', + { + description: 'Undo a given number of the last executed commands in the diagram and reverts their changes.', + inputSchema: { + sessionId: z.string().describe('Session ID where undo should be performed'), + commandsToUndo: z.number().min(1).describe('Number of commands to undo') + } + }, + params => this.handle(params) + ); + } + + async handle({ sessionId, commandsToUndo }: { sessionId: string; commandsToUndo: number }): Promise { + this.logger.info(`'undo' invoked for session '${sessionId}'`); + + if (!sessionId) { + return createToolResult('No session id provided.', true); + } + + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + return createToolResult('Session not found', true); + } + + const commandStack = session.container.get(CommandStack); + + if (!commandStack.canUndo()) { + return createToolResult('Nothing to undo', true); + } + + for (let i = 0; i < commandsToUndo; i++) { + const action = UndoAction.create(); + await session.actionDispatcher.dispatch(action); + } + + return createToolResult('Undo successful', false); + } +} diff --git a/packages/server-mcp/src/tools/handlers/validate-diagram-handler.ts b/packages/server-mcp/src/tools/handlers/validate-diagram-handler.ts new file mode 100644 index 0000000..8f552c7 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/validate-diagram-handler.ts @@ -0,0 +1,106 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { ClientSessionManager, Logger, MarkersReason, ModelState, ModelValidator } from '@eclipse-glsp/server'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { GLSPMcpServer, McpIdAliasService, McpToolHandler } from '../../server'; +import { createToolResult, objectArrayToMarkdownTable } from '../../util'; + +/** + * Validates the given session's model. + */ +@injectable() +export class ValidateDiagramMcpToolHandler implements McpToolHandler { + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + registerTool(server: GLSPMcpServer): void { + server.registerTool( + 'validate-diagram', + { + description: + 'Validate diagram elements and return validation markers (errors, warnings, info). ' + + 'Triggers active validation computation. Use elementIds parameter to validate specific elements, ' + + 'or omit to validate the entire model.', + inputSchema: { + sessionId: z.string().describe('Session ID to validate'), + elementIds: z + .array(z.string()) + .optional() + .describe('Array of element IDs to validate. If not provided, validates entire model starting from root.'), + reason: z + .enum([MarkersReason.BATCH, MarkersReason.LIVE]) + .optional() + .default(MarkersReason.LIVE) + .describe('Validation reason: "batch" for thorough validation, "live" for quick incremental checks') + } + }, + params => this.handle(params) + ); + } + + async handle({ + sessionId, + elementIds, + reason + }: { + sessionId: string; + elementIds?: string[]; + reason?: string; + }): Promise { + this.logger.info(`'validate-diagram' invoked for session '${sessionId}'`); + + if (!sessionId) { + return createToolResult('No session id provided.', true); + } + + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + return createToolResult('Session not found', true); + } + + const modelState = session.container.get(ModelState); + + let validator: ModelValidator; + try { + validator = session.container.get(ModelValidator); + } catch (error) { + return createToolResult('No validator configured for this diagram type', true); + } + + const mcpIdAliasService = session.container.get(McpIdAliasService); + + // Determine which elements to validate + const idsToValidate = + elementIds && elementIds.length > 0 ? elementIds.map(id => mcpIdAliasService.lookup(sessionId, id)) : [modelState.root.id]; + + // Get elements from index + const elements = modelState.index.getAll(idsToValidate); + + // Run validation + const markers = (await validator.validate(elements, reason ?? MarkersReason.BATCH)).map(marker => ({ + ...marker, + elementId: mcpIdAliasService.alias(sessionId, marker.elementId) + })); + + return createToolResult(objectArrayToMarkdownTable(markers), false); + } +} diff --git a/packages/server-mcp/src/tools/index.ts b/packages/server-mcp/src/tools/index.ts new file mode 100644 index 0000000..1604785 --- /dev/null +++ b/packages/server-mcp/src/tools/index.ts @@ -0,0 +1,30 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +export * from './handlers/change-view-handler'; +export * from './handlers/create-edges-handler'; +export * from './handlers/create-nodes-handler'; +export * from './handlers/delete-elements-handler'; +export * from './handlers/diagram-elements-handler'; +export * from './handlers/get-selection-handler'; +export * from './handlers/modify-edges-handler'; +export * from './handlers/modify-nodes-handler'; +export * from './handlers/redo-handler'; +export * from './handlers/request-layout-handler'; +export * from './handlers/save-model-handler'; +export * from './handlers/undo-handler'; +export * from './handlers/validate-diagram-handler'; +export * from './mcp-tool-contribution'; diff --git a/packages/server-mcp/src/tools/mcp-tool-contribution.ts b/packages/server-mcp/src/tools/mcp-tool-contribution.ts new file mode 100644 index 0000000..c288535 --- /dev/null +++ b/packages/server-mcp/src/tools/mcp-tool-contribution.ts @@ -0,0 +1,37 @@ +/******************************************************************************** + * Copyright (c) 2025 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { injectable, multiInject } from 'inversify'; +import { GLSPMcpServer, McpServerContribution, McpToolHandler } from '../server'; + +/** + * MCP server contribution that provides tools for performing actions on + * GLSP diagrams, including validation and element creation. + * + * This contribution should not be overriden or extended if another resource is required. + * Instead, a new {@link McpToolHandler} should be registered like: + * @example + * bindAsService(bind, McpToolHandler, CreateNodeMcpToolHandler); + */ +@injectable() +export class McpToolContribution implements McpServerContribution { + @multiInject(McpToolHandler) + protected mcpToolHandlers: McpToolHandler[]; + + configure(server: GLSPMcpServer): void { + this.mcpToolHandlers.forEach(handler => handler.registerTool(server)); + } +} diff --git a/packages/server-mcp/src/util/index.ts b/packages/server-mcp/src/util/index.ts new file mode 100644 index 0000000..092fef5 --- /dev/null +++ b/packages/server-mcp/src/util/index.ts @@ -0,0 +1,18 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +export * from './markdown-util'; +export * from './mcp-util'; diff --git a/packages/server-mcp/src/util/markdown-util.ts b/packages/server-mcp/src/util/markdown-util.ts new file mode 100644 index 0000000..092acc7 --- /dev/null +++ b/packages/server-mcp/src/util/markdown-util.ts @@ -0,0 +1,43 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +/** + * This function serializes a given array of objects into a Markdown table string. + */ +export function objectArrayToMarkdownTable(data: Record[]): string { + if (!data.length) { + return ''; + } + + const headers = Object.keys(data[0]); + const headerRow = `| ${headers.join(' | ')} |`; + const separatorRow = `| ${headers.map(() => '---').join(' | ')} |`; + + const dataRows = data.map((obj: Record) => { + const rowString = headers + .map(header => { + const value = obj[header] ?? ''; + if (typeof value === 'object') { + return JSON.stringify(value).replace(/["{}]/g, ''); + } + return value; + }) + .join(' | '); + return `| ${rowString} |`; + }); + + return [headerRow, separatorRow, ...dataRows].join('\n'); +} diff --git a/packages/server-mcp/src/util/mcp-util.ts b/packages/server-mcp/src/util/mcp-util.ts new file mode 100644 index 0000000..5270a7c --- /dev/null +++ b/packages/server-mcp/src/util/mcp-util.ts @@ -0,0 +1,73 @@ +/******************************************************************************** + * Copyright (c) 2025 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { CallToolResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types'; +import { ResourceHandlerResult } from '../server'; + +/** + * Extracts a single parameter value from MCP resource template parameters. + * Parameters can be either a string or an array of strings. + * + * @param params The parameter record from the resource template + * @param key The parameter key to extract + * @returns The first value if it's an array, or the value directly if it's a string + */ +export function extractResourceParam(params: Record, key: string): string | undefined { + const value = params[key]; + return Array.isArray(value) ? value[0] : value; +} + +/** + * Wraps the result of a resource handler ({@link ResourceHandlerResult}) into a result for an + * MCP resource endpoint ({@link ReadResourceResult}). + */ +export function createResourceResult(result: ResourceHandlerResult): ReadResourceResult { + return { + contents: [result.content] + }; +} + +/** + * Wraps the result of a resource handler ({@link ResourceHandlerResult}) into a result for an + * MCP tool endpoint ({@link ReadResourceResult}). This is necessary if the server is configured + * to provide no resources and only tools (as some MCP clients may require this). + */ +export function createResourceToolResult(result: ResourceHandlerResult): CallToolResult { + return { + isError: result.isError, + content: [ + { + type: 'text', + text: (result.content as any).text + } + ] + }; +} + +/** + * Generates a proper {@link CallToolResult} from a text and error flag. + */ +export function createToolResult(text: string, isError: boolean): CallToolResult { + return { + isError, + content: [ + { + type: 'text', + text + } + ] + }; +} diff --git a/packages/server/src/common/gmodel/gmodel-create-node-operation-handler.ts b/packages/server/src/common/gmodel/gmodel-create-node-operation-handler.ts index c2f3cea..b909498 100644 --- a/packages/server/src/common/gmodel/gmodel-create-node-operation-handler.ts +++ b/packages/server/src/common/gmodel/gmodel-create-node-operation-handler.ts @@ -56,6 +56,11 @@ export abstract class GModelCreateNodeOperationHandler extends GModelOperationHa const relativeLocation = this.getRelativeLocation(operation); const element = this.createNode(operation, relativeLocation); if (element) { + // When handling IDs that are not guaranteed unique, ensure no collisions + // Since this is a constant time access, the performance impact should be negligable + while (this.modelState.index.find(element.id)) { + element.id = `${element.id}-`; + } container.children.push(element); element.parent = container; this.actionDispatcher.dispatchAfterNextUpdate(SelectAction.create({ selectedElementsIDs: [element.id] })); diff --git a/packages/server/src/common/launch/jsonrpc-server-launcher.ts b/packages/server/src/common/launch/jsonrpc-server-launcher.ts index 8e777be..109a066 100644 --- a/packages/server/src/common/launch/jsonrpc-server-launcher.ts +++ b/packages/server/src/common/launch/jsonrpc-server-launcher.ts @@ -83,6 +83,9 @@ export abstract class JsonRpcGLSPServerLauncher extends GLSPServerLauncher serverInstance.clientConnection.onNotification(JsonrpcGLSPClient.ShutdownNotification, () => this.disposeServerInstance(serverInstance) ); + // A connection may be unceremoniously be closed (e.g., closing/reloading the browser) in which + // case the server must still be disposed + serverInstance.clientConnection.onClose(() => this.disposeServerInstance(serverInstance)); this.logger.info('Starting GLSP server connection'); } diff --git a/packages/server/src/common/protocol/glsp-server.ts b/packages/server/src/common/protocol/glsp-server.ts index a733779..6026596 100644 --- a/packages/server/src/common/protocol/glsp-server.ts +++ b/packages/server/src/common/protocol/glsp-server.ts @@ -25,6 +25,7 @@ import { InitializeParameters, InitializeResult, MaybePromise, + McpInitializeParameters, MessageAction, ServerActions, distinctAdd, @@ -105,7 +106,12 @@ export class DefaultGLSPServer implements GLSPServer { let result = { protocolVersion: DefaultGLSPServer.PROTOCOL_VERSION, serverActions }; - result = await this.initializeServer(params, result); + // This server is generated as response on diagram request, + // i.e., the WebSocketServerLauncher starts DefaultGLSPServer per WS connection and this starts the McpServerManager + // For the simple browser "client", this means every client has a new GLSP Server and MCP Server, but this does not generally hold + const mcpParams: McpInitializeParameters = { mcpServer: {}, ...params }; + + result = await this.initializeServer(mcpParams, result); // keep for backwards compatibility // eslint-disable-next-line deprecation/deprecation result = await this.handleInitializeArgs(result, params.args);