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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/workflow-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"dependencies": {
"@eclipse-glsp/layout-elk": "2.6.0-next",
"@eclipse-glsp/server": "2.6.0-next",
"@eclipse-glsp/server-mcp": "2.6.0-next",
"inversify": "^6.1.3"
},
"devDependencies": {
Expand Down
4 changes: 2 additions & 2 deletions examples/workflow-server/src/common/graph-extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export class TaskNodeBuilder<T extends TaskNode = TaskNode> 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();
}
Expand Down Expand Up @@ -151,7 +151,7 @@ export class CategoryNodeBuilder<T extends Category = Category> 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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/********************************************************************************
* Copyright (c) 2026 EclipseSource and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import { GLabel, GModelElement } from '@eclipse-glsp/server';
import { CreateNodesMcpToolHandler } from '@eclipse-glsp/server-mcp';
import { injectable } from 'inversify';
import { ModelTypes } from '../util/model-types';

@injectable()
export class WorkflowCreateNodesMcpToolHandler extends CreateNodesMcpToolHandler {
override getCorrespondingLabelId(element: GModelElement): string | undefined {
// Category labels are nested in a header component
if (element.type === ModelTypes.CATEGORY) {
return element.children.find(child => child.type === ModelTypes.COMP_HEADER)?.children.find(child => child instanceof GLabel)
?.id;
}

// Assume that generally, labelled nodes have those labels as direct children
return element.children.find(child => child instanceof GLabel)?.id;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/********************************************************************************
* Copyright (c) 2026 EclipseSource and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import { DefaultTypes } from '@eclipse-glsp/server';
import {
createResourceToolResult,
ElementTypesMcpResourceHandler,
GLSPMcpServer,
objectArrayToMarkdownTable,
ResourceHandlerResult
} from '@eclipse-glsp/server-mcp';
import { injectable } from 'inversify';
import { ModelTypes } from '../util/model-types';
import * as z from 'zod/v4';

interface ElementType {
id: string;
label: string;
description: string;
hasLabel: boolean;
}

const WORKFLOW_NODE_ELEMENT_TYPES: ElementType[] = [
{
id: ModelTypes.AUTOMATED_TASK,
label: 'Automated Task',
description: 'Task without human input',
hasLabel: true
},
{
id: ModelTypes.MANUAL_TASK,
label: 'Manual Task',
description: 'Task done by a human',
hasLabel: true
},
{
id: ModelTypes.JOIN_NODE,
label: 'Join Node',
description: 'Gateway that merges parallel flows',
hasLabel: false
},
{
id: ModelTypes.FORK_NODE,
label: 'Fork Node',
description: 'Gateway that splits into parallel flows',
hasLabel: false
},
{
id: ModelTypes.MERGE_NODE,
label: 'Merge Node',
description: 'Gateway that merges alternative flows',
hasLabel: false
},
{
id: ModelTypes.DECISION_NODE,
label: 'Decision Node',
description: 'Gateway that splits into alternative flows',
hasLabel: false
},
{
id: ModelTypes.CATEGORY,
label: 'Category',
description: 'Container node that groups other elements',
hasLabel: true
}
];
const WORKFLOW_EDGE_ELEMENT_TYPES: ElementType[] = [
{
id: DefaultTypes.EDGE,
label: 'Edge',
description: 'Standard control flow edge',
hasLabel: false
},
{
id: ModelTypes.WEIGHTED_EDGE,
label: 'Weighted Edge',
description: 'Edge that indicates a weighted probability. Typically used with a Decision Node.',
hasLabel: false
}
];

const WORKFLOW_ELEMENT_TYPES_STRING = [
'# Creatable element types for diagram type "workflow-diagram"',
'## Node Types',
objectArrayToMarkdownTable(WORKFLOW_NODE_ELEMENT_TYPES),
'## Edge Types',
objectArrayToMarkdownTable(WORKFLOW_EDGE_ELEMENT_TYPES)
].join('\n');

/**
* The default {@link ElementTypesMcpResourceHandler} extracts a list of operations generically from
* the `OperationHandlerRegistry`, because it can't know the details of a specific GLSP implementation.
* This is naturally quite limited in expression and relies on semantically meaningful model types to be
* able to inform an MCP client reliably.
*
* However, when overriding this for a specific implementation, we don't have those limitations. Rather,
* since the available element types do not change dynamically, we can simply provide a statically generated
* string.
*/
@injectable()
export class WorkflowElementTypesMcpResourceHandler extends ElementTypesMcpResourceHandler {
override registerToolAlternative(server: GLSPMcpServer): void {
server.registerTool(
'element-types',
{
title: 'Creatable Element Types',
description:
'List all element types (nodes and edges) that can be created for a specific diagram type. ' +
'Use this to discover valid elementTypeId values for creation tools.',
inputSchema: {
diagramType: z.string().describe('Diagram type whose elements should be discovered')
}
},
async params => createResourceToolResult(await this.handle(params))
);
}

override async handle({ diagramType }: { diagramType?: string }): Promise<ResourceHandlerResult> {
this.logger.info(`'element-types' invoked for diagram type '${diagramType}'`);

// In this specifc GLSP implementation, only 'workflow-diagram' is valid
if (diagramType !== 'workflow-diagram') {
return {
content: {
uri: `glsp://types/${diagramType}/elements`,
mimeType: 'text/plain',
text: 'Invalid diagram type.'
},
isError: true
};
}

return {
content: {
uri: `glsp://types/${diagramType}/elements`,
mimeType: 'text/markdown',
text: WORKFLOW_ELEMENT_TYPES_STRING
},
isError: false
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/********************************************************************************
* Copyright (c) 2026 EclipseSource and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import { GModelElement } from '@eclipse-glsp/graph';
import { DefaultTypes } from '@eclipse-glsp/server';
import { DefaultMcpModelSerializer } from '@eclipse-glsp/server-mcp';
import { injectable } from 'inversify';
import { ModelTypes } from '../util/model-types';

/**
* As compared to the {@link DefaultMcpModelSerializer}, this is a specific implementation and we
* know not only the structure of our graph but also each relevant attribute. This enables us to
* order them semantically so the produced serialization makes more sense if read with semantics
* mind. As LLMs (i.e., the MCP clients) work semantically, this is superior to a random ordering.
* Furthermore, including only the relevant information without redundancies decreases context size.
*/
@injectable()
export class WorkflowMcpModelSerializer extends DefaultMcpModelSerializer {
override prepareElement(element: GModelElement): Record<string, Record<string, any>[]> {
const elements = this.flattenStructure(element);

// Define the order of keys
const result: Record<string, Record<string, any>[]> = {
[DefaultTypes.GRAPH]: [],
[ModelTypes.CATEGORY]: [],
[ModelTypes.AUTOMATED_TASK]: [],
[ModelTypes.MANUAL_TASK]: [],
[ModelTypes.FORK_NODE]: [],
[ModelTypes.JOIN_NODE]: [],
[ModelTypes.DECISION_NODE]: [],
[ModelTypes.MERGE_NODE]: [],
[DefaultTypes.EDGE]: [],
[ModelTypes.WEIGHTED_EDGE]: []
};
elements.forEach(element => {
this.combinePositionAndSize(element);

const adjustedElement = this.adjustElement(element);
if (!adjustedElement) {
return;
}

result[element.type].push(adjustedElement);
});

return result;
}

private adjustElement(element: Record<string, any>): Record<string, any> | 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;
}
}
}
Loading