From d0e330a67e946e838814a50f21acf16d33370bc0 Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Fri, 6 Mar 2026 15:29:40 +0100 Subject: [PATCH 01/26] Restructured MCP resources and added PNG resource --- examples/workflow-server/src/node/app.ts | 14 +- ... default-mcp-resource-contribution.old.ts} | 26 ++- .../src/default-mcp-tool-contribution.ts | 6 +- packages/server-mcp/src/di.config.ts | 13 +- packages/server-mcp/src/feature-flags.ts | 33 ++++ packages/server-mcp/src/index.ts | 10 +- .../export-png-action-handler-contribution.ts | 38 +++++ packages/server-mcp/src/init/index.ts | 18 ++ .../server-mcp/src/init/init-module.config.ts | 29 ++++ .../default-mcp-resource-contribution.ts | 158 ++++++++++++++++++ .../default-mcp-resource-tool-contribution.ts | 126 ++++++++++++++ .../handlers/documentation-handler.ts | 119 +++++++++++++ .../resources/handlers/export-png-handler.ts | 127 ++++++++++++++ .../src/resources/handlers/model-handler.ts | 81 +++++++++ .../src/resources/handlers/session-handler.ts | 74 ++++++++ packages/server-mcp/src/resources/index.ts | 24 +++ .../src/resources/resource-module.config.ts | 45 +++++ .../services/mcp-model-serializer.ts | 128 ++++++++++++++ .../{ => server}/http-server-with-sessions.ts | 0 packages/server-mcp/src/server/index.ts | 19 +++ .../{ => server}/mcp-server-contribution.ts | 4 + .../src/{ => server}/mcp-server-manager.ts | 12 +- packages/server-mcp/src/util/index.ts | 18 ++ packages/server-mcp/src/util/markdown-util.ts | 43 +++++ .../server-mcp/src/{ => util}/mcp-util.ts | 30 +++- .../server/src/common/protocol/glsp-server.ts | 9 +- 26 files changed, 1173 insertions(+), 31 deletions(-) rename packages/server-mcp/src/{default-mcp-resource-contribution.ts => default-mcp-resource-contribution.old.ts} (92%) create mode 100644 packages/server-mcp/src/feature-flags.ts create mode 100644 packages/server-mcp/src/init/export-png-action-handler-contribution.ts create mode 100644 packages/server-mcp/src/init/index.ts create mode 100644 packages/server-mcp/src/init/init-module.config.ts create mode 100644 packages/server-mcp/src/resources/default-mcp-resource-contribution.ts create mode 100644 packages/server-mcp/src/resources/default-mcp-resource-tool-contribution.ts create mode 100644 packages/server-mcp/src/resources/handlers/documentation-handler.ts create mode 100644 packages/server-mcp/src/resources/handlers/export-png-handler.ts create mode 100644 packages/server-mcp/src/resources/handlers/model-handler.ts create mode 100644 packages/server-mcp/src/resources/handlers/session-handler.ts create mode 100644 packages/server-mcp/src/resources/index.ts create mode 100644 packages/server-mcp/src/resources/resource-module.config.ts create mode 100644 packages/server-mcp/src/resources/services/mcp-model-serializer.ts rename packages/server-mcp/src/{ => server}/http-server-with-sessions.ts (100%) create mode 100644 packages/server-mcp/src/server/index.ts rename packages/server-mcp/src/{ => server}/mcp-server-contribution.ts (92%) rename packages/server-mcp/src/{ => server}/mcp-server-manager.ts (89%) create mode 100644 packages/server-mcp/src/util/index.ts create mode 100644 packages/server-mcp/src/util/markdown-util.ts rename packages/server-mcp/src/{ => util}/mcp-util.ts (74%) diff --git a/examples/workflow-server/src/node/app.ts b/examples/workflow-server/src/node/app.ts index 3a659bc..3b000f8 100644 --- a/examples/workflow-server/src/node/app.ts +++ b/examples/workflow-server/src/node/app.ts @@ -19,7 +19,7 @@ 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, configureMcpModules } from '@eclipse-glsp/server-mcp'; import { WorkflowLayoutConfigurator } from '../common/layout/workflow-layout-configurator'; import { WorkflowDiagramModule, WorkflowServerModule } from '../common/workflow-diagram-module'; import { createWorkflowCliParser } from './workflow-cli-parser'; @@ -40,15 +40,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 mcpModules = configureMcpModules(); // 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, ...mcpModules); await launcher.start({ port: options.port, host: options.host, path: 'workflow' }); } else { const launcher = appContainer.resolve(SocketServerLauncher); - launcher.configure(serverModule, mcpModule); + launcher.configure(serverModule, ...mcpModules); await launcher.start({ port: options.port, host: options.host }); } } diff --git a/packages/server-mcp/src/default-mcp-resource-contribution.ts b/packages/server-mcp/src/default-mcp-resource-contribution.old.ts similarity index 92% rename from packages/server-mcp/src/default-mcp-resource-contribution.ts rename to packages/server-mcp/src/default-mcp-resource-contribution.old.ts index 83c723e..1859660 100644 --- a/packages/server-mcp/src/default-mcp-resource-contribution.ts +++ b/packages/server-mcp/src/default-mcp-resource-contribution.old.ts @@ -14,11 +14,12 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +// TODO this class should be removed in time but serves as a development resource for now + import { ClientSessionManager, CreateOperationHandler, DiagramModules, - GModelSerializer, Logger, ModelState, OperationHandlerRegistry @@ -26,9 +27,10 @@ import { 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'; +import { McpModelSerializer } from './resources/services/mcp-model-serializer'; +import { McpServerContribution } from './server/mcp-server-contribution'; +import { GLSPMcpServer } from './server/mcp-server-manager'; +import { extractParam } from './util/mcp-util'; /** * Default MCP server contribution that provides read-only resources for accessing @@ -306,16 +308,22 @@ export class DefaultMcpResourceContribution implements McpServerContribution { } const modelState = session.container.get(ModelState); - const serializer = session.container.get(GModelSerializer); + // const serializer = session.container.get(GModelSerializer); + + // const schema = serializer.createSchema(modelState.root); - const schema = serializer.createSchema(modelState.root); + const mcpSerializer = session.container.get(McpModelSerializer); + const mcpString = mcpSerializer.serialize(modelState.root); return { contents: [ { - uri: `glsp://diagrams/${sessionId}/model`, - mimeType: 'application/json', - text: JSON.stringify(schema, undefined, 2) + // uri: `glsp://diagrams/${sessionId}/model`, + // mimeType: 'application/json', + // text: JSON.stringify(schema, undefined, 2) + uri: `glsp://diagrams/${sessionId}/model.md`, + mimeType: 'text/markdown', + text: mcpString } ] }; diff --git a/packages/server-mcp/src/default-mcp-tool-contribution.ts b/packages/server-mcp/src/default-mcp-tool-contribution.ts index 6ae5453..69c8f26 100644 --- a/packages/server-mcp/src/default-mcp-tool-contribution.ts +++ b/packages/server-mcp/src/default-mcp-tool-contribution.ts @@ -27,9 +27,9 @@ import { 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'; +import { McpServerContribution } from './server/mcp-server-contribution'; +import { GLSPMcpServer } from './server/mcp-server-manager'; +import { createToolError, createToolSuccess } from './util/mcp-util'; /** * Default MCP server contribution that provides tools for performing actions on diff --git a/packages/server-mcp/src/di.config.ts b/packages/server-mcp/src/di.config.ts index a8a7426..01647a0 100644 --- a/packages/server-mcp/src/di.config.ts +++ b/packages/server-mcp/src/di.config.ts @@ -15,19 +15,20 @@ ********************************************************************************/ import { GLSPServerInitContribution, GLSPServerListener, bindAsService } 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 { configureMcpResourceModule } from './resources'; +import { McpServerContribution, McpServerManager } from './server'; -export function configureMcpModule(): ContainerModule { +export function configureMcpModules(): ContainerModule[] { + return [configureMcpServerModule(), configureMcpResourceModule()]; +} + +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); }); } diff --git a/packages/server-mcp/src/feature-flags.ts b/packages/server-mcp/src/feature-flags.ts new file mode 100644 index 0000000..6a2674c --- /dev/null +++ b/packages/server-mcp/src/feature-flags.ts @@ -0,0 +1,33 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +// TODO development tool, replace with proper implementation as needed +/** + * Provides a simple interface to enable/disable specific features during development + */ +export const FEATURE_FLAGS = { + /** + * Changes how resources are registered + * + * true -> MCP resources + * + * false -> MCP tools + */ + useResources: true, + resources: { + png: false + } +}; diff --git a/packages/server-mcp/src/index.ts b/packages/server-mcp/src/index.ts index 174d0b8..34bb850 100644 --- a/packages/server-mcp/src/index.ts +++ b/packages/server-mcp/src/index.ts @@ -13,10 +13,10 @@ * * 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 './feature-flags'; +export * from './init'; +export * from './resources'; +export * from './server'; +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..f8cd8e7 --- /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 { DefaultMcpResourcePngHandler } 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(DefaultMcpResourcePngHandler) + protected pngHandler: DefaultMcpResourcePngHandler; + + initialize(args?: Args): void { + this.registry.registerHandler(this.pngHandler); + } +} diff --git a/packages/server-mcp/src/init/index.ts b/packages/server-mcp/src/init/index.ts new file mode 100644 index 0000000..3412eb4 --- /dev/null +++ b/packages/server-mcp/src/init/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 './export-png-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..9d1dcf5 --- /dev/null +++ b/packages/server-mcp/src/init/init-module.config.ts @@ -0,0 +1,29 @@ +/******************************************************************************** + * 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 { ClientSessionInitializer } from '@eclipse-glsp/server'; +import { ContainerModule } from 'inversify'; +import { ExportMcpPngActionHandlerInitContribution } from './export-png-action-handler-contribution'; + +// TODO this only exists to inject additional action handlers without interfering too much with the given module hierarchy +// however, as this is somewhat hacky, it is likely better to just extend `ServerModule` (e.g., `McpServerModule`) to register the handlers +// it could even be completely unnecessary if all the action handlers registered are for useless features that are removed anyway +export function configureMcpInitModule(): ContainerModule { + return new ContainerModule(bind => { + bind(ExportMcpPngActionHandlerInitContribution).toSelf().inSingletonScope(); + bind(ClientSessionInitializer).toService(ExportMcpPngActionHandlerInitContribution); + }); +} diff --git a/packages/server-mcp/src/resources/default-mcp-resource-contribution.ts b/packages/server-mcp/src/resources/default-mcp-resource-contribution.ts new file mode 100644 index 0000000..9833a6e --- /dev/null +++ b/packages/server-mcp/src/resources/default-mcp-resource-contribution.ts @@ -0,0 +1,158 @@ +/******************************************************************************** + * 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 { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp'; +import { inject, injectable } from 'inversify'; +import { FEATURE_FLAGS } from '../feature-flags'; +import { GLSPMcpServer, McpServerContribution } from '../server'; +import { createResourceResult, extractParam } from '../util'; +import { McpResourceDocumentationHandler } from './handlers/documentation-handler'; +import { McpResourcePngHandler } from './handlers/export-png-handler'; +import { McpResourceModelHandler } from './handlers/model-handler'; +import { McpResourceSessionHandler } from './handlers/session-handler'; + +/** + * Default MCP server contribution that provides read-only resources for accessing + * GLSP server state, including sessions, element types, and diagram models. + * + * This contribution can be overridden to customize or extend resource functionality. + */ +@injectable() +export class DefaultMcpResourceContribution implements McpServerContribution { + @inject(McpResourceDocumentationHandler) + protected documentationHandler: McpResourceDocumentationHandler; + @inject(McpResourceSessionHandler) + protected sessionHandler: McpResourceSessionHandler; + @inject(McpResourceModelHandler) + protected modelHandler: McpResourceModelHandler; + @inject(McpResourcePngHandler) + protected pngHandler: McpResourcePngHandler; + + configure(server: GLSPMcpServer): void { + this.registerSessionsListResource(server); + this.registerElementTypesResource(server); + this.registerDiagramModelResource(server); + if (FEATURE_FLAGS.resources.png) { + this.registerDiagramPngResource(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: 'text/markdown' + }, + () => createResourceResult(this.sessionHandler.getAllSessions()) + ); + } + + protected registerElementTypesResource(server: GLSPMcpServer): void { + server.registerResource( + 'element-types', + new ResourceTemplate('glsp://types/{diagramType}/elements', { + list: () => { + const diagramTypes = this.documentationHandler.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.documentationHandler.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' + }, + (_uri, params) => createResourceResult(this.documentationHandler.getElementTypes(extractParam(params, 'diagramType'))) + ); + } + + protected registerDiagramModelResource(server: GLSPMcpServer): void { + server.registerResource( + 'diagram-model', + new ResourceTemplate('glsp://diagrams/{sessionId}/model', { + list: () => { + const sessionIds = this.sessionHandler.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.sessionHandler.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' + }, + (_uri, params) => createResourceResult(this.modelHandler.getDiagramModel(extractParam(params, 'sessionId'))) + ); + } + + protected registerDiagramPngResource(server: GLSPMcpServer): void { + server.registerResource( + 'diagram-png', + new ResourceTemplate('glsp://diagrams/{sessionId}/png', { + list: () => { + const sessionIds = this.sessionHandler.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.sessionHandler.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) => { + const result = await this.pngHandler.getModelPng(extractParam(params, 'sessionId')); + return createResourceResult(result); + } + ); + } +} diff --git a/packages/server-mcp/src/resources/default-mcp-resource-tool-contribution.ts b/packages/server-mcp/src/resources/default-mcp-resource-tool-contribution.ts new file mode 100644 index 0000000..41b6eb5 --- /dev/null +++ b/packages/server-mcp/src/resources/default-mcp-resource-tool-contribution.ts @@ -0,0 +1,126 @@ +/******************************************************************************** + * 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 * as z from 'zod/v4'; +import { FEATURE_FLAGS } from '../feature-flags'; +import { GLSPMcpServer, McpServerContribution } from '../server'; +import { createResourceToolResult } from '../util'; +import { McpResourceDocumentationHandler } from './handlers/documentation-handler'; +import { McpResourcePngHandler } from './handlers/export-png-handler'; +import { McpResourceModelHandler } from './handlers/model-handler'; +import { McpResourceSessionHandler } from './handlers/session-handler'; + +/** + * Default MCP server contribution that provides read-only resources for accessing + * GLSP server state, including sessions, element types, and diagram models. + * + * However, as not all MCP clients are able to deal with resources, this class instead + * provides them as tools. For the core implementation, see {@link DefaultMcpResourceContribution}. + * + * This contribution can be overridden to customize or extend resource functionality. + */ +@injectable() +export class DefaultMcpResourceToolContribution implements McpServerContribution { + @inject(McpResourceDocumentationHandler) + protected documentationHandler: McpResourceDocumentationHandler; + @inject(McpResourceSessionHandler) + protected sessionHandler: McpResourceSessionHandler; + @inject(McpResourceModelHandler) + protected modelHandler: McpResourceModelHandler; + @inject(McpResourcePngHandler) + protected pngHandler: McpResourcePngHandler; + + configure(server: GLSPMcpServer): void { + this.registerSessionsListResource(server); + this.registerElementTypesResource(server); + this.registerDiagramModelResource(server); + if (FEATURE_FLAGS.resources.png) { + this.registerDiagramPngResource(server); + } + } + + protected registerSessionsListResource(server: GLSPMcpServer): void { + server.registerTool( + 'sessions-list', + { + title: 'GLSP Sessions List', + description: 'List all active GLSP client sessions across all diagram types' + }, + () => createResourceToolResult(this.sessionHandler.getAllSessions()) + ); + } + + protected registerElementTypesResource(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') + } + }, + params => createResourceToolResult(this.documentationHandler.getElementTypes(params.diagramType)) + ); + } + + protected registerDiagramModelResource(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 where the node should be created') + } + }, + params => createResourceToolResult(this.modelHandler.getDiagramModel(params.sessionId)) + ); + } + + protected registerDiagramPngResource(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.pngHandler.getModelPng(params.sessionId); + return { + isError: result.isError, + content: [ + { + type: 'image', + data: (result.content as any).blob, + mimeType: 'image/png' + } + ] + }; + } + ); + } +} diff --git a/packages/server-mcp/src/resources/handlers/documentation-handler.ts b/packages/server-mcp/src/resources/handlers/documentation-handler.ts new file mode 100644 index 0000000..06a70e3 --- /dev/null +++ b/packages/server-mcp/src/resources/handlers/documentation-handler.ts @@ -0,0 +1,119 @@ +/******************************************************************************** + * 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 { ContainerModule, inject, injectable } from 'inversify'; +import { ResourceHandlerResult } from '../../server'; +import { objectArrayToMarkdownTable } from '../../util'; + +export const McpResourceDocumentationHandler = Symbol('McpResourceDocumentationHandler'); + +/** + * The `McpResourceDocumentationHandler` provides handler functions supplying information about diagrams, + * their available elements, and other general information. This is independent from the current state of any given diagram. + */ +export interface McpResourceDocumentationHandler { + /** + * Lists the available diagram types. + */ + getDiagramTypes(): string[]; + + /** + * Lists the available element types. 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. + * @param diagramType The diagram type to query the element types for. + */ + getElementTypes(diagramType: string | undefined): ResourceHandlerResult; +} + +@injectable() +export class DefaultMcpResourceDocumentationHandler implements McpResourceDocumentationHandler { + @inject(Logger) + protected logger: Logger; + + @inject(DiagramModules) + protected diagramModules: Map; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + getDiagramTypes(): string[] { + return Array.from(this.diagramModules.keys()); + } + + getElementTypes(diagramType: string | undefined): ResourceHandlerResult { + this.logger.info(`getElementTypes 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 }> = []; + + 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 }); + } + } + } + + const result = [ + `# Creatable element types for ${diagramType} diagrams`, + '## Node Types', + objectArrayToMarkdownTable(nodeTypes), + '## Edge Types', + objectArrayToMarkdownTable(edgeTypes) + ].join('\n'); + + return { + content: { + uri: `glsp://types/${diagramType}/elements`, + mimeType: 'text/markdown', + text: result + }, + isError: false + }; + } +} diff --git a/packages/server-mcp/src/resources/handlers/export-png-handler.ts b/packages/server-mcp/src/resources/handlers/export-png-handler.ts new file mode 100644 index 0000000..e406355 --- /dev/null +++ b/packages/server-mcp/src/resources/handlers/export-png-handler.ts @@ -0,0 +1,127 @@ +/******************************************************************************** + * 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, + ActionDispatcher, + ActionHandler, + ClientSessionManager, + ExportMcpPngAction, + Logger, + RequestExportMcpPngAction +} from '@eclipse-glsp/server'; +import { inject, injectable } from 'inversify'; +import { ResourceHandlerResult } from '../../server'; + +export const McpResourcePngHandler = Symbol('McpResourcePngHandler'); + +/** + * The `McpResourcePngHandler` provides a handler function to produce a PNG of the current model. + */ +export interface McpResourcePngHandler { + /** + * Creates a base64-encoded PNG of the given session's model state. + * @param sessionId The relevant session. + */ + getModelPng(sessionId: string | undefined): Promise; +} + +/** + * 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 RequestExportMcpPngAction} and register this class as an + * `ActionHandler` for the response action {@link ExportMcpPngAction}. 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 DefaultMcpResourcePngHandler implements McpResourcePngHandler, ActionHandler { + actionKinds = [ExportMcpPngAction.KIND]; + + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + protected promiseResolveFn: (value: ResourceHandlerResult | PromiseLike) => void; + + async execute(action: ExportMcpPngAction): Promise { + const sessionId = action.options?.sessionId ?? ''; + this.logger.info(`ExportMcpPngAction received for session ${sessionId}`); + + this.promiseResolveFn?.({ + content: { + uri: `glsp://diagrams/${sessionId}/png`, + mimeType: 'image/png', + blob: action.png + }, + isError: false + }); + + return []; + } + + async getModelPng(sessionId: string | undefined): Promise { + this.logger.info(`getModelPng 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 actionDispatcher = session.container.get(ActionDispatcher); + + actionDispatcher.dispatch(RequestExportMcpPngAction.create({ options: { sessionId } })); + + return new Promise(resolve => { + this.promiseResolveFn = resolve; + setTimeout( + () => + resolve({ + content: { + uri: `glsp://diagrams/${sessionId}/png`, + mimeType: 'text/plain', + text: 'The generation of the PNG timed out.' + }, + isError: true + }), + 5000 + ); + }); + } +} diff --git a/packages/server-mcp/src/resources/handlers/model-handler.ts b/packages/server-mcp/src/resources/handlers/model-handler.ts new file mode 100644 index 0000000..d6a7e8a --- /dev/null +++ b/packages/server-mcp/src/resources/handlers/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, Logger, ModelState } from '@eclipse-glsp/server'; +import { inject, injectable } from 'inversify'; +import { ResourceHandlerResult } from '../../server'; +import { McpModelSerializer } from '../services/mcp-model-serializer'; + +export const McpResourceModelHandler = Symbol('McpResourceModelHandler'); + +/** + * The `McpResourceModelHandler` provides information about a specific model. + */ +export interface McpResourceModelHandler { + /** + * Creates a serialized representation of the given session's model state. + * @param sessionId The relevant session. + */ + getDiagramModel(sessionId: string | undefined): ResourceHandlerResult; +} + +@injectable() +export class DefaultMcpResourceModelHandler implements McpResourceModelHandler { + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + getDiagramModel(sessionId: string | undefined): ResourceHandlerResult { + this.logger.info(`getDiagramModel 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 mcpSerializer = session.container.get(McpModelSerializer); + const mcpString = mcpSerializer.serialize(modelState.root); + + return { + content: { + uri: `glsp://diagrams/${sessionId}/model`, + mimeType: 'text/markdown', + text: mcpString + }, + isError: false + }; + } +} diff --git a/packages/server-mcp/src/resources/handlers/session-handler.ts b/packages/server-mcp/src/resources/handlers/session-handler.ts new file mode 100644 index 0000000..0c0ce54 --- /dev/null +++ b/packages/server-mcp/src/resources/handlers/session-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, Logger, ModelState } from '@eclipse-glsp/server'; +import { inject, injectable } from 'inversify'; +import { ResourceHandlerResult } from '../../server'; +import { objectArrayToMarkdownTable } from '../../util'; + +export const McpResourceSessionHandler = Symbol('McpResourceSessionHandler'); + +/** + * The `McpResourceSessionHandler` provides information about the sessions currently active. + */ +export interface McpResourceSessionHandler { + /** + * Lists the current session ids according to the {@link ClientSessionManager}. + */ + getSessionIds(): string[]; + + /** + * 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. + */ + getAllSessions(): ResourceHandlerResult; +} + +@injectable() +export class DefaultMcpResourceSessionHandler implements McpResourceSessionHandler { + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + getSessionIds(): string[] { + return this.clientSessionManager.getSessions().map(s => s.id); + } + + getAllSessions(): ResourceHandlerResult { + this.logger.info('getAllSessions 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..a98ba0b --- /dev/null +++ b/packages/server-mcp/src/resources/index.ts @@ -0,0 +1,24 @@ +/******************************************************************************** + * 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 './default-mcp-resource-contribution'; +export * from './default-mcp-resource-tool-contribution'; +export * from './handlers/documentation-handler'; +export * from './handlers/export-png-handler'; +export * from './handlers/model-handler'; +export * from './handlers/session-handler'; +export * from './resource-module.config'; +export * from './services/mcp-model-serializer'; diff --git a/packages/server-mcp/src/resources/resource-module.config.ts b/packages/server-mcp/src/resources/resource-module.config.ts new file mode 100644 index 0000000..c3bfc57 --- /dev/null +++ b/packages/server-mcp/src/resources/resource-module.config.ts @@ -0,0 +1,45 @@ +/******************************************************************************** + * 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 { bindAsService } from '@eclipse-glsp/server'; +import { ContainerModule } from 'inversify'; +import { FEATURE_FLAGS } from '../feature-flags'; +import { McpServerContribution } from '../server'; +import { DefaultMcpResourceContribution } from './default-mcp-resource-contribution'; +import { DefaultMcpResourceToolContribution } from './default-mcp-resource-tool-contribution'; +import { DefaultMcpResourceDocumentationHandler, McpResourceDocumentationHandler } from './handlers/documentation-handler'; +import { DefaultMcpResourcePngHandler, McpResourcePngHandler } from './handlers/export-png-handler'; +import { DefaultMcpResourceModelHandler, McpResourceModelHandler } from './handlers/model-handler'; +import { DefaultMcpResourceSessionHandler, McpResourceSessionHandler } from './handlers/session-handler'; +import { DefaultMcpModelSerializer, McpModelSerializer } from './services/mcp-model-serializer'; + +export function configureMcpResourceModule(): ContainerModule { + return new ContainerModule(bind => { + bindAsService(bind, McpModelSerializer, DefaultMcpModelSerializer); + + bindAsService(bind, McpResourceDocumentationHandler, DefaultMcpResourceDocumentationHandler); + bindAsService(bind, McpResourceSessionHandler, DefaultMcpResourceSessionHandler); + bindAsService(bind, McpResourceModelHandler, DefaultMcpResourceModelHandler); + bindAsService(bind, McpResourcePngHandler, DefaultMcpResourcePngHandler); + + // TODO currently only development tool + // think of nice switching mechanism for starting MCP servers with only tools or tools + resources + if (FEATURE_FLAGS.useResources) { + bindAsService(bind, McpServerContribution, DefaultMcpResourceContribution); + } else { + bindAsService(bind, McpServerContribution, DefaultMcpResourceToolContribution); + } + }); +} 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..9ac1cc7 --- /dev/null +++ b/packages/server-mcp/src/resources/services/mcp-model-serializer.ts @@ -0,0 +1,128 @@ +/******************************************************************************** + * 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. + * @returns The transformed string. + */ + serialize(element: GModelElement): string; +} + +@injectable() +export class DefaultMcpModelSerializer implements McpModelSerializer { + @inject(GModelSerializer) + protected gModelSerialzer: GModelSerializer; + + private keysToRemove: string[] = [ + 'cssClasses', + 'revision', + 'layout', + 'args', + 'layoutOptions', + 'alignment', + 'children', + 'routingPoints', + 'resizeLocations' + ]; + + serialize(element: GModelElement): string { + const elementsByType = this.prepareElement(element); + + return Object.entries(elementsByType) + .flatMap(([type, elements]) => [`# ${type}`, objectArrayToMarkdownTable(elements)]) + .join('\n'); + } + + protected prepareElement(element: GModelElement): Record[]> { + const schema = this.gModelSerialzer.createSchema(element); + + const elements = this.flattenStructure(schema, undefined); + + 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; + } + + private flattenStructure(schema: Record, parentId?: string): Record[] { + const result: Record[] = []; + + // this element is sure to exist but is irrelevant for the AI + if (schema.type !== 'graph') { + result.push(schema); + } + if (schema.children !== undefined) { + schema.children + .flatMap((child: Record) => this.flattenStructure(child, schema.id)) + .forEach((element: Record) => result.push(element)); + schema.children = undefined; + } + schema.parent = parentId; + + return result; + } + + private removeKeys(schema: Record): void { + for (const key in schema) { + if (this.keysToRemove.includes(key)) { + delete schema[key]; + } + } + } + + private combinePositionAndSize(schema: Record): void { + const position = schema.position; + if (position) { + // Not all positioned elements necessarily possess a size + const size = schema.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); + + delete schema['position']; + delete schema['size']; + + schema['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 100% rename from packages/server-mcp/src/http-server-with-sessions.ts rename to packages/server-mcp/src/server/http-server-with-sessions.ts diff --git a/packages/server-mcp/src/server/index.ts b/packages/server-mcp/src/server/index.ts new file mode 100644 index 0000000..350685d --- /dev/null +++ b/packages/server-mcp/src/server/index.ts @@ -0,0 +1,19 @@ +/******************************************************************************** + * 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'; diff --git a/packages/server-mcp/src/mcp-server-contribution.ts b/packages/server-mcp/src/server/mcp-server-contribution.ts similarity index 92% rename from packages/server-mcp/src/mcp-server-contribution.ts rename to packages/server-mcp/src/server/mcp-server-contribution.ts index 159166d..a9c5de3 100644 --- a/packages/server-mcp/src/mcp-server-contribution.ts +++ b/packages/server-mcp/src/server/mcp-server-contribution.ts @@ -25,3 +25,7 @@ export const McpServerContribution = Symbol('McpServerContribution'); export type ToolResultContent = CallToolResult['content'][number]; export type ResourceResultContent = ReadResourceResult['contents'][number]; +export interface ResourceHandlerResult { + content: ResourceResultContent; + isError: boolean; +} diff --git a/packages/server-mcp/src/mcp-server-manager.ts b/packages/server-mcp/src/server/mcp-server-manager.ts similarity index 89% rename from packages/server-mcp/src/mcp-server-manager.ts rename to packages/server-mcp/src/server/mcp-server-manager.ts index 8cbeba8..07d960a 100644 --- a/packages/server-mcp/src/mcp-server-manager.ts +++ b/packages/server-mcp/src/server/mcp-server-manager.ts @@ -37,6 +37,9 @@ export type FullMcpServerConfiguration = Required; export interface GLSPMcpServer extends Pick {} +// TODO for easier testing +let MCP_SERVER: McpHttpServerWithSessions | undefined = undefined; + @injectable() export class McpServerManager implements GLSPServerInitContribution, GLSPServerListener, Disposable { @inject(Logger) protected logger: Logger; @@ -52,12 +55,19 @@ export class McpServerManager implements GLSPServerInitContribution, GLSPServerL return result; } - const { port = 0, host = '127.0.0.1', route = '/glsp-mcp', name = 'glspMcpServer' } = mcpServerParam; + // TODO for easier testing + MCP_SERVER?.dispose(); + await new Promise(res => setTimeout(res, 500)); + + // TODO use fixed 60000 instead of 0 so that the MCP server need only be registered once and can thus be easier tested + const { port = 60000, host = '127.0.0.1', route = '/glsp-mcp', name = 'glspMcpServer' } = mcpServerParam; const mcpServerConfig: FullMcpServerConfiguration = { port, host, route, name }; const httpServer = new McpHttpServerWithSessions(this.logger); httpServer.onSessionInitialized(client => this.onSessionInitialized(client, mcpServerConfig)); this.toDispose.push(httpServer); + // TODO for easier testing + MCP_SERVER = httpServer; const address = await httpServer.start(mcpServerConfig); this.serverUrl = this.toServerUrl(address, route); 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..25a6bd3 --- /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/mcp-util.ts b/packages/server-mcp/src/util/mcp-util.ts similarity index 74% rename from packages/server-mcp/src/mcp-util.ts rename to packages/server-mcp/src/util/mcp-util.ts index 1344da6..395784f 100644 --- a/packages/server-mcp/src/mcp-util.ts +++ b/packages/server-mcp/src/util/mcp-util.ts @@ -14,7 +14,8 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { CallToolResult } from '@modelcontextprotocol/sdk/types'; +import { CallToolResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types'; +import { ResourceHandlerResult } from '../server'; /** * Extracts a single parameter value from MCP resource template parameters. @@ -70,3 +71,30 @@ export function createToolSuccess = Record = Record>(message: string, details?: T): CallToolResult { return createToolResult({ success: false, message, error: message, ...(details && { details }) }); } + +/** + * 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: 'resource', + resource: result.content + } + ] + }; +} diff --git a/packages/server/src/common/protocol/glsp-server.ts b/packages/server/src/common/protocol/glsp-server.ts index a733779..6c70f75 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,13 @@ export class DefaultGLSPServer implements GLSPServer { let result = { protocolVersion: DefaultGLSPServer.PROTOCOL_VERSION, serverActions }; - result = await this.initializeServer(params, result); + // TODO handle via parameter or something + // 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); From c6fed18d03f64d8c3899dd508faad01addd6bfd9 Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Sat, 7 Mar 2026 23:07:04 +0100 Subject: [PATCH 02/26] Restructured MCP tools --- .../default-mcp-resource-contribution.old.ts | 8 +- ...s => default-mcp-tool-contribution.old.ts} | 45 ++++- packages/server-mcp/src/di.config.ts | 11 +- packages/server-mcp/src/index.ts | 2 +- .../default-mcp-resource-contribution.ts | 8 +- .../src/server/mcp-server-contribution.ts | 1 + .../tools/default-mcp-tool-contribution.ts | 174 ++++++++++++++++++ .../src/tools/handlers/creation-handler.ts | 173 +++++++++++++++++ .../src/tools/handlers/deletion-handler.ts | 85 +++++++++ .../src/tools/handlers/model-handler.ts | 65 +++++++ .../src/tools/handlers/validation-handler.ts | 80 ++++++++ packages/server-mcp/src/tools/index.ts | 22 +++ .../src/tools/tool-module.config.ts | 34 ++++ packages/server-mcp/src/util/mcp-util.ts | 56 ++---- 14 files changed, 705 insertions(+), 59 deletions(-) rename packages/server-mcp/src/{default-mcp-tool-contribution.ts => default-mcp-tool-contribution.old.ts} (93%) create mode 100644 packages/server-mcp/src/tools/default-mcp-tool-contribution.ts create mode 100644 packages/server-mcp/src/tools/handlers/creation-handler.ts create mode 100644 packages/server-mcp/src/tools/handlers/deletion-handler.ts create mode 100644 packages/server-mcp/src/tools/handlers/model-handler.ts create mode 100644 packages/server-mcp/src/tools/handlers/validation-handler.ts create mode 100644 packages/server-mcp/src/tools/index.ts create mode 100644 packages/server-mcp/src/tools/tool-module.config.ts diff --git a/packages/server-mcp/src/default-mcp-resource-contribution.old.ts b/packages/server-mcp/src/default-mcp-resource-contribution.old.ts index 1859660..ad487db 100644 --- a/packages/server-mcp/src/default-mcp-resource-contribution.old.ts +++ b/packages/server-mcp/src/default-mcp-resource-contribution.old.ts @@ -30,7 +30,7 @@ import { ContainerModule, inject, injectable } from 'inversify'; import { McpModelSerializer } from './resources/services/mcp-model-serializer'; import { McpServerContribution } from './server/mcp-server-contribution'; import { GLSPMcpServer } from './server/mcp-server-manager'; -import { extractParam } from './util/mcp-util'; +import { extractResourceParam } from './util/mcp-util'; /** * Default MCP server contribution that provides read-only resources for accessing @@ -194,7 +194,7 @@ export class DefaultMcpResourceContribution implements McpServerContribution { } protected async getSessionInfo(params: Record): Promise { - const sessionId = extractParam(params, 'sessionId'); + const sessionId = extractResourceParam(params, 'sessionId'); if (!sessionId) { return { contents: [] }; } @@ -238,7 +238,7 @@ export class DefaultMcpResourceContribution implements McpServerContribution { } protected async getElementTypes(params: Record): Promise { - const diagramType = extractParam(params, 'diagramType'); + const diagramType = extractResourceParam(params, 'diagramType'); if (!diagramType) { return { contents: [] }; } @@ -297,7 +297,7 @@ export class DefaultMcpResourceContribution implements McpServerContribution { } protected async getDiagramModel(params: Record): Promise { - const sessionId = extractParam(params, 'sessionId'); + const sessionId = extractResourceParam(params, 'sessionId'); if (!sessionId) { return { contents: [] }; } diff --git a/packages/server-mcp/src/default-mcp-tool-contribution.ts b/packages/server-mcp/src/default-mcp-tool-contribution.old.ts similarity index 93% rename from packages/server-mcp/src/default-mcp-tool-contribution.ts rename to packages/server-mcp/src/default-mcp-tool-contribution.old.ts index 69c8f26..52575ac 100644 --- a/packages/server-mcp/src/default-mcp-tool-contribution.ts +++ b/packages/server-mcp/src/default-mcp-tool-contribution.old.ts @@ -14,6 +14,8 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +// TODO this class should be removed in time but serves as a development resource for now + import { DeleteElementOperation, MarkersReason, RedoAction, SaveModelAction, UndoAction } from '@eclipse-glsp/protocol'; import { ClientSessionManager, @@ -29,7 +31,48 @@ import { inject, injectable } from 'inversify'; import * as z from 'zod/v4'; import { McpServerContribution } from './server/mcp-server-contribution'; import { GLSPMcpServer } from './server/mcp-server-manager'; -import { createToolError, createToolSuccess } from './util/mcp-util'; + +/** + * 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 + */ +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 + */ +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 + */ +function createToolError = Record>(message: string, details?: T): CallToolResult { + return createToolResult({ success: false, message, error: message, ...(details && { details }) }); +} /** * Default MCP server contribution that provides tools for performing actions on diff --git a/packages/server-mcp/src/di.config.ts b/packages/server-mcp/src/di.config.ts index 01647a0..e1a8754 100644 --- a/packages/server-mcp/src/di.config.ts +++ b/packages/server-mcp/src/di.config.ts @@ -13,14 +13,15 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { GLSPServerInitContribution, GLSPServerListener, bindAsService } from '@eclipse-glsp/server'; +import { GLSPServerInitContribution, GLSPServerListener } from '@eclipse-glsp/server'; import { ContainerModule } from 'inversify'; -import { DefaultMcpToolContribution } from './default-mcp-tool-contribution'; import { configureMcpResourceModule } from './resources'; -import { McpServerContribution, McpServerManager } from './server'; +import { McpServerManager } from './server'; +import { configureMcpToolModule } from './tools'; +// TODO possibly instead of wholly separate modules, just provide functions using bind context from tools/resources export function configureMcpModules(): ContainerModule[] { - return [configureMcpServerModule(), configureMcpResourceModule()]; + return [configureMcpServerModule(), configureMcpResourceModule(), configureMcpToolModule()]; } function configureMcpServerModule(): ContainerModule { @@ -28,7 +29,5 @@ function configureMcpServerModule(): ContainerModule { bind(McpServerManager).toSelf().inSingletonScope(); bind(GLSPServerInitContribution).toService(McpServerManager); bind(GLSPServerListener).toService(McpServerManager); - - bindAsService(bind, McpServerContribution, DefaultMcpToolContribution); }); } diff --git a/packages/server-mcp/src/index.ts b/packages/server-mcp/src/index.ts index 34bb850..616f8af 100644 --- a/packages/server-mcp/src/index.ts +++ b/packages/server-mcp/src/index.ts @@ -13,10 +13,10 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -export * from './default-mcp-tool-contribution'; export * from './di.config'; export * from './feature-flags'; export * from './init'; export * from './resources'; export * from './server'; +export * from './tools'; export * from './util'; diff --git a/packages/server-mcp/src/resources/default-mcp-resource-contribution.ts b/packages/server-mcp/src/resources/default-mcp-resource-contribution.ts index 9833a6e..f4dea6b 100644 --- a/packages/server-mcp/src/resources/default-mcp-resource-contribution.ts +++ b/packages/server-mcp/src/resources/default-mcp-resource-contribution.ts @@ -18,7 +18,7 @@ import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp'; import { inject, injectable } from 'inversify'; import { FEATURE_FLAGS } from '../feature-flags'; import { GLSPMcpServer, McpServerContribution } from '../server'; -import { createResourceResult, extractParam } from '../util'; +import { createResourceResult, extractResourceParam } from '../util'; import { McpResourceDocumentationHandler } from './handlers/documentation-handler'; import { McpResourcePngHandler } from './handlers/export-png-handler'; import { McpResourceModelHandler } from './handlers/model-handler'; @@ -89,7 +89,7 @@ export class DefaultMcpResourceContribution implements McpServerContribution { 'Use this to discover valid elementTypeId values for creation tools.', mimeType: 'text/markdown' }, - (_uri, params) => createResourceResult(this.documentationHandler.getElementTypes(extractParam(params, 'diagramType'))) + (_uri, params) => createResourceResult(this.documentationHandler.getElementTypes(extractResourceParam(params, 'diagramType'))) ); } @@ -119,7 +119,7 @@ export class DefaultMcpResourceContribution implements McpServerContribution { 'Includes all nodes, edges, and their relevant properties.', mimeType: 'text/markdown' }, - (_uri, params) => createResourceResult(this.modelHandler.getDiagramModel(extractParam(params, 'sessionId'))) + (_uri, params) => createResourceResult(this.modelHandler.getDiagramModel(extractResourceParam(params, 'sessionId'))) ); } @@ -150,7 +150,7 @@ export class DefaultMcpResourceContribution implements McpServerContribution { mimeType: 'image/png' }, async (_uri, params) => { - const result = await this.pngHandler.getModelPng(extractParam(params, 'sessionId')); + const result = await this.pngHandler.getModelPng(extractResourceParam(params, 'sessionId')); return createResourceResult(result); } ); diff --git a/packages/server-mcp/src/server/mcp-server-contribution.ts b/packages/server-mcp/src/server/mcp-server-contribution.ts index a9c5de3..ed98122 100644 --- a/packages/server-mcp/src/server/mcp-server-contribution.ts +++ b/packages/server-mcp/src/server/mcp-server-contribution.ts @@ -25,6 +25,7 @@ export const McpServerContribution = Symbol('McpServerContribution'); export type ToolResultContent = CallToolResult['content'][number]; export type ResourceResultContent = ReadResourceResult['contents'][number]; + export interface ResourceHandlerResult { content: ResourceResultContent; isError: boolean; diff --git a/packages/server-mcp/src/tools/default-mcp-tool-contribution.ts b/packages/server-mcp/src/tools/default-mcp-tool-contribution.ts new file mode 100644 index 0000000..f5bac06 --- /dev/null +++ b/packages/server-mcp/src/tools/default-mcp-tool-contribution.ts @@ -0,0 +1,174 @@ +/******************************************************************************** + * 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 { MarkersReason } from '@eclipse-glsp/protocol'; +import { ClientSessionManager, Logger } from '@eclipse-glsp/server'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { GLSPMcpServer, McpServerContribution } from '../server'; +import { McpToolCreationHandler } from './handlers/creation-handler'; +import { McpToolDeletionHandler } from './handlers/deletion-handler'; +import { McpToolModelHandler } from './handlers/model-handler'; +import { McpToolValidationHandler } from './handlers/validation-handler'; + +/** + * 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; + + @inject(McpToolValidationHandler) + protected validationHandler: McpToolValidationHandler; + @inject(McpToolCreationHandler) + protected creationHandler: McpToolCreationHandler; + @inject(McpToolDeletionHandler) + protected deletionHandler: McpToolDeletionHandler; + @inject(McpToolModelHandler) + protected modelHandler: McpToolModelHandler; + + configure(server: GLSPMcpServer): void { + this.registerValidateDiagramTool(server); + this.registerCreateNodeTool(server); + this.registerCreateEdgeTool(server); + this.registerDeleteElementTool(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') + } + }, + params => this.validationHandler.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)') + } + }, + params => this.creationHandler.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)') + } + }, + params => this.creationHandler.createEdge(params) + ); + } + + 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.') + } + }, + params => this.deletionHandler.deleteElement(params) + ); + } + + 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.') + } + }, + params => this.modelHandler.saveModel(params) + ); + } +} diff --git a/packages/server-mcp/src/tools/handlers/creation-handler.ts b/packages/server-mcp/src/tools/handlers/creation-handler.ts new file mode 100644 index 0000000..603678a --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/creation-handler.ts @@ -0,0 +1,173 @@ +/******************************************************************************** + * 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, CreateEdgeOperation, CreateNodeOperation, Logger, ModelState } from '@eclipse-glsp/server'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types'; +import { inject, injectable } from 'inversify'; +import { createToolResult } from '../../util'; + +export const McpToolCreationHandler = Symbol('McpToolCreationHandler'); + +/** + * The `McpToolCreationHandler` + */ +export interface McpToolCreationHandler { + createNode(params: { + sessionId: string; + elementTypeId: string; + location: { x: number; y: number }; + containerId?: string; + args?: Record; + }): Promise; + + createEdge(params: { + sessionId: string; + elementTypeId: string; + sourceElementId: string; + targetElementId: string; + args?: Record; + }): Promise; +} + +@injectable() +export class DefaultMcpToolCreationHandler implements McpToolCreationHandler { + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + async createNode({ + sessionId, + elementTypeId, + location, + containerId, + args + }: { + sessionId: string; + elementTypeId: string; + location: { x: number; y: number }; + containerId?: string; + args?: Record; + }): Promise { + this.logger.info(`createNode invoked for session ${sessionId}`); + + try { + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + return createToolResult('Session not found', true); + } + + const modelState = session.container.get(ModelState); + + // Check if model is readonly + if (modelState.isReadonly) { + return createToolResult('Model is read-only', true); + } + + // 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 createToolResult('Node creation succeeded but could not determine element ID', true); + } + + return createToolResult(`Node created successfully with element ID: ${newElementId}`, false); + } catch (error) { + this.logger.error('Node creation failed', error); + return createToolResult(`Node creation failed: ${error instanceof Error ? error.message : String(error)}`, true); + } + } + + async createEdge({ + sessionId, + elementTypeId, + sourceElementId, + targetElementId, + args + }: { + sessionId: string; + elementTypeId: string; + sourceElementId: string; + targetElementId: string; + args?: Record; + }): Promise { + this.logger.info(`createEdge invoked for session ${sessionId}`); + + try { + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + return createToolResult('Session not found', true); + } + + const modelState = session.container.get(ModelState); + + // Check if model is readonly + if (modelState.isReadonly) { + return createToolResult('Model is read-only', true); + } + + // Validate source and target exist + const source = modelState.index.find(sourceElementId); + if (!source) { + return createToolResult(`Source element not found: ${sourceElementId}`, true); + } + + const target = modelState.index.find(targetElementId); + if (!target) { + return createToolResult(`Target element not found: ${targetElementId}`, true); + } + + // 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 createToolResult('Edge creation succeeded but could not determine element ID', true); + } + + return createToolResult(`Edge created successfully with element ID: ${newElementId}`, false); + } catch (error) { + this.logger.error('Edge creation failed', error); + return createToolResult(`Edge creation failed: ${error instanceof Error ? error.message : String(error)}`, true); + } + } +} diff --git a/packages/server-mcp/src/tools/handlers/deletion-handler.ts b/packages/server-mcp/src/tools/handlers/deletion-handler.ts new file mode 100644 index 0000000..7f58639 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/deletion-handler.ts @@ -0,0 +1,85 @@ +/******************************************************************************** + * 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 { createToolResult } from '../../util'; + +export const McpToolDeletionHandler = Symbol('McpToolDeletionHandler'); + +/** + * The `McpToolDeletionHandler` + */ +export interface McpToolDeletionHandler { + deleteElement(params: { sessionId: string; elementIds: string[] }): Promise; +} + +@injectable() +export class DefaultMcpToolDeletionHandler implements McpToolDeletionHandler { + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + async deleteElement({ sessionId, elementIds }: { sessionId: string; elementIds: string[] }): Promise { + this.logger.info(`deleteElement invoked for session ${sessionId}`); + + try { + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + return createToolResult('Session not found', true); + } + + const modelState = session.container.get(ModelState); + + // Check if model is readonly + if (modelState.isReadonly) { + return createToolResult('Model is read-only', true); + } + + // 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 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(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 createToolResult(`Successfully deleted ${deletedCount} element(s) (including dependents)`, false); + } catch (error) { + this.logger.error('Element deletion failed', error); + return createToolResult(`Element deletion failed: ${error instanceof Error ? error.message : String(error)}`, true); + } + } +} diff --git a/packages/server-mcp/src/tools/handlers/model-handler.ts b/packages/server-mcp/src/tools/handlers/model-handler.ts new file mode 100644 index 0000000..32ed5fc --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/model-handler.ts @@ -0,0 +1,65 @@ +/******************************************************************************** + * 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 { createToolResult } from '../../util'; + +export const McpToolModelHandler = Symbol('McpToolModelHandler'); + +/** + * The `McpToolModelHandler` + */ +export interface McpToolModelHandler { + saveModel(params: { sessionId: string; fileUri?: string }): Promise; +} + +@injectable() +export class DefaultMcpToolModelHandler implements McpToolModelHandler { + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + async saveModel({ sessionId, fileUri }: { sessionId: string; fileUri?: string }): Promise { + this.logger.info(`saveModel invoked for session ${sessionId}`); + + try { + 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); + } catch (error) { + this.logger.error('Save failed', error); + return createToolResult(`Save failed: ${error instanceof Error ? error.message : String(error)}`, true); + } + } +} diff --git a/packages/server-mcp/src/tools/handlers/validation-handler.ts b/packages/server-mcp/src/tools/handlers/validation-handler.ts new file mode 100644 index 0000000..4b81d7f --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/validation-handler.ts @@ -0,0 +1,80 @@ +/******************************************************************************** + * 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 { createToolResult, objectArrayToMarkdownTable } from '../../util'; + +export const McpToolValidationHandler = Symbol('McpToolValidationHandler'); + +/** + * The `McpToolValidationHandler` + */ +export interface McpToolValidationHandler { + validateDiagram(params: { sessionId: string; elementIds?: string[]; reason?: string }): Promise; +} + +@injectable() +export class DefaultMcpToolValidationHandler implements McpToolValidationHandler { + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + async validateDiagram({ + sessionId, + elementIds, + reason + }: { + sessionId: string; + elementIds?: string[]; + reason?: string; + }): Promise { + this.logger.info(`validateDiagram invoked for session ${sessionId}`); + + try { + 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); + } + + // 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 createToolResult(objectArrayToMarkdownTable(markers), false); + } catch (error) { + this.logger.error('Validation failed', error); + return createToolResult(`Validation failed: ${error instanceof Error ? error.message : String(error)}`, true); + } + } +} diff --git a/packages/server-mcp/src/tools/index.ts b/packages/server-mcp/src/tools/index.ts new file mode 100644 index 0000000..e3c467d --- /dev/null +++ b/packages/server-mcp/src/tools/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 './default-mcp-tool-contribution'; +export * from './handlers/creation-handler'; +export * from './handlers/deletion-handler'; +export * from './handlers/model-handler'; +export * from './handlers/validation-handler'; +export * from './tool-module.config'; diff --git a/packages/server-mcp/src/tools/tool-module.config.ts b/packages/server-mcp/src/tools/tool-module.config.ts new file mode 100644 index 0000000..59f38c6 --- /dev/null +++ b/packages/server-mcp/src/tools/tool-module.config.ts @@ -0,0 +1,34 @@ +/******************************************************************************** + * 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 { bindAsService } from '@eclipse-glsp/server'; +import { ContainerModule } from 'inversify'; +import { McpServerContribution } from '../server'; +import { DefaultMcpToolContribution } from './default-mcp-tool-contribution'; +import { DefaultMcpToolCreationHandler, McpToolCreationHandler } from './handlers/creation-handler'; +import { DefaultMcpToolDeletionHandler, McpToolDeletionHandler } from './handlers/deletion-handler'; +import { DefaultMcpToolModelHandler, McpToolModelHandler } from './handlers/model-handler'; +import { DefaultMcpToolValidationHandler, McpToolValidationHandler } from './handlers/validation-handler'; + +export function configureMcpToolModule(): ContainerModule { + return new ContainerModule(bind => { + bindAsService(bind, McpToolValidationHandler, DefaultMcpToolValidationHandler); + bindAsService(bind, McpToolCreationHandler, DefaultMcpToolCreationHandler); + bindAsService(bind, McpToolDeletionHandler, DefaultMcpToolDeletionHandler); + bindAsService(bind, McpToolModelHandler, DefaultMcpToolModelHandler); + + bindAsService(bind, McpServerContribution, DefaultMcpToolContribution); + }); +} diff --git a/packages/server-mcp/src/util/mcp-util.ts b/packages/server-mcp/src/util/mcp-util.ts index 395784f..90d273c 100644 --- a/packages/server-mcp/src/util/mcp-util.ts +++ b/packages/server-mcp/src/util/mcp-util.ts @@ -25,53 +25,11 @@ import { ResourceHandlerResult } from '../server'; * @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 { +export function extractResourceParam(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 }) }); -} - /** * Wraps the result of a resource handler ({@link ResourceHandlerResult}) into a result for an * MCP resource endpoint ({@link ReadResourceResult}). @@ -98,3 +56,15 @@ export function createResourceToolResult(result: ResourceHandlerResult): CallToo ] }; } + +export function createToolResult(text: string, isError: boolean): CallToolResult { + return { + isError, + content: [ + { + type: 'text', + text + } + ] + }; +} From e41d24f13a8b33f37d4e7736393205411ee68766 Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Sun, 8 Mar 2026 00:13:59 +0100 Subject: [PATCH 03/26] Reorganized tool and resource registration --- .../export-png-action-handler-contribution.ts | 6 +- .../default-mcp-resource-contribution.ts | 158 ---------------- .../default-mcp-resource-tool-contribution.ts | 126 ------------- .../handlers/diagram-model-handler.ts | 124 +++++++++++++ ...-png-handler.ts => diagram-png-handler.ts} | 119 +++++++++--- ...on-handler.ts => element-types-handler.ts} | 82 ++++++--- .../src/resources/handlers/model-handler.ts | 81 -------- ...on-handler.ts => sessions-list-handler.ts} | 52 +++--- packages/server-mcp/src/resources/index.ts | 11 +- .../resources/mcp-resource-contribution.ts | 44 +++++ .../src/resources/resource-module.config.ts | 30 ++- .../src/server/mcp-server-contribution.ts | 28 +++ .../tools/default-mcp-tool-contribution.ts | 174 ------------------ ...tion-handler.ts => create-edge-handler.ts} | 108 +++-------- .../src/tools/handlers/create-node-handler.ts | 122 ++++++++++++ ...n-handler.ts => delete-element-handler.ts} | 33 +++- ...model-handler.ts => save-model-handler.ts} | 36 +++- ...handler.ts => validate-diagram-handler.ts} | 41 ++++- packages/server-mcp/src/tools/index.ts | 11 +- .../src/tools/mcp-tool-contribution.ts | 37 ++++ .../src/tools/tool-module.config.ts | 24 +-- 21 files changed, 679 insertions(+), 768 deletions(-) delete mode 100644 packages/server-mcp/src/resources/default-mcp-resource-contribution.ts delete mode 100644 packages/server-mcp/src/resources/default-mcp-resource-tool-contribution.ts create mode 100644 packages/server-mcp/src/resources/handlers/diagram-model-handler.ts rename packages/server-mcp/src/resources/handlers/{export-png-handler.ts => diagram-png-handler.ts} (56%) rename packages/server-mcp/src/resources/handlers/{documentation-handler.ts => element-types-handler.ts} (55%) delete mode 100644 packages/server-mcp/src/resources/handlers/model-handler.ts rename packages/server-mcp/src/resources/handlers/{session-handler.ts => sessions-list-handler.ts} (56%) create mode 100644 packages/server-mcp/src/resources/mcp-resource-contribution.ts delete mode 100644 packages/server-mcp/src/tools/default-mcp-tool-contribution.ts rename packages/server-mcp/src/tools/handlers/{creation-handler.ts => create-edge-handler.ts} (55%) create mode 100644 packages/server-mcp/src/tools/handlers/create-node-handler.ts rename packages/server-mcp/src/tools/handlers/{deletion-handler.ts => delete-element-handler.ts} (70%) rename packages/server-mcp/src/tools/handlers/{model-handler.ts => save-model-handler.ts} (63%) rename packages/server-mcp/src/tools/handlers/{validation-handler.ts => validate-diagram-handler.ts} (64%) create mode 100644 packages/server-mcp/src/tools/mcp-tool-contribution.ts 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 index f8cd8e7..a79b678 100644 --- a/packages/server-mcp/src/init/export-png-action-handler-contribution.ts +++ b/packages/server-mcp/src/init/export-png-action-handler-contribution.ts @@ -16,7 +16,7 @@ import { ActionHandlerFactory, ActionHandlerRegistry, Args, ClientSessionInitializer } from '@eclipse-glsp/server'; import { inject, injectable } from 'inversify'; -import { DefaultMcpResourcePngHandler } from '../resources'; +import { DiagramPngMcpResourceHandler } from '../resources'; /** * This `ClientSessionInitializer` serves to register an additional `ActionHandler` without needing to extend `ServerModule`. @@ -29,8 +29,8 @@ export class ExportMcpPngActionHandlerInitContribution implements ClientSessionI protected factory: ActionHandlerFactory; @inject(ActionHandlerRegistry) protected registry: ActionHandlerRegistry; - @inject(DefaultMcpResourcePngHandler) - protected pngHandler: DefaultMcpResourcePngHandler; + @inject(DiagramPngMcpResourceHandler) + protected pngHandler: DiagramPngMcpResourceHandler; initialize(args?: Args): void { this.registry.registerHandler(this.pngHandler); diff --git a/packages/server-mcp/src/resources/default-mcp-resource-contribution.ts b/packages/server-mcp/src/resources/default-mcp-resource-contribution.ts deleted file mode 100644 index f4dea6b..0000000 --- a/packages/server-mcp/src/resources/default-mcp-resource-contribution.ts +++ /dev/null @@ -1,158 +0,0 @@ -/******************************************************************************** - * 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 { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp'; -import { inject, injectable } from 'inversify'; -import { FEATURE_FLAGS } from '../feature-flags'; -import { GLSPMcpServer, McpServerContribution } from '../server'; -import { createResourceResult, extractResourceParam } from '../util'; -import { McpResourceDocumentationHandler } from './handlers/documentation-handler'; -import { McpResourcePngHandler } from './handlers/export-png-handler'; -import { McpResourceModelHandler } from './handlers/model-handler'; -import { McpResourceSessionHandler } from './handlers/session-handler'; - -/** - * Default MCP server contribution that provides read-only resources for accessing - * GLSP server state, including sessions, element types, and diagram models. - * - * This contribution can be overridden to customize or extend resource functionality. - */ -@injectable() -export class DefaultMcpResourceContribution implements McpServerContribution { - @inject(McpResourceDocumentationHandler) - protected documentationHandler: McpResourceDocumentationHandler; - @inject(McpResourceSessionHandler) - protected sessionHandler: McpResourceSessionHandler; - @inject(McpResourceModelHandler) - protected modelHandler: McpResourceModelHandler; - @inject(McpResourcePngHandler) - protected pngHandler: McpResourcePngHandler; - - configure(server: GLSPMcpServer): void { - this.registerSessionsListResource(server); - this.registerElementTypesResource(server); - this.registerDiagramModelResource(server); - if (FEATURE_FLAGS.resources.png) { - this.registerDiagramPngResource(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: 'text/markdown' - }, - () => createResourceResult(this.sessionHandler.getAllSessions()) - ); - } - - protected registerElementTypesResource(server: GLSPMcpServer): void { - server.registerResource( - 'element-types', - new ResourceTemplate('glsp://types/{diagramType}/elements', { - list: () => { - const diagramTypes = this.documentationHandler.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.documentationHandler.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' - }, - (_uri, params) => createResourceResult(this.documentationHandler.getElementTypes(extractResourceParam(params, 'diagramType'))) - ); - } - - protected registerDiagramModelResource(server: GLSPMcpServer): void { - server.registerResource( - 'diagram-model', - new ResourceTemplate('glsp://diagrams/{sessionId}/model', { - list: () => { - const sessionIds = this.sessionHandler.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.sessionHandler.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' - }, - (_uri, params) => createResourceResult(this.modelHandler.getDiagramModel(extractResourceParam(params, 'sessionId'))) - ); - } - - protected registerDiagramPngResource(server: GLSPMcpServer): void { - server.registerResource( - 'diagram-png', - new ResourceTemplate('glsp://diagrams/{sessionId}/png', { - list: () => { - const sessionIds = this.sessionHandler.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.sessionHandler.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) => { - const result = await this.pngHandler.getModelPng(extractResourceParam(params, 'sessionId')); - return createResourceResult(result); - } - ); - } -} diff --git a/packages/server-mcp/src/resources/default-mcp-resource-tool-contribution.ts b/packages/server-mcp/src/resources/default-mcp-resource-tool-contribution.ts deleted file mode 100644 index 41b6eb5..0000000 --- a/packages/server-mcp/src/resources/default-mcp-resource-tool-contribution.ts +++ /dev/null @@ -1,126 +0,0 @@ -/******************************************************************************** - * 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 * as z from 'zod/v4'; -import { FEATURE_FLAGS } from '../feature-flags'; -import { GLSPMcpServer, McpServerContribution } from '../server'; -import { createResourceToolResult } from '../util'; -import { McpResourceDocumentationHandler } from './handlers/documentation-handler'; -import { McpResourcePngHandler } from './handlers/export-png-handler'; -import { McpResourceModelHandler } from './handlers/model-handler'; -import { McpResourceSessionHandler } from './handlers/session-handler'; - -/** - * Default MCP server contribution that provides read-only resources for accessing - * GLSP server state, including sessions, element types, and diagram models. - * - * However, as not all MCP clients are able to deal with resources, this class instead - * provides them as tools. For the core implementation, see {@link DefaultMcpResourceContribution}. - * - * This contribution can be overridden to customize or extend resource functionality. - */ -@injectable() -export class DefaultMcpResourceToolContribution implements McpServerContribution { - @inject(McpResourceDocumentationHandler) - protected documentationHandler: McpResourceDocumentationHandler; - @inject(McpResourceSessionHandler) - protected sessionHandler: McpResourceSessionHandler; - @inject(McpResourceModelHandler) - protected modelHandler: McpResourceModelHandler; - @inject(McpResourcePngHandler) - protected pngHandler: McpResourcePngHandler; - - configure(server: GLSPMcpServer): void { - this.registerSessionsListResource(server); - this.registerElementTypesResource(server); - this.registerDiagramModelResource(server); - if (FEATURE_FLAGS.resources.png) { - this.registerDiagramPngResource(server); - } - } - - protected registerSessionsListResource(server: GLSPMcpServer): void { - server.registerTool( - 'sessions-list', - { - title: 'GLSP Sessions List', - description: 'List all active GLSP client sessions across all diagram types' - }, - () => createResourceToolResult(this.sessionHandler.getAllSessions()) - ); - } - - protected registerElementTypesResource(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') - } - }, - params => createResourceToolResult(this.documentationHandler.getElementTypes(params.diagramType)) - ); - } - - protected registerDiagramModelResource(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 where the node should be created') - } - }, - params => createResourceToolResult(this.modelHandler.getDiagramModel(params.sessionId)) - ); - } - - protected registerDiagramPngResource(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.pngHandler.getModelPng(params.sessionId); - return { - isError: result.isError, - content: [ - { - type: 'image', - data: (result.content as any).blob, - mimeType: 'image/png' - } - ] - }; - } - ); - } -} 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..dbbbe1d --- /dev/null +++ b/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts @@ -0,0 +1,124 @@ +/******************************************************************************** + * 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'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { GLSPMcpServer, 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 where the node should be created') + } + }, + async params => createResourceToolResult(await this.handle(params)) + ); + } + + async handle({ sessionId }: { sessionId?: string }): Promise { + this.logger.info(`DiagramModelMcpResourceHandler 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 mcpSerializer = session.container.get(McpModelSerializer); + const mcpString = mcpSerializer.serialize(modelState.root); + + 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/export-png-handler.ts b/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts similarity index 56% rename from packages/server-mcp/src/resources/handlers/export-png-handler.ts rename to packages/server-mcp/src/resources/handlers/diagram-png-handler.ts index e406355..4f68c75 100644 --- a/packages/server-mcp/src/resources/handlers/export-png-handler.ts +++ b/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts @@ -23,23 +23,16 @@ import { Logger, RequestExportMcpPngAction } from '@eclipse-glsp/server'; +import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp'; import { inject, injectable } from 'inversify'; -import { ResourceHandlerResult } from '../../server'; - -export const McpResourcePngHandler = Symbol('McpResourcePngHandler'); - -/** - * The `McpResourcePngHandler` provides a handler function to produce a PNG of the current model. - */ -export interface McpResourcePngHandler { - /** - * Creates a base64-encoded PNG of the given session's model state. - * @param sessionId The relevant session. - */ - getModelPng(sessionId: string | undefined): Promise; -} +import * as z from 'zod/v4'; +import { FEATURE_FLAGS } from '../../feature-flags'; +import { GLSPMcpServer, McpResourceHandler, ResourceHandlerResult } from '../../server'; +import { createResourceResult, 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. @@ -52,7 +45,7 @@ export interface McpResourcePngHandler { * However, it is unclear whether this works in all circumstances, as it introduces impure functions. */ @injectable() -export class DefaultMcpResourcePngHandler implements McpResourcePngHandler, ActionHandler { +export class DiagramPngMcpResourceHandler implements McpResourceHandler, ActionHandler { actionKinds = [ExportMcpPngAction.KIND]; @inject(Logger) @@ -63,24 +56,72 @@ export class DefaultMcpResourcePngHandler implements McpResourcePngHandler, Acti protected promiseResolveFn: (value: ResourceHandlerResult | PromiseLike) => void; - async execute(action: ExportMcpPngAction): Promise { - const sessionId = action.options?.sessionId ?? ''; - this.logger.info(`ExportMcpPngAction received for session ${sessionId}`); - - this.promiseResolveFn?.({ - content: { - uri: `glsp://diagrams/${sessionId}/png`, - mimeType: 'image/png', - blob: action.png + registerResource(server: GLSPMcpServer): void { + if (!FEATURE_FLAGS.resources.png) { + return; + } + 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' }, - isError: false - }); + async (_uri, params) => createResourceResult(await this.handle({ sessionId: extractResourceParam(params, 'sessionId') })) + ); + } - return []; + registerTool(server: GLSPMcpServer): void { + if (!FEATURE_FLAGS.resources.png) { + return; + } + 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); + return { + isError: result.isError, + content: [ + { + type: 'image', + data: (result.content as any).blob, + mimeType: 'image/png' + } + ] + }; + } + ); } - async getModelPng(sessionId: string | undefined): Promise { - this.logger.info(`getModelPng invoked for session ${sessionId}`); + async handle({ sessionId }: { sessionId?: string }): Promise { + this.logger.info(`DiagramPngMcpResourceHandler invoked for session ${sessionId}`); if (!sessionId) { return { content: { @@ -124,4 +165,24 @@ export class DefaultMcpResourcePngHandler implements McpResourcePngHandler, Acti ); }); } + + async execute(action: ExportMcpPngAction): Promise { + const sessionId = action.options?.sessionId ?? ''; + this.logger.info(`ExportMcpPngAction received for session ${sessionId}`); + + this.promiseResolveFn?.({ + content: { + uri: `glsp://diagrams/${sessionId}/png`, + mimeType: 'image/png', + blob: action.png + }, + isError: false + }); + + return []; + } + + protected getSessionIds(): string[] { + return this.clientSessionManager.getSessions().map(s => s.id); + } } diff --git a/packages/server-mcp/src/resources/handlers/documentation-handler.ts b/packages/server-mcp/src/resources/handlers/element-types-handler.ts similarity index 55% rename from packages/server-mcp/src/resources/handlers/documentation-handler.ts rename to packages/server-mcp/src/resources/handlers/element-types-handler.ts index 06a70e3..0757bab 100644 --- a/packages/server-mcp/src/resources/handlers/documentation-handler.ts +++ b/packages/server-mcp/src/resources/handlers/element-types-handler.ts @@ -15,32 +15,18 @@ ********************************************************************************/ import { ClientSessionManager, CreateOperationHandler, DiagramModules, Logger, OperationHandlerRegistry } from '@eclipse-glsp/server'; +import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp'; import { ContainerModule, inject, injectable } from 'inversify'; -import { ResourceHandlerResult } from '../../server'; -import { objectArrayToMarkdownTable } from '../../util'; - -export const McpResourceDocumentationHandler = Symbol('McpResourceDocumentationHandler'); +import * as z from 'zod/v4'; +import { GLSPMcpServer, McpResourceHandler, ResourceHandlerResult } from '../../server'; +import { createResourceResult, createResourceToolResult, extractResourceParam, objectArrayToMarkdownTable } from '../../util'; /** - * The `McpResourceDocumentationHandler` provides handler functions supplying information about diagrams, - * their available elements, and other general information. This is independent from the current state of any given diagram. + * 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. */ -export interface McpResourceDocumentationHandler { - /** - * Lists the available diagram types. - */ - getDiagramTypes(): string[]; - - /** - * Lists the available element types. 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. - * @param diagramType The diagram type to query the element types for. - */ - getElementTypes(diagramType: string | undefined): ResourceHandlerResult; -} - @injectable() -export class DefaultMcpResourceDocumentationHandler implements McpResourceDocumentationHandler { +export class ElementTypesMcpResourceHandler implements McpResourceHandler { @inject(Logger) protected logger: Logger; @@ -50,12 +36,54 @@ export class DefaultMcpResourceDocumentationHandler implements McpResourceDocume @inject(ClientSessionManager) protected clientSessionManager: ClientSessionManager; - getDiagramTypes(): string[] { - return Array.from(this.diagramModules.keys()); + 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') })) + ); } - getElementTypes(diagramType: string | undefined): ResourceHandlerResult { - this.logger.info(`getElementTypes invoked for diagram type ${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(`ElementTypesMcpResourceHandler invoked for diagram type ${diagramType}`); if (!diagramType) { return { content: { @@ -116,4 +144,8 @@ export class DefaultMcpResourceDocumentationHandler implements McpResourceDocume isError: false }; } + + protected getDiagramTypes(): string[] { + return Array.from(this.diagramModules.keys()); + } } diff --git a/packages/server-mcp/src/resources/handlers/model-handler.ts b/packages/server-mcp/src/resources/handlers/model-handler.ts deleted file mode 100644 index d6a7e8a..0000000 --- a/packages/server-mcp/src/resources/handlers/model-handler.ts +++ /dev/null @@ -1,81 +0,0 @@ -/******************************************************************************** - * 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 { ResourceHandlerResult } from '../../server'; -import { McpModelSerializer } from '../services/mcp-model-serializer'; - -export const McpResourceModelHandler = Symbol('McpResourceModelHandler'); - -/** - * The `McpResourceModelHandler` provides information about a specific model. - */ -export interface McpResourceModelHandler { - /** - * Creates a serialized representation of the given session's model state. - * @param sessionId The relevant session. - */ - getDiagramModel(sessionId: string | undefined): ResourceHandlerResult; -} - -@injectable() -export class DefaultMcpResourceModelHandler implements McpResourceModelHandler { - @inject(Logger) - protected logger: Logger; - - @inject(ClientSessionManager) - protected clientSessionManager: ClientSessionManager; - - getDiagramModel(sessionId: string | undefined): ResourceHandlerResult { - this.logger.info(`getDiagramModel 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 mcpSerializer = session.container.get(McpModelSerializer); - const mcpString = mcpSerializer.serialize(modelState.root); - - return { - content: { - uri: `glsp://diagrams/${sessionId}/model`, - mimeType: 'text/markdown', - text: mcpString - }, - isError: false - }; - } -} diff --git a/packages/server-mcp/src/resources/handlers/session-handler.ts b/packages/server-mcp/src/resources/handlers/sessions-list-handler.ts similarity index 56% rename from packages/server-mcp/src/resources/handlers/session-handler.ts rename to packages/server-mcp/src/resources/handlers/sessions-list-handler.ts index 0c0ce54..c3d9596 100644 --- a/packages/server-mcp/src/resources/handlers/session-handler.ts +++ b/packages/server-mcp/src/resources/handlers/sessions-list-handler.ts @@ -16,41 +16,47 @@ import { ClientSessionManager, Logger, ModelState } from '@eclipse-glsp/server'; import { inject, injectable } from 'inversify'; -import { ResourceHandlerResult } from '../../server'; -import { objectArrayToMarkdownTable } from '../../util'; - -export const McpResourceSessionHandler = Symbol('McpResourceSessionHandler'); +import { GLSPMcpServer, McpResourceHandler, ResourceHandlerResult } from '../../server'; +import { createResourceResult, createResourceToolResult, objectArrayToMarkdownTable } from '../../util'; /** - * The `McpResourceSessionHandler` provides information about the sessions currently active. + * 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. */ -export interface McpResourceSessionHandler { - /** - * Lists the current session ids according to the {@link ClientSessionManager}. - */ - getSessionIds(): string[]; - - /** - * 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. - */ - getAllSessions(): ResourceHandlerResult; -} - @injectable() -export class DefaultMcpResourceSessionHandler implements McpResourceSessionHandler { +export class SessionsListMcpResourceHandler implements McpResourceHandler { @inject(Logger) protected logger: Logger; @inject(ClientSessionManager) protected clientSessionManager: ClientSessionManager; - getSessionIds(): string[] { - return this.clientSessionManager.getSessions().map(s => s.id); + 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({})) + ); } - getAllSessions(): ResourceHandlerResult { - this.logger.info('getAllSessions invoked'); + async handle(params: Record): Promise { + this.logger.info('SessionsListMcpResourceHandler invoked'); const sessions = this.clientSessionManager.getSessions(); const sessionsList = sessions.map(session => { const modelState = session.container.get(ModelState); diff --git a/packages/server-mcp/src/resources/index.ts b/packages/server-mcp/src/resources/index.ts index a98ba0b..6989a93 100644 --- a/packages/server-mcp/src/resources/index.ts +++ b/packages/server-mcp/src/resources/index.ts @@ -14,11 +14,10 @@ * 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-resource-tool-contribution'; -export * from './handlers/documentation-handler'; -export * from './handlers/export-png-handler'; -export * from './handlers/model-handler'; -export * from './handlers/session-handler'; +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 './resource-module.config'; 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..87a3e87 --- /dev/null +++ b/packages/server-mcp/src/resources/mcp-resource-contribution.ts @@ -0,0 +1,44 @@ +/******************************************************************************** + * 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 { FEATURE_FLAGS } from '../feature-flags'; +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 { + // TODO currently only development tool + // think of nice switching mechanism for starting MCP servers with only tools or tools + resources + if (FEATURE_FLAGS.useResources) { + this.mcpResourceHandlers.forEach(handler => handler.registerResource(server)); + } else { + this.mcpResourceHandlers.forEach(handler => handler.registerTool(server)); + } + } +} diff --git a/packages/server-mcp/src/resources/resource-module.config.ts b/packages/server-mcp/src/resources/resource-module.config.ts index c3bfc57..e6581e5 100644 --- a/packages/server-mcp/src/resources/resource-module.config.ts +++ b/packages/server-mcp/src/resources/resource-module.config.ts @@ -15,31 +15,23 @@ ********************************************************************************/ import { bindAsService } from '@eclipse-glsp/server'; import { ContainerModule } from 'inversify'; -import { FEATURE_FLAGS } from '../feature-flags'; -import { McpServerContribution } from '../server'; -import { DefaultMcpResourceContribution } from './default-mcp-resource-contribution'; -import { DefaultMcpResourceToolContribution } from './default-mcp-resource-tool-contribution'; -import { DefaultMcpResourceDocumentationHandler, McpResourceDocumentationHandler } from './handlers/documentation-handler'; -import { DefaultMcpResourcePngHandler, McpResourcePngHandler } from './handlers/export-png-handler'; -import { DefaultMcpResourceModelHandler, McpResourceModelHandler } from './handlers/model-handler'; -import { DefaultMcpResourceSessionHandler, McpResourceSessionHandler } from './handlers/session-handler'; +import { McpResourceHandler, McpServerContribution } from '../server'; +import { DiagramModelMcpResourceHandler } from './handlers/diagram-model-handler'; +import { DiagramPngMcpResourceHandler } from './handlers/diagram-png-handler'; +import { ElementTypesMcpResourceHandler } from './handlers/element-types-handler'; +import { SessionsListMcpResourceHandler } from './handlers/sessions-list-handler'; +import { McpResourceContribution } from './mcp-resource-contribution'; import { DefaultMcpModelSerializer, McpModelSerializer } from './services/mcp-model-serializer'; export function configureMcpResourceModule(): ContainerModule { return new ContainerModule(bind => { bindAsService(bind, McpModelSerializer, DefaultMcpModelSerializer); - bindAsService(bind, McpResourceDocumentationHandler, DefaultMcpResourceDocumentationHandler); - bindAsService(bind, McpResourceSessionHandler, DefaultMcpResourceSessionHandler); - bindAsService(bind, McpResourceModelHandler, DefaultMcpResourceModelHandler); - bindAsService(bind, McpResourcePngHandler, DefaultMcpResourcePngHandler); + bindAsService(bind, McpResourceHandler, SessionsListMcpResourceHandler); + bindAsService(bind, McpResourceHandler, ElementTypesMcpResourceHandler); + bindAsService(bind, McpResourceHandler, DiagramModelMcpResourceHandler); + bindAsService(bind, McpResourceHandler, DiagramPngMcpResourceHandler); - // TODO currently only development tool - // think of nice switching mechanism for starting MCP servers with only tools or tools + resources - if (FEATURE_FLAGS.useResources) { - bindAsService(bind, McpServerContribution, DefaultMcpResourceContribution); - } else { - bindAsService(bind, McpServerContribution, DefaultMcpResourceToolContribution); - } + bindAsService(bind, McpServerContribution, McpResourceContribution); }); } diff --git a/packages/server-mcp/src/server/mcp-server-contribution.ts b/packages/server-mcp/src/server/mcp-server-contribution.ts index ed98122..322054f 100644 --- a/packages/server-mcp/src/server/mcp-server-contribution.ts +++ b/packages/server-mcp/src/server/mcp-server-contribution.ts @@ -30,3 +30,31 @@ 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. + */ +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. + */ +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/tools/default-mcp-tool-contribution.ts b/packages/server-mcp/src/tools/default-mcp-tool-contribution.ts deleted file mode 100644 index f5bac06..0000000 --- a/packages/server-mcp/src/tools/default-mcp-tool-contribution.ts +++ /dev/null @@ -1,174 +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 { MarkersReason } from '@eclipse-glsp/protocol'; -import { ClientSessionManager, Logger } from '@eclipse-glsp/server'; -import { inject, injectable } from 'inversify'; -import * as z from 'zod/v4'; -import { GLSPMcpServer, McpServerContribution } from '../server'; -import { McpToolCreationHandler } from './handlers/creation-handler'; -import { McpToolDeletionHandler } from './handlers/deletion-handler'; -import { McpToolModelHandler } from './handlers/model-handler'; -import { McpToolValidationHandler } from './handlers/validation-handler'; - -/** - * 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; - - @inject(McpToolValidationHandler) - protected validationHandler: McpToolValidationHandler; - @inject(McpToolCreationHandler) - protected creationHandler: McpToolCreationHandler; - @inject(McpToolDeletionHandler) - protected deletionHandler: McpToolDeletionHandler; - @inject(McpToolModelHandler) - protected modelHandler: McpToolModelHandler; - - configure(server: GLSPMcpServer): void { - this.registerValidateDiagramTool(server); - this.registerCreateNodeTool(server); - this.registerCreateEdgeTool(server); - this.registerDeleteElementTool(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') - } - }, - params => this.validationHandler.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)') - } - }, - params => this.creationHandler.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)') - } - }, - params => this.creationHandler.createEdge(params) - ); - } - - 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.') - } - }, - params => this.deletionHandler.deleteElement(params) - ); - } - - 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.') - } - }, - params => this.modelHandler.saveModel(params) - ); - } -} diff --git a/packages/server-mcp/src/tools/handlers/creation-handler.ts b/packages/server-mcp/src/tools/handlers/create-edge-handler.ts similarity index 55% rename from packages/server-mcp/src/tools/handlers/creation-handler.ts rename to packages/server-mcp/src/tools/handlers/create-edge-handler.ts index 603678a..db617b3 100644 --- a/packages/server-mcp/src/tools/handlers/creation-handler.ts +++ b/packages/server-mcp/src/tools/handlers/create-edge-handler.ts @@ -14,98 +14,50 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { ClientSessionManager, CreateEdgeOperation, CreateNodeOperation, Logger, ModelState } from '@eclipse-glsp/server'; +import { 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, McpToolHandler } from '../../server'; import { createToolResult } from '../../util'; -export const McpToolCreationHandler = Symbol('McpToolCreationHandler'); - /** - * The `McpToolCreationHandler` + * Creates a new edge in the given session's model. */ -export interface McpToolCreationHandler { - createNode(params: { - sessionId: string; - elementTypeId: string; - location: { x: number; y: number }; - containerId?: string; - args?: Record; - }): Promise; - - createEdge(params: { - sessionId: string; - elementTypeId: string; - sourceElementId: string; - targetElementId: string; - args?: Record; - }): Promise; -} - @injectable() -export class DefaultMcpToolCreationHandler implements McpToolCreationHandler { +export class CreateEdgeMcpToolHandler implements McpToolHandler { @inject(Logger) protected logger: Logger; @inject(ClientSessionManager) protected clientSessionManager: ClientSessionManager; - async createNode({ - sessionId, - elementTypeId, - location, - containerId, - args - }: { - sessionId: string; - elementTypeId: string; - location: { x: number; y: number }; - containerId?: string; - args?: Record; - }): Promise { - this.logger.info(`createNode invoked for session ${sessionId}`); - - try { - const session = this.clientSessionManager.getSession(sessionId); - if (!session) { - return createToolResult('Session not found', true); - } - - const modelState = session.container.get(ModelState); - - // Check if model is readonly - if (modelState.isReadonly) { - return createToolResult('Model is read-only', true); - } - - // 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 createToolResult('Node creation succeeded but could not determine element ID', true); - } - - return createToolResult(`Node created successfully with element ID: ${newElementId}`, false); - } catch (error) { - this.logger.error('Node creation failed', error); - return createToolResult(`Node creation failed: ${error instanceof Error ? error.message : String(error)}`, true); - } + registerTool(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)') + } + }, + params => this.handle(params) + ); } - async createEdge({ + async handle({ sessionId, elementTypeId, sourceElementId, @@ -118,7 +70,7 @@ export class DefaultMcpToolCreationHandler implements McpToolCreationHandler { targetElementId: string; args?: Record; }): Promise { - this.logger.info(`createEdge invoked for session ${sessionId}`); + this.logger.info(`CreateEdgeMcpToolHandler invoked for session ${sessionId}`); try { const session = this.clientSessionManager.getSession(sessionId); diff --git a/packages/server-mcp/src/tools/handlers/create-node-handler.ts b/packages/server-mcp/src/tools/handlers/create-node-handler.ts new file mode 100644 index 0000000..24f40dd --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/create-node-handler.ts @@ -0,0 +1,122 @@ +/******************************************************************************** + * 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, CreateNodeOperation, 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, McpToolHandler } from '../../server'; +import { createToolResult } from '../../util'; + +/** + * Creates a new node in the given session's model. + */ +@injectable() +export class CreateNodeMcpToolHandler implements McpToolHandler { + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + registerTool(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)') + } + }, + params => this.handle(params) + ); + } + + async handle({ + sessionId, + elementTypeId, + location, + containerId, + args + }: { + sessionId: string; + elementTypeId: string; + location: { x: number; y: number }; + containerId?: string; + args?: Record; + }): Promise { + this.logger.info(`CreateNodeMcpToolHandler invoked for session ${sessionId}`); + + try { + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + return createToolResult('Session not found', true); + } + + const modelState = session.container.get(ModelState); + + // Check if model is readonly + if (modelState.isReadonly) { + return createToolResult('Model is read-only', true); + } + + // 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 createToolResult('Node creation succeeded but could not determine element ID', true); + } + + return createToolResult(`Node created successfully with element ID: ${newElementId}`, false); + } catch (error) { + this.logger.error('Node creation failed', error); + return createToolResult(`Node creation failed: ${error instanceof Error ? error.message : String(error)}`, true); + } + } +} diff --git a/packages/server-mcp/src/tools/handlers/deletion-handler.ts b/packages/server-mcp/src/tools/handlers/delete-element-handler.ts similarity index 70% rename from packages/server-mcp/src/tools/handlers/deletion-handler.ts rename to packages/server-mcp/src/tools/handlers/delete-element-handler.ts index 7f58639..0575bc0 100644 --- a/packages/server-mcp/src/tools/handlers/deletion-handler.ts +++ b/packages/server-mcp/src/tools/handlers/delete-element-handler.ts @@ -17,27 +17,40 @@ 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, McpToolHandler } from '../../server'; import { createToolResult } from '../../util'; -export const McpToolDeletionHandler = Symbol('McpToolDeletionHandler'); - /** - * The `McpToolDeletionHandler` + * Deletes an element using their element ID from the given session's model. */ -export interface McpToolDeletionHandler { - deleteElement(params: { sessionId: string; elementIds: string[] }): Promise; -} - @injectable() -export class DefaultMcpToolDeletionHandler implements McpToolDeletionHandler { +export class DeleteElementMcpToolHandler implements McpToolHandler { @inject(Logger) protected logger: Logger; @inject(ClientSessionManager) protected clientSessionManager: ClientSessionManager; - async deleteElement({ sessionId, elementIds }: { sessionId: string; elementIds: string[] }): Promise { - this.logger.info(`deleteElement invoked for session ${sessionId}`); + registerTool(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.') + } + }, + params => this.handle(params) + ); + } + + async handle({ sessionId, elementIds }: { sessionId: string; elementIds: string[] }): Promise { + this.logger.info(`DeleteElementMcpToolHandler invoked for session ${sessionId}`); try { const session = this.clientSessionManager.getSession(sessionId); diff --git a/packages/server-mcp/src/tools/handlers/model-handler.ts b/packages/server-mcp/src/tools/handlers/save-model-handler.ts similarity index 63% rename from packages/server-mcp/src/tools/handlers/model-handler.ts rename to packages/server-mcp/src/tools/handlers/save-model-handler.ts index 32ed5fc..adb344f 100644 --- a/packages/server-mcp/src/tools/handlers/model-handler.ts +++ b/packages/server-mcp/src/tools/handlers/save-model-handler.ts @@ -17,27 +17,43 @@ 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'; -export const McpToolModelHandler = Symbol('McpToolModelHandler'); - /** - * The `McpToolModelHandler` + * Saves the given session's model. */ -export interface McpToolModelHandler { - saveModel(params: { sessionId: string; fileUri?: string }): Promise; -} - @injectable() -export class DefaultMcpToolModelHandler implements McpToolModelHandler { +export class SaveModelMcpToolHandler implements McpToolHandler { @inject(Logger) protected logger: Logger; @inject(ClientSessionManager) protected clientSessionManager: ClientSessionManager; - async saveModel({ sessionId, fileUri }: { sessionId: string; fileUri?: string }): Promise { - this.logger.info(`saveModel invoked for session ${sessionId}`); + 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. ' + + '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(`SaveModelMcpToolHandler invoked for session ${sessionId}`); try { const session = this.clientSessionManager.getSession(sessionId); diff --git a/packages/server-mcp/src/tools/handlers/validation-handler.ts b/packages/server-mcp/src/tools/handlers/validate-diagram-handler.ts similarity index 64% rename from packages/server-mcp/src/tools/handlers/validation-handler.ts rename to packages/server-mcp/src/tools/handlers/validate-diagram-handler.ts index 4b81d7f..c773082 100644 --- a/packages/server-mcp/src/tools/handlers/validation-handler.ts +++ b/packages/server-mcp/src/tools/handlers/validate-diagram-handler.ts @@ -17,26 +17,47 @@ 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, McpToolHandler } from '../../server'; import { createToolResult, objectArrayToMarkdownTable } from '../../util'; -export const McpToolValidationHandler = Symbol('McpToolValidationHandler'); - /** - * The `McpToolValidationHandler` + * Validates the given session's model. */ -export interface McpToolValidationHandler { - validateDiagram(params: { sessionId: string; elementIds?: string[]; reason?: string }): Promise; -} - @injectable() -export class DefaultMcpToolValidationHandler implements McpToolValidationHandler { +export class ValidateDiagramMcpToolHandler implements McpToolHandler { @inject(Logger) protected logger: Logger; @inject(ClientSessionManager) protected clientSessionManager: ClientSessionManager; - async validateDiagram({ + 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 @@ -45,7 +66,7 @@ export class DefaultMcpToolValidationHandler implements McpToolValidationHandler elementIds?: string[]; reason?: string; }): Promise { - this.logger.info(`validateDiagram invoked for session ${sessionId}`); + this.logger.info(`ValidateDiagramMcpToolHandler invoked for session ${sessionId}`); try { const session = this.clientSessionManager.getSession(sessionId); diff --git a/packages/server-mcp/src/tools/index.ts b/packages/server-mcp/src/tools/index.ts index e3c467d..2d71d47 100644 --- a/packages/server-mcp/src/tools/index.ts +++ b/packages/server-mcp/src/tools/index.ts @@ -14,9 +14,10 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -export * from './default-mcp-tool-contribution'; -export * from './handlers/creation-handler'; -export * from './handlers/deletion-handler'; -export * from './handlers/model-handler'; -export * from './handlers/validation-handler'; +export * from './handlers/create-edge-handler'; +export * from './handlers/create-node-handler'; +export * from './handlers/delete-element-handler'; +export * from './handlers/save-model-handler'; +export * from './handlers/validate-diagram-handler'; +export * from './mcp-tool-contribution'; export * from './tool-module.config'; 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/tools/tool-module.config.ts b/packages/server-mcp/src/tools/tool-module.config.ts index 59f38c6..09a2294 100644 --- a/packages/server-mcp/src/tools/tool-module.config.ts +++ b/packages/server-mcp/src/tools/tool-module.config.ts @@ -15,20 +15,22 @@ ********************************************************************************/ import { bindAsService } from '@eclipse-glsp/server'; import { ContainerModule } from 'inversify'; -import { McpServerContribution } from '../server'; -import { DefaultMcpToolContribution } from './default-mcp-tool-contribution'; -import { DefaultMcpToolCreationHandler, McpToolCreationHandler } from './handlers/creation-handler'; -import { DefaultMcpToolDeletionHandler, McpToolDeletionHandler } from './handlers/deletion-handler'; -import { DefaultMcpToolModelHandler, McpToolModelHandler } from './handlers/model-handler'; -import { DefaultMcpToolValidationHandler, McpToolValidationHandler } from './handlers/validation-handler'; +import { McpServerContribution, McpToolHandler } from '../server'; +import { CreateEdgeMcpToolHandler } from './handlers/create-edge-handler'; +import { CreateNodeMcpToolHandler } from './handlers/create-node-handler'; +import { DeleteElementMcpToolHandler } from './handlers/delete-element-handler'; +import { SaveModelMcpToolHandler } from './handlers/save-model-handler'; +import { ValidateDiagramMcpToolHandler } from './handlers/validate-diagram-handler'; +import { McpToolContribution } from './mcp-tool-contribution'; export function configureMcpToolModule(): ContainerModule { return new ContainerModule(bind => { - bindAsService(bind, McpToolValidationHandler, DefaultMcpToolValidationHandler); - bindAsService(bind, McpToolCreationHandler, DefaultMcpToolCreationHandler); - bindAsService(bind, McpToolDeletionHandler, DefaultMcpToolDeletionHandler); - bindAsService(bind, McpToolModelHandler, DefaultMcpToolModelHandler); + bindAsService(bind, McpToolHandler, CreateNodeMcpToolHandler); + bindAsService(bind, McpToolHandler, CreateEdgeMcpToolHandler); + bindAsService(bind, McpToolHandler, DeleteElementMcpToolHandler); + bindAsService(bind, McpToolHandler, SaveModelMcpToolHandler); + bindAsService(bind, McpToolHandler, ValidateDiagramMcpToolHandler); - bindAsService(bind, McpServerContribution, DefaultMcpToolContribution); + bindAsService(bind, McpServerContribution, McpToolContribution); }); } From c6ec0f89da488e028492bde5e2f2ad798d1ec301 Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Sun, 8 Mar 2026 21:35:37 +0100 Subject: [PATCH 04/26] Added info and modifying tools --- .../handlers/diagram-model-handler.ts | 5 +- .../handlers/element-types-handler.ts | 2 + .../services/mcp-model-serializer.ts | 8 +- .../src/tools/handlers/create-edge-handler.ts | 2 +- .../src/tools/handlers/create-node-handler.ts | 30 +++- .../tools/handlers/diagram-element-handler.ts | 79 +++++++++ .../tools/handlers/modify-nodes-handler.ts | 151 ++++++++++++++++++ .../src/tools/handlers/save-model-handler.ts | 1 + .../src/tools/tool-module.config.ts | 4 + 9 files changed, 269 insertions(+), 13 deletions(-) create mode 100644 packages/server-mcp/src/tools/handlers/diagram-element-handler.ts create mode 100644 packages/server-mcp/src/tools/handlers/modify-nodes-handler.ts diff --git a/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts b/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts index dbbbe1d..8fb10f2 100644 --- a/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts +++ b/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts @@ -72,7 +72,7 @@ export class DiagramModelMcpResourceHandler implements McpResourceHandler { '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 where the node should be created') + sessionId: z.string().describe('Session ID for which to query the model.') } }, async params => createResourceToolResult(await this.handle(params)) @@ -108,6 +108,9 @@ export class DiagramModelMcpResourceHandler implements McpResourceHandler { const mcpSerializer = session.container.get(McpModelSerializer); const mcpString = mcpSerializer.serialize(modelState.root); + // TODO should likely not contain the generic name attribute but rather the label text + // TODO should ignore irrelevant/uncontrollable/indirect model elements like icons (and labels) + // should be done in workflow specific implementation return { content: { uri: `glsp://diagrams/${sessionId}/model`, diff --git a/packages/server-mcp/src/resources/handlers/element-types-handler.ts b/packages/server-mcp/src/resources/handlers/element-types-handler.ts index 0757bab..c67f289 100644 --- a/packages/server-mcp/src/resources/handlers/element-types-handler.ts +++ b/packages/server-mcp/src/resources/handlers/element-types-handler.ts @@ -127,6 +127,8 @@ export class ElementTypesMcpResourceHandler implements McpResourceHandler { } } + // TODO should likely also contain information about whether a node is labeled + // should be done in workflow specific implementation const result = [ `# Creatable element types for ${diagramType} diagrams`, '## Node Types', diff --git a/packages/server-mcp/src/resources/services/mcp-model-serializer.ts b/packages/server-mcp/src/resources/services/mcp-model-serializer.ts index 9ac1cc7..d6e7ff5 100644 --- a/packages/server-mcp/src/resources/services/mcp-model-serializer.ts +++ b/packages/server-mcp/src/resources/services/mcp-model-serializer.ts @@ -62,7 +62,7 @@ export class DefaultMcpModelSerializer implements McpModelSerializer { protected prepareElement(element: GModelElement): Record[]> { const schema = this.gModelSerialzer.createSchema(element); - const elements = this.flattenStructure(schema, undefined); + const elements = this.flattenStructure(schema, element.parent?.id); const result: Record[]> = {}; elements.forEach(element => { @@ -114,9 +114,11 @@ export class DefaultMcpModelSerializer implements McpModelSerializer { const width = Math.trunc(size.width); const height = Math.trunc(size.height); - delete schema['position']; - delete schema['size']; + // Only expose the truncated sizes for smaller context size at irrelevant precision loss + schema['position'] = { x, y }; + schema['size'] = { width, height }; + // Add bounds in addition to position and size to reduce derived calculations schema['bounds'] = { left: x, right: x + width, diff --git a/packages/server-mcp/src/tools/handlers/create-edge-handler.ts b/packages/server-mcp/src/tools/handlers/create-edge-handler.ts index db617b3..8c74d2d 100644 --- a/packages/server-mcp/src/tools/handlers/create-edge-handler.ts +++ b/packages/server-mcp/src/tools/handlers/create-edge-handler.ts @@ -113,7 +113,7 @@ export class CreateEdgeMcpToolHandler implements McpToolHandler { const newElementId = newIds.length > 0 ? newIds[0] : undefined; if (!newElementId) { - return createToolResult('Edge creation succeeded but could not determine element ID', true); + return createToolResult('Edge creation likely failed, because no new element ID could be determined', true); } return createToolResult(`Edge created successfully with element ID: ${newElementId}`, false); diff --git a/packages/server-mcp/src/tools/handlers/create-node-handler.ts b/packages/server-mcp/src/tools/handlers/create-node-handler.ts index 24f40dd..e6e0bed 100644 --- a/packages/server-mcp/src/tools/handlers/create-node-handler.ts +++ b/packages/server-mcp/src/tools/handlers/create-node-handler.ts @@ -14,7 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { ClientSessionManager, CreateNodeOperation, Logger, ModelState } from '@eclipse-glsp/server'; +import { ApplyLabelEditOperation, ClientSessionManager, CreateNodeOperation, GLabel, Logger, ModelState } from '@eclipse-glsp/server'; import { CallToolResult } from '@modelcontextprotocol/sdk/types'; import { inject, injectable } from 'inversify'; import * as z from 'zod/v4'; @@ -37,7 +37,8 @@ export class CreateNodeMcpToolHandler implements McpToolHandler { 'create-node', { description: - 'Create a new node element in the diagram at a specified location. ' + + 'Create a new node 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: { @@ -48,12 +49,13 @@ export class CreateNodeMcpToolHandler implements McpToolHandler { 'Element type ID (e.g., "task:manual", "task:automated"). ' + 'Use element-types resource to discover valid IDs.' ), - location: z + 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()) @@ -68,13 +70,15 @@ export class CreateNodeMcpToolHandler implements McpToolHandler { async handle({ sessionId, elementTypeId, - location, + position, + text, containerId, args }: { sessionId: string; elementTypeId: string; - location: { x: number; y: number }; + position: { x: number; y: number }; + text?: string; containerId?: string; args?: Record; }): Promise { @@ -97,7 +101,8 @@ export class CreateNodeMcpToolHandler implements McpToolHandler { const beforeIds = new Set(modelState.index.allIds()); // Create operation - const operation = CreateNodeOperation.create(elementTypeId, { location, containerId, args }); + // Using the name "position" instead of "location", as this is the name in the elements properties + const operation = CreateNodeOperation.create(elementTypeId, { location: position, containerId, args }); // Dispatch operation await session.actionDispatcher.dispatch(operation); @@ -110,10 +115,19 @@ export class CreateNodeMcpToolHandler implements McpToolHandler { const newElementId = newIds.length > 0 ? newIds[0] : undefined; if (!newElementId) { - return createToolResult('Node creation succeeded but could not determine element ID', true); + return createToolResult('Node creation likely failed, because no new element ID could be determined', true); } - return createToolResult(`Node created successfully with element ID: ${newElementId}`, false); + // Assume that generally, labelled nodes have those labels as direct children + const newElementLabelId = modelState.index.get(newElementId).children.find(child => child instanceof GLabel)?.id; + // 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); + } + + return createToolResult(`Node created successfully with the element ID: ${newElementId}`, false); } catch (error) { this.logger.error('Node creation failed', error); return createToolResult(`Node creation failed: ${error instanceof Error ? error.message : String(error)}`, true); diff --git a/packages/server-mcp/src/tools/handlers/diagram-element-handler.ts b/packages/server-mcp/src/tools/handlers/diagram-element-handler.ts new file mode 100644 index 0000000..11d8aa1 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/diagram-element-handler.ts @@ -0,0 +1,79 @@ +/******************************************************************************** + * 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 { 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, McpToolHandler } from '../../server'; +import { createToolResult } from '../../util'; + +/** + * Creates a serialized representation of a specific element of a given session's model. + */ +@injectable() +export class DiagramElementMcpToolHandler implements McpToolHandler { + @inject(Logger) + protected logger: Logger; + + @inject(ClientSessionManager) + protected clientSessionManager: ClientSessionManager; + + registerTool(server: GLSPMcpServer): void { + server.registerTool( + 'diagram-element', + { + title: 'Diagram Model Element', + description: + 'Get the a single element of a GLSP model for a session as a markdown structure. ' + + 'This is a more specific query than diagram-model.', + inputSchema: { + sessionId: z.string().describe('Session ID containing the relevant model.'), + elementId: z.string().describe('Element ID that should be queried.') + } + }, + params => this.handle(params) + ); + } + + async handle({ sessionId, elementId }: { sessionId?: string; elementId?: string }): Promise { + this.logger.info(`DiagramElementMcpToolHandler invoked for session ${sessionId} and element ${elementId}`); + if (!sessionId) { + return createToolResult('No session id provided.', true); + } + if (!elementId) { + return createToolResult('No element id 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 element = modelState.index.find(elementId); + if (!element) { + return createToolResult('No element found for this element id.', true); + } + + const mcpSerializer = session.container.get(McpModelSerializer); + const mcpString = mcpSerializer.serialize(element); + + return createToolResult(mcpString, 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..364e972 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/modify-nodes-handler.ts @@ -0,0 +1,151 @@ +/******************************************************************************** + * 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, McpToolHandler } from '../../server'; +import { createToolResult } from '../../util'; + +/** + * Modifies a specific 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(`ModifyNodesMcpToolHandler invoked for session ${sessionId}`); + 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); + } + + // Map the list of changes to their underlying element + const elements: [(typeof changes)[number], GShapeElement][] = changes.map(change => [ + change, + modelState.index.find(change.elementId) as GShapeElement + ]); + + // If any element could not be resolved, do not proceed + const undefinedElements = elements.filter(([change, element]) => !element).map(([change]) => change.elementId); + if (undefinedElements.length) { + return createToolResult(`No elements 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 { elementId, size, position, text } = change; + + // 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 = element.children.find(child => child instanceof GLabel)?.id; + 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('Nodes succesfully modified', 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 index adb344f..647ac49 100644 --- a/packages/server-mcp/src/tools/handlers/save-model-handler.ts +++ b/packages/server-mcp/src/tools/handlers/save-model-handler.ts @@ -39,6 +39,7 @@ export class SaveModelMcpToolHandler implements McpToolHandler { 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'), diff --git a/packages/server-mcp/src/tools/tool-module.config.ts b/packages/server-mcp/src/tools/tool-module.config.ts index 09a2294..2e3aa2b 100644 --- a/packages/server-mcp/src/tools/tool-module.config.ts +++ b/packages/server-mcp/src/tools/tool-module.config.ts @@ -19,6 +19,8 @@ import { McpServerContribution, McpToolHandler } from '../server'; import { CreateEdgeMcpToolHandler } from './handlers/create-edge-handler'; import { CreateNodeMcpToolHandler } from './handlers/create-node-handler'; import { DeleteElementMcpToolHandler } from './handlers/delete-element-handler'; +import { DiagramElementMcpToolHandler } from './handlers/diagram-element-handler'; +import { ModifyNodesMcpToolHandler } from './handlers/modify-nodes-handler'; import { SaveModelMcpToolHandler } from './handlers/save-model-handler'; import { ValidateDiagramMcpToolHandler } from './handlers/validate-diagram-handler'; import { McpToolContribution } from './mcp-tool-contribution'; @@ -30,6 +32,8 @@ export function configureMcpToolModule(): ContainerModule { bindAsService(bind, McpToolHandler, DeleteElementMcpToolHandler); bindAsService(bind, McpToolHandler, SaveModelMcpToolHandler); bindAsService(bind, McpToolHandler, ValidateDiagramMcpToolHandler); + bindAsService(bind, McpToolHandler, DiagramElementMcpToolHandler); + bindAsService(bind, McpToolHandler, ModifyNodesMcpToolHandler); bindAsService(bind, McpServerContribution, McpToolContribution); }); From 5737dad4d9ded587494813e35df9011fabe07ebe Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Mon, 9 Mar 2026 00:36:08 +0100 Subject: [PATCH 05/26] Switched IDs from UUIDs to semantic IDs --- .../src/common/graph-extension.ts | 2 +- packages/graph/src/gmodel-element.ts | 17 +++++++++++++++++ packages/server-mcp/src/feature-flags.ts | 4 +++- packages/server-mcp/src/tools/index.ts | 2 ++ .../gmodel-create-node-operation-handler.ts | 6 ++++++ 5 files changed, 29 insertions(+), 2 deletions(-) diff --git a/examples/workflow-server/src/common/graph-extension.ts b/examples/workflow-server/src/common/graph-extension.ts index 2b684e8..e9a1c9d 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(); } diff --git a/packages/graph/src/gmodel-element.ts b/packages/graph/src/gmodel-element.ts index 2997458..b6d7a2b 100644 --- a/packages/graph/src/gmodel-element.ts +++ b/packages/graph/src/gmodel-element.ts @@ -101,7 +101,24 @@ export abstract class GModelElementBuilder { return this; } + /** + * While UUIDs are virtually always unique, they are long, meaningless strings. Typically, + * this is not an issue since classic software doesn't care about the semantics of some + * identifier. However, considering LLMs, this becomes a problem. Since the strings are random + * and meaningless, tokenization is less efficient, thus expanding context size. Furthermore, + * the lack of semantics hurts reasoning and memory. + */ + private generateId(): string { + // 36^4 possible hashes + const randomPart = Math.floor(Math.random() * 1679615); + const hash = randomPart.toString(36).padStart(4, '0'); + // type + 36^4 hashes makes collisions very unlikely + return `${this.proxy.type}_${hash}`; + } + build(): G { + // TODO re-evaluate ID generation method + this.proxy.id = this.generateId(); const element = new this.elementConstructor(); Object.assign(element, this.proxy); element.children.forEach(child => (child.parent = element)); diff --git a/packages/server-mcp/src/feature-flags.ts b/packages/server-mcp/src/feature-flags.ts index 6a2674c..0c730e7 100644 --- a/packages/server-mcp/src/feature-flags.ts +++ b/packages/server-mcp/src/feature-flags.ts @@ -20,7 +20,9 @@ */ export const FEATURE_FLAGS = { /** - * Changes how resources are registered + * Changes how resources are registered. + * This is relevant since some MCP clients are unable to deal with MCP resource endpoints + * and thus they must be provided as tools. * * true -> MCP resources * diff --git a/packages/server-mcp/src/tools/index.ts b/packages/server-mcp/src/tools/index.ts index 2d71d47..44730e2 100644 --- a/packages/server-mcp/src/tools/index.ts +++ b/packages/server-mcp/src/tools/index.ts @@ -17,6 +17,8 @@ export * from './handlers/create-edge-handler'; export * from './handlers/create-node-handler'; export * from './handlers/delete-element-handler'; +export * from './handlers/diagram-element-handler'; +export * from './handlers/modify-nodes-handler'; export * from './handlers/save-model-handler'; export * from './handlers/validate-diagram-handler'; export * from './mcp-tool-contribution'; 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..e782f77 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,12 @@ export abstract class GModelCreateNodeOperationHandler extends GModelOperationHa const relativeLocation = this.getRelativeLocation(operation); const element = this.createNode(operation, relativeLocation); if (element) { + // TODO re-evaluate ID generation method + // When handling IDs that are not guaranteed unique, ensure no collisions + // However, if collisions are unlikely enough, maybe just skip this check entirely + 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] })); From 6b8b49f7bd7ebc8d155f1625c78cf70f5a3a7d0c Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Mon, 9 Mar 2026 16:35:32 +0100 Subject: [PATCH 06/26] Moved new ID generation from generic framework code to actual implementation --- .../src/common/graph-extension.ts | 15 +++++++++++++++ .../handler/create-activity-node-handler.ts | 8 ++++++-- .../common/handler/create-category-handler.ts | 3 ++- .../src/common/handler/create-edge-handler.ts | 3 ++- .../src/common/handler/create-task-handler.ts | 3 ++- .../handler/create-weighted-edge-handler.ts | 10 ++++++++-- packages/graph/src/gmodel-element.ts | 17 ----------------- .../gmodel-create-node-operation-handler.ts | 3 +-- 8 files changed, 36 insertions(+), 26 deletions(-) diff --git a/examples/workflow-server/src/common/graph-extension.ts b/examples/workflow-server/src/common/graph-extension.ts index e9a1c9d..37f1885 100644 --- a/examples/workflow-server/src/common/graph-extension.ts +++ b/examples/workflow-server/src/common/graph-extension.ts @@ -165,3 +165,18 @@ export class CategoryNodeBuilder extends Activity .build(); } } + +/** + * While UUIDs are virtually always unique, they are long, meaningless strings. Typically, + * this is not an issue since classic software doesn't care about the semantics of some + * identifier. However, considering LLMs, this becomes a problem. Since the strings are random + * and meaningless, tokenization is less efficient, thus expanding context size. Furthermore, + * the lack of semantics hurts reasoning and memory. + */ +export function generateId(type: string): string { + // 36^4 possible hashes + const randomPart = Math.floor(Math.random() * 1679615); + const hash = randomPart.toString(36).padStart(4, '0'); + // type + 36^4 hashes makes collisions very unlikely + return `${type}_${hash}`; +} diff --git a/examples/workflow-server/src/common/handler/create-activity-node-handler.ts b/examples/workflow-server/src/common/handler/create-activity-node-handler.ts index 6d620f1..cf66b9e 100644 --- a/examples/workflow-server/src/common/handler/create-activity-node-handler.ts +++ b/examples/workflow-server/src/common/handler/create-activity-node-handler.ts @@ -15,7 +15,7 @@ ********************************************************************************/ import { CreateNodeOperation, GNode, GhostElement, Point } from '@eclipse-glsp/server'; import { injectable } from 'inversify'; -import { ActivityNode, ActivityNodeBuilder } from '../graph-extension'; +import { ActivityNode, ActivityNodeBuilder, generateId } from '../graph-extension'; import { ModelTypes } from '../util/model-types'; import { CreateWorkflowNodeOperationHandler } from './create-workflow-node-operation-handler'; @@ -26,7 +26,11 @@ export abstract class CreateActivityNodeHandler extends CreateWorkflowNodeOperat } protected builder(point: Point = Point.ORIGIN, elementTypeId = this.elementTypeIds[0]): ActivityNodeBuilder { - return ActivityNode.builder().position(point).type(elementTypeId).nodeType(ModelTypes.toNodeType(elementTypeId)); + return ActivityNode.builder() + .id(generateId(elementTypeId)) + .position(point) + .type(elementTypeId) + .nodeType(ModelTypes.toNodeType(elementTypeId)); } override createTriggerGhostElement(elementTypeId: string): GhostElement | undefined { diff --git a/examples/workflow-server/src/common/handler/create-category-handler.ts b/examples/workflow-server/src/common/handler/create-category-handler.ts index 702af1d..62db0ff 100644 --- a/examples/workflow-server/src/common/handler/create-category-handler.ts +++ b/examples/workflow-server/src/common/handler/create-category-handler.ts @@ -14,7 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ import { ArgsUtil, CreateNodeOperation, GNode, GhostElement, Point } from '@eclipse-glsp/server'; -import { Category, CategoryNodeBuilder } from '../graph-extension'; +import { Category, CategoryNodeBuilder, generateId } from '../graph-extension'; import { ModelTypes } from '../util/model-types'; import { CreateWorkflowNodeOperationHandler } from './create-workflow-node-operation-handler'; @@ -28,6 +28,7 @@ export class CreateCategoryHandler extends CreateWorkflowNodeOperationHandler { protected builder(point: Point = Point.ORIGIN, elementTypeId = this.elementTypeIds[0]): CategoryNodeBuilder { return Category.builder() + .id(generateId(elementTypeId)) .type(elementTypeId) .position(point) .name(this.label.replace(' ', '') + this.modelState.index.getAllByClass(Category).length) diff --git a/examples/workflow-server/src/common/handler/create-edge-handler.ts b/examples/workflow-server/src/common/handler/create-edge-handler.ts index 93a5da7..06dddf6 100644 --- a/examples/workflow-server/src/common/handler/create-edge-handler.ts +++ b/examples/workflow-server/src/common/handler/create-edge-handler.ts @@ -14,12 +14,13 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ import { DefaultTypes, GEdge, GEdgeBuilder, GModelCreateEdgeOperationHandler, GModelElement } from '@eclipse-glsp/server'; +import { generateId } from '../graph-extension'; export class CreateEdgeHandler extends GModelCreateEdgeOperationHandler { label = 'Edge'; elementTypeIds = [DefaultTypes.EDGE]; createEdge(source: GModelElement, target: GModelElement): GEdge | undefined { - return new GEdgeBuilder(GEdge).sourceId(source.id).targetId(target.id).build(); + return new GEdgeBuilder(GEdge).id(generateId(DefaultTypes.EDGE)).sourceId(source.id).targetId(target.id).build(); } } diff --git a/examples/workflow-server/src/common/handler/create-task-handler.ts b/examples/workflow-server/src/common/handler/create-task-handler.ts index 477885f..65ebab6 100644 --- a/examples/workflow-server/src/common/handler/create-task-handler.ts +++ b/examples/workflow-server/src/common/handler/create-task-handler.ts @@ -16,7 +16,7 @@ import { GhostElement, Point } from '@eclipse-glsp/protocol'; import { CreateNodeOperation, GNode } from '@eclipse-glsp/server'; import { injectable } from 'inversify'; -import { TaskNode, TaskNodeBuilder } from '../graph-extension'; +import { generateId, TaskNode, TaskNodeBuilder } from '../graph-extension'; import { ModelTypes } from '../util/model-types'; import { CreateWorkflowNodeOperationHandler } from './create-workflow-node-operation-handler'; @@ -28,6 +28,7 @@ export abstract class CreateTaskHandler extends CreateWorkflowNodeOperationHandl protected builder(point: Point = Point.ORIGIN, elementTypeId = this.elementTypeIds[0]): TaskNodeBuilder { return TaskNode.builder() + .id(generateId(elementTypeId)) .position(point ?? Point.ORIGIN) .name(this.label.replace(' ', '') + this.modelState.index.getAllByClass(TaskNode).length) .type(elementTypeId) diff --git a/examples/workflow-server/src/common/handler/create-weighted-edge-handler.ts b/examples/workflow-server/src/common/handler/create-weighted-edge-handler.ts index a4db956..0daf2b6 100644 --- a/examples/workflow-server/src/common/handler/create-weighted-edge-handler.ts +++ b/examples/workflow-server/src/common/handler/create-weighted-edge-handler.ts @@ -14,7 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ import { GEdge, GModelCreateEdgeOperationHandler, GModelElement } from '@eclipse-glsp/server'; -import { WeightedEdge } from '../graph-extension'; +import { generateId, WeightedEdge } from '../graph-extension'; import { ModelTypes } from '../util/model-types'; export class CreateWeightedEdgeHandler extends GModelCreateEdgeOperationHandler { @@ -22,6 +22,12 @@ export class CreateWeightedEdgeHandler extends GModelCreateEdgeOperationHandler label = 'Weighted edge'; createEdge(source: GModelElement, target: GModelElement): GEdge | undefined { - return WeightedEdge.builder().sourceId(source.id).targetId(target.id).probability('medium').addCssClass('medium').build(); + return WeightedEdge.builder() + .id(generateId(ModelTypes.WEIGHTED_EDGE)) + .sourceId(source.id) + .targetId(target.id) + .probability('medium') + .addCssClass('medium') + .build(); } } diff --git a/packages/graph/src/gmodel-element.ts b/packages/graph/src/gmodel-element.ts index b6d7a2b..2997458 100644 --- a/packages/graph/src/gmodel-element.ts +++ b/packages/graph/src/gmodel-element.ts @@ -101,24 +101,7 @@ export abstract class GModelElementBuilder { return this; } - /** - * While UUIDs are virtually always unique, they are long, meaningless strings. Typically, - * this is not an issue since classic software doesn't care about the semantics of some - * identifier. However, considering LLMs, this becomes a problem. Since the strings are random - * and meaningless, tokenization is less efficient, thus expanding context size. Furthermore, - * the lack of semantics hurts reasoning and memory. - */ - private generateId(): string { - // 36^4 possible hashes - const randomPart = Math.floor(Math.random() * 1679615); - const hash = randomPart.toString(36).padStart(4, '0'); - // type + 36^4 hashes makes collisions very unlikely - return `${this.proxy.type}_${hash}`; - } - build(): G { - // TODO re-evaluate ID generation method - this.proxy.id = this.generateId(); const element = new this.elementConstructor(); Object.assign(element, this.proxy); element.children.forEach(child => (child.parent = element)); 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 e782f77..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,9 +56,8 @@ export abstract class GModelCreateNodeOperationHandler extends GModelOperationHa const relativeLocation = this.getRelativeLocation(operation); const element = this.createNode(operation, relativeLocation); if (element) { - // TODO re-evaluate ID generation method // When handling IDs that are not guaranteed unique, ensure no collisions - // However, if collisions are unlikely enough, maybe just skip this check entirely + // Since this is a constant time access, the performance impact should be negligable while (this.modelState.index.find(element.id)) { element.id = `${element.id}-`; } From 14dd726a19d52a7a39866c51cd789cad3fcd5e92 Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Mon, 9 Mar 2026 20:28:33 +0100 Subject: [PATCH 07/26] Added workflow specific implementations --- .../src/common/graph-extension.ts | 4 +- .../mcp/workflow-element-types-handler.ts | 131 +++++++++++++++ .../mcp/workflow-mcp-model-serializer.ts | 156 ++++++++++++++++++ .../src/common/mcp/workflow-mcp-module.ts | 27 +++ examples/workflow-server/src/node/app.ts | 5 +- .../handlers/diagram-model-handler.ts | 3 - .../handlers/element-types-handler.ts | 8 +- .../src/resources/resource-module.config.ts | 2 +- .../services/mcp-model-serializer.ts | 9 +- .../src/server/mcp-server-contribution.ts | 14 ++ packages/server-mcp/src/util/markdown-util.ts | 2 +- 11 files changed, 342 insertions(+), 19 deletions(-) create mode 100644 examples/workflow-server/src/common/mcp/workflow-element-types-handler.ts create mode 100644 examples/workflow-server/src/common/mcp/workflow-mcp-model-serializer.ts create mode 100644 examples/workflow-server/src/common/mcp/workflow-mcp-module.ts diff --git a/examples/workflow-server/src/common/graph-extension.ts b/examples/workflow-server/src/common/graph-extension.ts index 37f1885..1c7050b 100644 --- a/examples/workflow-server/src/common/graph-extension.ts +++ b/examples/workflow-server/src/common/graph-extension.ts @@ -85,7 +85,7 @@ export class TaskNodeBuilder extends GNodeBuilder protected createCompartmentHeader(): GLabel { return new GLabelBuilder(GLabel) - .type(ModelTypes.LABEL_HEADING) + .type(ModelTypes.LABEL_TEXT) .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/mcp/workflow-element-types-handler.ts b/examples/workflow-server/src/common/mcp/workflow-element-types-handler.ts new file mode 100644 index 0000000..05fea98 --- /dev/null +++ b/examples/workflow-server/src/common/mcp/workflow-element-types-handler.ts @@ -0,0 +1,131 @@ +/******************************************************************************** + * 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 { ElementTypesMcpResourceHandler, objectArrayToMarkdownTable, ResourceHandlerResult } from '@eclipse-glsp/server-mcp'; +import { injectable } from 'inversify'; +import { ModelTypes } from '../util/model-types'; + +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 async handle({ diagramType }: { diagramType?: string }): Promise { + this.logger.info(`WorkflowElementTypesMcpResourceHandler invoked for diagram type ${diagramType}`); + + 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..3e679c5 --- /dev/null +++ b/examples/workflow-server/src/common/mcp/workflow-mcp-model-serializer.ts @@ -0,0 +1,156 @@ +/******************************************************************************** + * 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, objectArrayToMarkdownTable } from '@eclipse-glsp/server-mcp'; +import { injectable } from 'inversify'; +import { ModelTypes } from '../util/model-types'; + +@injectable() +export class WorkflowMcpModelSerializer extends DefaultMcpModelSerializer { + override keysToRemove: string[] = [ + 'type', + 'cssClasses', + 'revision', + 'layout', + 'args', + 'layoutOptions', + 'alignment', + 'children', + 'routingPoints', + 'resizeLocations', + 'taskType', + 'nodeType' + ]; + + override serialize(element: GModelElement): string { + const elementsByType = this.prepareElement(element); + + return Object.entries(elementsByType) + .flatMap(([type, elements]) => [`# ${type}`, objectArrayToMarkdownTable(elements)]) + .join('\n'); + } + + override prepareElement(element: GModelElement): Record[]> { + const schema = this.gModelSerialzer.createSchema(element); + + const elements = this.flattenStructure(schema, element.parent?.id); + + // Define the order of keys + const result: Record[]> = { + [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); + + this.removeKeys(adjustedElement); + }); + + return result; + } + + private adjustElement(element: Record): Record | undefined { + if ([ModelTypes.AUTOMATED_TASK, ModelTypes.MANUAL_TASK].includes(element.type)) { + const label = element.children.find((child: { type: string }) => child.type.startsWith('label')); + + // 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, + parent: element.parent + }; + } + + if ([ModelTypes.CATEGORY].includes(element.type)) { + const label = element.children.find((child: { type: string }) => child.type.startsWith('label')); + + const labelSize = { + width: Math.trunc(label.size.width + 20), + height: Math.trunc(label.size.height + 20) + }; + + const usableSpaceSize = { + width: Math.trunc(element.size - 10), + height: Math.trunc(Math.max(0, element.size - labelSize.height - 10)) + }; + + return { + id: element.id, + position: element.position, + size: element.size, + bounds: element.bounds, + label: label.text, + labelSize: labelSize, + usableSpaceSize: usableSpaceSize, + parent: element.parent + }; + } + + if ([ModelTypes.JOIN_NODE, ModelTypes.MERGE_NODE, ModelTypes.DECISION_NODE, ModelTypes.FORK_NODE].includes(element.type)) { + return { + id: element.id, + position: element.position, + size: element.size, + bounds: element.bounds, + parent: element.parent + }; + } + + // elements to exclude + if ( + [ + ModelTypes.ICON, + ModelTypes.LABEL_HEADING, + ModelTypes.LABEL_ICON, + ModelTypes.LABEL_TEXT, + ModelTypes.STRUCTURE, + ModelTypes.COMP_HEADER + ].includes(element.type) + ) { + return undefined; + } + + return element; + } +} 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..b9d4a51 --- /dev/null +++ b/examples/workflow-server/src/common/mcp/workflow-mcp-module.ts @@ -0,0 +1,27 @@ +/******************************************************************************** + * 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 { ElementTypesMcpResourceHandler, McpModelSerializer } from '@eclipse-glsp/server-mcp'; +import { ContainerModule } from 'inversify'; +import { WorkflowElementTypesMcpResourceHandler } from './workflow-element-types-handler'; +import { WorkflowMcpModelSerializer } from './workflow-mcp-model-serializer'; + +export function configureWorfklowMcpModule(): ContainerModule { + return new ContainerModule((bind, unbind, isBound, rebind) => { + rebind(McpModelSerializer).to(WorkflowMcpModelSerializer).inSingletonScope(); + rebind(ElementTypesMcpResourceHandler).to(WorkflowElementTypesMcpResourceHandler).inSingletonScope(); + }); +} diff --git a/examples/workflow-server/src/node/app.ts b/examples/workflow-server/src/node/app.ts index 3b000f8..068aa19 100644 --- a/examples/workflow-server/src/node/app.ts +++ b/examples/workflow-server/src/node/app.ts @@ -21,6 +21,7 @@ import { Container } from 'inversify'; import { configureMcpInitModule, configureMcpModules } 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'; @@ -48,11 +49,11 @@ async function launch(argv?: string[]): Promise { const mcpModules = configureMcpModules(); // must not be part of `configureDiagramModule` to ensure MCP server launch if (options.webSocket) { const launcher = appContainer.resolve(WebSocketServerLauncher); - launcher.configure(serverModule, ...mcpModules); + launcher.configure(serverModule, ...mcpModules, configureWorfklowMcpModule()); await launcher.start({ port: options.port, host: options.host, path: 'workflow' }); } else { const launcher = appContainer.resolve(SocketServerLauncher); - launcher.configure(serverModule, ...mcpModules); + launcher.configure(serverModule, ...mcpModules, configureWorfklowMcpModule()); await launcher.start({ port: options.port, host: options.host }); } } diff --git a/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts b/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts index 8fb10f2..62b06f5 100644 --- a/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts +++ b/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts @@ -108,9 +108,6 @@ export class DiagramModelMcpResourceHandler implements McpResourceHandler { const mcpSerializer = session.container.get(McpModelSerializer); const mcpString = mcpSerializer.serialize(modelState.root); - // TODO should likely not contain the generic name attribute but rather the label text - // TODO should ignore irrelevant/uncontrollable/indirect model elements like icons (and labels) - // should be done in workflow specific implementation return { content: { uri: `glsp://diagrams/${sessionId}/model`, diff --git a/packages/server-mcp/src/resources/handlers/element-types-handler.ts b/packages/server-mcp/src/resources/handlers/element-types-handler.ts index c67f289..7415c58 100644 --- a/packages/server-mcp/src/resources/handlers/element-types-handler.ts +++ b/packages/server-mcp/src/resources/handlers/element-types-handler.ts @@ -119,18 +119,16 @@ export class ElementTypesMcpResourceHandler implements McpResourceHandler { if (handler && CreateOperationHandler.is(handler)) { if (key.startsWith('createNode_')) { const elementTypeId = key.substring('createNode_'.length); - nodeTypes.push({ id: elementTypeId, label: elementTypeId }); + nodeTypes.push({ id: elementTypeId, label: handler.label }); } else if (key.startsWith('createEdge_')) { const elementTypeId = key.substring('createEdge_'.length); - edgeTypes.push({ id: elementTypeId, label: elementTypeId }); + edgeTypes.push({ id: elementTypeId, label: handler.label }); } } } - // TODO should likely also contain information about whether a node is labeled - // should be done in workflow specific implementation const result = [ - `# Creatable element types for ${diagramType} diagrams`, + `# Creatable element types for diagram type "${diagramType}"`, '## Node Types', objectArrayToMarkdownTable(nodeTypes), '## Edge Types', diff --git a/packages/server-mcp/src/resources/resource-module.config.ts b/packages/server-mcp/src/resources/resource-module.config.ts index e6581e5..d6bf44b 100644 --- a/packages/server-mcp/src/resources/resource-module.config.ts +++ b/packages/server-mcp/src/resources/resource-module.config.ts @@ -25,7 +25,7 @@ import { DefaultMcpModelSerializer, McpModelSerializer } from './services/mcp-mo export function configureMcpResourceModule(): ContainerModule { return new ContainerModule(bind => { - bindAsService(bind, McpModelSerializer, DefaultMcpModelSerializer); + bind(McpModelSerializer).to(DefaultMcpModelSerializer).inSingletonScope(); bindAsService(bind, McpResourceHandler, SessionsListMcpResourceHandler); bindAsService(bind, McpResourceHandler, ElementTypesMcpResourceHandler); diff --git a/packages/server-mcp/src/resources/services/mcp-model-serializer.ts b/packages/server-mcp/src/resources/services/mcp-model-serializer.ts index d6e7ff5..aac93c6 100644 --- a/packages/server-mcp/src/resources/services/mcp-model-serializer.ts +++ b/packages/server-mcp/src/resources/services/mcp-model-serializer.ts @@ -39,7 +39,7 @@ export class DefaultMcpModelSerializer implements McpModelSerializer { @inject(GModelSerializer) protected gModelSerialzer: GModelSerializer; - private keysToRemove: string[] = [ + protected keysToRemove: string[] = [ 'cssClasses', 'revision', 'layout', @@ -77,7 +77,7 @@ export class DefaultMcpModelSerializer implements McpModelSerializer { return result; } - private flattenStructure(schema: Record, parentId?: string): Record[] { + protected flattenStructure(schema: Record, parentId?: string): Record[] { const result: Record[] = []; // this element is sure to exist but is irrelevant for the AI @@ -88,14 +88,13 @@ export class DefaultMcpModelSerializer implements McpModelSerializer { schema.children .flatMap((child: Record) => this.flattenStructure(child, schema.id)) .forEach((element: Record) => result.push(element)); - schema.children = undefined; } schema.parent = parentId; return result; } - private removeKeys(schema: Record): void { + protected removeKeys(schema: Record): void { for (const key in schema) { if (this.keysToRemove.includes(key)) { delete schema[key]; @@ -103,7 +102,7 @@ export class DefaultMcpModelSerializer implements McpModelSerializer { } } - private combinePositionAndSize(schema: Record): void { + protected combinePositionAndSize(schema: Record): void { const position = schema.position; if (position) { // Not all positioned elements necessarily possess a size diff --git a/packages/server-mcp/src/server/mcp-server-contribution.ts b/packages/server-mcp/src/server/mcp-server-contribution.ts index 322054f..395e394 100644 --- a/packages/server-mcp/src/server/mcp-server-contribution.ts +++ b/packages/server-mcp/src/server/mcp-server-contribution.ts @@ -36,6 +36,13 @@ export interface ResourceHandlerResult { * 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*/ @@ -50,6 +57,13 @@ 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 */ diff --git a/packages/server-mcp/src/util/markdown-util.ts b/packages/server-mcp/src/util/markdown-util.ts index 25a6bd3..092acc7 100644 --- a/packages/server-mcp/src/util/markdown-util.ts +++ b/packages/server-mcp/src/util/markdown-util.ts @@ -31,7 +31,7 @@ export function objectArrayToMarkdownTable(data: Record[]): string .map(header => { const value = obj[header] ?? ''; if (typeof value === 'object') { - return JSON.stringify(value).replace(/"/g, ''); + return JSON.stringify(value).replace(/["{}]/g, ''); } return value; }) From 87f4ce0fdac3f28a60ba211ba75f73c5cbcd0cad Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Tue, 10 Mar 2026 00:25:17 +0100 Subject: [PATCH 08/26] Further refined model creation and modification, specifically for workflow --- .../src/common/graph-extension.ts | 2 +- .../mcp/workflow-create-node-handler.ts | 33 ++++ .../mcp/workflow-mcp-model-serializer.ts | 182 +++++++++--------- .../src/common/mcp/workflow-mcp-module.ts | 11 +- .../mcp/workflow-modify-nodes-handler.ts | 32 +++ .../services/mcp-model-serializer.ts | 10 +- .../src/tools/handlers/create-edge-handler.ts | 1 + .../src/tools/handlers/create-node-handler.ts | 19 +- .../tools/handlers/modify-nodes-handler.ts | 7 +- 9 files changed, 193 insertions(+), 104 deletions(-) create mode 100644 examples/workflow-server/src/common/mcp/workflow-create-node-handler.ts create mode 100644 examples/workflow-server/src/common/mcp/workflow-modify-nodes-handler.ts diff --git a/examples/workflow-server/src/common/graph-extension.ts b/examples/workflow-server/src/common/graph-extension.ts index 1c7050b..07de888 100644 --- a/examples/workflow-server/src/common/graph-extension.ts +++ b/examples/workflow-server/src/common/graph-extension.ts @@ -85,7 +85,7 @@ export class TaskNodeBuilder extends GNodeBuilder protected createCompartmentHeader(): GLabel { return new GLabelBuilder(GLabel) - .type(ModelTypes.LABEL_TEXT) + .type(ModelTypes.LABEL_HEADING) .id(this.proxy.id + '_label') .text(this.proxy.name) .build(); diff --git a/examples/workflow-server/src/common/mcp/workflow-create-node-handler.ts b/examples/workflow-server/src/common/mcp/workflow-create-node-handler.ts new file mode 100644 index 0000000..7278ed8 --- /dev/null +++ b/examples/workflow-server/src/common/mcp/workflow-create-node-handler.ts @@ -0,0 +1,33 @@ +/******************************************************************************** + * 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 { CreateNodeMcpToolHandler } from '@eclipse-glsp/server-mcp'; +import { injectable } from 'inversify'; +import { ModelTypes } from '../util/model-types'; + +@injectable() +export class WorkflowCreateNodeMcpToolHandler extends CreateNodeMcpToolHandler { + override getCorrespondingLabelId(element: GModelElement): string | undefined { + 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-mcp-model-serializer.ts b/examples/workflow-server/src/common/mcp/workflow-mcp-model-serializer.ts index 3e679c5..c38cf87 100644 --- a/examples/workflow-server/src/common/mcp/workflow-mcp-model-serializer.ts +++ b/examples/workflow-server/src/common/mcp/workflow-mcp-model-serializer.ts @@ -22,21 +22,6 @@ import { ModelTypes } from '../util/model-types'; @injectable() export class WorkflowMcpModelSerializer extends DefaultMcpModelSerializer { - override keysToRemove: string[] = [ - 'type', - 'cssClasses', - 'revision', - 'layout', - 'args', - 'layoutOptions', - 'alignment', - 'children', - 'routingPoints', - 'resizeLocations', - 'taskType', - 'nodeType' - ]; - override serialize(element: GModelElement): string { const elementsByType = this.prepareElement(element); @@ -46,12 +31,11 @@ export class WorkflowMcpModelSerializer extends DefaultMcpModelSerializer { } override prepareElement(element: GModelElement): Record[]> { - const schema = this.gModelSerialzer.createSchema(element); - - const elements = this.flattenStructure(schema, element.parent?.id); + const elements = this.flattenStructure(element); // Define the order of keys const result: Record[]> = { + [DefaultTypes.GRAPH]: [], [ModelTypes.CATEGORY]: [], [ModelTypes.AUTOMATED_TASK]: [], [ModelTypes.MANUAL_TASK]: [], @@ -71,86 +55,100 @@ export class WorkflowMcpModelSerializer extends DefaultMcpModelSerializer { } result[element.type].push(adjustedElement); - - this.removeKeys(adjustedElement); }); return result; } private adjustElement(element: Record): Record | undefined { - if ([ModelTypes.AUTOMATED_TASK, ModelTypes.MANUAL_TASK].includes(element.type)) { - const label = element.children.find((child: { type: string }) => child.type.startsWith('label')); - - // 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, - parent: element.parent - }; - } - - if ([ModelTypes.CATEGORY].includes(element.type)) { - const label = element.children.find((child: { type: string }) => child.type.startsWith('label')); - - const labelSize = { - width: Math.trunc(label.size.width + 20), - height: Math.trunc(label.size.height + 20) - }; - - const usableSpaceSize = { - width: Math.trunc(element.size - 10), - height: Math.trunc(Math.max(0, element.size - labelSize.height - 10)) - }; - - return { - id: element.id, - position: element.position, - size: element.size, - bounds: element.bounds, - label: label.text, - labelSize: labelSize, - usableSpaceSize: usableSpaceSize, - parent: element.parent - }; - } - - if ([ModelTypes.JOIN_NODE, ModelTypes.MERGE_NODE, ModelTypes.DECISION_NODE, ModelTypes.FORK_NODE].includes(element.type)) { - return { - id: element.id, - position: element.position, - size: element.size, - bounds: element.bounds, - parent: element.parent - }; - } - - // elements to exclude - if ( - [ - ModelTypes.ICON, - ModelTypes.LABEL_HEADING, - ModelTypes.LABEL_ICON, - ModelTypes.LABEL_TEXT, - ModelTypes.STRUCTURE, - ModelTypes.COMP_HEADER - ].includes(element.type) - ) { - return 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; } - - return element; } } diff --git a/examples/workflow-server/src/common/mcp/workflow-mcp-module.ts b/examples/workflow-server/src/common/mcp/workflow-mcp-module.ts index b9d4a51..87f2499 100644 --- a/examples/workflow-server/src/common/mcp/workflow-mcp-module.ts +++ b/examples/workflow-server/src/common/mcp/workflow-mcp-module.ts @@ -14,14 +14,23 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { ElementTypesMcpResourceHandler, McpModelSerializer } from '@eclipse-glsp/server-mcp'; +import { + CreateNodeMcpToolHandler, + ElementTypesMcpResourceHandler, + McpModelSerializer, + ModifyNodesMcpToolHandler +} from '@eclipse-glsp/server-mcp'; import { ContainerModule } from 'inversify'; +import { WorkflowCreateNodeMcpToolHandler } from './workflow-create-node-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(CreateNodeMcpToolHandler).to(WorkflowCreateNodeMcpToolHandler).inSingletonScope(); + rebind(ModifyNodesMcpToolHandler).to(WorkflowModifyNodesMcpToolHandler).inSingletonScope(); }); } 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..e267c8e --- /dev/null +++ b/examples/workflow-server/src/common/mcp/workflow-modify-nodes-handler.ts @@ -0,0 +1,32 @@ +/******************************************************************************** + * 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 { + if (element.type === ModelTypes.CATEGORY) { + return element.children.find(child => child.type === ModelTypes.COMP_HEADER)?.children.find(child => child instanceof GLabel) + ?.id; + } + + return element.children.find(child => child instanceof GLabel)?.id; + } +} diff --git a/packages/server-mcp/src/resources/services/mcp-model-serializer.ts b/packages/server-mcp/src/resources/services/mcp-model-serializer.ts index aac93c6..b7e6bc0 100644 --- a/packages/server-mcp/src/resources/services/mcp-model-serializer.ts +++ b/packages/server-mcp/src/resources/services/mcp-model-serializer.ts @@ -48,7 +48,8 @@ export class DefaultMcpModelSerializer implements McpModelSerializer { 'alignment', 'children', 'routingPoints', - 'resizeLocations' + 'resizeLocations', + 'parent' ]; serialize(element: GModelElement): string { @@ -80,16 +81,13 @@ export class DefaultMcpModelSerializer implements McpModelSerializer { protected flattenStructure(schema: Record, parentId?: string): Record[] { const result: Record[] = []; - // this element is sure to exist but is irrelevant for the AI - if (schema.type !== 'graph') { - result.push(schema); - } + result.push(schema); if (schema.children !== undefined) { schema.children .flatMap((child: Record) => this.flattenStructure(child, schema.id)) .forEach((element: Record) => result.push(element)); } - schema.parent = parentId; + schema.parentId = parentId; return result; } diff --git a/packages/server-mcp/src/tools/handlers/create-edge-handler.ts b/packages/server-mcp/src/tools/handlers/create-edge-handler.ts index 8c74d2d..21f87f5 100644 --- a/packages/server-mcp/src/tools/handlers/create-edge-handler.ts +++ b/packages/server-mcp/src/tools/handlers/create-edge-handler.ts @@ -32,6 +32,7 @@ export class CreateEdgeMcpToolHandler implements McpToolHandler { @inject(ClientSessionManager) protected clientSessionManager: ClientSessionManager; + // TODO make multi creation registerTool(server: GLSPMcpServer): void { server.registerTool( 'create-edge', diff --git a/packages/server-mcp/src/tools/handlers/create-node-handler.ts b/packages/server-mcp/src/tools/handlers/create-node-handler.ts index e6e0bed..cd7479e 100644 --- a/packages/server-mcp/src/tools/handlers/create-node-handler.ts +++ b/packages/server-mcp/src/tools/handlers/create-node-handler.ts @@ -14,7 +14,15 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { ApplyLabelEditOperation, ClientSessionManager, CreateNodeOperation, GLabel, Logger, ModelState } from '@eclipse-glsp/server'; +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'; @@ -32,6 +40,7 @@ export class CreateNodeMcpToolHandler implements McpToolHandler { @inject(ClientSessionManager) protected clientSessionManager: ClientSessionManager; + // TODO make multi creation registerTool(server: GLSPMcpServer): void { server.registerTool( 'create-node', @@ -118,8 +127,7 @@ export class CreateNodeMcpToolHandler implements McpToolHandler { return createToolResult('Node creation likely failed, because no new element ID could be determined', true); } - // Assume that generally, labelled nodes have those labels as direct children - const newElementLabelId = modelState.index.get(newElementId).children.find(child => child instanceof GLabel)?.id; + 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 @@ -133,4 +141,9 @@ export class CreateNodeMcpToolHandler implements McpToolHandler { return createToolResult(`Node creation failed: ${error instanceof Error ? error.message : String(error)}`, true); } } + + protected getCorrespondingLabelId(element: GModelElement): string | undefined { + // Assume that generally, labelled nodes have those labels as direct children + return element.children.find(child => child instanceof GLabel)?.id; + } } diff --git a/packages/server-mcp/src/tools/handlers/modify-nodes-handler.ts b/packages/server-mcp/src/tools/handlers/modify-nodes-handler.ts index 364e972..5f13d7e 100644 --- a/packages/server-mcp/src/tools/handlers/modify-nodes-handler.ts +++ b/packages/server-mcp/src/tools/handlers/modify-nodes-handler.ts @@ -136,7 +136,7 @@ export class ModifyNodesMcpToolHandler implements McpToolHandler { } // Change the label if applicable - const newElementLabelId = element.children.find(child => child instanceof GLabel)?.id; + const newElementLabelId = this.getCorrespondingLabelId(element); if (newElementLabelId && text) { const editLabelOperation = ApplyLabelEditOperation.create({ labelId: newElementLabelId, text }); promises.push(session.actionDispatcher.dispatch(editLabelOperation)); @@ -148,4 +148,9 @@ export class ModifyNodesMcpToolHandler implements McpToolHandler { return createToolResult('Nodes succesfully modified', false); } + + protected getCorrespondingLabelId(element: GShapeElement): string | undefined { + // Assume that generally, labelled nodes have those labels as direct children + return element.children.find(child => child instanceof GLabel)?.id; + } } From e67d7da8d52c4f734806d4ac4343b2826ed67524 Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Tue, 10 Mar 2026 01:43:28 +0100 Subject: [PATCH 09/26] Make node and edge creation multi-input handlers --- .../src/tools/handlers/create-edge-handler.ts | 103 +++++++++------ .../src/tools/handlers/create-node-handler.ts | 124 +++++++++++------- 2 files changed, 137 insertions(+), 90 deletions(-) diff --git a/packages/server-mcp/src/tools/handlers/create-edge-handler.ts b/packages/server-mcp/src/tools/handlers/create-edge-handler.ts index 21f87f5..37af108 100644 --- a/packages/server-mcp/src/tools/handlers/create-edge-handler.ts +++ b/packages/server-mcp/src/tools/handlers/create-edge-handler.ts @@ -32,7 +32,6 @@ export class CreateEdgeMcpToolHandler implements McpToolHandler { @inject(ClientSessionManager) protected clientSessionManager: ClientSessionManager; - // TODO make multi creation registerTool(server: GLSPMcpServer): void { server.registerTool( 'create-edge', @@ -43,15 +42,24 @@ export class CreateEdgeMcpToolHandler implements McpToolHandler { '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)') + 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)'), + 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) @@ -60,16 +68,10 @@ export class CreateEdgeMcpToolHandler implements McpToolHandler { async handle({ sessionId, - elementTypeId, - sourceElementId, - targetElementId, - args + edges }: { sessionId: string; - elementTypeId: string; - sourceElementId: string; - targetElementId: string; - args?: Record; + edges: { elementTypeId: string; sourceElementId: string; targetElementId: string; args?: Record }[]; }): Promise { this.logger.info(`CreateEdgeMcpToolHandler invoked for session ${sessionId}`); @@ -86,38 +88,57 @@ export class CreateEdgeMcpToolHandler implements McpToolHandler { return createToolResult('Model is read-only', true); } - // Validate source and target exist - const source = modelState.index.find(sourceElementId); - if (!source) { - return createToolResult(`Source element not found: ${sourceElementId}`, true); - } + // Snapshot element IDs before operation using index.allIds() + let beforeIds = new Set(modelState.index.allIds()); + + const errors = []; + const successIds = []; + // Since we need sequential handling of the created elements, we can't call all in parallel + for (const edge of edges) { + const { elementTypeId, sourceElementId, targetElementId, args } = edge; + + // Validate source and target exist + const source = modelState.index.find(sourceElementId); + if (!source) { + errors.push(`Source element not found: ${sourceElementId}`); + } - const target = modelState.index.find(targetElementId); - if (!target) { - return createToolResult(`Target element not found: ${targetElementId}`, true); - } + const target = modelState.index.find(targetElementId); + if (!target) { + errors.push(`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); - // Create operation - const operation = CreateEdgeOperation.create({ elementTypeId, sourceElementId, targetElementId, args }); + // Snapshot element IDs after operation + const afterIds = modelState.index.allIds(); - // Dispatch operation - await session.actionDispatcher.dispatch(operation); + // Find new element ID + const newIds = afterIds.filter(id => !beforeIds.has(id)); + const newElementId = newIds.length > 0 ? newIds[0] : undefined; - // Snapshot element IDs after operation - const afterIds = modelState.index.allIds(); + beforeIds = new Set(afterIds); - // Find new element ID - const newIds = afterIds.filter(id => !beforeIds.has(id)); - const newElementId = newIds.length > 0 ? newIds[0] : undefined; + if (!newElementId) { + errors.push(`Edge creation failed for input: ${JSON.stringify(edge)}`); + continue; + } + + successIds.push(newElementId); + } - if (!newElementId) { - return createToolResult('Edge creation likely failed, because no new element ID could be determined', true); + let failureStr = ''; + if (errors.length) { + const failureListStr = errors.map(error => `- ${error}\n`); + failureStr = `\nThe following errors occured:\n${failureListStr}`; } - return createToolResult(`Edge created successfully with element ID: ${newElementId}`, false); + const successListStr = successIds.map(successId => `- ${successId}`).join('\n'); + return createToolResult(`Edge created successfully with element ID:\n${successListStr}${failureStr}`, false); } catch (error) { this.logger.error('Edge creation failed', error); return createToolResult(`Edge creation failed: ${error instanceof Error ? error.message : String(error)}`, true); diff --git a/packages/server-mcp/src/tools/handlers/create-node-handler.ts b/packages/server-mcp/src/tools/handlers/create-node-handler.ts index cd7479e..bce4b4f 100644 --- a/packages/server-mcp/src/tools/handlers/create-node-handler.ts +++ b/packages/server-mcp/src/tools/handlers/create-node-handler.ts @@ -40,7 +40,6 @@ export class CreateNodeMcpToolHandler implements McpToolHandler { @inject(ClientSessionManager) protected clientSessionManager: ClientSessionManager; - // TODO make multi creation registerTool(server: GLSPMcpServer): void { server.registerTool( 'create-node', @@ -52,24 +51,34 @@ export class CreateNodeMcpToolHandler implements McpToolHandler { '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.' - ), - 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)') + 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) @@ -78,18 +87,16 @@ export class CreateNodeMcpToolHandler implements McpToolHandler { async handle({ sessionId, - elementTypeId, - position, - text, - containerId, - args + nodes }: { sessionId: string; - elementTypeId: string; - position: { x: number; y: number }; - text?: string; - containerId?: string; - args?: Record; + nodes: { + elementTypeId: string; + position: { x: number; y: number }; + text?: string; + containerId?: string; + args?: Record; + }[]; }): Promise { this.logger.info(`CreateNodeMcpToolHandler invoked for session ${sessionId}`); @@ -107,35 +114,54 @@ export class CreateNodeMcpToolHandler implements McpToolHandler { } // Snapshot element IDs before operation using index.allIds() - const beforeIds = new Set(modelState.index.allIds()); + let beforeIds = new Set(modelState.index.allIds()); - // Create operation - // Using the name "position" instead of "location", as this is the name in the elements properties - const operation = CreateNodeOperation.create(elementTypeId, { location: position, containerId, args }); + const failures = []; + const successIds = []; + // 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, containerId, args } = node; - // Dispatch operation - await session.actionDispatcher.dispatch(operation); + // Create operation + // Using the name "position" instead of "location", as this is the name in the elements properties + const operation = CreateNodeOperation.create(elementTypeId, { location: position, containerId, args }); - // Snapshot element IDs after operation - const afterIds = modelState.index.allIds(); + // Dispatch operation + await session.actionDispatcher.dispatch(operation); - // Find new element ID - const newIds = afterIds.filter(id => !beforeIds.has(id)); - const newElementId = newIds.length > 0 ? newIds[0] : undefined; + // Snapshot element IDs after operation + const afterIds = modelState.index.allIds(); - if (!newElementId) { - return createToolResult('Node creation likely failed, because no new element ID could be determined', true); + // Find new element ID + const newIds = afterIds.filter(id => !beforeIds.has(id)); + const newElementId = newIds.length > 0 ? newIds[0] : undefined; + + beforeIds = new Set(afterIds); + + if (!newElementId) { + failures.push(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); + } + + successIds.push(newElementId); } - 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); + let failureStr = ''; + if (failures.length) { + const failureListStr = failures.map(failure => `- ${JSON.stringify(failure)}\n`); + failureStr = `\nThe following inputs likely failed, because no new element ID could be determined:\n${failureListStr}`; } - return createToolResult(`Node created successfully with the element ID: ${newElementId}`, false); + const successListStr = successIds.map(successId => `- ${successId}`).join('\n'); + return createToolResult(`Nodes created successfully with the element IDs:\n${successListStr}${failureStr}`, false); } catch (error) { this.logger.error('Node creation failed', error); return createToolResult(`Node creation failed: ${error instanceof Error ? error.message : String(error)}`, true); From 305103798c5ebf57fb583de45539046024e76ab8 Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Tue, 10 Mar 2026 18:15:26 +0100 Subject: [PATCH 10/26] Aligned logic across handlers and added more documentation --- ...er.ts => workflow-create-nodes-handler.ts} | 5 +- .../mcp/workflow-element-types-handler.ts | 3 +- .../mcp/workflow-mcp-model-serializer.ts | 7 + .../src/common/mcp/workflow-mcp-module.ts | 6 +- .../mcp/workflow-modify-nodes-handler.ts | 2 + .../handlers/diagram-model-handler.ts | 3 +- .../resources/handlers/diagram-png-handler.ts | 21 +- .../handlers/element-types-handler.ts | 4 +- .../handlers/sessions-list-handler.ts | 3 +- .../services/mcp-model-serializer.ts | 8 + .../src/tools/handlers/create-edge-handler.ts | 147 ------------- .../tools/handlers/create-edges-handler.ts | 156 ++++++++++++++ .../src/tools/handlers/create-node-handler.ts | 175 ---------------- .../tools/handlers/create-nodes-handler.ts | 194 ++++++++++++++++++ ...-handler.ts => delete-elements-handler.ts} | 74 +++---- .../tools/handlers/diagram-element-handler.ts | 5 +- .../tools/handlers/modify-nodes-handler.ts | 22 +- .../src/tools/handlers/save-model-handler.ts | 37 ++-- .../handlers/validate-diagram-handler.ts | 47 +++-- packages/server-mcp/src/tools/index.ts | 6 +- .../src/tools/tool-module.config.ts | 12 +- 21 files changed, 495 insertions(+), 442 deletions(-) rename examples/workflow-server/src/common/mcp/{workflow-create-node-handler.ts => workflow-create-nodes-handler.ts} (87%) delete mode 100644 packages/server-mcp/src/tools/handlers/create-edge-handler.ts create mode 100644 packages/server-mcp/src/tools/handlers/create-edges-handler.ts delete mode 100644 packages/server-mcp/src/tools/handlers/create-node-handler.ts create mode 100644 packages/server-mcp/src/tools/handlers/create-nodes-handler.ts rename packages/server-mcp/src/tools/handlers/{delete-element-handler.ts => delete-elements-handler.ts} (53%) diff --git a/examples/workflow-server/src/common/mcp/workflow-create-node-handler.ts b/examples/workflow-server/src/common/mcp/workflow-create-nodes-handler.ts similarity index 87% rename from examples/workflow-server/src/common/mcp/workflow-create-node-handler.ts rename to examples/workflow-server/src/common/mcp/workflow-create-nodes-handler.ts index 7278ed8..68a587f 100644 --- a/examples/workflow-server/src/common/mcp/workflow-create-node-handler.ts +++ b/examples/workflow-server/src/common/mcp/workflow-create-nodes-handler.ts @@ -15,13 +15,14 @@ ********************************************************************************/ import { GLabel, GModelElement } from '@eclipse-glsp/server'; -import { CreateNodeMcpToolHandler } from '@eclipse-glsp/server-mcp'; +import { CreateNodesMcpToolHandler } from '@eclipse-glsp/server-mcp'; import { injectable } from 'inversify'; import { ModelTypes } from '../util/model-types'; @injectable() -export class WorkflowCreateNodeMcpToolHandler extends CreateNodeMcpToolHandler { +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; 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 index 05fea98..025d652 100644 --- a/examples/workflow-server/src/common/mcp/workflow-element-types-handler.ts +++ b/examples/workflow-server/src/common/mcp/workflow-element-types-handler.ts @@ -106,8 +106,9 @@ const WORKFLOW_ELEMENT_TYPES_STRING = [ @injectable() export class WorkflowElementTypesMcpResourceHandler extends ElementTypesMcpResourceHandler { override async handle({ diagramType }: { diagramType?: string }): Promise { - this.logger.info(`WorkflowElementTypesMcpResourceHandler invoked for diagram type ${diagramType}`); + 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: { 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 index c38cf87..2d10a92 100644 --- a/examples/workflow-server/src/common/mcp/workflow-mcp-model-serializer.ts +++ b/examples/workflow-server/src/common/mcp/workflow-mcp-model-serializer.ts @@ -20,6 +20,13 @@ import { DefaultMcpModelSerializer, objectArrayToMarkdownTable } from '@eclipse- 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 serialize(element: GModelElement): string { diff --git a/examples/workflow-server/src/common/mcp/workflow-mcp-module.ts b/examples/workflow-server/src/common/mcp/workflow-mcp-module.ts index 87f2499..bf880fb 100644 --- a/examples/workflow-server/src/common/mcp/workflow-mcp-module.ts +++ b/examples/workflow-server/src/common/mcp/workflow-mcp-module.ts @@ -15,13 +15,13 @@ ********************************************************************************/ import { - CreateNodeMcpToolHandler, + CreateNodesMcpToolHandler, ElementTypesMcpResourceHandler, McpModelSerializer, ModifyNodesMcpToolHandler } from '@eclipse-glsp/server-mcp'; import { ContainerModule } from 'inversify'; -import { WorkflowCreateNodeMcpToolHandler } from './workflow-create-node-handler'; +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'; @@ -30,7 +30,7 @@ export function configureWorfklowMcpModule(): ContainerModule { return new ContainerModule((bind, unbind, isBound, rebind) => { rebind(McpModelSerializer).to(WorkflowMcpModelSerializer).inSingletonScope(); rebind(ElementTypesMcpResourceHandler).to(WorkflowElementTypesMcpResourceHandler).inSingletonScope(); - rebind(CreateNodeMcpToolHandler).to(WorkflowCreateNodeMcpToolHandler).inSingletonScope(); + rebind(CreateNodesMcpToolHandler).to(WorkflowCreateNodesMcpToolHandler).inSingletonScope(); rebind(ModifyNodesMcpToolHandler).to(WorkflowModifyNodesMcpToolHandler).inSingletonScope(); }); } 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 index e267c8e..c8684f5 100644 --- a/examples/workflow-server/src/common/mcp/workflow-modify-nodes-handler.ts +++ b/examples/workflow-server/src/common/mcp/workflow-modify-nodes-handler.ts @@ -22,11 +22,13 @@ 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/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts b/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts index 62b06f5..7713f37 100644 --- a/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts +++ b/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts @@ -80,7 +80,8 @@ export class DiagramModelMcpResourceHandler implements McpResourceHandler { } async handle({ sessionId }: { sessionId?: string }): Promise { - this.logger.info(`DiagramModelMcpResourceHandler invoked for session ${sessionId}`); + this.logger.info(`'diagram-model' invoked for session '${sessionId}'`); + if (!sessionId) { return { content: { diff --git a/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts b/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts index 4f68c75..a29749d 100644 --- a/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts +++ b/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts @@ -14,15 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { - Action, - ActionDispatcher, - ActionHandler, - ClientSessionManager, - ExportMcpPngAction, - Logger, - RequestExportMcpPngAction -} from '@eclipse-glsp/server'; +import { Action, ActionHandler, ClientSessionManager, ExportMcpPngAction, Logger, RequestExportMcpPngAction } from '@eclipse-glsp/server'; import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp'; import { inject, injectable } from 'inversify'; import * as z from 'zod/v4'; @@ -121,7 +113,8 @@ export class DiagramPngMcpResourceHandler implements McpResourceHandler, ActionH } async handle({ sessionId }: { sessionId?: string }): Promise { - this.logger.info(`DiagramPngMcpResourceHandler invoked for session ${sessionId}`); + this.logger.info(`'diagram-png' invoked for session ${sessionId}`); + if (!sessionId) { return { content: { @@ -145,10 +138,9 @@ export class DiagramPngMcpResourceHandler implements McpResourceHandler, ActionH }; } - const actionDispatcher = session.container.get(ActionDispatcher); - - actionDispatcher.dispatch(RequestExportMcpPngAction.create({ options: { sessionId } })); + session.actionDispatcher.dispatch(RequestExportMcpPngAction.create({ options: { sessionId } })); + // Start a promise and save the resolve function to the class return new Promise(resolve => { this.promiseResolveFn = resolve; setTimeout( @@ -168,8 +160,9 @@ export class DiagramPngMcpResourceHandler implements McpResourceHandler, ActionH async execute(action: ExportMcpPngAction): Promise { const sessionId = action.options?.sessionId ?? ''; - this.logger.info(`ExportMcpPngAction received for session ${sessionId}`); + this.logger.info(`ExportMcpPngAction received for session '${sessionId}'`); + // Resolve the previously started promise this.promiseResolveFn?.({ content: { uri: `glsp://diagrams/${sessionId}/png`, diff --git a/packages/server-mcp/src/resources/handlers/element-types-handler.ts b/packages/server-mcp/src/resources/handlers/element-types-handler.ts index 7415c58..3ddb1fe 100644 --- a/packages/server-mcp/src/resources/handlers/element-types-handler.ts +++ b/packages/server-mcp/src/resources/handlers/element-types-handler.ts @@ -83,7 +83,8 @@ export class ElementTypesMcpResourceHandler implements McpResourceHandler { } async handle({ diagramType }: { diagramType?: string }): Promise { - this.logger.info(`ElementTypesMcpResourceHandler invoked for diagram type ${diagramType}`); + this.logger.info(`'element-types' invoked for diagram type '${diagramType}'`); + if (!diagramType) { return { content: { @@ -114,6 +115,7 @@ export class ElementTypesMcpResourceHandler implements McpResourceHandler { 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)) { diff --git a/packages/server-mcp/src/resources/handlers/sessions-list-handler.ts b/packages/server-mcp/src/resources/handlers/sessions-list-handler.ts index c3d9596..1673a69 100644 --- a/packages/server-mcp/src/resources/handlers/sessions-list-handler.ts +++ b/packages/server-mcp/src/resources/handlers/sessions-list-handler.ts @@ -56,7 +56,8 @@ export class SessionsListMcpResourceHandler implements McpResourceHandler { } async handle(params: Record): Promise { - this.logger.info('SessionsListMcpResourceHandler invoked'); + this.logger.info("'sessions-list' invoked"); + const sessions = this.clientSessionManager.getSessions(); const sessionsList = sessions.map(session => { const modelState = session.container.get(ModelState); diff --git a/packages/server-mcp/src/resources/services/mcp-model-serializer.ts b/packages/server-mcp/src/resources/services/mcp-model-serializer.ts index b7e6bc0..efc7e8d 100644 --- a/packages/server-mcp/src/resources/services/mcp-model-serializer.ts +++ b/packages/server-mcp/src/resources/services/mcp-model-serializer.ts @@ -34,6 +34,14 @@ export interface McpModelSerializer { serialize(element: GModelElement): string; } +/** + * 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) diff --git a/packages/server-mcp/src/tools/handlers/create-edge-handler.ts b/packages/server-mcp/src/tools/handlers/create-edge-handler.ts deleted file mode 100644 index 37af108..0000000 --- a/packages/server-mcp/src/tools/handlers/create-edge-handler.ts +++ /dev/null @@ -1,147 +0,0 @@ -/******************************************************************************** - * 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, 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, McpToolHandler } from '../../server'; -import { createToolResult } from '../../util'; - -/** - * Creates a new edge in the given session's model. - */ -@injectable() -export class CreateEdgeMcpToolHandler implements McpToolHandler { - @inject(Logger) - protected logger: Logger; - - @inject(ClientSessionManager) - protected clientSessionManager: ClientSessionManager; - - registerTool(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'), - 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)'), - 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; args?: Record }[]; - }): Promise { - this.logger.info(`CreateEdgeMcpToolHandler invoked for session ${sessionId}`); - - try { - const session = this.clientSessionManager.getSession(sessionId); - if (!session) { - return createToolResult('Session not found', true); - } - - const modelState = session.container.get(ModelState); - - // Check if model is readonly - if (modelState.isReadonly) { - return createToolResult('Model is read-only', true); - } - - // Snapshot element IDs before operation using index.allIds() - let beforeIds = new Set(modelState.index.allIds()); - - const errors = []; - const successIds = []; - // Since we need sequential handling of the created elements, we can't call all in parallel - for (const edge of edges) { - const { elementTypeId, sourceElementId, targetElementId, args } = edge; - - // Validate source and target exist - const source = modelState.index.find(sourceElementId); - if (!source) { - errors.push(`Source element not found: ${sourceElementId}`); - } - - const target = modelState.index.find(targetElementId); - if (!target) { - errors.push(`Target element not found: ${targetElementId}`); - } - - // 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; - - beforeIds = new Set(afterIds); - - if (!newElementId) { - errors.push(`Edge creation failed for input: ${JSON.stringify(edge)}`); - continue; - } - - successIds.push(newElementId); - } - - 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'); - return createToolResult(`Edge created successfully with element ID:\n${successListStr}${failureStr}`, false); - } catch (error) { - this.logger.error('Edge creation failed', error); - return createToolResult(`Edge creation failed: ${error instanceof Error ? error.message : String(error)}`, true); - } - } -} 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..c7138c9 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/create-edges-handler.ts @@ -0,0 +1,156 @@ +/******************************************************************************** + * 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, 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, 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)'), + 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; 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); + } + + // Snapshot element IDs before operation + let beforeIds = modelState.index.allIds(); + + const errors = []; + const successIds = []; + // Since we need sequential handling of the created elements, we can't call all in parallel + for (const edge of edges) { + const { elementTypeId, sourceElementId, targetElementId, args } = edge; + + // Validate source and target exist + const source = modelState.index.find(sourceElementId); + if (!source) { + errors.push(`Source element not found: ${sourceElementId}`); + } + const target = modelState.index.find(targetElementId); + if (!target) { + errors.push(`Target element not found: ${targetElementId}`); + } + + // 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); + + // 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)); + // ...and in case that multiple exist (i.e., derived elements were created as well), + // assume that the first new ID represents the actually relevant element + const newElementId = newIds.length > 0 ? newIds[0] : undefined; + + // 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; + } + + successIds.push(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) with element IDs:\n${successListStr}${failureStr}`, + false + ); + } +} diff --git a/packages/server-mcp/src/tools/handlers/create-node-handler.ts b/packages/server-mcp/src/tools/handlers/create-node-handler.ts deleted file mode 100644 index bce4b4f..0000000 --- a/packages/server-mcp/src/tools/handlers/create-node-handler.ts +++ /dev/null @@ -1,175 +0,0 @@ -/******************************************************************************** - * 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, McpToolHandler } from '../../server'; -import { createToolResult } from '../../util'; - -/** - * Creates a new node in the given session's model. - */ -@injectable() -export class CreateNodeMcpToolHandler implements McpToolHandler { - @inject(Logger) - protected logger: Logger; - - @inject(ClientSessionManager) - protected clientSessionManager: ClientSessionManager; - - registerTool(server: GLSPMcpServer): void { - server.registerTool( - 'create-node', - { - description: - 'Create a new node 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(`CreateNodeMcpToolHandler invoked for session ${sessionId}`); - - try { - const session = this.clientSessionManager.getSession(sessionId); - if (!session) { - return createToolResult('Session not found', true); - } - - const modelState = session.container.get(ModelState); - - // Check if model is readonly - if (modelState.isReadonly) { - return createToolResult('Model is read-only', true); - } - - // Snapshot element IDs before operation using index.allIds() - let beforeIds = new Set(modelState.index.allIds()); - - const failures = []; - const successIds = []; - // 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, containerId, args } = node; - - // Create operation - // Using the name "position" instead of "location", as this is the name in the elements properties - const operation = CreateNodeOperation.create(elementTypeId, { location: position, 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; - - beforeIds = new Set(afterIds); - - if (!newElementId) { - failures.push(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); - } - - successIds.push(newElementId); - } - - let failureStr = ''; - if (failures.length) { - const failureListStr = failures.map(failure => `- ${JSON.stringify(failure)}\n`); - failureStr = `\nThe following inputs likely failed, because no new element ID could be determined:\n${failureListStr}`; - } - - const successListStr = successIds.map(successId => `- ${successId}`).join('\n'); - return createToolResult(`Nodes created successfully with the element IDs:\n${successListStr}${failureStr}`, false); - } catch (error) { - this.logger.error('Node creation failed', error); - return createToolResult(`Node creation failed: ${error instanceof Error ? error.message : String(error)}`, true); - } - } - - protected getCorrespondingLabelId(element: GModelElement): string | undefined { - // Assume that generally, labelled nodes have those labels as direct children - return element.children.find(child => child instanceof GLabel)?.id; - } -} 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..53c5e9e --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/create-nodes-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 { + 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, 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); + } + + // Snapshot element IDs before operation + let beforeIds = modelState.index.allIds(); + + const errors = []; + const successIds = []; + // 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, containerId, args } = node; + + // 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); + + // 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)); + // ...and in case that multiple exist (i.e., derived elements were created as well), + // assume that the first new ID represents the actually relevant element + const newElementId = newIds.length > 0 ? newIds[0] : undefined; + + // 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); + } + + successIds.push(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) 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-element-handler.ts b/packages/server-mcp/src/tools/handlers/delete-elements-handler.ts similarity index 53% rename from packages/server-mcp/src/tools/handlers/delete-element-handler.ts rename to packages/server-mcp/src/tools/handlers/delete-elements-handler.ts index 0575bc0..4ed6c54 100644 --- a/packages/server-mcp/src/tools/handlers/delete-element-handler.ts +++ b/packages/server-mcp/src/tools/handlers/delete-elements-handler.ts @@ -22,10 +22,10 @@ import { GLSPMcpServer, McpToolHandler } from '../../server'; import { createToolResult } from '../../util'; /** - * Deletes an element using their element ID from the given session's model. + * Deletes one or more element using their element ID from the given session's model. */ @injectable() -export class DeleteElementMcpToolHandler implements McpToolHandler { +export class DeleteElementsMcpToolHandler implements McpToolHandler { @inject(Logger) protected logger: Logger; @@ -34,7 +34,7 @@ export class DeleteElementMcpToolHandler implements McpToolHandler { registerTool(server: GLSPMcpServer): void { server.registerTool( - 'delete-element', + 'delete-elements', { description: 'Delete one or more elements (nodes or edges) from the diagram. ' + @@ -50,49 +50,49 @@ export class DeleteElementMcpToolHandler implements McpToolHandler { } async handle({ sessionId, elementIds }: { sessionId: string; elementIds: string[] }): Promise { - this.logger.info(`DeleteElementMcpToolHandler invoked for session ${sessionId}`); + this.logger.info(`'delete-elements' invoked for session '${sessionId}' with ${elementIds.length} elements`); - try { - const session = this.clientSessionManager.getSession(sessionId); - if (!session) { - return createToolResult('Session not found', true); - } + if (!sessionId) { + return createToolResult('No session id provided.', true); + } + if (!elementIds || !elementIds.length) { + return createToolResult('No elementIds provided.', true); + } - const modelState = session.container.get(ModelState); + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + return createToolResult('Session not found', true); + } - // Check if model is readonly - if (modelState.isReadonly) { - return createToolResult('Model is read-only', true); - } + const modelState = session.container.get(ModelState); + if (modelState.isReadonly) { + return createToolResult('Model is read-only', true); + } - // Validate elements exist - const missingIds: string[] = []; - for (const elementId of elementIds) { - const element = modelState.index.find(elementId); - if (!element) { - missingIds.push(elementId); - } + // 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 createToolResult(`Some elements not found: ${missingIds}`, true); - } + if (missingIds.length > 0) { + return createToolResult(`Some elements not found: ${missingIds}`, true); + } - // Snapshot element count before operation - const beforeCount = modelState.index.allIds().length; + // 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); + // 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; + // 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); - } catch (error) { - this.logger.error('Element deletion failed', error); - return createToolResult(`Element deletion failed: ${error instanceof Error ? error.message : String(error)}`, true); - } + return createToolResult(`Successfully deleted ${deletedCount} element(s) (including dependents)`, false); } } diff --git a/packages/server-mcp/src/tools/handlers/diagram-element-handler.ts b/packages/server-mcp/src/tools/handlers/diagram-element-handler.ts index 11d8aa1..6293567 100644 --- a/packages/server-mcp/src/tools/handlers/diagram-element-handler.ts +++ b/packages/server-mcp/src/tools/handlers/diagram-element-handler.ts @@ -50,8 +50,9 @@ export class DiagramElementMcpToolHandler implements McpToolHandler { ); } - async handle({ sessionId, elementId }: { sessionId?: string; elementId?: string }): Promise { - this.logger.info(`DiagramElementMcpToolHandler invoked for session ${sessionId} and element ${elementId}`); + async handle({ sessionId, elementId }: { sessionId: string; elementId: string }): Promise { + this.logger.info(`'diagram-element' invoked for session '${sessionId}' and element '${elementId}'`); + if (!sessionId) { return createToolResult('No session id provided.', true); } diff --git a/packages/server-mcp/src/tools/handlers/modify-nodes-handler.ts b/packages/server-mcp/src/tools/handlers/modify-nodes-handler.ts index 5f13d7e..03febd7 100644 --- a/packages/server-mcp/src/tools/handlers/modify-nodes-handler.ts +++ b/packages/server-mcp/src/tools/handlers/modify-nodes-handler.ts @@ -30,7 +30,7 @@ import { GLSPMcpServer, McpToolHandler } from '../../server'; import { createToolResult } from '../../util'; /** - * Modifies a specific nodes in the given session's model. + * Modifies onr or more nodes in the given session's model. */ @injectable() export class ModifyNodesMcpToolHandler implements McpToolHandler { @@ -89,9 +89,10 @@ export class ModifyNodesMcpToolHandler implements McpToolHandler { changes }: { sessionId: string; - changes?: { elementId: string; position?: { x: number; y: number }; size?: { width: number; height: number }; text?: string }[]; + changes: { elementId: string; position?: { x: number; y: number }; size?: { width: number; height: number }; text?: string }[]; }): Promise { - this.logger.info(`ModifyNodesMcpToolHandler invoked for session ${sessionId}`); + this.logger.info(`'modify-nodes' invoked for session '${sessionId}' with ${changes.length} changes`); + if (!sessionId) { return createToolResult('No session id provided.', true); } @@ -116,9 +117,10 @@ export class ModifyNodesMcpToolHandler implements McpToolHandler { ]); // 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 elements found for the following element ids: ${undefinedElements}`, true); + 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 @@ -146,11 +148,19 @@ export class ModifyNodesMcpToolHandler implements McpToolHandler { // Wait for all dispatches to finish before notifying the caller await Promise.all(promises); - return createToolResult('Nodes succesfully modified', false); + return createToolResult(`Succesfully modified ${changes.length} node(s)`, 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 { - // Assume that generally, labelled nodes have those labels as direct children return element.children.find(child => child instanceof GLabel)?.id; } } diff --git a/packages/server-mcp/src/tools/handlers/save-model-handler.ts b/packages/server-mcp/src/tools/handlers/save-model-handler.ts index 647ac49..150af53 100644 --- a/packages/server-mcp/src/tools/handlers/save-model-handler.ts +++ b/packages/server-mcp/src/tools/handlers/save-model-handler.ts @@ -54,29 +54,28 @@ export class SaveModelMcpToolHandler implements McpToolHandler { } async handle({ sessionId, fileUri }: { sessionId: string; fileUri?: string }): Promise { - this.logger.info(`SaveModelMcpToolHandler invoked for session ${sessionId}`); + this.logger.info(`'save-model' invoked for session '${sessionId}'`); - try { - const session = this.clientSessionManager.getSession(sessionId); - if (!session) { - return createToolResult('Session not found', true); - } - - const commandStack = session.container.get(CommandStack); + if (!sessionId) { + return createToolResult('No session id provided.', true); + } - // Check if there are unsaved changes - if (!commandStack.isDirty) { - return createToolResult('No changes to save', false); - } + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + return createToolResult('Session not found', true); + } - // Dispatch save action - const action = SaveModelAction.create({ fileUri }); - await session.actionDispatcher.dispatch(action); + const commandStack = session.container.get(CommandStack); - return createToolResult('Model saved successfully', false); - } catch (error) { - this.logger.error('Save failed', error); - return createToolResult(`Save failed: ${error instanceof Error ? error.message : String(error)}`, true); + // 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/validate-diagram-handler.ts b/packages/server-mcp/src/tools/handlers/validate-diagram-handler.ts index c773082..effdc1f 100644 --- a/packages/server-mcp/src/tools/handlers/validate-diagram-handler.ts +++ b/packages/server-mcp/src/tools/handlers/validate-diagram-handler.ts @@ -66,36 +66,35 @@ export class ValidateDiagramMcpToolHandler implements McpToolHandler { elementIds?: string[]; reason?: string; }): Promise { - this.logger.info(`ValidateDiagramMcpToolHandler invoked for session ${sessionId}`); + this.logger.info(`'validate-diagram' invoked for session '${sessionId}'`); - try { - const session = this.clientSessionManager.getSession(sessionId); - if (!session) { - return createToolResult('Session not found', true); - } + if (!sessionId) { + return createToolResult('No session id provided.', true); + } - const modelState = session.container.get(ModelState); + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + return createToolResult('Session not found', true); + } - let validator: ModelValidator; - try { - validator = session.container.get(ModelValidator); - } catch (error) { - return createToolResult('No validator configured for this diagram type', true); - } + const modelState = session.container.get(ModelState); - // Determine which elements to validate - const idsToValidate = elementIds && elementIds.length > 0 ? elementIds : [modelState.root.id]; + let validator: ModelValidator; + try { + validator = session.container.get(ModelValidator); + } catch (error) { + return createToolResult('No validator configured for this diagram type', true); + } - // Get elements from index - const elements = modelState.index.getAll(idsToValidate); + // Determine which elements to validate + const idsToValidate = elementIds && elementIds.length > 0 ? elementIds : [modelState.root.id]; - // Run validation - const markers = await validator.validate(elements, reason ?? MarkersReason.BATCH); + // Get elements from index + const elements = modelState.index.getAll(idsToValidate); - return createToolResult(objectArrayToMarkdownTable(markers), false); - } catch (error) { - this.logger.error('Validation failed', error); - return createToolResult(`Validation failed: ${error instanceof Error ? error.message : String(error)}`, true); - } + // Run validation + const markers = await validator.validate(elements, reason ?? MarkersReason.BATCH); + + return createToolResult(objectArrayToMarkdownTable(markers), false); } } diff --git a/packages/server-mcp/src/tools/index.ts b/packages/server-mcp/src/tools/index.ts index 44730e2..1b2c8c4 100644 --- a/packages/server-mcp/src/tools/index.ts +++ b/packages/server-mcp/src/tools/index.ts @@ -14,9 +14,9 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -export * from './handlers/create-edge-handler'; -export * from './handlers/create-node-handler'; -export * from './handlers/delete-element-handler'; +export * from './handlers/create-edges-handler'; +export * from './handlers/create-nodes-handler'; +export * from './handlers/delete-elements-handler'; export * from './handlers/diagram-element-handler'; export * from './handlers/modify-nodes-handler'; export * from './handlers/save-model-handler'; diff --git a/packages/server-mcp/src/tools/tool-module.config.ts b/packages/server-mcp/src/tools/tool-module.config.ts index 2e3aa2b..f9f5bfb 100644 --- a/packages/server-mcp/src/tools/tool-module.config.ts +++ b/packages/server-mcp/src/tools/tool-module.config.ts @@ -16,9 +16,9 @@ import { bindAsService } from '@eclipse-glsp/server'; import { ContainerModule } from 'inversify'; import { McpServerContribution, McpToolHandler } from '../server'; -import { CreateEdgeMcpToolHandler } from './handlers/create-edge-handler'; -import { CreateNodeMcpToolHandler } from './handlers/create-node-handler'; -import { DeleteElementMcpToolHandler } from './handlers/delete-element-handler'; +import { CreateEdgesMcpToolHandler } from './handlers/create-edges-handler'; +import { CreateNodesMcpToolHandler } from './handlers/create-nodes-handler'; +import { DeleteElementsMcpToolHandler } from './handlers/delete-elements-handler'; import { DiagramElementMcpToolHandler } from './handlers/diagram-element-handler'; import { ModifyNodesMcpToolHandler } from './handlers/modify-nodes-handler'; import { SaveModelMcpToolHandler } from './handlers/save-model-handler'; @@ -27,9 +27,9 @@ import { McpToolContribution } from './mcp-tool-contribution'; export function configureMcpToolModule(): ContainerModule { return new ContainerModule(bind => { - bindAsService(bind, McpToolHandler, CreateNodeMcpToolHandler); - bindAsService(bind, McpToolHandler, CreateEdgeMcpToolHandler); - bindAsService(bind, McpToolHandler, DeleteElementMcpToolHandler); + bindAsService(bind, McpToolHandler, CreateNodesMcpToolHandler); + bindAsService(bind, McpToolHandler, CreateEdgesMcpToolHandler); + bindAsService(bind, McpToolHandler, DeleteElementsMcpToolHandler); bindAsService(bind, McpToolHandler, SaveModelMcpToolHandler); bindAsService(bind, McpToolHandler, ValidateDiagramMcpToolHandler); bindAsService(bind, McpToolHandler, DiagramElementMcpToolHandler); From 25ebb63b384529683d1ef0199c2ef466fe86f1a5 Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Tue, 10 Mar 2026 21:14:16 +0100 Subject: [PATCH 11/26] Added undo/redo tools --- .../tools/handlers/create-edges-handler.ts | 5 +- .../tools/handlers/create-nodes-handler.ts | 6 +- .../tools/handlers/modify-nodes-handler.ts | 2 +- .../src/tools/handlers/redo-handler.ts | 74 +++++++++++++++++++ .../src/tools/handlers/undo-handler.ts | 74 +++++++++++++++++++ .../src/tools/tool-module.config.ts | 4 + 6 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 packages/server-mcp/src/tools/handlers/redo-handler.ts create mode 100644 packages/server-mcp/src/tools/handlers/undo-handler.ts diff --git a/packages/server-mcp/src/tools/handlers/create-edges-handler.ts b/packages/server-mcp/src/tools/handlers/create-edges-handler.ts index c7138c9..29daa49 100644 --- a/packages/server-mcp/src/tools/handlers/create-edges-handler.ts +++ b/packages/server-mcp/src/tools/handlers/create-edges-handler.ts @@ -105,10 +105,12 @@ export class CreateEdgesMcpToolHandler implements McpToolHandler { 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 @@ -149,7 +151,8 @@ export class CreateEdgesMcpToolHandler implements McpToolHandler { // 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) with element IDs:\n${successListStr}${failureStr}`, + `Sucessfully created ${successIds.length} edge(s) (in ${successIds.length} 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 index 53c5e9e..a47082b 100644 --- a/packages/server-mcp/src/tools/handlers/create-nodes-handler.ts +++ b/packages/server-mcp/src/tools/handlers/create-nodes-handler.ts @@ -122,6 +122,7 @@ export class CreateNodesMcpToolHandler implements McpToolHandler { const errors = []; const successIds = []; + 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, containerId, args } = node; @@ -132,6 +133,7 @@ export class CreateNodesMcpToolHandler implements McpToolHandler { 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(); @@ -158,6 +160,7 @@ export class CreateNodesMcpToolHandler implements McpToolHandler { // ...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(newElementId); @@ -174,7 +177,8 @@ export class CreateNodesMcpToolHandler implements McpToolHandler { // 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) with the element IDs:\n${successListStr}${failureStr}`, + `Successfully created ${successIds.length} node(s) (in ${dispatchedOperations} commands) ` + + `with the element IDs:\n${successListStr}${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 index 03febd7..676c133 100644 --- a/packages/server-mcp/src/tools/handlers/modify-nodes-handler.ts +++ b/packages/server-mcp/src/tools/handlers/modify-nodes-handler.ts @@ -148,7 +148,7 @@ export class ModifyNodesMcpToolHandler implements McpToolHandler { // Wait for all dispatches to finish before notifying the caller await Promise.all(promises); - return createToolResult(`Succesfully modified ${changes.length} node(s)`, false); + return createToolResult(`Succesfully modified ${changes.length} node(s) (in ${promises.length} commands)`, false); } /** 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/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/tool-module.config.ts b/packages/server-mcp/src/tools/tool-module.config.ts index f9f5bfb..4146c4b 100644 --- a/packages/server-mcp/src/tools/tool-module.config.ts +++ b/packages/server-mcp/src/tools/tool-module.config.ts @@ -21,7 +21,9 @@ import { CreateNodesMcpToolHandler } from './handlers/create-nodes-handler'; import { DeleteElementsMcpToolHandler } from './handlers/delete-elements-handler'; import { DiagramElementMcpToolHandler } from './handlers/diagram-element-handler'; import { ModifyNodesMcpToolHandler } from './handlers/modify-nodes-handler'; +import { RedoMcpToolHandler } from './handlers/redo-handler'; import { SaveModelMcpToolHandler } from './handlers/save-model-handler'; +import { UndoMcpToolHandler } from './handlers/undo-handler'; import { ValidateDiagramMcpToolHandler } from './handlers/validate-diagram-handler'; import { McpToolContribution } from './mcp-tool-contribution'; @@ -34,6 +36,8 @@ export function configureMcpToolModule(): ContainerModule { bindAsService(bind, McpToolHandler, ValidateDiagramMcpToolHandler); bindAsService(bind, McpToolHandler, DiagramElementMcpToolHandler); bindAsService(bind, McpToolHandler, ModifyNodesMcpToolHandler); + bindAsService(bind, McpToolHandler, UndoMcpToolHandler); + bindAsService(bind, McpToolHandler, RedoMcpToolHandler); bindAsService(bind, McpServerContribution, McpToolContribution); }); From 832028e5891f1133e1f33d5c12507ddde0bb0040 Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Tue, 10 Mar 2026 22:02:49 +0100 Subject: [PATCH 12/26] Added modify-edges tool --- .../services/mcp-model-serializer.ts | 32 +-- .../tools/handlers/create-edges-handler.ts | 33 +++- .../tools/handlers/modify-edges-handler.ts | 184 ++++++++++++++++++ .../src/tools/tool-module.config.ts | 2 + 4 files changed, 232 insertions(+), 19 deletions(-) create mode 100644 packages/server-mcp/src/tools/handlers/modify-edges-handler.ts diff --git a/packages/server-mcp/src/resources/services/mcp-model-serializer.ts b/packages/server-mcp/src/resources/services/mcp-model-serializer.ts index efc7e8d..2c79cde 100644 --- a/packages/server-mcp/src/resources/services/mcp-model-serializer.ts +++ b/packages/server-mcp/src/resources/services/mcp-model-serializer.ts @@ -86,33 +86,35 @@ export class DefaultMcpModelSerializer implements McpModelSerializer { return result; } - protected flattenStructure(schema: Record, parentId?: string): Record[] { + protected flattenStructure(element: Record, parentId?: string): Record[] { + const newElement = { ...element }; + const result: Record[] = []; - result.push(schema); - if (schema.children !== undefined) { - schema.children - .flatMap((child: Record) => this.flattenStructure(child, schema.id)) + result.push(newElement); + if (newElement.children !== undefined) { + newElement.children + .flatMap((child: Record) => this.flattenStructure(child, newElement.id)) .forEach((element: Record) => result.push(element)); } - schema.parentId = parentId; + newElement.parentId = parentId; return result; } - protected removeKeys(schema: Record): void { - for (const key in schema) { + protected removeKeys(element: Record): void { + for (const key in element) { if (this.keysToRemove.includes(key)) { - delete schema[key]; + delete element[key]; } } } - protected combinePositionAndSize(schema: Record): void { - const position = schema.position; + protected combinePositionAndSize(element: Record): void { + const position = element.position; if (position) { // Not all positioned elements necessarily possess a size - const size = schema.size ?? { width: 0, height: 0 }; + const size = element.size ?? { width: 0, height: 0 }; const x = Math.trunc(position.x); const y = Math.trunc(position.y); @@ -120,11 +122,11 @@ export class DefaultMcpModelSerializer implements McpModelSerializer { const height = Math.trunc(size.height); // Only expose the truncated sizes for smaller context size at irrelevant precision loss - schema['position'] = { x, y }; - schema['size'] = { width, height }; + element['position'] = { x, y }; + element['size'] = { width, height }; // Add bounds in addition to position and size to reduce derived calculations - schema['bounds'] = { + element['bounds'] = { left: x, right: x + width, top: y, diff --git a/packages/server-mcp/src/tools/handlers/create-edges-handler.ts b/packages/server-mcp/src/tools/handlers/create-edges-handler.ts index 29daa49..fb8497e 100644 --- a/packages/server-mcp/src/tools/handlers/create-edges-handler.ts +++ b/packages/server-mcp/src/tools/handlers/create-edges-handler.ts @@ -14,7 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { ClientSessionManager, CreateEdgeOperation, Logger, ModelState } from '@eclipse-glsp/server'; +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'; @@ -52,6 +52,15 @@ export class CreateEdgesMcpToolHandler implements McpToolHandler { ), 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() @@ -71,7 +80,13 @@ export class CreateEdgesMcpToolHandler implements McpToolHandler { edges }: { sessionId: string; - edges: { elementTypeId: string; sourceElementId: string; targetElementId: string; args?: Record }[]; + 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`); @@ -97,9 +112,10 @@ export class CreateEdgesMcpToolHandler implements McpToolHandler { const errors = []; const successIds = []; + 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, sourceElementId, targetElementId, args } = edge; + const { elementTypeId, sourceElementId, targetElementId, routingPoints, args } = edge; // Validate source and target exist const source = modelState.index.find(sourceElementId); @@ -117,6 +133,7 @@ export class CreateEdgesMcpToolHandler implements McpToolHandler { 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(); @@ -137,6 +154,14 @@ export class CreateEdgesMcpToolHandler implements McpToolHandler { continue; } + if (routingPoints) { + const routingPointsOperation = ChangeRoutingPointsOperation.create([ + { elementId: newElementId, newRoutingPoints: routingPoints } + ]); + await session.actionDispatcher.dispatch(routingPointsOperation); + dispatchedOperations++; + } + successIds.push(newElementId); } @@ -151,7 +176,7 @@ export class CreateEdgesMcpToolHandler implements McpToolHandler { // 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 ${successIds.length} commands) ` + + `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/modify-edges-handler.ts b/packages/server-mcp/src/tools/handlers/modify-edges-handler.ts new file mode 100644 index 0000000..ba82f80 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/modify-edges-handler.ts @@ -0,0 +1,184 @@ +/******************************************************************************** + * 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, + GLabel, + 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, 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); + } + + // Map the list of changes to their underlying element + const elements: [(typeof changes)[number], GShapeElement][] = changes.map(change => [ + change, + modelState.index.find(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 { elementId, sourceElementId, targetElementId, routingPoints } = change; + + // 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} edge(s) (in ${promises.length} commands)${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: GShapeElement): string | undefined { + return element.children.find(child => child instanceof GLabel)?.id; + } +} diff --git a/packages/server-mcp/src/tools/tool-module.config.ts b/packages/server-mcp/src/tools/tool-module.config.ts index 4146c4b..dd4c230 100644 --- a/packages/server-mcp/src/tools/tool-module.config.ts +++ b/packages/server-mcp/src/tools/tool-module.config.ts @@ -20,6 +20,7 @@ import { CreateEdgesMcpToolHandler } from './handlers/create-edges-handler'; import { CreateNodesMcpToolHandler } from './handlers/create-nodes-handler'; import { DeleteElementsMcpToolHandler } from './handlers/delete-elements-handler'; import { DiagramElementMcpToolHandler } from './handlers/diagram-element-handler'; +import { ModifyEdgesMcpToolHandler } from './handlers/modify-edges-handler'; import { ModifyNodesMcpToolHandler } from './handlers/modify-nodes-handler'; import { RedoMcpToolHandler } from './handlers/redo-handler'; import { SaveModelMcpToolHandler } from './handlers/save-model-handler'; @@ -36,6 +37,7 @@ export function configureMcpToolModule(): ContainerModule { bindAsService(bind, McpToolHandler, ValidateDiagramMcpToolHandler); bindAsService(bind, McpToolHandler, DiagramElementMcpToolHandler); bindAsService(bind, McpToolHandler, ModifyNodesMcpToolHandler); + bindAsService(bind, McpToolHandler, ModifyEdgesMcpToolHandler); bindAsService(bind, McpToolHandler, UndoMcpToolHandler); bindAsService(bind, McpToolHandler, RedoMcpToolHandler); From 6472a41a929e242600425d86a5ffb362582370ee Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Tue, 10 Mar 2026 22:52:04 +0100 Subject: [PATCH 13/26] Added request-layout tool --- .../src/common/mcp/workflow-mcp-module.ts | 6 +- packages/server-mcp/src/feature-flags.ts | 4 ++ .../src/tools/handlers/redo-handler.ts | 4 ++ .../tools/handlers/request-layout-handler.ts | 72 +++++++++++++++++++ .../src/tools/handlers/undo-handler.ts | 4 ++ packages/server-mcp/src/tools/index.ts | 4 ++ 6 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 packages/server-mcp/src/tools/handlers/request-layout-handler.ts diff --git a/examples/workflow-server/src/common/mcp/workflow-mcp-module.ts b/examples/workflow-server/src/common/mcp/workflow-mcp-module.ts index bf880fb..34b0134 100644 --- a/examples/workflow-server/src/common/mcp/workflow-mcp-module.ts +++ b/examples/workflow-server/src/common/mcp/workflow-mcp-module.ts @@ -14,11 +14,14 @@ * 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, - ModifyNodesMcpToolHandler + McpToolHandler, + ModifyNodesMcpToolHandler, + RequestLayoutMcpToolHandler } from '@eclipse-glsp/server-mcp'; import { ContainerModule } from 'inversify'; import { WorkflowCreateNodesMcpToolHandler } from './workflow-create-nodes-handler'; @@ -32,5 +35,6 @@ export function configureWorfklowMcpModule(): ContainerModule { rebind(ElementTypesMcpResourceHandler).to(WorkflowElementTypesMcpResourceHandler).inSingletonScope(); rebind(CreateNodesMcpToolHandler).to(WorkflowCreateNodesMcpToolHandler).inSingletonScope(); rebind(ModifyNodesMcpToolHandler).to(WorkflowModifyNodesMcpToolHandler).inSingletonScope(); + bindAsService(bind, McpToolHandler, RequestLayoutMcpToolHandler); }); } diff --git a/packages/server-mcp/src/feature-flags.ts b/packages/server-mcp/src/feature-flags.ts index 0c730e7..c236489 100644 --- a/packages/server-mcp/src/feature-flags.ts +++ b/packages/server-mcp/src/feature-flags.ts @@ -31,5 +31,9 @@ export const FEATURE_FLAGS = { useResources: true, resources: { png: false + }, + tools: { + undo: true, + redo: true } }; diff --git a/packages/server-mcp/src/tools/handlers/redo-handler.ts b/packages/server-mcp/src/tools/handlers/redo-handler.ts index 2246de8..2646145 100644 --- a/packages/server-mcp/src/tools/handlers/redo-handler.ts +++ b/packages/server-mcp/src/tools/handlers/redo-handler.ts @@ -18,6 +18,7 @@ import { ClientSessionManager, CommandStack, Logger, RedoAction } from '@eclipse import { CallToolResult } from '@modelcontextprotocol/sdk/types'; import { inject, injectable } from 'inversify'; import * as z from 'zod/v4'; +import { FEATURE_FLAGS } from '../../feature-flags'; import { GLSPMcpServer, McpToolHandler } from '../../server'; import { createToolResult } from '../../util'; @@ -33,6 +34,9 @@ export class RedoMcpToolHandler implements McpToolHandler { protected clientSessionManager: ClientSessionManager; registerTool(server: GLSPMcpServer): void { + if (!FEATURE_FLAGS.tools.redo) { + return; + } server.registerTool( 'redo', { 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/undo-handler.ts b/packages/server-mcp/src/tools/handlers/undo-handler.ts index b9f84fe..49597c4 100644 --- a/packages/server-mcp/src/tools/handlers/undo-handler.ts +++ b/packages/server-mcp/src/tools/handlers/undo-handler.ts @@ -18,6 +18,7 @@ import { ClientSessionManager, CommandStack, Logger, UndoAction } from '@eclipse import { CallToolResult } from '@modelcontextprotocol/sdk/types'; import { inject, injectable } from 'inversify'; import * as z from 'zod/v4'; +import { FEATURE_FLAGS } from '../../feature-flags'; import { GLSPMcpServer, McpToolHandler } from '../../server'; import { createToolResult } from '../../util'; @@ -33,6 +34,9 @@ export class UndoMcpToolHandler implements McpToolHandler { protected clientSessionManager: ClientSessionManager; registerTool(server: GLSPMcpServer): void { + if (!FEATURE_FLAGS.tools.undo) { + return; + } server.registerTool( 'undo', { diff --git a/packages/server-mcp/src/tools/index.ts b/packages/server-mcp/src/tools/index.ts index 1b2c8c4..d670fa1 100644 --- a/packages/server-mcp/src/tools/index.ts +++ b/packages/server-mcp/src/tools/index.ts @@ -18,8 +18,12 @@ export * from './handlers/create-edges-handler'; export * from './handlers/create-nodes-handler'; export * from './handlers/delete-elements-handler'; export * from './handlers/diagram-element-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'; export * from './tool-module.config'; From 80a42a8492e1d53f9aa62766ab2ac889e785c92b Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Wed, 11 Mar 2026 16:53:47 +0100 Subject: [PATCH 14/26] Added get-selection tool and refined diagram-elements --- .../mcp/workflow-mcp-model-serializer.ts | 10 +-- .../export-png-action-handler-contribution.ts | 5 +- ...t-selection-action-handler-contribution.ts | 38 ++++++++ packages/server-mcp/src/init/index.ts | 1 + .../server-mcp/src/init/init-module.config.ts | 4 +- .../services/mcp-model-serializer.ts | 27 ++++++ ...handler.ts => diagram-elements-handler.ts} | 38 ++++---- .../tools/handlers/get-selection-handler.ts | 88 +++++++++++++++++++ packages/server-mcp/src/tools/index.ts | 3 +- .../src/tools/tool-module.config.ts | 6 +- 10 files changed, 190 insertions(+), 30 deletions(-) create mode 100644 packages/server-mcp/src/init/get-selection-action-handler-contribution.ts rename packages/server-mcp/src/tools/handlers/{diagram-element-handler.ts => diagram-elements-handler.ts} (65%) create mode 100644 packages/server-mcp/src/tools/handlers/get-selection-handler.ts 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 index 2d10a92..2d72af4 100644 --- a/examples/workflow-server/src/common/mcp/workflow-mcp-model-serializer.ts +++ b/examples/workflow-server/src/common/mcp/workflow-mcp-model-serializer.ts @@ -16,7 +16,7 @@ import { GModelElement } from '@eclipse-glsp/graph'; import { DefaultTypes } from '@eclipse-glsp/server'; -import { DefaultMcpModelSerializer, objectArrayToMarkdownTable } from '@eclipse-glsp/server-mcp'; +import { DefaultMcpModelSerializer } from '@eclipse-glsp/server-mcp'; import { injectable } from 'inversify'; import { ModelTypes } from '../util/model-types'; @@ -29,14 +29,6 @@ import { ModelTypes } from '../util/model-types'; */ @injectable() export class WorkflowMcpModelSerializer extends DefaultMcpModelSerializer { - override serialize(element: GModelElement): string { - const elementsByType = this.prepareElement(element); - - return Object.entries(elementsByType) - .flatMap(([type, elements]) => [`# ${type}`, objectArrayToMarkdownTable(elements)]) - .join('\n'); - } - override prepareElement(element: GModelElement): Record[]> { const elements = this.flattenStructure(element); 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 index a79b678..ff026bf 100644 --- a/packages/server-mcp/src/init/export-png-action-handler-contribution.ts +++ b/packages/server-mcp/src/init/export-png-action-handler-contribution.ts @@ -16,6 +16,7 @@ import { ActionHandlerFactory, ActionHandlerRegistry, Args, ClientSessionInitializer } from '@eclipse-glsp/server'; import { inject, injectable } from 'inversify'; +import { FEATURE_FLAGS } from '../feature-flags'; import { DiagramPngMcpResourceHandler } from '../resources'; /** @@ -33,6 +34,8 @@ export class ExportMcpPngActionHandlerInitContribution implements ClientSessionI protected pngHandler: DiagramPngMcpResourceHandler; initialize(args?: Args): void { - this.registry.registerHandler(this.pngHandler); + if (FEATURE_FLAGS.resources.png) { + 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/init/index.ts b/packages/server-mcp/src/init/index.ts index 3412eb4..5587ae4 100644 --- a/packages/server-mcp/src/init/index.ts +++ b/packages/server-mcp/src/init/index.ts @@ -15,4 +15,5 @@ ********************************************************************************/ 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 index 9d1dcf5..1c291eb 100644 --- a/packages/server-mcp/src/init/init-module.config.ts +++ b/packages/server-mcp/src/init/init-module.config.ts @@ -14,9 +14,10 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { ClientSessionInitializer } from '@eclipse-glsp/server'; +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'; // TODO this only exists to inject additional action handlers without interfering too much with the given module hierarchy // however, as this is somewhat hacky, it is likely better to just extend `ServerModule` (e.g., `McpServerModule`) to register the handlers @@ -25,5 +26,6 @@ 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/resources/services/mcp-model-serializer.ts b/packages/server-mcp/src/resources/services/mcp-model-serializer.ts index 2c79cde..793e11f 100644 --- a/packages/server-mcp/src/resources/services/mcp-model-serializer.ts +++ b/packages/server-mcp/src/resources/services/mcp-model-serializer.ts @@ -32,6 +32,15 @@ export interface McpModelSerializer { * @returns The transformed string. */ serialize(element: GModelElement): string; + + /** + * 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. + * @returns The transformed string. + */ + serializeArray(elements: GModelElement[]): string; } /** @@ -68,6 +77,24 @@ export class DefaultMcpModelSerializer implements McpModelSerializer { .join('\n'); } + serializeArray(elements: GModelElement[]): string { + 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()); + }); + + return Object.entries(result) + .flatMap(([type, elements]) => [`# ${type}`, objectArrayToMarkdownTable(elements)]) + .join('\n'); + } + protected prepareElement(element: GModelElement): Record[]> { const schema = this.gModelSerialzer.createSchema(element); diff --git a/packages/server-mcp/src/tools/handlers/diagram-element-handler.ts b/packages/server-mcp/src/tools/handlers/diagram-elements-handler.ts similarity index 65% rename from packages/server-mcp/src/tools/handlers/diagram-element-handler.ts rename to packages/server-mcp/src/tools/handlers/diagram-elements-handler.ts index 6293567..2b0cccc 100644 --- a/packages/server-mcp/src/tools/handlers/diagram-element-handler.ts +++ b/packages/server-mcp/src/tools/handlers/diagram-elements-handler.ts @@ -14,7 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { ClientSessionManager, Logger, ModelState } from '@eclipse-glsp/server'; +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'; @@ -22,11 +22,13 @@ import { McpModelSerializer } from '../../resources/services/mcp-model-serialize import { GLSPMcpServer, McpToolHandler } from '../../server'; import { createToolResult } from '../../util'; +// TODO extend to multiple + /** - * Creates a serialized representation of a specific element of a given session's model. + * Creates a serialized representation of one or more specific elements of a given session's model. */ @injectable() -export class DiagramElementMcpToolHandler implements McpToolHandler { +export class DiagramElementsMcpToolHandler implements McpToolHandler { @inject(Logger) protected logger: Logger; @@ -35,29 +37,29 @@ export class DiagramElementMcpToolHandler implements McpToolHandler { registerTool(server: GLSPMcpServer): void { server.registerTool( - 'diagram-element', + 'diagram-elements', { - title: 'Diagram Model Element', + title: 'Diagram Model Elements', description: - 'Get the a single element of a GLSP model for a session as a markdown structure. ' + - 'This is a more specific query than diagram-model.', + '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.'), - elementId: z.string().describe('Element ID that should be queried.') + elementIds: z.array(z.string()).min(1).describe('Element IDs that should be queried.') } }, params => this.handle(params) ); } - async handle({ sessionId, elementId }: { sessionId: string; elementId: string }): Promise { - this.logger.info(`'diagram-element' invoked for session '${sessionId}' and element '${elementId}'`); + 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 (!elementId) { - return createToolResult('No element id provided.', true); + if (!elementIds || !elementIds.length) { + return createToolResult('No element ids provided.', true); } const session = this.clientSessionManager.getSession(sessionId); @@ -67,13 +69,17 @@ export class DiagramElementMcpToolHandler implements McpToolHandler { const modelState = session.container.get(ModelState); - const element = modelState.index.find(elementId); - if (!element) { - return createToolResult('No element found for this element id.', true); + const elements: GModelElement[] = []; + for (const elementId of elementIds) { + const element = modelState.index.find(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.serialize(element); + 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..ef7b060 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/get-selection-handler.ts @@ -0,0 +1,88 @@ +/******************************************************************************** + * 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, Logger, SelectionMcpResult } 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'; + +/** + * Queries the currently selected elements for a given session's diagram. + */ +@injectable() +export class GetSelectionMcpToolHandler implements McpToolHandler, ActionHandler { + actionKinds = [SelectionMcpResult.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] = resolve; + setTimeout(() => resolve(createToolResult('The request timed out.', true)), 5000); + }); + } + + async execute(action: SelectionMcpResult): Promise { + const requestId = action.mcpRequestId; + this.logger.info(`SelectionMcpResult received with request ID '${requestId}'`); + + // Resolve the previously started promise + const selectedIdsStr = action.selectedElementsIDs.map(id => `- ${id}`).join('\n'); + this.resolvers[requestId]?.(createToolResult(`Following element IDs are selected:\n${selectedIdsStr}`, false)); + + return []; + } +} diff --git a/packages/server-mcp/src/tools/index.ts b/packages/server-mcp/src/tools/index.ts index d670fa1..c242b0e 100644 --- a/packages/server-mcp/src/tools/index.ts +++ b/packages/server-mcp/src/tools/index.ts @@ -17,7 +17,8 @@ export * from './handlers/create-edges-handler'; export * from './handlers/create-nodes-handler'; export * from './handlers/delete-elements-handler'; -export * from './handlers/diagram-element-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'; diff --git a/packages/server-mcp/src/tools/tool-module.config.ts b/packages/server-mcp/src/tools/tool-module.config.ts index dd4c230..fc47ece 100644 --- a/packages/server-mcp/src/tools/tool-module.config.ts +++ b/packages/server-mcp/src/tools/tool-module.config.ts @@ -19,7 +19,8 @@ import { McpServerContribution, McpToolHandler } from '../server'; import { CreateEdgesMcpToolHandler } from './handlers/create-edges-handler'; import { CreateNodesMcpToolHandler } from './handlers/create-nodes-handler'; import { DeleteElementsMcpToolHandler } from './handlers/delete-elements-handler'; -import { DiagramElementMcpToolHandler } from './handlers/diagram-element-handler'; +import { DiagramElementsMcpToolHandler } from './handlers/diagram-elements-handler'; +import { GetSelectionMcpToolHandler } from './handlers/get-selection-handler'; import { ModifyEdgesMcpToolHandler } from './handlers/modify-edges-handler'; import { ModifyNodesMcpToolHandler } from './handlers/modify-nodes-handler'; import { RedoMcpToolHandler } from './handlers/redo-handler'; @@ -35,11 +36,12 @@ export function configureMcpToolModule(): ContainerModule { bindAsService(bind, McpToolHandler, DeleteElementsMcpToolHandler); bindAsService(bind, McpToolHandler, SaveModelMcpToolHandler); bindAsService(bind, McpToolHandler, ValidateDiagramMcpToolHandler); - bindAsService(bind, McpToolHandler, DiagramElementMcpToolHandler); + 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, McpServerContribution, McpToolContribution); }); From eeeabbe51e74d7bd17213c330c1c51efaf4cfa75 Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Wed, 11 Mar 2026 17:18:13 +0100 Subject: [PATCH 15/26] Restructured MCP png components --- packages/server-mcp/src/feature-flags.ts | 2 +- .../resources/handlers/diagram-png-handler.ts | 29 ++++++++++++------- .../handlers/diagram-elements-handler.ts | 2 -- .../tools/handlers/get-selection-handler.ts | 16 +++++++--- 4 files changed, 31 insertions(+), 18 deletions(-) diff --git a/packages/server-mcp/src/feature-flags.ts b/packages/server-mcp/src/feature-flags.ts index c236489..dfdaae6 100644 --- a/packages/server-mcp/src/feature-flags.ts +++ b/packages/server-mcp/src/feature-flags.ts @@ -30,7 +30,7 @@ export const FEATURE_FLAGS = { */ useResources: true, resources: { - png: false + png: true }, tools: { undo: true, diff --git a/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts b/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts index a29749d..b70d1bc 100644 --- a/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts +++ b/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts @@ -14,7 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { Action, ActionHandler, ClientSessionManager, ExportMcpPngAction, Logger, RequestExportMcpPngAction } from '@eclipse-glsp/server'; +import { Action, ActionHandler, ClientSessionManager, ExportPngMcpAction, ExportPngMcpActionResult, Logger } from '@eclipse-glsp/server'; import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp'; import { inject, injectable } from 'inversify'; import * as z from 'zod/v4'; @@ -30,15 +30,15 @@ import { createResourceResult, extractResourceParam } from '../../util'; * 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 RequestExportMcpPngAction} and register this class as an - * `ActionHandler` for the response action {@link ExportMcpPngAction}. This is necessary, because we can't just + * 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 = [ExportMcpPngAction.KIND]; + actionKinds = [ExportPngMcpActionResult.KIND]; @inject(Logger) protected logger: Logger; @@ -46,7 +46,10 @@ export class DiagramPngMcpResourceHandler implements McpResourceHandler, ActionH @inject(ClientSessionManager) protected clientSessionManager: ClientSessionManager; - protected promiseResolveFn: (value: ResourceHandlerResult | PromiseLike) => void; + protected resolvers: Record< + string, + { sessionId: string; resolve: (value: ResourceHandlerResult | PromiseLike) => void } + > = {}; registerResource(server: GLSPMcpServer): void { if (!FEATURE_FLAGS.resources.png) { @@ -138,11 +141,13 @@ export class DiagramPngMcpResourceHandler implements McpResourceHandler, ActionH }; } - session.actionDispatcher.dispatch(RequestExportMcpPngAction.create({ options: { sessionId } })); + 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.promiseResolveFn = resolve; + this.resolvers[requestId] = { sessionId, resolve }; setTimeout( () => resolve({ @@ -158,12 +163,13 @@ export class DiagramPngMcpResourceHandler implements McpResourceHandler, ActionH }); } - async execute(action: ExportMcpPngAction): Promise { - const sessionId = action.options?.sessionId ?? ''; - this.logger.info(`ExportMcpPngAction received for session '${sessionId}'`); + async execute(action: ExportPngMcpActionResult): Promise { + const requestId = action.mcpRequestId; + this.logger.info(`ExportPngMcpActionResult received with request ID '${requestId}'`); // Resolve the previously started promise - this.promiseResolveFn?.({ + const { sessionId, resolve } = this.resolvers[requestId]; + resolve?.({ content: { uri: `glsp://diagrams/${sessionId}/png`, mimeType: 'image/png', @@ -171,6 +177,7 @@ export class DiagramPngMcpResourceHandler implements McpResourceHandler, ActionH }, isError: false }); + delete this.resolvers[requestId]; return []; } diff --git a/packages/server-mcp/src/tools/handlers/diagram-elements-handler.ts b/packages/server-mcp/src/tools/handlers/diagram-elements-handler.ts index 2b0cccc..e6f6245 100644 --- a/packages/server-mcp/src/tools/handlers/diagram-elements-handler.ts +++ b/packages/server-mcp/src/tools/handlers/diagram-elements-handler.ts @@ -22,8 +22,6 @@ import { McpModelSerializer } from '../../resources/services/mcp-model-serialize import { GLSPMcpServer, McpToolHandler } from '../../server'; import { createToolResult } from '../../util'; -// TODO extend to multiple - /** * Creates a serialized representation of one or more specific elements of a given session's model. */ diff --git a/packages/server-mcp/src/tools/handlers/get-selection-handler.ts b/packages/server-mcp/src/tools/handlers/get-selection-handler.ts index ef7b060..eb733f2 100644 --- a/packages/server-mcp/src/tools/handlers/get-selection-handler.ts +++ b/packages/server-mcp/src/tools/handlers/get-selection-handler.ts @@ -14,7 +14,14 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { Action, ActionHandler, ClientSessionManager, GetSelectionMcpAction, Logger, SelectionMcpResult } from '@eclipse-glsp/server'; +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'; @@ -26,7 +33,7 @@ import { createToolResult } from '../../util'; */ @injectable() export class GetSelectionMcpToolHandler implements McpToolHandler, ActionHandler { - actionKinds = [SelectionMcpResult.KIND]; + actionKinds = [GetSelectionMcpResultAction.KIND]; @inject(Logger) protected logger: Logger; @@ -75,13 +82,14 @@ export class GetSelectionMcpToolHandler implements McpToolHandler, ActionHandler }); } - async execute(action: SelectionMcpResult): Promise { + async execute(action: GetSelectionMcpResultAction): Promise { const requestId = action.mcpRequestId; - this.logger.info(`SelectionMcpResult received with request ID '${requestId}'`); + this.logger.info(`GetSelectionMcpResultAction received with request ID '${requestId}'`); // Resolve the previously started promise const selectedIdsStr = action.selectedElementsIDs.map(id => `- ${id}`).join('\n'); this.resolvers[requestId]?.(createToolResult(`Following element IDs are selected:\n${selectedIdsStr}`, false)); + delete this.resolvers[requestId]; return []; } From 6407fcadd1dfc4069c5f63303c23841032e37067 Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Wed, 11 Mar 2026 17:53:37 +0100 Subject: [PATCH 16/26] Added viewport changes via change-view --- packages/server-mcp/src/feature-flags.ts | 7 +- .../export-png-action-handler-contribution.ts | 2 +- .../resources/handlers/diagram-png-handler.ts | 4 +- .../src/tools/handlers/change-view-handler.ts | 106 ++++++++++++++++++ .../src/tools/handlers/save-model-handler.ts | 4 + .../src/tools/tool-module.config.ts | 2 + packages/server-mcp/src/util/mcp-util.ts | 7 +- 7 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 packages/server-mcp/src/tools/handlers/change-view-handler.ts diff --git a/packages/server-mcp/src/feature-flags.ts b/packages/server-mcp/src/feature-flags.ts index dfdaae6..2bdf98c 100644 --- a/packages/server-mcp/src/feature-flags.ts +++ b/packages/server-mcp/src/feature-flags.ts @@ -28,12 +28,13 @@ export const FEATURE_FLAGS = { * * false -> MCP tools */ - useResources: true, + useResources: false, resources: { - png: true + diagramPng: true }, tools: { undo: true, - redo: true + redo: true, + saveModel: true } }; 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 index ff026bf..3a65607 100644 --- a/packages/server-mcp/src/init/export-png-action-handler-contribution.ts +++ b/packages/server-mcp/src/init/export-png-action-handler-contribution.ts @@ -34,7 +34,7 @@ export class ExportMcpPngActionHandlerInitContribution implements ClientSessionI protected pngHandler: DiagramPngMcpResourceHandler; initialize(args?: Args): void { - if (FEATURE_FLAGS.resources.png) { + if (FEATURE_FLAGS.resources.diagramPng) { this.registry.registerHandler(this.pngHandler); } } diff --git a/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts b/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts index b70d1bc..6ce96ad 100644 --- a/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts +++ b/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts @@ -52,7 +52,7 @@ export class DiagramPngMcpResourceHandler implements McpResourceHandler, ActionH > = {}; registerResource(server: GLSPMcpServer): void { - if (!FEATURE_FLAGS.resources.png) { + if (!FEATURE_FLAGS.resources.diagramPng) { return; } server.registerResource( @@ -85,7 +85,7 @@ export class DiagramPngMcpResourceHandler implements McpResourceHandler, ActionH } registerTool(server: GLSPMcpServer): void { - if (!FEATURE_FLAGS.resources.png) { + if (!FEATURE_FLAGS.resources.diagramPng) { return; } server.registerTool( 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..3af8824 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/change-view-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 { 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, 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(); + } + + 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': + // TODO `OriginViewportAction` is not available, because it lives in feature space, not protocol space + // TODO 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/save-model-handler.ts b/packages/server-mcp/src/tools/handlers/save-model-handler.ts index 150af53..4a31153 100644 --- a/packages/server-mcp/src/tools/handlers/save-model-handler.ts +++ b/packages/server-mcp/src/tools/handlers/save-model-handler.ts @@ -18,6 +18,7 @@ import { ClientSessionManager, CommandStack, Logger, SaveModelAction } from '@ec import { CallToolResult } from '@modelcontextprotocol/sdk/types'; import { inject, injectable } from 'inversify'; import * as z from 'zod/v4'; +import { FEATURE_FLAGS } from '../../feature-flags'; import { GLSPMcpServer, McpToolHandler } from '../../server'; import { createToolResult } from '../../util'; @@ -33,6 +34,9 @@ export class SaveModelMcpToolHandler implements McpToolHandler { protected clientSessionManager: ClientSessionManager; registerTool(server: GLSPMcpServer): void { + if (!FEATURE_FLAGS.tools.saveModel) { + return; + } server.registerTool( 'save-model', { diff --git a/packages/server-mcp/src/tools/tool-module.config.ts b/packages/server-mcp/src/tools/tool-module.config.ts index fc47ece..71c9260 100644 --- a/packages/server-mcp/src/tools/tool-module.config.ts +++ b/packages/server-mcp/src/tools/tool-module.config.ts @@ -16,6 +16,7 @@ import { bindAsService } from '@eclipse-glsp/server'; import { ContainerModule } from 'inversify'; import { McpServerContribution, McpToolHandler } from '../server'; +import { ChangeViewMcpToolHandler } from './handlers/change-view-handler'; import { CreateEdgesMcpToolHandler } from './handlers/create-edges-handler'; import { CreateNodesMcpToolHandler } from './handlers/create-nodes-handler'; import { DeleteElementsMcpToolHandler } from './handlers/delete-elements-handler'; @@ -42,6 +43,7 @@ export function configureMcpToolModule(): ContainerModule { 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/util/mcp-util.ts b/packages/server-mcp/src/util/mcp-util.ts index 90d273c..5270a7c 100644 --- a/packages/server-mcp/src/util/mcp-util.ts +++ b/packages/server-mcp/src/util/mcp-util.ts @@ -50,13 +50,16 @@ export function createResourceToolResult(result: ResourceHandlerResult): CallToo isError: result.isError, content: [ { - type: 'resource', - resource: result.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, From 7aa6f98babf74bfb3dbcfaeb824e5db21b37e9ae Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Wed, 11 Mar 2026 17:54:42 +0100 Subject: [PATCH 17/26] Added export --- packages/server-mcp/src/tools/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/server-mcp/src/tools/index.ts b/packages/server-mcp/src/tools/index.ts index c242b0e..59540a5 100644 --- a/packages/server-mcp/src/tools/index.ts +++ b/packages/server-mcp/src/tools/index.ts @@ -14,6 +14,7 @@ * 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'; From bbaae34d4f3950cec125837d2025c843311f01da Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Fri, 13 Mar 2026 11:26:48 +0100 Subject: [PATCH 18/26] reverted custom ID generation --- .../workflow-server/src/common/graph-extension.ts | 15 --------------- .../handler/create-activity-node-handler.ts | 8 ++------ .../src/common/handler/create-category-handler.ts | 3 +-- .../handler/create-decision-node-handler.ts | 2 +- .../src/common/handler/create-edge-handler.ts | 3 +-- .../common/handler/create-merge-node-handler.ts | 2 +- .../src/common/handler/create-task-handler.ts | 3 +-- .../handler/create-weighted-edge-handler.ts | 10 ++-------- 8 files changed, 9 insertions(+), 37 deletions(-) diff --git a/examples/workflow-server/src/common/graph-extension.ts b/examples/workflow-server/src/common/graph-extension.ts index 07de888..0097c48 100644 --- a/examples/workflow-server/src/common/graph-extension.ts +++ b/examples/workflow-server/src/common/graph-extension.ts @@ -165,18 +165,3 @@ export class CategoryNodeBuilder extends Activity .build(); } } - -/** - * While UUIDs are virtually always unique, they are long, meaningless strings. Typically, - * this is not an issue since classic software doesn't care about the semantics of some - * identifier. However, considering LLMs, this becomes a problem. Since the strings are random - * and meaningless, tokenization is less efficient, thus expanding context size. Furthermore, - * the lack of semantics hurts reasoning and memory. - */ -export function generateId(type: string): string { - // 36^4 possible hashes - const randomPart = Math.floor(Math.random() * 1679615); - const hash = randomPart.toString(36).padStart(4, '0'); - // type + 36^4 hashes makes collisions very unlikely - return `${type}_${hash}`; -} diff --git a/examples/workflow-server/src/common/handler/create-activity-node-handler.ts b/examples/workflow-server/src/common/handler/create-activity-node-handler.ts index cf66b9e..6d620f1 100644 --- a/examples/workflow-server/src/common/handler/create-activity-node-handler.ts +++ b/examples/workflow-server/src/common/handler/create-activity-node-handler.ts @@ -15,7 +15,7 @@ ********************************************************************************/ import { CreateNodeOperation, GNode, GhostElement, Point } from '@eclipse-glsp/server'; import { injectable } from 'inversify'; -import { ActivityNode, ActivityNodeBuilder, generateId } from '../graph-extension'; +import { ActivityNode, ActivityNodeBuilder } from '../graph-extension'; import { ModelTypes } from '../util/model-types'; import { CreateWorkflowNodeOperationHandler } from './create-workflow-node-operation-handler'; @@ -26,11 +26,7 @@ export abstract class CreateActivityNodeHandler extends CreateWorkflowNodeOperat } protected builder(point: Point = Point.ORIGIN, elementTypeId = this.elementTypeIds[0]): ActivityNodeBuilder { - return ActivityNode.builder() - .id(generateId(elementTypeId)) - .position(point) - .type(elementTypeId) - .nodeType(ModelTypes.toNodeType(elementTypeId)); + return ActivityNode.builder().position(point).type(elementTypeId).nodeType(ModelTypes.toNodeType(elementTypeId)); } override createTriggerGhostElement(elementTypeId: string): GhostElement | undefined { diff --git a/examples/workflow-server/src/common/handler/create-category-handler.ts b/examples/workflow-server/src/common/handler/create-category-handler.ts index 62db0ff..702af1d 100644 --- a/examples/workflow-server/src/common/handler/create-category-handler.ts +++ b/examples/workflow-server/src/common/handler/create-category-handler.ts @@ -14,7 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ import { ArgsUtil, CreateNodeOperation, GNode, GhostElement, Point } from '@eclipse-glsp/server'; -import { Category, CategoryNodeBuilder, generateId } from '../graph-extension'; +import { Category, CategoryNodeBuilder } from '../graph-extension'; import { ModelTypes } from '../util/model-types'; import { CreateWorkflowNodeOperationHandler } from './create-workflow-node-operation-handler'; @@ -28,7 +28,6 @@ export class CreateCategoryHandler extends CreateWorkflowNodeOperationHandler { protected builder(point: Point = Point.ORIGIN, elementTypeId = this.elementTypeIds[0]): CategoryNodeBuilder { return Category.builder() - .id(generateId(elementTypeId)) .type(elementTypeId) .position(point) .name(this.label.replace(' ', '') + this.modelState.index.getAllByClass(Category).length) 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-edge-handler.ts b/examples/workflow-server/src/common/handler/create-edge-handler.ts index 06dddf6..93a5da7 100644 --- a/examples/workflow-server/src/common/handler/create-edge-handler.ts +++ b/examples/workflow-server/src/common/handler/create-edge-handler.ts @@ -14,13 +14,12 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ import { DefaultTypes, GEdge, GEdgeBuilder, GModelCreateEdgeOperationHandler, GModelElement } from '@eclipse-glsp/server'; -import { generateId } from '../graph-extension'; export class CreateEdgeHandler extends GModelCreateEdgeOperationHandler { label = 'Edge'; elementTypeIds = [DefaultTypes.EDGE]; createEdge(source: GModelElement, target: GModelElement): GEdge | undefined { - return new GEdgeBuilder(GEdge).id(generateId(DefaultTypes.EDGE)).sourceId(source.id).targetId(target.id).build(); + return new GEdgeBuilder(GEdge).sourceId(source.id).targetId(target.id).build(); } } 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/handler/create-task-handler.ts b/examples/workflow-server/src/common/handler/create-task-handler.ts index 65ebab6..477885f 100644 --- a/examples/workflow-server/src/common/handler/create-task-handler.ts +++ b/examples/workflow-server/src/common/handler/create-task-handler.ts @@ -16,7 +16,7 @@ import { GhostElement, Point } from '@eclipse-glsp/protocol'; import { CreateNodeOperation, GNode } from '@eclipse-glsp/server'; import { injectable } from 'inversify'; -import { generateId, TaskNode, TaskNodeBuilder } from '../graph-extension'; +import { TaskNode, TaskNodeBuilder } from '../graph-extension'; import { ModelTypes } from '../util/model-types'; import { CreateWorkflowNodeOperationHandler } from './create-workflow-node-operation-handler'; @@ -28,7 +28,6 @@ export abstract class CreateTaskHandler extends CreateWorkflowNodeOperationHandl protected builder(point: Point = Point.ORIGIN, elementTypeId = this.elementTypeIds[0]): TaskNodeBuilder { return TaskNode.builder() - .id(generateId(elementTypeId)) .position(point ?? Point.ORIGIN) .name(this.label.replace(' ', '') + this.modelState.index.getAllByClass(TaskNode).length) .type(elementTypeId) diff --git a/examples/workflow-server/src/common/handler/create-weighted-edge-handler.ts b/examples/workflow-server/src/common/handler/create-weighted-edge-handler.ts index 0daf2b6..a4db956 100644 --- a/examples/workflow-server/src/common/handler/create-weighted-edge-handler.ts +++ b/examples/workflow-server/src/common/handler/create-weighted-edge-handler.ts @@ -14,7 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ import { GEdge, GModelCreateEdgeOperationHandler, GModelElement } from '@eclipse-glsp/server'; -import { generateId, WeightedEdge } from '../graph-extension'; +import { WeightedEdge } from '../graph-extension'; import { ModelTypes } from '../util/model-types'; export class CreateWeightedEdgeHandler extends GModelCreateEdgeOperationHandler { @@ -22,12 +22,6 @@ export class CreateWeightedEdgeHandler extends GModelCreateEdgeOperationHandler label = 'Weighted edge'; createEdge(source: GModelElement, target: GModelElement): GEdge | undefined { - return WeightedEdge.builder() - .id(generateId(ModelTypes.WEIGHTED_EDGE)) - .sourceId(source.id) - .targetId(target.id) - .probability('medium') - .addCssClass('medium') - .build(); + return WeightedEdge.builder().sourceId(source.id).targetId(target.id).probability('medium').addCssClass('medium').build(); } } From bb0c276421315e74a7e2e79b45f258e4ca35867d Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Fri, 13 Mar 2026 23:23:29 +0100 Subject: [PATCH 19/26] Added JSON/Markdown switch --- .../mcp/workflow-element-types-handler.ts | 73 ++++++++++++++++--- .../default-mcp-resource-contribution.old.ts | 2 +- packages/server-mcp/src/feature-flags.ts | 4 + .../handlers/diagram-model-handler.ts | 17 +++-- .../handlers/element-types-handler.ts | 46 +++++++++--- .../handlers/sessions-list-handler.ts | 25 +++++-- .../services/mcp-model-serializer.ts | 39 +++++++--- .../src/server/mcp-server-contribution.ts | 2 + .../tools/handlers/create-edges-handler.ts | 26 ++++++- .../tools/handlers/create-nodes-handler.ts | 26 ++++++- .../handlers/diagram-elements-handler.ts | 14 +++- .../tools/handlers/get-selection-handler.ts | 19 +++-- .../tools/handlers/modify-edges-handler.ts | 25 ++++++- .../tools/handlers/modify-nodes-handler.ts | 18 ++++- .../handlers/validate-diagram-handler.ts | 23 +++++- packages/server-mcp/src/util/mcp-util.ts | 18 ++++- 16 files changed, 307 insertions(+), 70 deletions(-) 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 index 025d652..6a355ad 100644 --- a/examples/workflow-server/src/common/mcp/workflow-element-types-handler.ts +++ b/examples/workflow-server/src/common/mcp/workflow-element-types-handler.ts @@ -15,9 +15,17 @@ ********************************************************************************/ import { DefaultTypes } from '@eclipse-glsp/server'; -import { ElementTypesMcpResourceHandler, objectArrayToMarkdownTable, ResourceHandlerResult } from '@eclipse-glsp/server-mcp'; +import { + createResourceToolResult, + ElementTypesMcpResourceHandler, + FEATURE_FLAGS, + 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; @@ -85,13 +93,20 @@ const WORKFLOW_EDGE_ELEMENT_TYPES: ElementType[] = [ } ]; -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'); +const WORKFLOW_ELEMENTS_OBJ = { + diagramType: 'workflow-diagram', + nodeTypes: WORKFLOW_NODE_ELEMENT_TYPES, + edgeTypes: WORKFLOW_EDGE_ELEMENT_TYPES +}; +const WORKFLOW_ELEMENT_TYPES_STRING = FEATURE_FLAGS.useJson + ? JSON.stringify(WORKFLOW_ELEMENTS_OBJ) + : [ + '# 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 @@ -105,6 +120,43 @@ const WORKFLOW_ELEMENT_TYPES_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') + }, + outputSchema: FEATURE_FLAGS.useJson + ? z.object({ + diagramType: z.string(), + nodeTypes: z.array( + z.object({ + id: z.string(), + label: z.string(), + description: z.string(), + hasLabel: z.boolean() + }) + ), + edgeTypes: z.array( + z.object({ + id: z.string(), + label: z.string(), + description: z.string(), + hasLabel: z.boolean() + }) + ) + }) + : undefined + }, + async params => createResourceToolResult(await this.handle(params)) + ); + } + override async handle({ diagramType }: { diagramType?: string }): Promise { this.logger.info(`'element-types' invoked for diagram type '${diagramType}'`); @@ -123,10 +175,11 @@ export class WorkflowElementTypesMcpResourceHandler extends ElementTypesMcpResou return { content: { uri: `glsp://types/${diagramType}/elements`, - mimeType: 'text/markdown', + mimeType: FEATURE_FLAGS.useJson ? 'application/json' : 'text/markdown', text: WORKFLOW_ELEMENT_TYPES_STRING }, - isError: false + isError: false, + data: WORKFLOW_ELEMENTS_OBJ }; } } diff --git a/packages/server-mcp/src/default-mcp-resource-contribution.old.ts b/packages/server-mcp/src/default-mcp-resource-contribution.old.ts index ad487db..b39bba2 100644 --- a/packages/server-mcp/src/default-mcp-resource-contribution.old.ts +++ b/packages/server-mcp/src/default-mcp-resource-contribution.old.ts @@ -313,7 +313,7 @@ export class DefaultMcpResourceContribution implements McpServerContribution { // const schema = serializer.createSchema(modelState.root); const mcpSerializer = session.container.get(McpModelSerializer); - const mcpString = mcpSerializer.serialize(modelState.root); + const [mcpString] = mcpSerializer.serialize(modelState.root); return { contents: [ diff --git a/packages/server-mcp/src/feature-flags.ts b/packages/server-mcp/src/feature-flags.ts index 2bdf98c..e60fa7e 100644 --- a/packages/server-mcp/src/feature-flags.ts +++ b/packages/server-mcp/src/feature-flags.ts @@ -29,9 +29,13 @@ export const FEATURE_FLAGS = { * false -> MCP tools */ useResources: false, + /** Changes whether structured data should be returned as JSON or Markdown. */ + useJson: false, + /** Enable or disable unstable resources */ resources: { diagramPng: true }, + /** Enable or disable unstable tools */ tools: { undo: true, redo: true, diff --git a/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts b/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts index 7713f37..97159c3 100644 --- a/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts +++ b/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts @@ -21,6 +21,7 @@ import * as z from 'zod/v4'; import { GLSPMcpServer, McpResourceHandler, ResourceHandlerResult } from '../../server'; import { createResourceResult, createResourceToolResult, extractResourceParam } from '../../util'; import { McpModelSerializer } from '../services/mcp-model-serializer'; +import { FEATURE_FLAGS } from '../../feature-flags'; /** * Creates a serialized representation of a given session's model state. @@ -44,7 +45,7 @@ export class DiagramModelMcpResourceHandler implements McpResourceHandler { uri: `glsp://diagrams/${sessionId}/model`, name: `Diagram Model: ${sessionId}`, description: `Complete GLSP model structure for session ${sessionId}`, - mimeType: 'text/markdown' + mimeType: FEATURE_FLAGS.useJson ? 'application/json' : 'text/markdown' })) }; }, @@ -57,7 +58,7 @@ export class DiagramModelMcpResourceHandler implements McpResourceHandler { description: 'Get the complete GLSP model for a session as a markdown structure. ' + 'Includes all nodes, edges, and their relevant properties.', - mimeType: 'text/markdown' + mimeType: FEATURE_FLAGS.useJson ? 'application/json' : 'text/markdown' }, async (_uri, params) => createResourceResult(await this.handle({ sessionId: extractResourceParam(params, 'sessionId') })) ); @@ -73,7 +74,10 @@ export class DiagramModelMcpResourceHandler implements McpResourceHandler { 'Includes all nodes, edges, and their relevant properties.', inputSchema: { sessionId: z.string().describe('Session ID for which to query the model.') - } + }, + outputSchema: FEATURE_FLAGS.useJson + ? z.object().describe('Dictionary of diagram element type to a list of elements.') + : undefined }, async params => createResourceToolResult(await this.handle(params)) ); @@ -107,15 +111,16 @@ export class DiagramModelMcpResourceHandler implements McpResourceHandler { const modelState = session.container.get(ModelState); const mcpSerializer = session.container.get(McpModelSerializer); - const mcpString = mcpSerializer.serialize(modelState.root); + const [mcpString, flattenedGraph] = mcpSerializer.serialize(modelState.root); return { content: { uri: `glsp://diagrams/${sessionId}/model`, - mimeType: 'text/markdown', + mimeType: FEATURE_FLAGS.useJson ? 'application/json' : 'text/markdown', text: mcpString }, - isError: false + isError: false, + data: flattenedGraph }; } diff --git a/packages/server-mcp/src/resources/handlers/element-types-handler.ts b/packages/server-mcp/src/resources/handlers/element-types-handler.ts index 3ddb1fe..1321298 100644 --- a/packages/server-mcp/src/resources/handlers/element-types-handler.ts +++ b/packages/server-mcp/src/resources/handlers/element-types-handler.ts @@ -20,6 +20,7 @@ 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'; +import { FEATURE_FLAGS } from '../../feature-flags'; /** * Lists the available element types for a given diagram type. This should likely include not only their id but also some description. @@ -47,7 +48,7 @@ export class ElementTypesMcpResourceHandler implements McpResourceHandler { uri: `glsp://types/${type}/elements`, name: `Element Types: ${type}`, description: `Creatable element types for ${type} diagrams`, - mimeType: 'text/markdown' + mimeType: FEATURE_FLAGS.useJson ? 'application/json' : 'text/markdown' })) }; }, @@ -60,7 +61,7 @@ export class ElementTypesMcpResourceHandler implements McpResourceHandler { 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' + mimeType: FEATURE_FLAGS.useJson ? 'application/json' : 'text/markdown' }, async (_uri, params) => createResourceResult(await this.handle({ diagramType: extractResourceParam(params, 'diagramType') })) ); @@ -76,7 +77,24 @@ export class ElementTypesMcpResourceHandler implements McpResourceHandler { 'Use this to discover valid elementTypeId values for creation tools.', inputSchema: { diagramType: z.string().describe('Diagram type whose elements should be discovered') - } + }, + outputSchema: FEATURE_FLAGS.useJson + ? z.object({ + diagramType: z.string(), + nodeTypes: z.array( + z.object({ + id: z.string(), + label: z.string() + }) + ), + edgeTypes: z.array( + z.object({ + id: z.string(), + label: z.string() + }) + ) + }) + : undefined }, async params => createResourceToolResult(await this.handle(params)) ); @@ -129,21 +147,25 @@ export class ElementTypesMcpResourceHandler implements McpResourceHandler { } } - const result = [ - `# Creatable element types for diagram type "${diagramType}"`, - '## Node Types', - objectArrayToMarkdownTable(nodeTypes), - '## Edge Types', - objectArrayToMarkdownTable(edgeTypes) - ].join('\n'); + const elementTypesObj = { diagramType, nodeTypes, edgeTypes }; + const result = FEATURE_FLAGS.useJson + ? JSON.stringify(elementTypesObj) + : [ + `# 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', + mimeType: FEATURE_FLAGS.useJson ? 'application/json' : 'text/markdown', text: result }, - isError: false + isError: false, + data: elementTypesObj }; } diff --git a/packages/server-mcp/src/resources/handlers/sessions-list-handler.ts b/packages/server-mcp/src/resources/handlers/sessions-list-handler.ts index 1673a69..55ee0a0 100644 --- a/packages/server-mcp/src/resources/handlers/sessions-list-handler.ts +++ b/packages/server-mcp/src/resources/handlers/sessions-list-handler.ts @@ -18,6 +18,8 @@ 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'; +import { FEATURE_FLAGS } from '../../feature-flags'; +import * as z from 'zod/v4'; /** * Lists the current sessions according to the {@link ClientSessionManager}. This includes not only @@ -38,7 +40,7 @@ export class SessionsListMcpResourceHandler implements McpResourceHandler { { title: 'GLSP Sessions List', description: 'List all active GLSP client sessions across all diagram types', - mimeType: 'text/markdown' + mimeType: FEATURE_FLAGS.useJson ? 'application/json' : 'text/markdown' }, async () => createResourceResult(await this.handle({})) ); @@ -49,7 +51,19 @@ export class SessionsListMcpResourceHandler implements McpResourceHandler { 'sessions-list', { title: 'GLSP Sessions List', - description: 'List all active GLSP client sessions across all diagram types' + description: 'List all active GLSP client sessions across all diagram types', + outputSchema: FEATURE_FLAGS.useJson + ? z.object({ + sessionsList: z.array( + z.object({ + sessionId: z.string(), + diagramType: z.string(), + sourceUri: z.string().optional(), + readOnly: z.boolean() + }) + ) + }) + : undefined }, async () => createResourceToolResult(await this.handle({})) ); @@ -72,10 +86,11 @@ export class SessionsListMcpResourceHandler implements McpResourceHandler { return { content: { uri: 'glsp://sessions', - mimeType: 'text/markdown', - text: objectArrayToMarkdownTable(sessionsList) + mimeType: FEATURE_FLAGS.useJson ? 'application/json' : 'text/markdown', + text: FEATURE_FLAGS.useJson ? JSON.stringify(sessionsList) : objectArrayToMarkdownTable(sessionsList) }, - isError: false + isError: false, + data: { sessionsList } }; } } diff --git a/packages/server-mcp/src/resources/services/mcp-model-serializer.ts b/packages/server-mcp/src/resources/services/mcp-model-serializer.ts index 793e11f..61bf97b 100644 --- a/packages/server-mcp/src/resources/services/mcp-model-serializer.ts +++ b/packages/server-mcp/src/resources/services/mcp-model-serializer.ts @@ -18,6 +18,7 @@ import { GModelElement } from '@eclipse-glsp/graph'; import { GModelSerializer } from '@eclipse-glsp/server'; import { inject, injectable } from 'inversify'; import { objectArrayToMarkdownTable } from '../../util'; +import { FEATURE_FLAGS } from '../../feature-flags'; export const McpModelSerializer = Symbol('McpModelSerializer'); @@ -29,18 +30,18 @@ export interface McpModelSerializer { /** * Transforms the given {@link GModelElement} into a string representation. * @param element The element that should be serialized. - * @returns The transformed string. + * @returns The transformed string and the underlying flattened graph object. */ - serialize(element: GModelElement): string; + serialize(element: GModelElement): [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. - * @returns The transformed string. + * @returns The transformed string and the underlying flattened graph object. */ - serializeArray(elements: GModelElement[]): string; + serializeArray(elements: GModelElement[]): [string, Record[]>]; } /** @@ -69,15 +70,22 @@ export class DefaultMcpModelSerializer implements McpModelSerializer { 'parent' ]; - serialize(element: GModelElement): string { + serialize(element: GModelElement): [string, Record[]>] { const elementsByType = this.prepareElement(element); - return Object.entries(elementsByType) - .flatMap(([type, elements]) => [`# ${type}`, objectArrayToMarkdownTable(elements)]) - .join('\n'); + if (FEATURE_FLAGS.useJson) { + return [JSON.stringify(elementsByType), elementsByType]; + } + + return [ + Object.entries(elementsByType) + .flatMap(([type, elements]) => [`# ${type}`, objectArrayToMarkdownTable(elements)]) + .join('\n'), + elementsByType + ]; } - serializeArray(elements: GModelElement[]): string { + serializeArray(elements: GModelElement[]): [string, Record[]>] { const elementsByTypeArray = elements.map(element => this.prepareElement(element)); const result: Record[]> = {}; @@ -90,9 +98,16 @@ export class DefaultMcpModelSerializer implements McpModelSerializer { result[key] = Array.from(new Map(combined.map(item => [item.id, item])).values()); }); - return Object.entries(result) - .flatMap(([type, elements]) => [`# ${type}`, objectArrayToMarkdownTable(elements)]) - .join('\n'); + if (FEATURE_FLAGS.useJson) { + return [JSON.stringify(result), result]; + } + + return [ + Object.entries(result) + .flatMap(([type, elements]) => [`# ${type}`, objectArrayToMarkdownTable(elements)]) + .join('\n'), + result + ]; } protected prepareElement(element: GModelElement): Record[]> { diff --git a/packages/server-mcp/src/server/mcp-server-contribution.ts b/packages/server-mcp/src/server/mcp-server-contribution.ts index 395e394..0f41617 100644 --- a/packages/server-mcp/src/server/mcp-server-contribution.ts +++ b/packages/server-mcp/src/server/mcp-server-contribution.ts @@ -29,6 +29,8 @@ export type ResourceResultContent = ReadResourceResult['contents'][number]; export interface ResourceHandlerResult { content: ResourceResultContent; isError: boolean; + // TODO only relevant considering FEATURE_FLAGS.useJson + data?: Record; } /** diff --git a/packages/server-mcp/src/tools/handlers/create-edges-handler.ts b/packages/server-mcp/src/tools/handlers/create-edges-handler.ts index fb8497e..8044de0 100644 --- a/packages/server-mcp/src/tools/handlers/create-edges-handler.ts +++ b/packages/server-mcp/src/tools/handlers/create-edges-handler.ts @@ -19,7 +19,8 @@ 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'; +import { createToolResult, createToolResultJson } from '../../util'; +import { FEATURE_FLAGS } from '../../feature-flags'; /** * Creates one or multiple new edges in the given session's model. @@ -69,7 +70,14 @@ export class CreateEdgesMcpToolHandler implements McpToolHandler { ) .min(1) .describe('Array of edges to create. Must include at least one node.') - } + }, + outputSchema: FEATURE_FLAGS.useJson + ? z.object({ + edgeIds: z.array(z.string()).describe('List of IDs of the created edges.'), + errors: z.array(z.string()).optional().describe('List of errors encountered.'), + nrOfCommands: z.number().describe('The number of commands executed in the course of this tool call.') + }) + : undefined }, params => this.handle(params) ); @@ -110,8 +118,8 @@ export class CreateEdgesMcpToolHandler implements McpToolHandler { // Snapshot element IDs before operation let beforeIds = modelState.index.allIds(); - const errors = []; - const successIds = []; + 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) { @@ -165,6 +173,16 @@ export class CreateEdgesMcpToolHandler implements McpToolHandler { successIds.push(newElementId); } + if (FEATURE_FLAGS.useJson) { + const content = { + edgeIds: successIds, + errors: errors.length ? errors : undefined, + nrOfCommands: dispatchedOperations + }; + + return createToolResultJson(content); + } + // Create a failure string if any errors occurred let failureStr = ''; if (errors.length) { diff --git a/packages/server-mcp/src/tools/handlers/create-nodes-handler.ts b/packages/server-mcp/src/tools/handlers/create-nodes-handler.ts index a47082b..641ca5b 100644 --- a/packages/server-mcp/src/tools/handlers/create-nodes-handler.ts +++ b/packages/server-mcp/src/tools/handlers/create-nodes-handler.ts @@ -27,7 +27,8 @@ 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'; +import { createToolResult, createToolResultJson } from '../../util'; +import { FEATURE_FLAGS } from '../../feature-flags'; /** * Creates one or multiple new nodes in the given session's model. @@ -79,7 +80,14 @@ export class CreateNodesMcpToolHandler implements McpToolHandler { ) .min(1) .describe('Array of nodes to create. Must include at least one node.') - } + }, + outputSchema: FEATURE_FLAGS.useJson + ? z.object({ + nodeIds: z.array(z.string()).describe('List of IDs of the created nodes.'), + errors: z.array(z.string()).optional().describe('List of errors encountered.'), + nrOfCommands: z.number().describe('The number of commands executed in the course of this tool call.') + }) + : undefined }, params => this.handle(params) ); @@ -120,8 +128,8 @@ export class CreateNodesMcpToolHandler implements McpToolHandler { // Snapshot element IDs before operation let beforeIds = modelState.index.allIds(); - const errors = []; - const successIds = []; + 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) { @@ -166,6 +174,16 @@ export class CreateNodesMcpToolHandler implements McpToolHandler { successIds.push(newElementId); } + if (FEATURE_FLAGS.useJson) { + const content = { + nodeIds: successIds, + errors: errors.length ? errors : undefined, + nrOfCommands: dispatchedOperations + }; + + return createToolResultJson(content); + } + // Create a failure string if any errors occurred let failureStr = ''; if (errors.length) { diff --git a/packages/server-mcp/src/tools/handlers/diagram-elements-handler.ts b/packages/server-mcp/src/tools/handlers/diagram-elements-handler.ts index e6f6245..71d6ae2 100644 --- a/packages/server-mcp/src/tools/handlers/diagram-elements-handler.ts +++ b/packages/server-mcp/src/tools/handlers/diagram-elements-handler.ts @@ -20,7 +20,8 @@ import { inject, injectable } from 'inversify'; import * as z from 'zod/v4'; import { McpModelSerializer } from '../../resources/services/mcp-model-serializer'; import { GLSPMcpServer, McpToolHandler } from '../../server'; -import { createToolResult } from '../../util'; +import { createToolResult, createToolResultJson } from '../../util'; +import { FEATURE_FLAGS } from '../../feature-flags'; /** * Creates a serialized representation of one or more specific elements of a given session's model. @@ -44,7 +45,10 @@ export class DiagramElementsMcpToolHandler implements McpToolHandler { 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.') - } + }, + outputSchema: FEATURE_FLAGS.useJson + ? z.object().describe('Dictionary of diagram element type to a list of elements.') + : undefined }, params => this.handle(params) ); @@ -77,7 +81,11 @@ export class DiagramElementsMcpToolHandler implements McpToolHandler { } const mcpSerializer = session.container.get(McpModelSerializer); - const mcpString = mcpSerializer.serializeArray(elements); + const [mcpString, flattenedGraph] = mcpSerializer.serializeArray(elements); + + if (FEATURE_FLAGS.useJson) { + return createToolResultJson(flattenedGraph); + } 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 index eb733f2..d1243e0 100644 --- a/packages/server-mcp/src/tools/handlers/get-selection-handler.ts +++ b/packages/server-mcp/src/tools/handlers/get-selection-handler.ts @@ -26,7 +26,8 @@ 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'; +import { createToolResult, createToolResultJson } from '../../util'; +import { FEATURE_FLAGS } from '../../feature-flags'; /** * Queries the currently selected elements for a given session's diagram. @@ -53,7 +54,10 @@ export class GetSelectionMcpToolHandler implements McpToolHandler, ActionHandler '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') - } + }, + outputSchema: z.object({ + selectedIds: z.array(z.string()).describe('IDs of the selected diagram elements') + }) }, params => this.handle(params) ); @@ -86,9 +90,14 @@ export class GetSelectionMcpToolHandler implements McpToolHandler, ActionHandler const requestId = action.mcpRequestId; this.logger.info(`GetSelectionMcpResultAction received with request ID '${requestId}'`); - // Resolve the previously started promise - const selectedIdsStr = action.selectedElementsIDs.map(id => `- ${id}`).join('\n'); - this.resolvers[requestId]?.(createToolResult(`Following element IDs are selected:\n${selectedIdsStr}`, false)); + if (FEATURE_FLAGS.useJson) { + this.resolvers[requestId]?.(createToolResultJson({ selectedIds: action.selectedElementsIDs })); + } else { + // Resolve the previously started promise + const selectedIdsStr = action.selectedElementsIDs.map(id => `- ${id}`).join('\n'); + this.resolvers[requestId]?.(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 index ba82f80..fad1027 100644 --- a/packages/server-mcp/src/tools/handlers/modify-edges-handler.ts +++ b/packages/server-mcp/src/tools/handlers/modify-edges-handler.ts @@ -27,7 +27,8 @@ 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'; +import { createToolResult, createToolResultJson } from '../../util'; +import { FEATURE_FLAGS } from '../../feature-flags'; /** * Modifies onr or more edges in the given session's model. @@ -73,7 +74,14 @@ export class ModifyEdgesMcpToolHandler implements McpToolHandler { .describe( 'Array of change objects containing an element ID and their intended changes. Must include at least one change.' ) - } + }, + outputSchema: FEATURE_FLAGS.useJson + ? z.object({ + nrOfSuccesses: z.number().describe('The number of successful modifications.'), + nrOfCommands: z.number().describe('The number of commands executed in the course of this tool call.'), + errors: z.array(z.string()).optional().describe('List of errors encountered.') + }) + : undefined }, params => this.handle(params) ); @@ -159,6 +167,14 @@ export class ModifyEdgesMcpToolHandler implements McpToolHandler { // Wait for all dispatches to finish before notifying the caller await Promise.all(promises); + if (FEATURE_FLAGS.useJson) { + return createToolResultJson({ + nrOfSuccesses: changes.length - errors.length, + nrOfCommands: promises.length, + errors: errors.length ? errors : undefined + }); + } + // Create a failure string if any errors occurred let failureStr = ''; if (errors.length) { @@ -166,7 +182,10 @@ export class ModifyEdgesMcpToolHandler implements McpToolHandler { failureStr = `\nThe following errors occured:\n${failureListStr}`; } - return createToolResult(`Succesfully modified ${changes.length} edge(s) (in ${promises.length} commands)${failureStr}`, false); + 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 index 676c133..233e050 100644 --- a/packages/server-mcp/src/tools/handlers/modify-nodes-handler.ts +++ b/packages/server-mcp/src/tools/handlers/modify-nodes-handler.ts @@ -27,7 +27,8 @@ 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'; +import { createToolResult, createToolResultJson } from '../../util'; +import { FEATURE_FLAGS } from '../../feature-flags'; /** * Modifies onr or more nodes in the given session's model. @@ -78,7 +79,13 @@ export class ModifyNodesMcpToolHandler implements McpToolHandler { .describe( 'Array of change objects containing an element ID and their intended changes. Must include at least one change.' ) - } + }, + outputSchema: FEATURE_FLAGS.useJson + ? z.object({ + nrOfSuccesses: z.number().describe('The number of successful modifications.'), + nrOfCommands: z.number().describe('The number of commands executed in the course of this tool call.') + }) + : undefined }, params => this.handle(params) ); @@ -148,6 +155,13 @@ export class ModifyNodesMcpToolHandler implements McpToolHandler { // Wait for all dispatches to finish before notifying the caller await Promise.all(promises); + if (FEATURE_FLAGS.useJson) { + return createToolResultJson({ + nrOfSuccesses: changes.length, + nrOfCommands: promises.length + }); + } + return createToolResult(`Succesfully modified ${changes.length} node(s) (in ${promises.length} commands)`, 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 index effdc1f..68cf0fe 100644 --- a/packages/server-mcp/src/tools/handlers/validate-diagram-handler.ts +++ b/packages/server-mcp/src/tools/handlers/validate-diagram-handler.ts @@ -19,7 +19,8 @@ import { CallToolResult } from '@modelcontextprotocol/sdk/types'; import { inject, injectable } from 'inversify'; import * as z from 'zod/v4'; import { GLSPMcpServer, McpToolHandler } from '../../server'; -import { createToolResult, objectArrayToMarkdownTable } from '../../util'; +import { createToolResult, createToolResultJson, objectArrayToMarkdownTable } from '../../util'; +import { FEATURE_FLAGS } from '../../feature-flags'; /** * Validates the given session's model. @@ -51,7 +52,21 @@ export class ValidateDiagramMcpToolHandler implements McpToolHandler { .optional() .default(MarkersReason.LIVE) .describe('Validation reason: "batch" for thorough validation, "live" for quick incremental checks') - } + }, + outputSchema: FEATURE_FLAGS.useJson + ? z.object({ + markers: z + .array( + z.object({ + label: z.string(), + description: z.string(), + elementId: z.string(), + kind: z.string() + }) + ) + .describe('List of validation results.') + }) + : undefined }, params => this.handle(params) ); @@ -95,6 +110,10 @@ export class ValidateDiagramMcpToolHandler implements McpToolHandler { // Run validation const markers = await validator.validate(elements, reason ?? MarkersReason.BATCH); + if (FEATURE_FLAGS.useJson) { + return createToolResultJson({ markers }); + } + return createToolResult(objectArrayToMarkdownTable(markers), false); } } diff --git a/packages/server-mcp/src/util/mcp-util.ts b/packages/server-mcp/src/util/mcp-util.ts index 5270a7c..7ba5e52 100644 --- a/packages/server-mcp/src/util/mcp-util.ts +++ b/packages/server-mcp/src/util/mcp-util.ts @@ -16,6 +16,7 @@ import { CallToolResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types'; import { ResourceHandlerResult } from '../server'; +import { FEATURE_FLAGS } from '../feature-flags'; /** * Extracts a single parameter value from MCP resource template parameters. @@ -53,7 +54,8 @@ export function createResourceToolResult(result: ResourceHandlerResult): CallToo type: 'text', text: (result.content as any).text } - ] + ], + structuredContent: FEATURE_FLAGS.useJson ? result.data : undefined }; } @@ -71,3 +73,17 @@ export function createToolResult(text: string, isError: boolean): CallToolResult ] }; } + +// TODO relevant for FEATURE_FLAGS.useJson +export function createToolResultJson(content: Record): CallToolResult { + return { + isError: false, + content: [ + { + type: 'text', + text: JSON.stringify(content) + } + ], + structuredContent: content + }; +} From b2bf157ab3948636d2daa6595d7be03090daafec Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Sat, 14 Mar 2026 00:51:21 +0100 Subject: [PATCH 20/26] Added ID alias feature with flag --- packages/server-mcp/src/di.config.ts | 9 +- packages/server-mcp/src/feature-flags.ts | 2 + .../handlers/diagram-model-handler.ts | 8 +- .../services/mcp-model-serializer.ts | 41 ++++---- packages/server-mcp/src/server/index.ts | 1 + .../src/server/mcp-id-alias-service.ts | 98 +++++++++++++++++++ .../src/tools/handlers/change-view-handler.ts | 6 +- .../tools/handlers/create-edges-handler.ts | 10 +- .../tools/handlers/create-nodes-handler.ts | 9 +- .../tools/handlers/delete-elements-handler.ts | 6 +- .../handlers/diagram-elements-handler.ts | 6 +- .../tools/handlers/get-selection-handler.ts | 24 +++-- .../tools/handlers/modify-edges-handler.ts | 25 ++--- .../tools/handlers/modify-nodes-handler.ts | 9 +- .../handlers/validate-diagram-handler.ts | 12 ++- 15 files changed, 206 insertions(+), 60 deletions(-) create mode 100644 packages/server-mcp/src/server/mcp-id-alias-service.ts diff --git a/packages/server-mcp/src/di.config.ts b/packages/server-mcp/src/di.config.ts index e1a8754..ccfd2d2 100644 --- a/packages/server-mcp/src/di.config.ts +++ b/packages/server-mcp/src/di.config.ts @@ -16,8 +16,9 @@ import { GLSPServerInitContribution, GLSPServerListener } from '@eclipse-glsp/server'; import { ContainerModule } from 'inversify'; import { configureMcpResourceModule } from './resources'; -import { McpServerManager } from './server'; +import { DefaultMcpIdAliasService, DummyMcpIdAliasService, McpIdAliasService, McpServerManager } from './server'; import { configureMcpToolModule } from './tools'; +import { FEATURE_FLAGS } from './feature-flags'; // TODO possibly instead of wholly separate modules, just provide functions using bind context from tools/resources export function configureMcpModules(): ContainerModule[] { @@ -29,5 +30,11 @@ function configureMcpServerModule(): ContainerModule { bind(McpServerManager).toSelf().inSingletonScope(); bind(GLSPServerInitContribution).toService(McpServerManager); bind(GLSPServerListener).toService(McpServerManager); + + if (FEATURE_FLAGS.aliasIds) { + bind(McpIdAliasService).to(DefaultMcpIdAliasService).inSingletonScope(); + } else { + bind(McpIdAliasService).to(DummyMcpIdAliasService).inSingletonScope(); + } }); } diff --git a/packages/server-mcp/src/feature-flags.ts b/packages/server-mcp/src/feature-flags.ts index e60fa7e..7e46b8f 100644 --- a/packages/server-mcp/src/feature-flags.ts +++ b/packages/server-mcp/src/feature-flags.ts @@ -31,6 +31,8 @@ export const FEATURE_FLAGS = { useResources: false, /** Changes whether structured data should be returned as JSON or Markdown. */ useJson: false, + /** Changes string-based IDs to integer strings for MCP communication */ + aliasIds: true, /** Enable or disable unstable resources */ resources: { diagramPng: true diff --git a/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts b/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts index 97159c3..c4bac53 100644 --- a/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts +++ b/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts @@ -18,7 +18,7 @@ import { ClientSessionManager, Logger, ModelState } from '@eclipse-glsp/server'; import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp'; import { inject, injectable } from 'inversify'; import * as z from 'zod/v4'; -import { GLSPMcpServer, McpResourceHandler, ResourceHandlerResult } from '../../server'; +import { GLSPMcpServer, McpIdAliasService, McpResourceHandler, ResourceHandlerResult } from '../../server'; import { createResourceResult, createResourceToolResult, extractResourceParam } from '../../util'; import { McpModelSerializer } from '../services/mcp-model-serializer'; import { FEATURE_FLAGS } from '../../feature-flags'; @@ -110,8 +110,12 @@ export class DiagramModelMcpResourceHandler implements McpResourceHandler { } 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, flattenedGraph] = mcpSerializer.serialize(modelState.root); + const [mcpString, flattenedGraph] = mcpSerializer.serialize(modelState.root, aliasFn); return { content: { diff --git a/packages/server-mcp/src/resources/services/mcp-model-serializer.ts b/packages/server-mcp/src/resources/services/mcp-model-serializer.ts index 61bf97b..d8b92fc 100644 --- a/packages/server-mcp/src/resources/services/mcp-model-serializer.ts +++ b/packages/server-mcp/src/resources/services/mcp-model-serializer.ts @@ -30,18 +30,20 @@ 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): [string, Record[]>]; + 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[]): [string, Record[]>]; + serializeArray(elements: GModelElement[], aliasFn?: (id: string) => string): [string, Record[]>]; } /** @@ -70,22 +72,11 @@ export class DefaultMcpModelSerializer implements McpModelSerializer { 'parent' ]; - serialize(element: GModelElement): [string, Record[]>] { - const elementsByType = this.prepareElement(element); - - if (FEATURE_FLAGS.useJson) { - return [JSON.stringify(elementsByType), elementsByType]; - } - - return [ - Object.entries(elementsByType) - .flatMap(([type, elements]) => [`# ${type}`, objectArrayToMarkdownTable(elements)]) - .join('\n'), - elementsByType - ]; + serialize(element: GModelElement, aliasFn?: (id: string) => string): [string, Record[]>] { + return this.serializeArray([element], aliasFn); } - serializeArray(elements: GModelElement[]): [string, Record[]>] { + serializeArray(elements: GModelElement[], aliasFn?: (id: string) => string): [string, Record[]>] { const elementsByTypeArray = elements.map(element => this.prepareElement(element)); const result: Record[]> = {}; @@ -95,7 +86,7 @@ export class DefaultMcpModelSerializer implements McpModelSerializer { allKeys.forEach(key => { const combined = elementsByTypeArray.flatMap(obj => obj[key] || []); - result[key] = Array.from(new Map(combined.map(item => [item.id, item])).values()); + result[key] = Array.from(new Map(combined.map(item => [item.id, item])).values()).map(item => this.applyAlias(item, aliasFn)); }); if (FEATURE_FLAGS.useJson) { @@ -110,6 +101,22 @@ export class DefaultMcpModelSerializer implements McpModelSerializer { ]; } + 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); diff --git a/packages/server-mcp/src/server/index.ts b/packages/server-mcp/src/server/index.ts index 350685d..513f87b 100644 --- a/packages/server-mcp/src/server/index.ts +++ b/packages/server-mcp/src/server/index.ts @@ -17,3 +17,4 @@ export * from './http-server-with-sessions'; export * from './mcp-server-contribution'; export * from './mcp-server-manager'; +export * from './mcp-id-alias-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..ab9891a --- /dev/null +++ b/packages/server-mcp/src/server/mcp-id-alias-service.ts @@ -0,0 +1,98 @@ +/******************************************************************************** + * 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 } from 'inversify'; + +// TODO entire feature depends on FEATURE_FLAG.aliasIds + +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 { + // Map> + protected idAliasMap = new Map>(); + // Map> + protected aliasIdMap = new Map>(); + + protected counter = 0; + + alias(sessionId: string, uuid: string): string { + 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 { + 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; + } +} + +@injectable() +export class DummyMcpIdAliasService implements McpIdAliasService { + alias(sessionId: string, id: string): string { + return id; + } + + lookup(sessionId: string, alias: string): string { + return alias; + } +} diff --git a/packages/server-mcp/src/tools/handlers/change-view-handler.ts b/packages/server-mcp/src/tools/handlers/change-view-handler.ts index 3af8824..7631631 100644 --- a/packages/server-mcp/src/tools/handlers/change-view-handler.ts +++ b/packages/server-mcp/src/tools/handlers/change-view-handler.ts @@ -18,7 +18,7 @@ import { Action, CenterAction, ClientSessionManager, FitToScreenAction, Logger, import { CallToolResult } from '@modelcontextprotocol/sdk/types'; import { inject, injectable } from 'inversify'; import * as z from 'zod/v4'; -import { GLSPMcpServer, McpToolHandler } from '../../server'; +import { GLSPMcpServer, McpIdAliasService, McpToolHandler } from '../../server'; import { createToolResult } from '../../util'; /** @@ -78,6 +78,10 @@ export class ChangeViewMcpToolHandler implements McpToolHandler { 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; diff --git a/packages/server-mcp/src/tools/handlers/create-edges-handler.ts b/packages/server-mcp/src/tools/handlers/create-edges-handler.ts index 8044de0..ccad0c0 100644 --- a/packages/server-mcp/src/tools/handlers/create-edges-handler.ts +++ b/packages/server-mcp/src/tools/handlers/create-edges-handler.ts @@ -18,7 +18,7 @@ import { ChangeRoutingPointsOperation, ClientSessionManager, CreateEdgeOperation import { CallToolResult } from '@modelcontextprotocol/sdk/types'; import { inject, injectable } from 'inversify'; import * as z from 'zod/v4'; -import { GLSPMcpServer, McpToolHandler } from '../../server'; +import { GLSPMcpServer, McpIdAliasService, McpToolHandler } from '../../server'; import { createToolResult, createToolResultJson } from '../../util'; import { FEATURE_FLAGS } from '../../feature-flags'; @@ -115,6 +115,8 @@ export class CreateEdgesMcpToolHandler implements McpToolHandler { return createToolResult('Model is read-only', true); } + const mcpIdAliasService = session.container.get(McpIdAliasService); + // Snapshot element IDs before operation let beforeIds = modelState.index.allIds(); @@ -123,7 +125,9 @@ export class CreateEdgesMcpToolHandler implements McpToolHandler { 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, sourceElementId, targetElementId, routingPoints, args } = edge; + 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); @@ -170,7 +174,7 @@ export class CreateEdgesMcpToolHandler implements McpToolHandler { dispatchedOperations++; } - successIds.push(newElementId); + successIds.push(mcpIdAliasService.alias(sessionId, newElementId)); } if (FEATURE_FLAGS.useJson) { diff --git a/packages/server-mcp/src/tools/handlers/create-nodes-handler.ts b/packages/server-mcp/src/tools/handlers/create-nodes-handler.ts index 641ca5b..12624e0 100644 --- a/packages/server-mcp/src/tools/handlers/create-nodes-handler.ts +++ b/packages/server-mcp/src/tools/handlers/create-nodes-handler.ts @@ -26,7 +26,7 @@ import { import { CallToolResult } from '@modelcontextprotocol/sdk/types'; import { inject, injectable } from 'inversify'; import * as z from 'zod/v4'; -import { GLSPMcpServer, McpToolHandler } from '../../server'; +import { GLSPMcpServer, McpIdAliasService, McpToolHandler } from '../../server'; import { createToolResult, createToolResultJson } from '../../util'; import { FEATURE_FLAGS } from '../../feature-flags'; @@ -125,6 +125,8 @@ export class CreateNodesMcpToolHandler implements McpToolHandler { return createToolResult('Model is read-only', true); } + const mcpIdAliasService = session.container.get(McpIdAliasService); + // Snapshot element IDs before operation let beforeIds = modelState.index.allIds(); @@ -133,7 +135,8 @@ export class CreateNodesMcpToolHandler implements McpToolHandler { 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, containerId, args } = node; + 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 @@ -171,7 +174,7 @@ export class CreateNodesMcpToolHandler implements McpToolHandler { dispatchedOperations++; } - successIds.push(newElementId); + successIds.push(mcpIdAliasService.alias(sessionId, newElementId)); } if (FEATURE_FLAGS.useJson) { diff --git a/packages/server-mcp/src/tools/handlers/delete-elements-handler.ts b/packages/server-mcp/src/tools/handlers/delete-elements-handler.ts index 4ed6c54..dfa99ac 100644 --- a/packages/server-mcp/src/tools/handlers/delete-elements-handler.ts +++ b/packages/server-mcp/src/tools/handlers/delete-elements-handler.ts @@ -18,7 +18,7 @@ import { ClientSessionManager, DeleteElementOperation, Logger, ModelState } from import { CallToolResult } from '@modelcontextprotocol/sdk/types'; import { inject, injectable } from 'inversify'; import * as z from 'zod/v4'; -import { GLSPMcpServer, McpToolHandler } from '../../server'; +import { GLSPMcpServer, McpIdAliasService, McpToolHandler } from '../../server'; import { createToolResult } from '../../util'; /** @@ -69,10 +69,12 @@ export class DeleteElementsMcpToolHandler implements McpToolHandler { return createToolResult('Model is read-only', true); } + const mcpIdAliasService = session.container.get(McpIdAliasService); + // Validate elements exist const missingIds: string[] = []; for (const elementId of elementIds) { - const element = modelState.index.find(elementId); + const element = modelState.index.find(mcpIdAliasService.lookup(sessionId, elementId)); if (!element) { missingIds.push(elementId); } diff --git a/packages/server-mcp/src/tools/handlers/diagram-elements-handler.ts b/packages/server-mcp/src/tools/handlers/diagram-elements-handler.ts index 71d6ae2..397ecc1 100644 --- a/packages/server-mcp/src/tools/handlers/diagram-elements-handler.ts +++ b/packages/server-mcp/src/tools/handlers/diagram-elements-handler.ts @@ -19,7 +19,7 @@ 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, McpToolHandler } from '../../server'; +import { GLSPMcpServer, McpIdAliasService, McpToolHandler } from '../../server'; import { createToolResult, createToolResultJson } from '../../util'; import { FEATURE_FLAGS } from '../../feature-flags'; @@ -71,9 +71,11 @@ export class DiagramElementsMcpToolHandler implements McpToolHandler { 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(elementId); + const element = modelState.index.find(mcpIdAliasService.lookup(sessionId, elementId)); if (!element) { return createToolResult('No element found for this element id.', true); } diff --git a/packages/server-mcp/src/tools/handlers/get-selection-handler.ts b/packages/server-mcp/src/tools/handlers/get-selection-handler.ts index d1243e0..3573ca9 100644 --- a/packages/server-mcp/src/tools/handlers/get-selection-handler.ts +++ b/packages/server-mcp/src/tools/handlers/get-selection-handler.ts @@ -25,7 +25,7 @@ import { import { CallToolResult } from '@modelcontextprotocol/sdk/types'; import { inject, injectable } from 'inversify'; import * as z from 'zod/v4'; -import { GLSPMcpServer, McpToolHandler } from '../../server'; +import { GLSPMcpServer, McpIdAliasService, McpToolHandler } from '../../server'; import { createToolResult, createToolResultJson } from '../../util'; import { FEATURE_FLAGS } from '../../feature-flags'; @@ -42,7 +42,7 @@ export class GetSelectionMcpToolHandler implements McpToolHandler, ActionHandler @inject(ClientSessionManager) protected clientSessionManager: ClientSessionManager; - protected resolvers: Record) => void> = {}; + protected resolvers: Record) => void }> = {}; registerTool(server: GLSPMcpServer): void { server.registerTool( @@ -81,7 +81,7 @@ export class GetSelectionMcpToolHandler implements McpToolHandler, ActionHandler // Start a promise and save the resolve function to the class return new Promise(resolve => { - this.resolvers[requestId] = resolve; + this.resolvers[requestId] = { sessionId, resolve }; setTimeout(() => resolve(createToolResult('The request timed out.', true)), 5000); }); } @@ -90,12 +90,24 @@ export class GetSelectionMcpToolHandler implements McpToolHandler, ActionHandler 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)); + if (FEATURE_FLAGS.useJson) { - this.resolvers[requestId]?.(createToolResultJson({ selectedIds: action.selectedElementsIDs })); + resolve?.(createToolResultJson({ selectedIds })); } else { // Resolve the previously started promise - const selectedIdsStr = action.selectedElementsIDs.map(id => `- ${id}`).join('\n'); - this.resolvers[requestId]?.(createToolResult(`Following element IDs are selected:\n${selectedIdsStr}`, false)); + const selectedIdsStr = selectedIds.map(id => `- ${id}`).join('\n'); + resolve?.(createToolResult(`Following element IDs are selected:\n${selectedIdsStr}`, false)); } delete this.resolvers[requestId]; diff --git a/packages/server-mcp/src/tools/handlers/modify-edges-handler.ts b/packages/server-mcp/src/tools/handlers/modify-edges-handler.ts index fad1027..92dcb38 100644 --- a/packages/server-mcp/src/tools/handlers/modify-edges-handler.ts +++ b/packages/server-mcp/src/tools/handlers/modify-edges-handler.ts @@ -17,7 +17,6 @@ import { ChangeRoutingPointsOperation, ClientSessionManager, - GLabel, GShapeElement, Logger, ModelState, @@ -26,7 +25,7 @@ import { import { CallToolResult } from '@modelcontextprotocol/sdk/types'; import { inject, injectable } from 'inversify'; import * as z from 'zod/v4'; -import { GLSPMcpServer, McpToolHandler } from '../../server'; +import { GLSPMcpServer, McpIdAliasService, McpToolHandler } from '../../server'; import { createToolResult, createToolResultJson } from '../../util'; import { FEATURE_FLAGS } from '../../feature-flags'; @@ -113,10 +112,12 @@ export class ModifyEdgesMcpToolHandler implements McpToolHandler { 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(change.elementId) as GShapeElement + modelState.index.find(mcpIdAliasService.lookup(sessionId, change.elementId)) as GShapeElement ]); // If any element could not be resolved, do not proceed @@ -130,7 +131,10 @@ export class ModifyEdgesMcpToolHandler implements McpToolHandler { const promises: Promise[] = []; const errors: string[] = []; elements.forEach(([change, element]) => { - const { elementId, sourceElementId, targetElementId, routingPoints } = change; + 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)) { @@ -187,17 +191,4 @@ export class ModifyEdgesMcpToolHandler implements McpToolHandler { 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/modify-nodes-handler.ts b/packages/server-mcp/src/tools/handlers/modify-nodes-handler.ts index 233e050..28f6e5a 100644 --- a/packages/server-mcp/src/tools/handlers/modify-nodes-handler.ts +++ b/packages/server-mcp/src/tools/handlers/modify-nodes-handler.ts @@ -26,7 +26,7 @@ import { import { CallToolResult } from '@modelcontextprotocol/sdk/types'; import { inject, injectable } from 'inversify'; import * as z from 'zod/v4'; -import { GLSPMcpServer, McpToolHandler } from '../../server'; +import { GLSPMcpServer, McpIdAliasService, McpToolHandler } from '../../server'; import { createToolResult, createToolResultJson } from '../../util'; import { FEATURE_FLAGS } from '../../feature-flags'; @@ -117,10 +117,12 @@ export class ModifyNodesMcpToolHandler implements McpToolHandler { 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(change.elementId) as GShapeElement + modelState.index.find(mcpIdAliasService.lookup(sessionId, change.elementId)) as GShapeElement ]); // If any element could not be resolved, do not proceed @@ -133,7 +135,8 @@ export class ModifyNodesMcpToolHandler implements McpToolHandler { // Do all dispatches in parallel, as they should not interfere with each other const promises: Promise[] = []; elements.forEach(([change, element]) => { - const { elementId, size, position, text } = change; + const { size, position, text } = change; + const elementId = mcpIdAliasService.lookup(sessionId, change.elementId); // Resize and/or move the affected node if applicable if (size || position) { diff --git a/packages/server-mcp/src/tools/handlers/validate-diagram-handler.ts b/packages/server-mcp/src/tools/handlers/validate-diagram-handler.ts index 68cf0fe..6fa4679 100644 --- a/packages/server-mcp/src/tools/handlers/validate-diagram-handler.ts +++ b/packages/server-mcp/src/tools/handlers/validate-diagram-handler.ts @@ -18,7 +18,7 @@ import { ClientSessionManager, Logger, MarkersReason, ModelState, ModelValidator import { CallToolResult } from '@modelcontextprotocol/sdk/types'; import { inject, injectable } from 'inversify'; import * as z from 'zod/v4'; -import { GLSPMcpServer, McpToolHandler } from '../../server'; +import { GLSPMcpServer, McpIdAliasService, McpToolHandler } from '../../server'; import { createToolResult, createToolResultJson, objectArrayToMarkdownTable } from '../../util'; import { FEATURE_FLAGS } from '../../feature-flags'; @@ -101,14 +101,20 @@ export class ValidateDiagramMcpToolHandler implements McpToolHandler { 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 : [modelState.root.id]; + 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); + const markers = (await validator.validate(elements, reason ?? MarkersReason.BATCH)).map(marker => ({ + ...marker, + elementId: mcpIdAliasService.alias(sessionId, marker.elementId) + })); if (FEATURE_FLAGS.useJson) { return createToolResultJson({ markers }); From bbe418c8245ed3f47a3aad437ca5e634b6addfe6 Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Tue, 24 Mar 2026 21:16:43 +0100 Subject: [PATCH 21/26] Cleanup and agent guidance --- examples/workflow-server/src/node/app.ts | 8 +- .../default-mcp-resource-contribution.old.ts | 341 ---------- .../src/default-mcp-tool-contribution.old.ts | 605 ------------------ packages/server-mcp/src/di.config.ts | 71 +- packages/server-mcp/src/feature-flags.ts | 12 +- .../export-png-action-handler-contribution.ts | 5 +- .../server-mcp/src/init/init-module.config.ts | 6 +- .../handlers/diagram-model-handler.ts | 2 +- .../resources/handlers/diagram-png-handler.ts | 9 +- .../handlers/element-types-handler.ts | 2 +- packages/server-mcp/src/resources/index.ts | 1 - .../src/resources/resource-module.config.ts | 37 -- .../src/server/http-server-with-sessions.ts | 8 +- .../src/server/mcp-server-manager.ts | 16 +- .../src/tools/handlers/redo-handler.ts | 4 - .../src/tools/handlers/save-model-handler.ts | 4 - .../src/tools/handlers/undo-handler.ts | 4 - packages/server-mcp/src/tools/index.ts | 1 - .../src/tools/tool-module.config.ts | 50 -- 19 files changed, 92 insertions(+), 1094 deletions(-) delete mode 100644 packages/server-mcp/src/default-mcp-resource-contribution.old.ts delete mode 100644 packages/server-mcp/src/default-mcp-tool-contribution.old.ts delete mode 100644 packages/server-mcp/src/resources/resource-module.config.ts delete mode 100644 packages/server-mcp/src/tools/tool-module.config.ts diff --git a/examples/workflow-server/src/node/app.ts b/examples/workflow-server/src/node/app.ts index 068aa19..24fb002 100644 --- a/examples/workflow-server/src/node/app.ts +++ b/examples/workflow-server/src/node/app.ts @@ -19,7 +19,7 @@ import { configureELKLayoutModule } from '@eclipse-glsp/layout-elk'; import { GModelStorage, Logger, SocketServerLauncher, WebSocketServerLauncher, createAppModule } from '@eclipse-glsp/server/node'; import { Container } from 'inversify'; -import { configureMcpInitModule, configureMcpModules } 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'; @@ -46,14 +46,14 @@ async function launch(argv?: string[]): Promise { elkLayoutModule, configureMcpInitModule() // needs to be part of `configureDiagramModule` to ensure correct initialization ); - const mcpModules = configureMcpModules(); // must not be part of `configureDiagramModule` to ensure MCP server launch + 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, ...mcpModules, configureWorfklowMcpModule()); + 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, ...mcpModules, configureWorfklowMcpModule()); + launcher.configure(serverModule, mcpModule, configureWorfklowMcpModule()); await launcher.start({ port: options.port, host: options.host }); } } diff --git a/packages/server-mcp/src/default-mcp-resource-contribution.old.ts b/packages/server-mcp/src/default-mcp-resource-contribution.old.ts deleted file mode 100644 index b39bba2..0000000 --- a/packages/server-mcp/src/default-mcp-resource-contribution.old.ts +++ /dev/null @@ -1,341 +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 - ********************************************************************************/ - -// TODO this class should be removed in time but serves as a development resource for now - -import { - ClientSessionManager, - CreateOperationHandler, - DiagramModules, - 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 { McpModelSerializer } from './resources/services/mcp-model-serializer'; -import { McpServerContribution } from './server/mcp-server-contribution'; -import { GLSPMcpServer } from './server/mcp-server-manager'; -import { extractResourceParam } from './util/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 = extractResourceParam(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 = extractResourceParam(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 = extractResourceParam(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); - - const mcpSerializer = session.container.get(McpModelSerializer); - const [mcpString] = mcpSerializer.serialize(modelState.root); - - return { - contents: [ - { - // uri: `glsp://diagrams/${sessionId}/model`, - // mimeType: 'application/json', - // text: JSON.stringify(schema, undefined, 2) - uri: `glsp://diagrams/${sessionId}/model.md`, - mimeType: 'text/markdown', - text: mcpString - } - ] - }; - } - - // --- 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.old.ts b/packages/server-mcp/src/default-mcp-tool-contribution.old.ts deleted file mode 100644 index 52575ac..0000000 --- a/packages/server-mcp/src/default-mcp-tool-contribution.old.ts +++ /dev/null @@ -1,605 +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 - ********************************************************************************/ - -// TODO this class should be removed in time but serves as a development resource for now - -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 './server/mcp-server-contribution'; -import { GLSPMcpServer } from './server/mcp-server-manager'; - -/** - * 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 - */ -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 - */ -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 - */ -function createToolError = Record>(message: string, details?: T): CallToolResult { - return createToolResult({ success: false, message, error: message, ...(details && { details }) }); -} - -/** - * 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 ccfd2d2..fb22abe 100644 --- a/packages/server-mcp/src/di.config.ts +++ b/packages/server-mcp/src/di.config.ts @@ -13,19 +13,44 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { GLSPServerInitContribution, GLSPServerListener } from '@eclipse-glsp/server'; +import { bindAsService, GLSPServerInitContribution, GLSPServerListener } from '@eclipse-glsp/server'; import { ContainerModule } from 'inversify'; -import { configureMcpResourceModule } from './resources'; -import { DefaultMcpIdAliasService, DummyMcpIdAliasService, McpIdAliasService, McpServerManager } from './server'; -import { configureMcpToolModule } from './tools'; +import { + DefaultMcpModelSerializer, + DiagramModelMcpResourceHandler, + DiagramPngMcpResourceHandler, + ElementTypesMcpResourceHandler, + McpModelSerializer, + McpResourceContribution, + SessionsListMcpResourceHandler +} from './resources'; +import { + DefaultMcpIdAliasService, + DummyMcpIdAliasService, + McpIdAliasService, + McpResourceHandler, + McpServerContribution, + McpServerManager, + McpToolHandler +} from './server'; +import { + ChangeViewMcpToolHandler, + CreateEdgesMcpToolHandler, + CreateNodesMcpToolHandler, + DeleteElementsMcpToolHandler, + DiagramElementsMcpToolHandler, + GetSelectionMcpToolHandler, + McpToolContribution, + ModifyEdgesMcpToolHandler, + ModifyNodesMcpToolHandler, + RedoMcpToolHandler, + SaveModelMcpToolHandler, + UndoMcpToolHandler, + ValidateDiagramMcpToolHandler +} from './tools'; import { FEATURE_FLAGS } from './feature-flags'; -// TODO possibly instead of wholly separate modules, just provide functions using bind context from tools/resources -export function configureMcpModules(): ContainerModule[] { - return [configureMcpServerModule(), configureMcpResourceModule(), configureMcpToolModule()]; -} - -function configureMcpServerModule(): ContainerModule { +export function configureMcpServerModule(): ContainerModule { return new ContainerModule(bind => { bind(McpServerManager).toSelf().inSingletonScope(); bind(GLSPServerInitContribution).toService(McpServerManager); @@ -36,5 +61,31 @@ function configureMcpServerModule(): ContainerModule { } else { bind(McpIdAliasService).to(DummyMcpIdAliasService).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/feature-flags.ts b/packages/server-mcp/src/feature-flags.ts index 7e46b8f..21fdc50 100644 --- a/packages/server-mcp/src/feature-flags.ts +++ b/packages/server-mcp/src/feature-flags.ts @@ -32,15 +32,5 @@ export const FEATURE_FLAGS = { /** Changes whether structured data should be returned as JSON or Markdown. */ useJson: false, /** Changes string-based IDs to integer strings for MCP communication */ - aliasIds: true, - /** Enable or disable unstable resources */ - resources: { - diagramPng: true - }, - /** Enable or disable unstable tools */ - tools: { - undo: true, - redo: true, - saveModel: true - } + aliasIds: true }; 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 index 3a65607..a79b678 100644 --- a/packages/server-mcp/src/init/export-png-action-handler-contribution.ts +++ b/packages/server-mcp/src/init/export-png-action-handler-contribution.ts @@ -16,7 +16,6 @@ import { ActionHandlerFactory, ActionHandlerRegistry, Args, ClientSessionInitializer } from '@eclipse-glsp/server'; import { inject, injectable } from 'inversify'; -import { FEATURE_FLAGS } from '../feature-flags'; import { DiagramPngMcpResourceHandler } from '../resources'; /** @@ -34,8 +33,6 @@ export class ExportMcpPngActionHandlerInitContribution implements ClientSessionI protected pngHandler: DiagramPngMcpResourceHandler; initialize(args?: Args): void { - if (FEATURE_FLAGS.resources.diagramPng) { - this.registry.registerHandler(this.pngHandler); - } + this.registry.registerHandler(this.pngHandler); } } diff --git a/packages/server-mcp/src/init/init-module.config.ts b/packages/server-mcp/src/init/init-module.config.ts index 1c291eb..ee96358 100644 --- a/packages/server-mcp/src/init/init-module.config.ts +++ b/packages/server-mcp/src/init/init-module.config.ts @@ -19,9 +19,9 @@ import { ContainerModule } from 'inversify'; import { ExportMcpPngActionHandlerInitContribution } from './export-png-action-handler-contribution'; import { GetSelectionActionHandlerInitContribution } from './get-selection-action-handler-contribution'; -// TODO this only exists to inject additional action handlers without interfering too much with the given module hierarchy -// however, as this is somewhat hacky, it is likely better to just extend `ServerModule` (e.g., `McpServerModule`) to register the handlers -// it could even be completely unnecessary if all the action handlers registered are for useless features that are removed anyway +/** + * 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(); diff --git a/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts b/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts index c4bac53..54f8121 100644 --- a/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts +++ b/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts @@ -15,7 +15,7 @@ ********************************************************************************/ import { ClientSessionManager, Logger, ModelState } from '@eclipse-glsp/server'; -import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp'; +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'; diff --git a/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts b/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts index 6ce96ad..6e4bb86 100644 --- a/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts +++ b/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts @@ -15,10 +15,9 @@ ********************************************************************************/ import { Action, ActionHandler, ClientSessionManager, ExportPngMcpAction, ExportPngMcpActionResult, Logger } from '@eclipse-glsp/server'; -import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp'; +import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; import { inject, injectable } from 'inversify'; import * as z from 'zod/v4'; -import { FEATURE_FLAGS } from '../../feature-flags'; import { GLSPMcpServer, McpResourceHandler, ResourceHandlerResult } from '../../server'; import { createResourceResult, extractResourceParam } from '../../util'; @@ -52,9 +51,6 @@ export class DiagramPngMcpResourceHandler implements McpResourceHandler, ActionH > = {}; registerResource(server: GLSPMcpServer): void { - if (!FEATURE_FLAGS.resources.diagramPng) { - return; - } server.registerResource( 'diagram-png', new ResourceTemplate('glsp://diagrams/{sessionId}/png', { @@ -85,9 +81,6 @@ export class DiagramPngMcpResourceHandler implements McpResourceHandler, ActionH } registerTool(server: GLSPMcpServer): void { - if (!FEATURE_FLAGS.resources.diagramPng) { - return; - } server.registerTool( 'diagram-png', { diff --git a/packages/server-mcp/src/resources/handlers/element-types-handler.ts b/packages/server-mcp/src/resources/handlers/element-types-handler.ts index 1321298..587021d 100644 --- a/packages/server-mcp/src/resources/handlers/element-types-handler.ts +++ b/packages/server-mcp/src/resources/handlers/element-types-handler.ts @@ -15,7 +15,7 @@ ********************************************************************************/ import { ClientSessionManager, CreateOperationHandler, DiagramModules, Logger, OperationHandlerRegistry } from '@eclipse-glsp/server'; -import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp'; +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'; diff --git a/packages/server-mcp/src/resources/index.ts b/packages/server-mcp/src/resources/index.ts index 6989a93..13de476 100644 --- a/packages/server-mcp/src/resources/index.ts +++ b/packages/server-mcp/src/resources/index.ts @@ -19,5 +19,4 @@ 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 './resource-module.config'; export * from './services/mcp-model-serializer'; diff --git a/packages/server-mcp/src/resources/resource-module.config.ts b/packages/server-mcp/src/resources/resource-module.config.ts deleted file mode 100644 index d6bf44b..0000000 --- a/packages/server-mcp/src/resources/resource-module.config.ts +++ /dev/null @@ -1,37 +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 { bindAsService } from '@eclipse-glsp/server'; -import { ContainerModule } from 'inversify'; -import { McpResourceHandler, McpServerContribution } from '../server'; -import { DiagramModelMcpResourceHandler } from './handlers/diagram-model-handler'; -import { DiagramPngMcpResourceHandler } from './handlers/diagram-png-handler'; -import { ElementTypesMcpResourceHandler } from './handlers/element-types-handler'; -import { SessionsListMcpResourceHandler } from './handlers/sessions-list-handler'; -import { McpResourceContribution } from './mcp-resource-contribution'; -import { DefaultMcpModelSerializer, McpModelSerializer } from './services/mcp-model-serializer'; - -export function configureMcpResourceModule(): ContainerModule { - return new ContainerModule(bind => { - bind(McpModelSerializer).to(DefaultMcpModelSerializer).inSingletonScope(); - - bindAsService(bind, McpResourceHandler, SessionsListMcpResourceHandler); - bindAsService(bind, McpResourceHandler, ElementTypesMcpResourceHandler); - bindAsService(bind, McpResourceHandler, DiagramModelMcpResourceHandler); - bindAsService(bind, McpResourceHandler, DiagramPngMcpResourceHandler); - - bindAsService(bind, McpServerContribution, McpResourceContribution); - }); -} diff --git a/packages/server-mcp/src/server/http-server-with-sessions.ts b/packages/server-mcp/src/server/http-server-with-sessions.ts index e851b9b..5bd570c 100644 --- a/packages/server-mcp/src/server/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/mcp-server-manager.ts b/packages/server-mcp/src/server/mcp-server-manager.ts index 07d960a..4e1e3b7 100644 --- a/packages/server-mcp/src/server/mcp-server-manager.ts +++ b/packages/server-mcp/src/server/mcp-server-manager.ts @@ -37,6 +37,20 @@ export type FullMcpServerConfiguration = Required; export interface GLSPMcpServer extends Pick {} +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. +- 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. +`; + // TODO for easier testing let MCP_SERVER: McpHttpServerWithSessions | undefined = undefined; @@ -90,7 +104,7 @@ export class McpServerManager implements GLSPServerInitContribution, GLSPServerL } protected createMcpServer({ name }: FullMcpServerConfiguration): McpServer { - const server = new McpServer({ name, version: '1.0.0' }, { capabilities: { logging: {} } }); + 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), diff --git a/packages/server-mcp/src/tools/handlers/redo-handler.ts b/packages/server-mcp/src/tools/handlers/redo-handler.ts index 2646145..2246de8 100644 --- a/packages/server-mcp/src/tools/handlers/redo-handler.ts +++ b/packages/server-mcp/src/tools/handlers/redo-handler.ts @@ -18,7 +18,6 @@ import { ClientSessionManager, CommandStack, Logger, RedoAction } from '@eclipse import { CallToolResult } from '@modelcontextprotocol/sdk/types'; import { inject, injectable } from 'inversify'; import * as z from 'zod/v4'; -import { FEATURE_FLAGS } from '../../feature-flags'; import { GLSPMcpServer, McpToolHandler } from '../../server'; import { createToolResult } from '../../util'; @@ -34,9 +33,6 @@ export class RedoMcpToolHandler implements McpToolHandler { protected clientSessionManager: ClientSessionManager; registerTool(server: GLSPMcpServer): void { - if (!FEATURE_FLAGS.tools.redo) { - return; - } server.registerTool( 'redo', { diff --git a/packages/server-mcp/src/tools/handlers/save-model-handler.ts b/packages/server-mcp/src/tools/handlers/save-model-handler.ts index 4a31153..150af53 100644 --- a/packages/server-mcp/src/tools/handlers/save-model-handler.ts +++ b/packages/server-mcp/src/tools/handlers/save-model-handler.ts @@ -18,7 +18,6 @@ import { ClientSessionManager, CommandStack, Logger, SaveModelAction } from '@ec import { CallToolResult } from '@modelcontextprotocol/sdk/types'; import { inject, injectable } from 'inversify'; import * as z from 'zod/v4'; -import { FEATURE_FLAGS } from '../../feature-flags'; import { GLSPMcpServer, McpToolHandler } from '../../server'; import { createToolResult } from '../../util'; @@ -34,9 +33,6 @@ export class SaveModelMcpToolHandler implements McpToolHandler { protected clientSessionManager: ClientSessionManager; registerTool(server: GLSPMcpServer): void { - if (!FEATURE_FLAGS.tools.saveModel) { - return; - } server.registerTool( 'save-model', { diff --git a/packages/server-mcp/src/tools/handlers/undo-handler.ts b/packages/server-mcp/src/tools/handlers/undo-handler.ts index 49597c4..b9f84fe 100644 --- a/packages/server-mcp/src/tools/handlers/undo-handler.ts +++ b/packages/server-mcp/src/tools/handlers/undo-handler.ts @@ -18,7 +18,6 @@ import { ClientSessionManager, CommandStack, Logger, UndoAction } from '@eclipse import { CallToolResult } from '@modelcontextprotocol/sdk/types'; import { inject, injectable } from 'inversify'; import * as z from 'zod/v4'; -import { FEATURE_FLAGS } from '../../feature-flags'; import { GLSPMcpServer, McpToolHandler } from '../../server'; import { createToolResult } from '../../util'; @@ -34,9 +33,6 @@ export class UndoMcpToolHandler implements McpToolHandler { protected clientSessionManager: ClientSessionManager; registerTool(server: GLSPMcpServer): void { - if (!FEATURE_FLAGS.tools.undo) { - return; - } server.registerTool( 'undo', { diff --git a/packages/server-mcp/src/tools/index.ts b/packages/server-mcp/src/tools/index.ts index 59540a5..1604785 100644 --- a/packages/server-mcp/src/tools/index.ts +++ b/packages/server-mcp/src/tools/index.ts @@ -28,4 +28,3 @@ export * from './handlers/save-model-handler'; export * from './handlers/undo-handler'; export * from './handlers/validate-diagram-handler'; export * from './mcp-tool-contribution'; -export * from './tool-module.config'; diff --git a/packages/server-mcp/src/tools/tool-module.config.ts b/packages/server-mcp/src/tools/tool-module.config.ts deleted file mode 100644 index 71c9260..0000000 --- a/packages/server-mcp/src/tools/tool-module.config.ts +++ /dev/null @@ -1,50 +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 { bindAsService } from '@eclipse-glsp/server'; -import { ContainerModule } from 'inversify'; -import { McpServerContribution, McpToolHandler } from '../server'; -import { ChangeViewMcpToolHandler } from './handlers/change-view-handler'; -import { CreateEdgesMcpToolHandler } from './handlers/create-edges-handler'; -import { CreateNodesMcpToolHandler } from './handlers/create-nodes-handler'; -import { DeleteElementsMcpToolHandler } from './handlers/delete-elements-handler'; -import { DiagramElementsMcpToolHandler } from './handlers/diagram-elements-handler'; -import { GetSelectionMcpToolHandler } from './handlers/get-selection-handler'; -import { ModifyEdgesMcpToolHandler } from './handlers/modify-edges-handler'; -import { ModifyNodesMcpToolHandler } from './handlers/modify-nodes-handler'; -import { RedoMcpToolHandler } from './handlers/redo-handler'; -import { SaveModelMcpToolHandler } from './handlers/save-model-handler'; -import { UndoMcpToolHandler } from './handlers/undo-handler'; -import { ValidateDiagramMcpToolHandler } from './handlers/validate-diagram-handler'; -import { McpToolContribution } from './mcp-tool-contribution'; - -export function configureMcpToolModule(): ContainerModule { - return new ContainerModule(bind => { - 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); - }); -} From 3cfa2768cc0cf14c2364ac7bcb93fe3f7c9f8250 Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Tue, 24 Mar 2026 21:24:00 +0100 Subject: [PATCH 22/26] Removed JSON flag --- .../mcp/workflow-element-types-handler.ts | 50 ++++--------------- packages/server-mcp/src/feature-flags.ts | 2 - .../handlers/diagram-model-handler.ts | 17 +++---- .../handlers/element-types-handler.ts | 46 +++++------------ .../handlers/sessions-list-handler.ts | 25 ++-------- .../services/mcp-model-serializer.ts | 5 -- .../src/server/mcp-server-contribution.ts | 2 - .../tools/handlers/create-edges-handler.ts | 22 +------- .../tools/handlers/create-nodes-handler.ts | 22 +------- .../handlers/diagram-elements-handler.ts | 14 ++---- .../tools/handlers/get-selection-handler.ts | 18 ++----- .../tools/handlers/modify-edges-handler.ts | 20 +------- .../tools/handlers/modify-nodes-handler.ts | 18 +------ .../handlers/validate-diagram-handler.ts | 23 +-------- packages/server-mcp/src/util/mcp-util.ts | 18 +------ 15 files changed, 52 insertions(+), 250 deletions(-) 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 index 6a355ad..3a416dd 100644 --- a/examples/workflow-server/src/common/mcp/workflow-element-types-handler.ts +++ b/examples/workflow-server/src/common/mcp/workflow-element-types-handler.ts @@ -18,7 +18,6 @@ import { DefaultTypes } from '@eclipse-glsp/server'; import { createResourceToolResult, ElementTypesMcpResourceHandler, - FEATURE_FLAGS, GLSPMcpServer, objectArrayToMarkdownTable, ResourceHandlerResult @@ -93,20 +92,13 @@ const WORKFLOW_EDGE_ELEMENT_TYPES: ElementType[] = [ } ]; -const WORKFLOW_ELEMENTS_OBJ = { - diagramType: 'workflow-diagram', - nodeTypes: WORKFLOW_NODE_ELEMENT_TYPES, - edgeTypes: WORKFLOW_EDGE_ELEMENT_TYPES -}; -const WORKFLOW_ELEMENT_TYPES_STRING = FEATURE_FLAGS.useJson - ? JSON.stringify(WORKFLOW_ELEMENTS_OBJ) - : [ - '# Creatable element types for diagram type "workflow-diagram"', - '## Node Types', - objectArrayToMarkdownTable(WORKFLOW_NODE_ELEMENT_TYPES), - '## Edge Types', - objectArrayToMarkdownTable(WORKFLOW_EDGE_ELEMENT_TYPES) - ].join('\n'); +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 @@ -130,28 +122,7 @@ export class WorkflowElementTypesMcpResourceHandler extends ElementTypesMcpResou 'Use this to discover valid elementTypeId values for creation tools.', inputSchema: { diagramType: z.string().describe('Diagram type whose elements should be discovered') - }, - outputSchema: FEATURE_FLAGS.useJson - ? z.object({ - diagramType: z.string(), - nodeTypes: z.array( - z.object({ - id: z.string(), - label: z.string(), - description: z.string(), - hasLabel: z.boolean() - }) - ), - edgeTypes: z.array( - z.object({ - id: z.string(), - label: z.string(), - description: z.string(), - hasLabel: z.boolean() - }) - ) - }) - : undefined + } }, async params => createResourceToolResult(await this.handle(params)) ); @@ -175,11 +146,10 @@ export class WorkflowElementTypesMcpResourceHandler extends ElementTypesMcpResou return { content: { uri: `glsp://types/${diagramType}/elements`, - mimeType: FEATURE_FLAGS.useJson ? 'application/json' : 'text/markdown', + mimeType: 'text/markdown', text: WORKFLOW_ELEMENT_TYPES_STRING }, - isError: false, - data: WORKFLOW_ELEMENTS_OBJ + isError: false }; } } diff --git a/packages/server-mcp/src/feature-flags.ts b/packages/server-mcp/src/feature-flags.ts index 21fdc50..dfb6148 100644 --- a/packages/server-mcp/src/feature-flags.ts +++ b/packages/server-mcp/src/feature-flags.ts @@ -29,8 +29,6 @@ export const FEATURE_FLAGS = { * false -> MCP tools */ useResources: false, - /** Changes whether structured data should be returned as JSON or Markdown. */ - useJson: false, /** Changes string-based IDs to integer strings for MCP communication */ aliasIds: true }; diff --git a/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts b/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts index 54f8121..895fbf6 100644 --- a/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts +++ b/packages/server-mcp/src/resources/handlers/diagram-model-handler.ts @@ -21,7 +21,6 @@ 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'; -import { FEATURE_FLAGS } from '../../feature-flags'; /** * Creates a serialized representation of a given session's model state. @@ -45,7 +44,7 @@ export class DiagramModelMcpResourceHandler implements McpResourceHandler { uri: `glsp://diagrams/${sessionId}/model`, name: `Diagram Model: ${sessionId}`, description: `Complete GLSP model structure for session ${sessionId}`, - mimeType: FEATURE_FLAGS.useJson ? 'application/json' : 'text/markdown' + mimeType: 'text/markdown' })) }; }, @@ -58,7 +57,7 @@ export class DiagramModelMcpResourceHandler implements McpResourceHandler { description: 'Get the complete GLSP model for a session as a markdown structure. ' + 'Includes all nodes, edges, and their relevant properties.', - mimeType: FEATURE_FLAGS.useJson ? 'application/json' : 'text/markdown' + mimeType: 'text/markdown' }, async (_uri, params) => createResourceResult(await this.handle({ sessionId: extractResourceParam(params, 'sessionId') })) ); @@ -74,10 +73,7 @@ export class DiagramModelMcpResourceHandler implements McpResourceHandler { 'Includes all nodes, edges, and their relevant properties.', inputSchema: { sessionId: z.string().describe('Session ID for which to query the model.') - }, - outputSchema: FEATURE_FLAGS.useJson - ? z.object().describe('Dictionary of diagram element type to a list of elements.') - : undefined + } }, async params => createResourceToolResult(await this.handle(params)) ); @@ -115,16 +111,15 @@ export class DiagramModelMcpResourceHandler implements McpResourceHandler { const aliasFn = mcpIdAliasService.alias.bind(mcpIdAliasService, sessionId); const mcpSerializer = session.container.get(McpModelSerializer); - const [mcpString, flattenedGraph] = mcpSerializer.serialize(modelState.root, aliasFn); + const [mcpString] = mcpSerializer.serialize(modelState.root, aliasFn); return { content: { uri: `glsp://diagrams/${sessionId}/model`, - mimeType: FEATURE_FLAGS.useJson ? 'application/json' : 'text/markdown', + mimeType: 'text/markdown', text: mcpString }, - isError: false, - data: flattenedGraph + isError: false }; } diff --git a/packages/server-mcp/src/resources/handlers/element-types-handler.ts b/packages/server-mcp/src/resources/handlers/element-types-handler.ts index 587021d..bf3e03e 100644 --- a/packages/server-mcp/src/resources/handlers/element-types-handler.ts +++ b/packages/server-mcp/src/resources/handlers/element-types-handler.ts @@ -20,7 +20,6 @@ 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'; -import { FEATURE_FLAGS } from '../../feature-flags'; /** * Lists the available element types for a given diagram type. This should likely include not only their id but also some description. @@ -48,7 +47,7 @@ export class ElementTypesMcpResourceHandler implements McpResourceHandler { uri: `glsp://types/${type}/elements`, name: `Element Types: ${type}`, description: `Creatable element types for ${type} diagrams`, - mimeType: FEATURE_FLAGS.useJson ? 'application/json' : 'text/markdown' + mimeType: 'text/markdown' })) }; }, @@ -61,7 +60,7 @@ export class ElementTypesMcpResourceHandler implements McpResourceHandler { 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: FEATURE_FLAGS.useJson ? 'application/json' : 'text/markdown' + mimeType: 'text/markdown' }, async (_uri, params) => createResourceResult(await this.handle({ diagramType: extractResourceParam(params, 'diagramType') })) ); @@ -77,24 +76,7 @@ export class ElementTypesMcpResourceHandler implements McpResourceHandler { 'Use this to discover valid elementTypeId values for creation tools.', inputSchema: { diagramType: z.string().describe('Diagram type whose elements should be discovered') - }, - outputSchema: FEATURE_FLAGS.useJson - ? z.object({ - diagramType: z.string(), - nodeTypes: z.array( - z.object({ - id: z.string(), - label: z.string() - }) - ), - edgeTypes: z.array( - z.object({ - id: z.string(), - label: z.string() - }) - ) - }) - : undefined + } }, async params => createResourceToolResult(await this.handle(params)) ); @@ -147,25 +129,21 @@ export class ElementTypesMcpResourceHandler implements McpResourceHandler { } } - const elementTypesObj = { diagramType, nodeTypes, edgeTypes }; - const result = FEATURE_FLAGS.useJson - ? JSON.stringify(elementTypesObj) - : [ - `# Creatable element types for diagram type "${diagramType}"`, - '## Node Types', - objectArrayToMarkdownTable(nodeTypes), - '## Edge Types', - objectArrayToMarkdownTable(edgeTypes) - ].join('\n'); + 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: FEATURE_FLAGS.useJson ? 'application/json' : 'text/markdown', + mimeType: 'text/markdown', text: result }, - isError: false, - data: elementTypesObj + isError: false }; } diff --git a/packages/server-mcp/src/resources/handlers/sessions-list-handler.ts b/packages/server-mcp/src/resources/handlers/sessions-list-handler.ts index 55ee0a0..1673a69 100644 --- a/packages/server-mcp/src/resources/handlers/sessions-list-handler.ts +++ b/packages/server-mcp/src/resources/handlers/sessions-list-handler.ts @@ -18,8 +18,6 @@ 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'; -import { FEATURE_FLAGS } from '../../feature-flags'; -import * as z from 'zod/v4'; /** * Lists the current sessions according to the {@link ClientSessionManager}. This includes not only @@ -40,7 +38,7 @@ export class SessionsListMcpResourceHandler implements McpResourceHandler { { title: 'GLSP Sessions List', description: 'List all active GLSP client sessions across all diagram types', - mimeType: FEATURE_FLAGS.useJson ? 'application/json' : 'text/markdown' + mimeType: 'text/markdown' }, async () => createResourceResult(await this.handle({})) ); @@ -51,19 +49,7 @@ export class SessionsListMcpResourceHandler implements McpResourceHandler { 'sessions-list', { title: 'GLSP Sessions List', - description: 'List all active GLSP client sessions across all diagram types', - outputSchema: FEATURE_FLAGS.useJson - ? z.object({ - sessionsList: z.array( - z.object({ - sessionId: z.string(), - diagramType: z.string(), - sourceUri: z.string().optional(), - readOnly: z.boolean() - }) - ) - }) - : undefined + description: 'List all active GLSP client sessions across all diagram types' }, async () => createResourceToolResult(await this.handle({})) ); @@ -86,11 +72,10 @@ export class SessionsListMcpResourceHandler implements McpResourceHandler { return { content: { uri: 'glsp://sessions', - mimeType: FEATURE_FLAGS.useJson ? 'application/json' : 'text/markdown', - text: FEATURE_FLAGS.useJson ? JSON.stringify(sessionsList) : objectArrayToMarkdownTable(sessionsList) + mimeType: 'text/markdown', + text: objectArrayToMarkdownTable(sessionsList) }, - isError: false, - data: { sessionsList } + isError: false }; } } diff --git a/packages/server-mcp/src/resources/services/mcp-model-serializer.ts b/packages/server-mcp/src/resources/services/mcp-model-serializer.ts index d8b92fc..460743c 100644 --- a/packages/server-mcp/src/resources/services/mcp-model-serializer.ts +++ b/packages/server-mcp/src/resources/services/mcp-model-serializer.ts @@ -18,7 +18,6 @@ import { GModelElement } from '@eclipse-glsp/graph'; import { GModelSerializer } from '@eclipse-glsp/server'; import { inject, injectable } from 'inversify'; import { objectArrayToMarkdownTable } from '../../util'; -import { FEATURE_FLAGS } from '../../feature-flags'; export const McpModelSerializer = Symbol('McpModelSerializer'); @@ -89,10 +88,6 @@ export class DefaultMcpModelSerializer implements McpModelSerializer { result[key] = Array.from(new Map(combined.map(item => [item.id, item])).values()).map(item => this.applyAlias(item, aliasFn)); }); - if (FEATURE_FLAGS.useJson) { - return [JSON.stringify(result), result]; - } - return [ Object.entries(result) .flatMap(([type, elements]) => [`# ${type}`, objectArrayToMarkdownTable(elements)]) diff --git a/packages/server-mcp/src/server/mcp-server-contribution.ts b/packages/server-mcp/src/server/mcp-server-contribution.ts index 0f41617..395e394 100644 --- a/packages/server-mcp/src/server/mcp-server-contribution.ts +++ b/packages/server-mcp/src/server/mcp-server-contribution.ts @@ -29,8 +29,6 @@ export type ResourceResultContent = ReadResourceResult['contents'][number]; export interface ResourceHandlerResult { content: ResourceResultContent; isError: boolean; - // TODO only relevant considering FEATURE_FLAGS.useJson - data?: Record; } /** diff --git a/packages/server-mcp/src/tools/handlers/create-edges-handler.ts b/packages/server-mcp/src/tools/handlers/create-edges-handler.ts index ccad0c0..1b91497 100644 --- a/packages/server-mcp/src/tools/handlers/create-edges-handler.ts +++ b/packages/server-mcp/src/tools/handlers/create-edges-handler.ts @@ -19,8 +19,7 @@ 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, createToolResultJson } from '../../util'; -import { FEATURE_FLAGS } from '../../feature-flags'; +import { createToolResult } from '../../util'; /** * Creates one or multiple new edges in the given session's model. @@ -70,14 +69,7 @@ export class CreateEdgesMcpToolHandler implements McpToolHandler { ) .min(1) .describe('Array of edges to create. Must include at least one node.') - }, - outputSchema: FEATURE_FLAGS.useJson - ? z.object({ - edgeIds: z.array(z.string()).describe('List of IDs of the created edges.'), - errors: z.array(z.string()).optional().describe('List of errors encountered.'), - nrOfCommands: z.number().describe('The number of commands executed in the course of this tool call.') - }) - : undefined + } }, params => this.handle(params) ); @@ -177,16 +169,6 @@ export class CreateEdgesMcpToolHandler implements McpToolHandler { successIds.push(mcpIdAliasService.alias(sessionId, newElementId)); } - if (FEATURE_FLAGS.useJson) { - const content = { - edgeIds: successIds, - errors: errors.length ? errors : undefined, - nrOfCommands: dispatchedOperations - }; - - return createToolResultJson(content); - } - // Create a failure string if any errors occurred let failureStr = ''; if (errors.length) { diff --git a/packages/server-mcp/src/tools/handlers/create-nodes-handler.ts b/packages/server-mcp/src/tools/handlers/create-nodes-handler.ts index 12624e0..8e6b7cd 100644 --- a/packages/server-mcp/src/tools/handlers/create-nodes-handler.ts +++ b/packages/server-mcp/src/tools/handlers/create-nodes-handler.ts @@ -27,8 +27,7 @@ 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, createToolResultJson } from '../../util'; -import { FEATURE_FLAGS } from '../../feature-flags'; +import { createToolResult } from '../../util'; /** * Creates one or multiple new nodes in the given session's model. @@ -80,14 +79,7 @@ export class CreateNodesMcpToolHandler implements McpToolHandler { ) .min(1) .describe('Array of nodes to create. Must include at least one node.') - }, - outputSchema: FEATURE_FLAGS.useJson - ? z.object({ - nodeIds: z.array(z.string()).describe('List of IDs of the created nodes.'), - errors: z.array(z.string()).optional().describe('List of errors encountered.'), - nrOfCommands: z.number().describe('The number of commands executed in the course of this tool call.') - }) - : undefined + } }, params => this.handle(params) ); @@ -177,16 +169,6 @@ export class CreateNodesMcpToolHandler implements McpToolHandler { successIds.push(mcpIdAliasService.alias(sessionId, newElementId)); } - if (FEATURE_FLAGS.useJson) { - const content = { - nodeIds: successIds, - errors: errors.length ? errors : undefined, - nrOfCommands: dispatchedOperations - }; - - return createToolResultJson(content); - } - // Create a failure string if any errors occurred let failureStr = ''; if (errors.length) { diff --git a/packages/server-mcp/src/tools/handlers/diagram-elements-handler.ts b/packages/server-mcp/src/tools/handlers/diagram-elements-handler.ts index 397ecc1..f74b4e5 100644 --- a/packages/server-mcp/src/tools/handlers/diagram-elements-handler.ts +++ b/packages/server-mcp/src/tools/handlers/diagram-elements-handler.ts @@ -20,8 +20,7 @@ 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, createToolResultJson } from '../../util'; -import { FEATURE_FLAGS } from '../../feature-flags'; +import { createToolResult } from '../../util'; /** * Creates a serialized representation of one or more specific elements of a given session's model. @@ -45,10 +44,7 @@ export class DiagramElementsMcpToolHandler implements McpToolHandler { 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.') - }, - outputSchema: FEATURE_FLAGS.useJson - ? z.object().describe('Dictionary of diagram element type to a list of elements.') - : undefined + } }, params => this.handle(params) ); @@ -83,11 +79,7 @@ export class DiagramElementsMcpToolHandler implements McpToolHandler { } const mcpSerializer = session.container.get(McpModelSerializer); - const [mcpString, flattenedGraph] = mcpSerializer.serializeArray(elements); - - if (FEATURE_FLAGS.useJson) { - return createToolResultJson(flattenedGraph); - } + 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 index 3573ca9..4bebba1 100644 --- a/packages/server-mcp/src/tools/handlers/get-selection-handler.ts +++ b/packages/server-mcp/src/tools/handlers/get-selection-handler.ts @@ -26,8 +26,7 @@ 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, createToolResultJson } from '../../util'; -import { FEATURE_FLAGS } from '../../feature-flags'; +import { createToolResult } from '../../util'; /** * Queries the currently selected elements for a given session's diagram. @@ -54,10 +53,7 @@ export class GetSelectionMcpToolHandler implements McpToolHandler, ActionHandler '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') - }, - outputSchema: z.object({ - selectedIds: z.array(z.string()).describe('IDs of the selected diagram elements') - }) + } }, params => this.handle(params) ); @@ -102,13 +98,9 @@ export class GetSelectionMcpToolHandler implements McpToolHandler, ActionHandler const selectedIds = action.selectedElementsIDs.map(id => mcpIdAliasService.alias(sessionId, id)); - if (FEATURE_FLAGS.useJson) { - resolve?.(createToolResultJson({ selectedIds })); - } else { - // Resolve the previously started promise - const selectedIdsStr = selectedIds.map(id => `- ${id}`).join('\n'); - resolve?.(createToolResult(`Following element IDs are selected:\n${selectedIdsStr}`, false)); - } + // 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]; diff --git a/packages/server-mcp/src/tools/handlers/modify-edges-handler.ts b/packages/server-mcp/src/tools/handlers/modify-edges-handler.ts index 92dcb38..9421dd9 100644 --- a/packages/server-mcp/src/tools/handlers/modify-edges-handler.ts +++ b/packages/server-mcp/src/tools/handlers/modify-edges-handler.ts @@ -26,8 +26,7 @@ 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, createToolResultJson } from '../../util'; -import { FEATURE_FLAGS } from '../../feature-flags'; +import { createToolResult } from '../../util'; /** * Modifies onr or more edges in the given session's model. @@ -73,14 +72,7 @@ export class ModifyEdgesMcpToolHandler implements McpToolHandler { .describe( 'Array of change objects containing an element ID and their intended changes. Must include at least one change.' ) - }, - outputSchema: FEATURE_FLAGS.useJson - ? z.object({ - nrOfSuccesses: z.number().describe('The number of successful modifications.'), - nrOfCommands: z.number().describe('The number of commands executed in the course of this tool call.'), - errors: z.array(z.string()).optional().describe('List of errors encountered.') - }) - : undefined + } }, params => this.handle(params) ); @@ -171,14 +163,6 @@ export class ModifyEdgesMcpToolHandler implements McpToolHandler { // Wait for all dispatches to finish before notifying the caller await Promise.all(promises); - if (FEATURE_FLAGS.useJson) { - return createToolResultJson({ - nrOfSuccesses: changes.length - errors.length, - nrOfCommands: promises.length, - errors: errors.length ? errors : undefined - }); - } - // Create a failure string if any errors occurred let failureStr = ''; if (errors.length) { diff --git a/packages/server-mcp/src/tools/handlers/modify-nodes-handler.ts b/packages/server-mcp/src/tools/handlers/modify-nodes-handler.ts index 28f6e5a..454e0b7 100644 --- a/packages/server-mcp/src/tools/handlers/modify-nodes-handler.ts +++ b/packages/server-mcp/src/tools/handlers/modify-nodes-handler.ts @@ -27,8 +27,7 @@ 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, createToolResultJson } from '../../util'; -import { FEATURE_FLAGS } from '../../feature-flags'; +import { createToolResult } from '../../util'; /** * Modifies onr or more nodes in the given session's model. @@ -79,13 +78,7 @@ export class ModifyNodesMcpToolHandler implements McpToolHandler { .describe( 'Array of change objects containing an element ID and their intended changes. Must include at least one change.' ) - }, - outputSchema: FEATURE_FLAGS.useJson - ? z.object({ - nrOfSuccesses: z.number().describe('The number of successful modifications.'), - nrOfCommands: z.number().describe('The number of commands executed in the course of this tool call.') - }) - : undefined + } }, params => this.handle(params) ); @@ -158,13 +151,6 @@ export class ModifyNodesMcpToolHandler implements McpToolHandler { // Wait for all dispatches to finish before notifying the caller await Promise.all(promises); - if (FEATURE_FLAGS.useJson) { - return createToolResultJson({ - nrOfSuccesses: changes.length, - nrOfCommands: promises.length - }); - } - return createToolResult(`Succesfully modified ${changes.length} node(s) (in ${promises.length} commands)`, 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 index 6fa4679..8f552c7 100644 --- a/packages/server-mcp/src/tools/handlers/validate-diagram-handler.ts +++ b/packages/server-mcp/src/tools/handlers/validate-diagram-handler.ts @@ -19,8 +19,7 @@ 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, createToolResultJson, objectArrayToMarkdownTable } from '../../util'; -import { FEATURE_FLAGS } from '../../feature-flags'; +import { createToolResult, objectArrayToMarkdownTable } from '../../util'; /** * Validates the given session's model. @@ -52,21 +51,7 @@ export class ValidateDiagramMcpToolHandler implements McpToolHandler { .optional() .default(MarkersReason.LIVE) .describe('Validation reason: "batch" for thorough validation, "live" for quick incremental checks') - }, - outputSchema: FEATURE_FLAGS.useJson - ? z.object({ - markers: z - .array( - z.object({ - label: z.string(), - description: z.string(), - elementId: z.string(), - kind: z.string() - }) - ) - .describe('List of validation results.') - }) - : undefined + } }, params => this.handle(params) ); @@ -116,10 +101,6 @@ export class ValidateDiagramMcpToolHandler implements McpToolHandler { elementId: mcpIdAliasService.alias(sessionId, marker.elementId) })); - if (FEATURE_FLAGS.useJson) { - return createToolResultJson({ markers }); - } - return createToolResult(objectArrayToMarkdownTable(markers), false); } } diff --git a/packages/server-mcp/src/util/mcp-util.ts b/packages/server-mcp/src/util/mcp-util.ts index 7ba5e52..5270a7c 100644 --- a/packages/server-mcp/src/util/mcp-util.ts +++ b/packages/server-mcp/src/util/mcp-util.ts @@ -16,7 +16,6 @@ import { CallToolResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types'; import { ResourceHandlerResult } from '../server'; -import { FEATURE_FLAGS } from '../feature-flags'; /** * Extracts a single parameter value from MCP resource template parameters. @@ -54,8 +53,7 @@ export function createResourceToolResult(result: ResourceHandlerResult): CallToo type: 'text', text: (result.content as any).text } - ], - structuredContent: FEATURE_FLAGS.useJson ? result.data : undefined + ] }; } @@ -73,17 +71,3 @@ export function createToolResult(text: string, isError: boolean): CallToolResult ] }; } - -// TODO relevant for FEATURE_FLAGS.useJson -export function createToolResultJson(content: Record): CallToolResult { - return { - isError: false, - content: [ - { - type: 'text', - text: JSON.stringify(content) - } - ], - structuredContent: content - }; -} From b6303f973487db273a8083ad6a046bbd4a43d3e3 Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Tue, 24 Mar 2026 22:02:06 +0100 Subject: [PATCH 23/26] Cleanup of feature flags --- packages/server-mcp/src/di.config.ts | 14 ++--- packages/server-mcp/src/feature-flags.ts | 34 ------------ .../resources/mcp-resource-contribution.ts | 5 +- packages/server-mcp/src/server/index.ts | 1 + .../src/server/mcp-id-alias-service.ts | 27 +++++----- .../src/server/mcp-option-service.ts | 53 +++++++++++++++++++ .../src/server/mcp-server-manager.ts | 15 ++++-- .../src/tools/handlers/change-view-handler.ts | 4 +- .../server/src/common/protocol/glsp-server.ts | 1 - 9 files changed, 87 insertions(+), 67 deletions(-) delete mode 100644 packages/server-mcp/src/feature-flags.ts create mode 100644 packages/server-mcp/src/server/mcp-option-service.ts diff --git a/packages/server-mcp/src/di.config.ts b/packages/server-mcp/src/di.config.ts index fb22abe..c745513 100644 --- a/packages/server-mcp/src/di.config.ts +++ b/packages/server-mcp/src/di.config.ts @@ -26,8 +26,10 @@ import { } from './resources'; import { DefaultMcpIdAliasService, - DummyMcpIdAliasService, + DefaultMcpOptionService, McpIdAliasService, + McpOptionService, + McpOptionServiceContribution, McpResourceHandler, McpServerContribution, McpServerManager, @@ -48,7 +50,6 @@ import { UndoMcpToolHandler, ValidateDiagramMcpToolHandler } from './tools'; -import { FEATURE_FLAGS } from './feature-flags'; export function configureMcpServerModule(): ContainerModule { return new ContainerModule(bind => { @@ -56,11 +57,10 @@ export function configureMcpServerModule(): ContainerModule { bind(GLSPServerInitContribution).toService(McpServerManager); bind(GLSPServerListener).toService(McpServerManager); - if (FEATURE_FLAGS.aliasIds) { - bind(McpIdAliasService).to(DefaultMcpIdAliasService).inSingletonScope(); - } else { - bind(McpIdAliasService).to(DummyMcpIdAliasService).inSingletonScope(); - } + bindAsService(bind, McpOptionService, DefaultMcpOptionService); + bindAsService(bind, McpServerContribution, McpOptionServiceContribution); + + bind(McpIdAliasService).to(DefaultMcpIdAliasService).inSingletonScope(); bind(McpModelSerializer).to(DefaultMcpModelSerializer).inSingletonScope(); diff --git a/packages/server-mcp/src/feature-flags.ts b/packages/server-mcp/src/feature-flags.ts deleted file mode 100644 index dfb6148..0000000 --- a/packages/server-mcp/src/feature-flags.ts +++ /dev/null @@ -1,34 +0,0 @@ -/******************************************************************************** - * 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 - ********************************************************************************/ - -// TODO development tool, replace with proper implementation as needed -/** - * Provides a simple interface to enable/disable specific features during development - */ -export const FEATURE_FLAGS = { - /** - * Changes how resources are registered. - * This is relevant since some MCP clients are unable to deal with MCP resource endpoints - * and thus they must be provided as tools. - * - * true -> MCP resources - * - * false -> MCP tools - */ - useResources: false, - /** Changes string-based IDs to integer strings for MCP communication */ - aliasIds: true -}; diff --git a/packages/server-mcp/src/resources/mcp-resource-contribution.ts b/packages/server-mcp/src/resources/mcp-resource-contribution.ts index 87a3e87..f18fd68 100644 --- a/packages/server-mcp/src/resources/mcp-resource-contribution.ts +++ b/packages/server-mcp/src/resources/mcp-resource-contribution.ts @@ -15,7 +15,6 @@ ********************************************************************************/ import { injectable, multiInject } from 'inversify'; -import { FEATURE_FLAGS } from '../feature-flags'; import { GLSPMcpServer, McpResourceHandler, McpServerContribution } from '../server'; /** @@ -33,9 +32,7 @@ export class McpResourceContribution implements McpServerContribution { protected mcpResourceHandlers: McpResourceHandler[]; configure(server: GLSPMcpServer): void { - // TODO currently only development tool - // think of nice switching mechanism for starting MCP servers with only tools or tools + resources - if (FEATURE_FLAGS.useResources) { + 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/server/index.ts b/packages/server-mcp/src/server/index.ts index 513f87b..6187dc6 100644 --- a/packages/server-mcp/src/server/index.ts +++ b/packages/server-mcp/src/server/index.ts @@ -18,3 +18,4 @@ 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 index ab9891a..3573205 100644 --- a/packages/server-mcp/src/server/mcp-id-alias-service.ts +++ b/packages/server-mcp/src/server/mcp-id-alias-service.ts @@ -14,9 +14,8 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { injectable } from 'inversify'; - -// TODO entire feature depends on FEATURE_FLAG.aliasIds +import { inject, injectable } from 'inversify'; +import { McpOptionService } from './mcp-option-service'; export const McpIdAliasService = Symbol('McpIdAliasService'); @@ -43,6 +42,9 @@ export interface McpIdAliasService { @injectable() export class DefaultMcpIdAliasService implements McpIdAliasService { + @inject(McpOptionService) + protected mcpOptionService: McpOptionService; + // Map> protected idAliasMap = new Map>(); // Map> @@ -51,6 +53,10 @@ export class DefaultMcpIdAliasService implements McpIdAliasService { 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); @@ -75,6 +81,10 @@ export class DefaultMcpIdAliasService implements McpIdAliasService { } lookup(sessionId: string, alias: string): string { + if (!this.mcpOptionService.get('aliasIds')) { + return alias; + } + const aliasToUuid = this.aliasIdMap.get(sessionId); const uuid = aliasToUuid?.get(alias); @@ -85,14 +95,3 @@ export class DefaultMcpIdAliasService implements McpIdAliasService { return uuid; } } - -@injectable() -export class DummyMcpIdAliasService implements McpIdAliasService { - alias(sessionId: string, id: string): string { - return id; - } - - lookup(sessionId: string, alias: string): string { - return alias; - } -} 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-manager.ts b/packages/server-mcp/src/server/mcp-server-manager.ts index 4e1e3b7..b57cf48 100644 --- a/packages/server-mcp/src/server/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,9 @@ 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 @@ -74,8 +77,9 @@ export class McpServerManager implements GLSPServerInitContribution, GLSPServerL await new Promise(res => setTimeout(res, 500)); // TODO use fixed 60000 instead of 0 so that the MCP server need only be registered once and can thus be easier tested - const { port = 60000, host = '127.0.0.1', route = '/glsp-mcp', name = 'glspMcpServer' } = mcpServerParam; - const mcpServerConfig: FullMcpServerConfiguration = { port, host, route, name }; + 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)); @@ -103,12 +107,13 @@ export class McpServerManager implements GLSPServerInitContribution, GLSPServerL this.toDispose.push(Disposable.create(() => server.close())); } - protected createMcpServer({ name }: FullMcpServerConfiguration): McpServer { + 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 index 7631631..8c64bb7 100644 --- a/packages/server-mcp/src/tools/handlers/change-view-handler.ts +++ b/packages/server-mcp/src/tools/handlers/change-view-handler.ts @@ -93,8 +93,8 @@ export class ChangeViewMcpToolHandler implements McpToolHandler { action = CenterAction.create(elementIds, { animate: true, retainZoom: true }); break; case 'reset-viewport': - // TODO `OriginViewportAction` is not available, because it lives in feature space, not protocol space - // TODO should this be removed? + // `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; } diff --git a/packages/server/src/common/protocol/glsp-server.ts b/packages/server/src/common/protocol/glsp-server.ts index 6c70f75..6026596 100644 --- a/packages/server/src/common/protocol/glsp-server.ts +++ b/packages/server/src/common/protocol/glsp-server.ts @@ -106,7 +106,6 @@ export class DefaultGLSPServer implements GLSPServer { let result = { protocolVersion: DefaultGLSPServer.PROTOCOL_VERSION, serverActions }; - // TODO handle via parameter or something // 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 From 6026bbcba2bca8d07e48614225589cb06ccf2b1d Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Wed, 25 Mar 2026 00:13:18 +0100 Subject: [PATCH 24/26] Added README --- packages/server-mcp/README.md | 474 ++++++++++++++++++++++++++++++++++ 1 file changed, 474 insertions(+) create mode 100644 packages/server-mcp/README.md 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 From 8d5c11904c0e035d53bd160df2def57860b1d584 Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Wed, 25 Mar 2026 13:35:06 +0100 Subject: [PATCH 25/26] Cleanup server across connections --- packages/server-mcp/src/index.ts | 1 - packages/server-mcp/src/server/mcp-server-manager.ts | 12 ++---------- .../src/common/launch/jsonrpc-server-launcher.ts | 3 +++ 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/server-mcp/src/index.ts b/packages/server-mcp/src/index.ts index 616f8af..206a050 100644 --- a/packages/server-mcp/src/index.ts +++ b/packages/server-mcp/src/index.ts @@ -14,7 +14,6 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ export * from './di.config'; -export * from './feature-flags'; export * from './init'; export * from './resources'; export * from './server'; diff --git a/packages/server-mcp/src/server/mcp-server-manager.ts b/packages/server-mcp/src/server/mcp-server-manager.ts index b57cf48..b2c18df 100644 --- a/packages/server-mcp/src/server/mcp-server-manager.ts +++ b/packages/server-mcp/src/server/mcp-server-manager.ts @@ -54,9 +54,6 @@ CLSP MCP server. You have to adhere to the following principles: - Layouting: If available, make use of automatic layouting when not given explicit custom layouting requirements. `; -// TODO for easier testing -let MCP_SERVER: McpHttpServerWithSessions | undefined = undefined; - @injectable() export class McpServerManager implements GLSPServerInitContribution, GLSPServerListener, Disposable { @inject(Logger) protected logger: Logger; @@ -72,11 +69,8 @@ export class McpServerManager implements GLSPServerInitContribution, GLSPServerL return result; } - // TODO for easier testing - MCP_SERVER?.dispose(); - await new Promise(res => setTimeout(res, 500)); - - // TODO use fixed 60000 instead of 0 so that the MCP server need only be registered once and can thus be easier tested + // 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 }; @@ -84,8 +78,6 @@ export class McpServerManager implements GLSPServerInitContribution, GLSPServerL const httpServer = new McpHttpServerWithSessions(this.logger); httpServer.onSessionInitialized(client => this.onSessionInitialized(client, mcpServerConfig)); this.toDispose.push(httpServer); - // TODO for easier testing - MCP_SERVER = httpServer; const address = await httpServer.start(mcpServerConfig); this.serverUrl = this.toServerUrl(address, route); 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'); } From 58d34ddb51868e870baaafb713375dc12dfa29d1 Mon Sep 17 00:00:00 2001 From: Andreas Hell Date: Wed, 25 Mar 2026 18:52:46 +0100 Subject: [PATCH 26/26] Fixed buggy or unstable behavior --- .../src/resources/handlers/diagram-png-handler.ts | 6 +++++- .../server-mcp/src/server/mcp-server-manager.ts | 2 +- .../src/tools/handlers/create-edges-handler.ts | 14 ++++++++++---- .../src/tools/handlers/create-nodes-handler.ts | 14 ++++++++++---- .../src/tools/handlers/delete-elements-handler.ts | 10 +++++++--- 5 files changed, 33 insertions(+), 13 deletions(-) diff --git a/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts b/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts index 6e4bb86..2d205c4 100644 --- a/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts +++ b/packages/server-mcp/src/resources/handlers/diagram-png-handler.ts @@ -19,7 +19,7 @@ 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, extractResourceParam } from '../../util'; +import { createResourceResult, createToolResult, extractResourceParam } from '../../util'; /** * Creates a base64-encoded PNG of the given session's model state. @@ -94,6 +94,10 @@ export class DiagramPngMcpResourceHandler implements McpResourceHandler, ActionH }, async params => { const result = await this.handle(params); + if (result.isError) { + return createToolResult((result.content as any).text, true); + } + return { isError: result.isError, content: [ diff --git a/packages/server-mcp/src/server/mcp-server-manager.ts b/packages/server-mcp/src/server/mcp-server-manager.ts index b2c18df..fadf75f 100644 --- a/packages/server-mcp/src/server/mcp-server-manager.ts +++ b/packages/server-mcp/src/server/mcp-server-manager.ts @@ -44,7 +44,7 @@ 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. +- 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. diff --git a/packages/server-mcp/src/tools/handlers/create-edges-handler.ts b/packages/server-mcp/src/tools/handlers/create-edges-handler.ts index 1b91497..a422a2e 100644 --- a/packages/server-mcp/src/tools/handlers/create-edges-handler.ts +++ b/packages/server-mcp/src/tools/handlers/create-edges-handler.ts @@ -142,11 +142,17 @@ export class CreateEdgesMcpToolHandler implements McpToolHandler { // Snapshot element IDs after operation const afterIds = modelState.index.allIds(); - // Find new element ID by filtering only the newly added ones... + // Find new element ID by filtering only the newly added ones,... const newIds = afterIds.filter(id => !beforeIds.includes(id)); - // ...and in case that multiple exist (i.e., derived elements were created as well), - // assume that the first new ID represents the actually relevant element - const newElementId = newIds.length > 0 ? newIds[0] : undefined; + // ...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; diff --git a/packages/server-mcp/src/tools/handlers/create-nodes-handler.ts b/packages/server-mcp/src/tools/handlers/create-nodes-handler.ts index 8e6b7cd..2b7b812 100644 --- a/packages/server-mcp/src/tools/handlers/create-nodes-handler.ts +++ b/packages/server-mcp/src/tools/handlers/create-nodes-handler.ts @@ -141,11 +141,17 @@ export class CreateNodesMcpToolHandler implements McpToolHandler { // Snapshot element IDs after operation const afterIds = modelState.index.allIds(); - // Find new element ID by filtering only the newly added ones... + // Find new element ID by filtering only the newly added ones,... const newIds = afterIds.filter(id => !beforeIds.includes(id)); - // ...and in case that multiple exist (i.e., derived elements were created as well), - // assume that the first new ID represents the actually relevant element - const newElementId = newIds.length > 0 ? newIds[0] : undefined; + // ...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; diff --git a/packages/server-mcp/src/tools/handlers/delete-elements-handler.ts b/packages/server-mcp/src/tools/handlers/delete-elements-handler.ts index dfa99ac..5cbfaa4 100644 --- a/packages/server-mcp/src/tools/handlers/delete-elements-handler.ts +++ b/packages/server-mcp/src/tools/handlers/delete-elements-handler.ts @@ -73,9 +73,13 @@ export class DeleteElementsMcpToolHandler implements McpToolHandler { // Validate elements exist const missingIds: string[] = []; + const realIds: string[] = []; for (const elementId of elementIds) { - const element = modelState.index.find(mcpIdAliasService.lookup(sessionId, elementId)); - if (!element) { + const realId = mcpIdAliasService.lookup(sessionId, elementId); + const element = modelState.index.find(realId); + if (element) { + realIds.push(realId); + } else { missingIds.push(elementId); } } @@ -88,7 +92,7 @@ export class DeleteElementsMcpToolHandler implements McpToolHandler { const beforeCount = modelState.index.allIds().length; // Create and dispatch delete operation - const operation = DeleteElementOperation.create(elementIds); + const operation = DeleteElementOperation.create(realIds); await session.actionDispatcher.dispatch(operation); // Calculate how many elements were deleted (including dependents)