diff --git a/.claude/skills/generate-changelog/SKILL.md b/.claude/skills/generate-changelog/SKILL.md index 59f7968..230efad 100644 --- a/.claude/skills/generate-changelog/SKILL.md +++ b/.claude/skills/generate-changelog/SKILL.md @@ -8,6 +8,7 @@ description: Generate changelog entries from merged PRs since the last release. Generate changelog entries by analyzing merged PRs since the last release. Execute these phases in order: + 1. **Data Gathering** — find last release, fetch PRs, classify, generate entries 2. **Changelog Update** — write entries into CHANGELOG.md 3. **Review & Approval** — present the actual diff to the user for review @@ -20,11 +21,13 @@ Execute these phases in order: ### Step 1.1: Find the latest release tag Run: + ```bash git tag --sort=-creatordate | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 ``` Record as `LAST_TAG`. Get the tag's date: + ```bash git log -1 --format=%aI $LAST_TAG ``` @@ -46,12 +49,13 @@ Use the date portion only from `TAG_DATE` (e.g. `2026-02-09`). ### Step 1.4: Filter PRs **Skip rules** (in order): + 1. PR has the `changelog` label → skip (already tracked in a previous changelog update) 2. Author login is `dependabot` or `dependabot[bot]` → skip 3. Title matches version bump patterns → skip: - - Starts with `Switch to` and contains `-next` - - Title is a bare version like `vX.Y.Z` - - Title starts with `Release v` + - Starts with `Switch to` and contains `-next` + - Title is a bare version like `vX.Y.Z` + - Title starts with `Release v` 4. Title contains `update changelog` (case insensitive) → skip 5. Title is purely CI/metadata (readme badges, workflow config) AND body does NOT contain `[x] This PR should be mentioned in the changelog` → skip @@ -62,9 +66,9 @@ Use the date portion only from `TAG_DATE` (e.g. `2026-02-09`). 1. If body contains `[x] This PR introduces a breaking change` → **breaking** 2. If body contains `[ ] This PR introduces a breaking change` → **normal** 3. If no checkbox info, analyze title and body: - - Breaking keywords: "refactor", "rename", "remove", "replace", "migrate", "breaking", "deprecate" - - Clearly non-breaking (bug fix, docs, minor enhancement) → **normal** - - Uncertain → flag for user review + - Breaking keywords: "refactor", "rename", "remove", "replace", "migrate", "breaking", "deprecate" + - Clearly non-breaking (bug fix, docs, minor enhancement) → **normal** + - Uncertain → flag for user review ### Step 1.6: Assign category tags @@ -75,11 +79,13 @@ Pick the most fitting tag from the vocabulary (Step 1.2) based on PR title and b #### Entry Format **Normal changes:** + ``` - [tag] Description [#N](https://github.com/OWNER/REPO/pull/N) ``` **Breaking changes** with migration sub-items: + ``` - [tag] Description [#N](https://github.com/OWNER/REPO/pull/N) - Migration detail 1 @@ -87,6 +93,7 @@ Pick the most fitting tag from the vocabulary (Step 1.2) based on PR title and b ``` **Multiple PRs for the same change:** + ``` - [tag] Description [#N](url) [#M](url) ``` @@ -94,38 +101,43 @@ Pick the most fitting tag from the vocabulary (Step 1.2) based on PR title and b #### Style Guide **Formatting:** -- Entry prefix: `- ` (dash + exactly 3 spaces) -- Sub-item indent: 4 spaces + `- ` (4 spaces from parent dash) -- Sub-sub-item indent: 8 spaces + `- ` -- Single space between `[tag]` and description text -- Single space before PR link at end of line -- PR links are mandatory, full URLs: `[#123](https://github.com/OWNER/REPO/pull/123)` + +- Entry prefix: `- ` (dash + exactly 3 spaces) +- Sub-item indent: 4 spaces + `- ` (4 spaces from parent dash) +- Sub-sub-item indent: 8 spaces + `- ` +- Single space between `[tag]` and description text +- Single space before PR link at end of line +- PR links are mandatory, full URLs: `[#123](https://github.com/OWNER/REPO/pull/123)` **Wording:** -- Always start with a **present tense imperative verb** (not past tense) -- Common verbs: Fix, Improve, Add, Update, Extend, Ensure, Introduce, Remove, Refactor, Rename, Provide, Allow, Support -- **Bug fixes**: "Fix a bug that caused/prevented...", "Fix X behavior" -- **Features**: "Introduce...", "Add support for...", "Provide..." -- **Enhancements**: "Improve...", "Extend...", "Ensure that..." -- **Refactors**: "Refactor...", "Rework...", "Rename..." -- Be specific — never "Fix various issues" + +- Always start with a **present tense imperative verb** (not past tense) +- Common verbs: Fix, Improve, Add, Update, Extend, Ensure, Introduce, Remove, Refactor, Rename, Provide, Allow, Support +- **Bug fixes**: "Fix a bug that caused/prevented...", "Fix X behavior" +- **Features**: "Introduce...", "Add support for...", "Provide..." +- **Enhancements**: "Improve...", "Extend...", "Ensure that..." +- **Refactors**: "Refactor...", "Rework...", "Rename..." +- Be specific — never "Fix various issues" **Capitalization:** -- Tags are always lowercase: `[diagram]`, not `[Diagram]` -- Description starts lowercase after the tag (unless proper noun or code element) -- Section headers: Title Case (`### Potentially Breaking Changes`) + +- Tags are always lowercase: `[diagram]`, not `[Diagram]` +- Description starts lowercase after the tag (unless proper noun or code element) +- Section headers: Title Case (`### Potentially Breaking Changes`) **Description cleanup from PR title:** -- Remove issue tracker prefixes (e.g. `GLSP-1234:`, `GH-123:`, `ISSUE-456:`) -- Remove conventional commit prefixes: `fix:`, `feat:`, `chore:` -- Rephrase bug-report style to changelog style: - - BAD: "Edit label UI does not resize on graph zoom" - - GOOD: "Fix edit label UI not resizing on graph zoom" -- Keep concise — one line + +- Remove issue tracker prefixes (e.g. `GLSP-1234:`, `GH-123:`, `ISSUE-456:`) +- Remove conventional commit prefixes: `fix:`, `feat:`, `chore:` +- Rephrase bug-report style to changelog style: + - BAD: "Edit label UI does not resize on graph zoom" + - GOOD: "Fix edit label UI not resizing on graph zoom" +- Keep concise — one line **Breaking changes:** -- Describe what changed, why it's breaking, and how to migrate -- Extract migration sub-items from the PR body's "What it does" section + +- Describe what changed, why it's breaking, and how to migrate +- Extract migration sub-items from the PR body's "What it does" section --- @@ -136,13 +148,15 @@ Pick the most fitting tag from the vocabulary (Step 1.2) based on PR title and b Read `CHANGELOG.md` in the repository root. **Active section detection:** -- An active section has the `- active` suffix (e.g. `## v2.7.0 - active`) -- If an active section exists → merge new entries into it -- If the topmost section is a released version (no `- active` suffix) → create a new active section above it + +- An active section has the `- active` suffix (e.g. `## v2.7.0 - active`) +- If an active section exists → merge new entries into it +- If the topmost section is a released version (no `- active` suffix) → create a new active section above it **Creating a new section:** -- Bump the minor version of `LAST_TAG` (e.g. `v2.6.0` → `v2.7.0`) -- Insert after the title line, before the first `## ` heading: + +- Bump the minor version of `LAST_TAG` (e.g. `v2.6.0` → `v2.7.0`) +- Insert after the title line, before the first `## ` heading: ```markdown ## v2.7.0 - active @@ -160,9 +174,10 @@ Read `CHANGELOG.md` in the repository root. Only include "Potentially Breaking Changes" if there are breaking entries. **Merging into existing active section:** -- Check PR numbers against existing entries to avoid duplicates -- Append new entries to the appropriate subsection -- Create missing subsections as needed + +- Check PR numbers against existing entries to avoid duplicates +- Append new entries to the appropriate subsection +- Create missing subsections as needed ### Step 2.2: Write the updated CHANGELOG.md @@ -185,13 +200,14 @@ Present the diff to the user. List any entries flagged as uncertain (category or breaking status) and ask the user to resolve them. Even if nothing is uncertain, ask: + > "Does everything look correct, or would you like to adjust any entries?" ### Step 3.3: Collect user feedback -- **Approve as-is** → done (or proceed to Phase 4 if PR was requested) -- **Request edits** → apply changes, show updated diff, ask again -- **Resolve uncertain items** → apply, show updated diff +- **Approve as-is** → done (or proceed to Phase 4 if PR was requested) +- **Request edits** → apply changes, show updated diff, ask again +- **Resolve uncertain items** → apply, show updated diff **Do NOT proceed to Phase 4 unless the user explicitly requests a PR.** @@ -200,11 +216,13 @@ Even if nothing is uncertain, ask: ## Phase 4: PR Creation (Optional) Only execute this phase if the user explicitly requests a PR (either in their initial prompt or after reviewing the changelog). If the user hasn't mentioned a PR, ask after approval: + > "Would you like me to create a PR for this changelog update, or are you done?" ### Step 4.1: Determine the branch name Check if `changelog-update` exists on remote: + ```bash git ls-remote --heads origin changelog-update ``` @@ -229,6 +247,7 @@ Report the PR URL. ### Step 4.4: Label referenced PRs Add the `changelog` label to every PR mentioned in the new entries: + ```bash gh pr edit PR_NUMBER --add-label "changelog" ``` diff --git a/eslint.config.mjs b/eslint.config.mjs index 6ef8bd8..ccac3d2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -22,6 +22,11 @@ export default [ rules: { '@typescript-eslint/no-shadow': 'off', '@typescript-eslint/padding-line-between-statements': 'off', + // The MCP SDK uses `exports` subpath patterns with explicit `.js` suffixes (e.g. + // `@modelcontextprotocol/sdk/server/mcp.js`). The TypeScript import resolver does + // not match these against the `./*` wildcard, even though tsc and Node resolve + // them correctly at compile- and runtime. + 'import-x/no-unresolved': ['error', { ignore: ['^@modelcontextprotocol/sdk/'] }], 'no-restricted-imports': [ 'warn', { diff --git a/examples/workflow-server/package.json b/examples/workflow-server/package.json index 645d206..9307c94 100644 --- a/examples/workflow-server/package.json +++ b/examples/workflow-server/package.json @@ -58,6 +58,7 @@ "dependencies": { "@eclipse-glsp/layout-elk": "2.7.0-next", "@eclipse-glsp/server": "2.7.0-next", + "@eclipse-glsp/server-mcp": "2.7.0-next", "inversify": "^6.1.3" }, "devDependencies": { diff --git a/examples/workflow-server/src/common/graph-extension.ts b/examples/workflow-server/src/common/graph-extension.ts index 2b684e8..e6ba108 100644 --- a/examples/workflow-server/src/common/graph-extension.ts +++ b/examples/workflow-server/src/common/graph-extension.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022-2023 STMicroelectronics and others. + * Copyright (c) 2022-2026 STMicroelectronics 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 @@ -86,7 +86,7 @@ export class TaskNodeBuilder extends GNodeBuilder protected createCompartmentHeader(): GLabel { return new GLabelBuilder(GLabel) .type(ModelTypes.LABEL_HEADING) - .id(this.proxy.id + '_classname') + .id(this.proxy.id + '_label') .text(this.proxy.name) .build(); } @@ -151,7 +151,7 @@ export class CategoryNodeBuilder extends Activity protected createCompartmentHeader(): GLabel { return new GLabelBuilder(GLabel) .type(ModelTypes.LABEL_HEADING) - .id(this.proxy.id + '_classname') + .id(this.proxy.id + '_label') .text(this.proxy.name) .build(); } diff --git a/examples/workflow-server/src/common/handler/create-decision-node-handler.ts b/examples/workflow-server/src/common/handler/create-decision-node-handler.ts index 1bfa574..356fbb9 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 @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022-2024 STMicroelectronics and others. + * Copyright (c) 2022-2026 STMicroelectronics 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 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..64baf54 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 @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022-2024 STMicroelectronics and others. + * Copyright (c) 2022-2026 STMicroelectronics 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 diff --git a/examples/workflow-server/src/common/mcp/workflow-element-types-provider.ts b/examples/workflow-server/src/common/mcp/workflow-element-types-provider.ts new file mode 100644 index 0000000..19696e6 --- /dev/null +++ b/examples/workflow-server/src/common/mcp/workflow-element-types-provider.ts @@ -0,0 +1,54 @@ +/******************************************************************************** + * 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 { ElementTypeEntry, ElementTypes, ElementTypesProvider } from '@eclipse-glsp/server-mcp'; +import { injectable } from 'inversify'; +import { ModelTypes } from '../util/model-types'; + +const NODE_TYPES: ElementTypeEntry[] = [ + { id: ModelTypes.AUTOMATED_TASK, label: 'Automated Task', description: 'Task without human input', acceptsText: true }, + { id: ModelTypes.MANUAL_TASK, label: 'Manual Task', description: 'Task done by a human', acceptsText: true }, + { id: ModelTypes.JOIN_NODE, label: 'Join Node', description: 'Gateway that merges parallel flows', acceptsText: false }, + { id: ModelTypes.FORK_NODE, label: 'Fork Node', description: 'Gateway that splits into parallel flows', acceptsText: false }, + { id: ModelTypes.MERGE_NODE, label: 'Merge Node', description: 'Gateway that merges alternative flows', acceptsText: false }, + { id: ModelTypes.DECISION_NODE, label: 'Decision Node', description: 'Gateway that splits into alternative flows', acceptsText: false }, + { id: ModelTypes.CATEGORY, label: 'Category', description: 'Container node that groups other elements', acceptsText: true } +]; + +const EDGE_TYPES: ElementTypeEntry[] = [ + { id: DefaultTypes.EDGE, label: 'Edge', description: 'Standard control flow edge', acceptsText: false }, + { + id: ModelTypes.WEIGHTED_EDGE, + label: 'Weighted Edge', + description: 'Edge that indicates a weighted probability. Typically used with a Decision Node.', + acceptsText: false + } +]; + +/** + * Workflow-specific {@link ElementTypesProvider}. Returns the constant set of creatable types + * with richer LLM-facing fields (`description`, `acceptsText`) than the default registry-scrape + * impl can infer. Bound on the workflow MCP diagram module via `bindElementTypesProvider()`; + * the standard `element-types` tool handler exposes the full entries via `structuredContent` + * (with a short summary in the text content) — no handler rebind needed. + */ +@injectable() +export class WorkflowElementTypesProvider implements ElementTypesProvider { + get(): ElementTypes { + return { nodeTypes: NODE_TYPES, edgeTypes: EDGE_TYPES }; + } +} diff --git a/examples/workflow-server/src/common/mcp/workflow-mcp-label-provider.ts b/examples/workflow-server/src/common/mcp/workflow-mcp-label-provider.ts new file mode 100644 index 0000000..8b2ef55 --- /dev/null +++ b/examples/workflow-server/src/common/mcp/workflow-mcp-label-provider.ts @@ -0,0 +1,37 @@ +/******************************************************************************** + * 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 { DefaultMcpLabelProvider } from '@eclipse-glsp/server-mcp'; +import { injectable } from 'inversify'; +import { ModelTypes } from '../util/model-types'; + +/** + * Workflow-specific {@link DefaultMcpLabelProvider}: category labels are nested inside a + * compartment-header child rather than directly under the category, so the default + * direct-child lookup misses them. Other workflow element types fall through to the + * inherited default. + */ +@injectable() +export class WorkflowMcpLabelProvider extends DefaultMcpLabelProvider { + override getLabel(element: GModelElement): GLabel | undefined { + if (element.type === ModelTypes.CATEGORY) { + const header = element.children.find(child => child.type === ModelTypes.COMP_HEADER); + return header?.children.find((child): child is GLabel => child instanceof GLabel); + } + return super.getLabel(element); + } +} 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..2c509dc --- /dev/null +++ b/examples/workflow-server/src/common/mcp/workflow-mcp-model-serializer.ts @@ -0,0 +1,187 @@ +/******************************************************************************** + * 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 { Dimension, DefaultTypes } from '@eclipse-glsp/server'; +import { MarkdownMcpModelSerializer, SerializedElement } from '@eclipse-glsp/server-mcp'; +import { injectable } from 'inversify'; +import { ModelTypes } from '../util/model-types'; + +/** + * As compared to the {@link MarkdownMcpModelSerializer}, 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 MarkdownMcpModelSerializer { + override prepareElement(element: GModelElement): Record { + // Pass the live parent's id so the input element gets `parentId` set even when the + // spread inside `flattenStructure` doesn't carry the (typically non-enumerable) `parent` + // reference. Without this, `query-elements` inspect on a single leaf drops `parentId`, + // creating an inconsistency with `diagram-model`. + const elements = this.flattenStructure(element as unknown as SerializedElement, element.parent?.id); + + // Define the order of keys + const result: Record = { + [DefaultTypes.GRAPH]: [], + [ModelTypes.CATEGORY]: [], + [ModelTypes.AUTOMATED_TASK]: [], + [ModelTypes.MANUAL_TASK]: [], + [ModelTypes.FORK_NODE]: [], + [ModelTypes.JOIN_NODE]: [], + [ModelTypes.DECISION_NODE]: [], + [ModelTypes.MERGE_NODE]: [], + [DefaultTypes.EDGE]: [], + [ModelTypes.WEIGHTED_EDGE]: [] + }; + elements.forEach(serialized => { + this.combinePositionAndSize(serialized); + + const adjustedElement = this.adjustElement(serialized); + if (!adjustedElement) { + return; + } + + const type = serialized.type; + if (typeof type === 'string' && result[type]) { + result[type].push(adjustedElement); + } + }); + + return result; + } + + private adjustElement(element: SerializedElement): SerializedElement | undefined { + const type = element.type; + switch (type) { + case ModelTypes.AUTOMATED_TASK: + case ModelTypes.MANUAL_TASK: { + const children = Array.isArray(element.children) ? (element.children as SerializedElement[]) : []; + const label = children.find(child => child.type === ModelTypes.LABEL_HEADING); + if (!label || !Dimension.is(label.size)) { + return undefined; + } + + // 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) + }; + + // If the task lives inside a `STRUCTURE` (a layout-only wrapper), report the + // structure's parent as the logical parent. Falls back to the direct `parentId` + // when the task is at the root or the structure has no parent. + const liveParent = (element as { parent?: GModelElement }).parent; + const parentId = liveParent?.type === ModelTypes.STRUCTURE ? liveParent.parent?.id ?? element.parentId : element.parentId; + return { + id: element.id, + type, + position: element.position, + size: element.size, + bounds: element.bounds, + label: label.text, + labelSize, + parentId + }; + } + case ModelTypes.CATEGORY: { + const children = Array.isArray(element.children) ? (element.children as SerializedElement[]) : []; + const header = children.find(child => child.type === ModelTypes.COMP_HEADER); + const headerChildren = header && Array.isArray(header.children) ? (header.children as SerializedElement[]) : []; + const label = headerChildren.find(child => child.type === ModelTypes.LABEL_HEADING); + if (!label || !Dimension.is(label.size)) { + return undefined; + } + + const labelSize = { + width: Math.trunc(label.size.width + 20), + height: Math.trunc(label.size.height + 20) + }; + + // `combinePositionAndSize` (in MarkdownMcpModelSerializer) omits `size`/`bounds` + // when an element has no explicit geometry yet, so the LLM doesn't try to "fix" + // placeholder bounds. Categories normally do have an explicit size in the model, + // but if a freshly created (or otherwise unsized) category lands here, skip the + // derived `usableSpaceSize` rather than crashing on `element.size.width`. + const usableSpaceSize = Dimension.is(element.size) + ? { + width: Math.trunc(Math.max(0, element.size.width - 10)), + height: Math.trunc(Math.max(0, element.size.height - labelSize.height - 10)) + } + : undefined; + + return { + id: element.id, + type, + isContainer: true, + position: element.position, + size: element.size, + bounds: element.bounds, + label: label.text, + labelSize, + usableSpaceSize, + parentId: element.parentId + }; + } + case ModelTypes.JOIN_NODE: + case ModelTypes.MERGE_NODE: + case ModelTypes.DECISION_NODE: + case ModelTypes.FORK_NODE: { + return { + id: element.id, + type, + position: element.position, + size: element.size, + bounds: element.bounds, + parentId: element.parentId + }; + } + case DefaultTypes.EDGE: { + return { + id: element.id, + type, + sourceId: element.sourceId, + targetId: element.targetId, + parentId: element.parentId + }; + } + case ModelTypes.WEIGHTED_EDGE: { + return { + id: element.id, + type, + sourceId: element.sourceId, + targetId: element.targetId, + probability: element.probability, + parentId: element.parentId + }; + } + case DefaultTypes.GRAPH: { + return { + id: element.id, + type, + isContainer: true + }; + } + default: + return undefined; + } + } +} diff --git a/examples/workflow-server/src/common/mcp/workflow-mcp-module.ts b/examples/workflow-server/src/common/mcp/workflow-mcp-module.ts new file mode 100644 index 0000000..436b9f5 --- /dev/null +++ b/examples/workflow-server/src/common/mcp/workflow-mcp-module.ts @@ -0,0 +1,54 @@ +/******************************************************************************** + * 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 { BindingTarget } from '@eclipse-glsp/server'; +import { + DefaultMcpDiagramModule, + DefaultMcpServerModule, + ElementTypesProvider, + McpLabelProvider, + McpModelSerializer +} from '@eclipse-glsp/server-mcp'; +import { WorkflowElementTypesProvider } from './workflow-element-types-provider'; +import { WorkflowMcpLabelProvider } from './workflow-mcp-label-provider'; +import { WorkflowMcpModelSerializer } from './workflow-mcp-model-serializer'; + +/** + * Workflow-specific server-scope MCP module. Currently no server-scope customizations beyond + * the defaults — every diagram-type-specific override lives on {@link WorkflowMcpDiagramModule}. + * Kept as the named entry point and as a hook for future server-scope extensions. + */ +export class WorkflowMcpServerModule extends DefaultMcpServerModule {} + +/** + * Workflow-specific diagram-scope MCP module — swaps in the workflow-aware + * {@link McpLabelProvider}, {@link McpModelSerializer}, and {@link ElementTypesProvider}. + * `LayoutMcpToolHandler` ships in the default tool set and self-skips when no `LayoutEngine` + * is bound, so workflow doesn't need to add it explicitly. + */ +export class WorkflowMcpDiagramModule extends DefaultMcpDiagramModule { + protected override bindLabelProvider(): BindingTarget { + return WorkflowMcpLabelProvider; + } + + protected override bindModelSerializer(): BindingTarget { + return WorkflowMcpModelSerializer; + } + + protected override bindElementTypesProvider(): BindingTarget { + return WorkflowElementTypesProvider; + } +} diff --git a/examples/workflow-server/src/common/workflow-diagram-module.ts b/examples/workflow-server/src/common/workflow-diagram-module.ts index 8cfdc82..06d5058 100644 --- a/examples/workflow-server/src/common/workflow-diagram-module.ts +++ b/examples/workflow-server/src/common/workflow-diagram-module.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022-2023 STMicroelectronics and others. + * Copyright (c) 2022-2026 STMicroelectronics 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 @@ -21,7 +21,7 @@ import { ContextMenuItemProvider, DiagramConfiguration, EdgeCreationChecker, - GLSPServer, + GLSPServerInitializer, GModelDiagramModule, InstanceMultiBinding, LabelEditValidator, @@ -57,13 +57,13 @@ import { TaskEditContextActionProvider } from './taskedit/task-edit-context-prov import { TaskEditValidator } from './taskedit/task-edit-validator'; import { WorkflowDiagramConfiguration } from './workflow-diagram-configuration'; import { WorkflowEdgeCreationChecker } from './workflow-edge-creation-checker'; -import { WorkflowGLSPServer } from './workflow-glsp-server'; +import { CustomArgsInitContribution } from './workflow-glsp-server'; import { WorkflowPopupFactory } from './workflow-popup-factory'; @injectable() export class WorkflowServerModule extends ServerModule { - protected override bindGLSPServer(): BindingTarget { - return WorkflowGLSPServer; + protected override configureGLSPServerInitializers(binding: MultiBinding): void { + binding.add(CustomArgsInitContribution); } } diff --git a/examples/workflow-server/src/common/workflow-glsp-server.ts b/examples/workflow-server/src/common/workflow-glsp-server.ts index aa7579a..62bf837 100644 --- a/examples/workflow-server/src/common/workflow-glsp-server.ts +++ b/examples/workflow-server/src/common/workflow-glsp-server.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022-2023 STMicroelectronics and others. + * Copyright (c) 2022-2026 STMicroelectronics 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 @@ -13,25 +13,32 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { Args, ArgsUtil, DefaultGLSPServer, InitializeResult, Logger, MaybePromise } from '@eclipse-glsp/server'; +import { + ArgsUtil, + GLSPServer, + GLSPServerInitializer, + InitializeParameters, + InitializeResult, + Logger, + MaybePromise +} from '@eclipse-glsp/server'; import { inject, injectable } from 'inversify'; @injectable() -export class WorkflowGLSPServer extends DefaultGLSPServer { +export class CustomArgsInitContribution implements GLSPServerInitializer { MESSAGE_KEY = 'message'; TIMESTAMP_KEY = 'timestamp'; - @inject(Logger) - protected override logger: Logger; + @inject(Logger) protected logger: Logger; - protected override handleInitializeArgs(result: InitializeResult, args: Args | undefined): MaybePromise { - if (!args) { + initializeServer(server: GLSPServer, params: InitializeParameters, result: InitializeResult): MaybePromise { + if (!params.args) { return result; } - const timestamp = ArgsUtil.get(args, this.TIMESTAMP_KEY); - const message = ArgsUtil.get(args, this.MESSAGE_KEY); + const timestamp = ArgsUtil.get(params.args, this.TIMESTAMP_KEY); + const message = ArgsUtil.get(params.args, this.MESSAGE_KEY); - this.logger.debug(`${timestamp}: ${message}`); + this.logger.info(`${timestamp}: ${message}`); return result; } } diff --git a/examples/workflow-server/src/node/app.ts b/examples/workflow-server/src/node/app.ts index 1bc2bfd..8555883 100644 --- a/examples/workflow-server/src/node/app.ts +++ b/examples/workflow-server/src/node/app.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022-2024 STMicroelectronics and others. + * Copyright (c) 2022-2026 STMicroelectronics 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 @@ -16,10 +16,11 @@ import 'reflect-metadata'; import { configureELKLayoutModule } from '@eclipse-glsp/layout-elk'; -import { createAppModule, GModelStorage, Logger, SocketServerLauncher, WebSocketServerLauncher } from '@eclipse-glsp/server/node'; +import { GModelStorage, Logger, SocketServerLauncher, WebSocketServerLauncher, createAppModule } from '@eclipse-glsp/server/node'; import { Container } from 'inversify'; import { WorkflowLayoutConfigurator } from '../common/layout/workflow-layout-configurator'; +import { WorkflowMcpDiagramModule, WorkflowMcpServerModule } from '../common/mcp/workflow-mcp-module'; import { WorkflowDiagramModule, WorkflowServerModule } from '../common/workflow-diagram-module'; import { createWorkflowCliParser } from './workflow-cli-parser'; @@ -39,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 serverModule = new WorkflowServerModule().configureDiagramModule( + new WorkflowDiagramModule(() => GModelStorage), + elkLayoutModule, + new WorkflowMcpDiagramModule() + ); + const mcpServerModule = new WorkflowMcpServerModule(); if (options.webSocket) { const launcher = appContainer.resolve(WebSocketServerLauncher); - launcher.configure(serverModule); + launcher.configure(serverModule, mcpServerModule); await launcher.start({ port: options.port, host: options.host, path: 'workflow' }); } else { const launcher = appContainer.resolve(SocketServerLauncher); - launcher.configure(serverModule); + launcher.configure(serverModule, mcpServerModule); await launcher.start({ port: options.port, host: options.host }); } } diff --git a/examples/workflow-server/tsconfig.json b/examples/workflow-server/tsconfig.json index f1c4e83..a82e1ff 100644 --- a/examples/workflow-server/tsconfig.json +++ b/examples/workflow-server/tsconfig.json @@ -12,6 +12,9 @@ { "path": "../../packages/server" }, + { + "path": "../../packages/server-mcp" + }, { "path": "../../packages/layout-elk" } diff --git a/packages/graph/src/gedge.ts b/packages/graph/src/gedge.ts index 945c409..ebc3e36 100644 --- a/packages/graph/src/gedge.ts +++ b/packages/graph/src/gedge.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022 STMicroelectronics and others. + * Copyright (c) 2022-2026 STMicroelectronics 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 @@ -30,7 +30,7 @@ export class GEdge extends GModelElement { export namespace GEdge { export function is(object: unknown): object is GEdge { - return GEdge.is(object); + return object instanceof GEdge; } } diff --git a/packages/graph/src/gshape-element.ts b/packages/graph/src/gshape-element.ts index 3b949f6..7128982 100644 --- a/packages/graph/src/gshape-element.ts +++ b/packages/graph/src/gshape-element.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022-2024 STMicroelectronics and others. + * Copyright (c) 2022-2026 STMicroelectronics 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 @@ -29,6 +29,12 @@ export abstract class GShapeElement extends GModelElement implements GBoundsAwar [GResizable] = true; } +export namespace GShapeElement { + export function is(object: unknown): object is GShapeElement { + return object instanceof GShapeElement; + } +} + export class GShapeElementBuilder extends GModelElementBuilder { position(x: number, y: number): this; position(position: Point): this; diff --git a/packages/server-mcp/.eslintrc.js b/packages/server-mcp/.eslintrc.js new file mode 100644 index 0000000..7e8a109 --- /dev/null +++ b/packages/server-mcp/.eslintrc.js @@ -0,0 +1,12 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: '../../.eslintrc.js', + rules: { + 'import/no-unresolved': [ + 'error', + { + ignore: ['^@modelcontextprotocol/sdk/'] + } + ] + } +}; diff --git a/packages/server-mcp/ARCHITECTURE.md b/packages/server-mcp/ARCHITECTURE.md new file mode 100644 index 0000000..737d4e3 --- /dev/null +++ b/packages/server-mcp/ARCHITECTURE.md @@ -0,0 +1,332 @@ +# @eclipse-glsp/server-mcp — Architecture & Extension Guide + +> **Status: Experimental.** The MCP integration is under active development. Option names, schema shapes, and handler contracts MAY change in minor releases until the feature graduates from experimental status. See the [README](./README.md) for the integration quickstart. + +## Table of Contents + +- [Architecture](#architecture) + - [Container Scopes](#container-scopes) + - [Session Lifecycle](#session-lifecycle) + - [Supported MCP Features](#supported-mcp-features) +- [Security & Threat Model](#security--threat-model) +- [Diagram-Specific Overrides](#diagram-specific-overrides) +- [Server Configuration](#server-configuration) +- [Deployment Model](#deployment-model) +- [Resource vs. Tool Mode](#resource-vs-tool-mode) +- [ID Aliasing](#id-aliasing) +- [Extending with Custom Handlers](#extending-with-custom-handlers) + +--- + +## Architecture + +A GLSP server process creates a GLSP server per connecting application, and each GLSP server runs its own MCP HTTP server. In typical desktop-IDE deployments only one app connects per process, so "one process, one MCP server" holds in practice; architecturally the GLSP server is still the unit. + +Two session terminologies coexist and are kept distinct in code: + +- **GLSP client session** — one open diagram, identified by `clientSessionId`. +- **MCP session** — one MCP client (e.g., an editor's chat panel) connected to the HTTP endpoint, identified by the MCP protocol's `mcp-session-id` header. + +The two have independent lifetimes. The same MCP client can talk to multiple GLSP client sessions through one MCP HTTP connection. + +```mermaid +%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 35}, 'themeVariables': {'fontSize': '12px'}}}%% +graph TB + proc["GLSP server process"] + glsp["GLSP Server
(0..N — one per connecting app)"] + cs["GLSP client sessions
(0..N per server,
id: clientSessionId)"] + mcp["MCP Server
(0..1 per GLSP server —
created on init w/ mcpServer)"] + ms["MCP sessions
(0..N per server,
id: mcp-session-id)"] + + proc -->|creates per app| glsp + glsp -->|hosts| cs + glsp -->|spawns| mcp + mcp -->|hosts| ms + ms -. "call (sessionId)" .-> cs +``` + +### Container Scopes + +Handlers and services are bound across two Inversify container scopes: + +| Scope | Lifetime | Conceptually | Bound via | +| ----------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| **Server** | Lives as long as the GLSP process | The MCP HTTP server, transport, and server-scope handlers (singleton across all diagrams). | `DefaultMcpServerModule` | +| **GLSP client session** | Lives as long as one open diagram | Per-diagram services (id alias, model serializer, element-type provider, …) and per-session handler instances. | `DefaultMcpDiagramModule` (mounted inside the GLSP-server-core `configureDiagramModule()`) | + +The split matters: server-scope services are singletons; client-session-scope services give each open diagram its own instance, which is why aliased IDs in one session can't bleed into another and why per-session handlers see "their" model state directly. + +### Session Lifecycle + +The GLSP-server, GLSP-client-session, and MCP-session lifetimes are independent. A GLSP client session can outlive the MCP client that triggered work in it; an MCP client can survive the close of any single GLSP client session. + +```mermaid +%%{init: {'sequence': {'actorFontSize': 12, 'messageFontSize': 12, 'noteFontSize': 12}, 'themeVariables': {'fontSize': '12px'}}}%% +sequenceDiagram + participant GLSPClient as GLSP client + participant Server as GLSP server process + participant MCPClient as MCP client + + Note over Server: server starts
(MCP off) + GLSPClient->>Server: initialize { mcpServer: {...} } + Note over Server: MCP HTTP transport boots,
URL announced via stdout marker + InitializeResult + + MCPClient->>Server: connect + Note over Server: assign mcp-session-id,
register tools/resources/prompts + + GLSPClient->>Server: open diagram + Note over Server: GLSP client session
scope created + + MCPClient->>Server: tool/resource call (sessionId) + Server-->>MCPClient: result via per-session handler + + GLSPClient->>Server: close diagram + Note over Server: GLSP client session
scope disposed + + MCPClient->>Server: disconnect + Note over Server: MCP-session disposed +``` + +### Supported MCP Features + +This package targets the **MCP `2025-06-18` specification** (revision pinned by `@modelcontextprotocol/sdk` `^1.29.0`). The matrix below tracks which spec features are exercised, declared, or deliberately deferred. + +| Feature | Status | Notes | +| ---------------------------------------------------------------------------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Tools** (`tools/list`, `tools/call`) | Supported | Default tool set covers diagram inspection and modification; adopters extend. | +| **Tool annotations** (`destructiveHint`, `readOnlyHint`, `idempotentHint`, `openWorldHint`, `title`) | Supported | Per-handler honest defaults; flat-field surface on the handler base. | +| **Tool `outputSchema` / `structuredContent`** | Supported | Dual-emit per-handler `outputSchema` + structured payload, forwarded to `CallToolResult.structuredContent`. | +| **Resources** (`resources/list`, `resources/read`, `resources/templates/list`, `resources/complete`) | Supported | Templated URIs supported via `{sessionId}` / `{diagramType}` placeholders. URI identity is kept only where it pays off (e.g., embeddable image content); other read endpoints ship as plain tools — see [Resource vs. Tool Mode](#resource-vs-tool-mode). | +| **Prompts** (`prompts/list`, `prompts/get`) | Supported | Infrastructure plus a couple of exemplar prompts; adopters extend. | +| **Logging** (`logging/setLevel`, `notifications/message`) | Supported | Handler-emitted logs route to both the GLSP `Logger` and the connected MCP client. `logging/setLevel` adjusts a per-MCP-session severity threshold (RFC 5424); messages below threshold are dropped on the MCP side, GLSP-side logging keeps everything. | +| **Progress** (`notifications/progress`) | Supported | Best-effort beat from server-side handlers. Client must opt in via `_meta.progressToken`; no-op otherwise. | +| **Capability sub-flags** (`listChanged`) | Supported | `resources.listChanged` flips on as soon as diagram-scope resources are bound (catalog mutates with GLSP client session add/remove); `tools` / `prompts` catalogs are fixed at MCP-session-init. | +| **Streamable HTTP transport** with SSE | Supported | `Last-Event-ID` resumability via a bounded LRU event store. | +| **`MCP-Protocol-Version` header validation** | Supported | Middleware validates per-spec on non-initialize requests: absent → pass-through (the server defaults to `2025-03-26`, itself spec-mandated for backwards-compatibility); unsupported → HTTP 400 with JSON-RPC envelope. | +| **Origin / Host validation** | Supported | DNS-rebinding mitigation per spec. | +| **Cancellation** (`notifications/cancelled`) | _Deferred_ | Cancel notifications aren't propagated to running handlers; long-running tools complete before any cancel takes effect. Real adopter need (slow exports, long validations); revisit when a use case surfaces. | +| **`tasks` capability** | _Deferred_ | Spec experimental in `2025-11-25`; effectively zero client support today. Progress notifications cover the same UX gap with universal client support. | +| **`resources/subscribe`** | _Deferred_ | Real feature work — needs subscription registry, model-state change-event bridge, debounce, URI granularity. Not in scope for this branch. | +| **Sampling** (`sampling/createMessage`) | _Not needed_ | This package is the MCP _server_; it doesn't request LLM completions back from the client. | +| **Roots** (`roots/list`) | _Not needed_ | The MCP server has no filesystem-roots concern; diagram source URIs are managed by GLSP core. | + +--- + +## Security & Threat Model + +The shipped defaults assume a **single trusted local process** reaching the MCP server over loopback — the desktop-IDE deployment shape (VS Code Copilot, Theia AI, Claude Desktop). The server is **not** hardened for hostile multi-tenant or public-internet exposure. + +The server binds to loopback only by default, with the spec-mandated `Host`-header validation enabled to defeat DNS rebinding. If an adopter widens the bind beyond loopback without explicitly acknowledging the missing authentication layer, the launcher refuses to start — an opt-in flag exists for the case where an external mechanism (reverse proxy, mTLS, network ACL) authenticates traffic before it reaches the endpoint. + +There is no CORS handling, no authentication, and no per-session connection cap or rate limiting. Combined with the spec-mandated long-lived SSE streams, a misbehaving local client can hold sockets open. This is acceptable for desktop dev tooling; not acceptable beyond loopback. + +SSE event resumability uses an in-memory per-session LRU event store with a configurable cap; sessions and their stores are released on transport close. + +If the deployment shape changes — exposing the server to a network, or allowing untrusted local processes — the adopter must add an authenticating reverse proxy (or equivalent) in front. This package does not provide one. + +--- + +## Diagram-Specific Overrides + +The MCP integration ships drop-in defaults that work on any GLSP model, but three of them produce deliberately generic output and emit a one-time warning when used. Non-workflow adopters get measurably better LLM-driven results by binding diagram-specific subclasses on their `DiagramModule`: + +| Service | Default behavior | Why override | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `McpModelSerializer` | Flattens every attribute and renders one Markdown table per element type. Correct on any GLSP model; loses semantic structure (compartments, ordering). | Preserve type-aware ordering, drop attributes that mean nothing to the LLM, surface domain-relevant grouping. | +| `ElementTypesProvider` | Scrapes `OperationHandlerRegistry` for `createNode_`/`createEdge_` keys. Returns an empty list when adopters use other registration conventions. | Avoid the silent-empty trap when adopter keys don't follow the prefix convention; supply rich `description` / `acceptsText`. | +| `McpLabelProvider` | Returns the first direct `GLabel` child. Misses labels nested inside header/compartment containers. | Reach labels nested inside header/compartment containers — one `getLabel(element)` override fixes every label-aware tool at once. | + +The workflow example (`examples/workflow-server`) ships a workflow-specific serializer and label provider as a reference implementation. + +--- + +## Server Configuration + +The MCP server is configured through the GLSP `InitializeParameters`. **The presence of an `mcpServer` key — even as an empty object `{}` — is the opt-in signal**: with it, the MCP server starts; without it, MCP is disabled. The value's content controls how the server is configured. + +```typescript +// MCP enabled, all defaults +{ ..., mcpServer: {} } + +// MCP enabled, custom port +{ ..., mcpServer: { port: 12345 } } + +// MCP disabled (do not include the key) +{ ... } +``` + +The configuration surface is split along an **init/deploy axis**: + +- **Init-controllable** (table below) — the IDE/MCP-aware GLSP client sets these per `initialize` call. Behavioral and tuning fields whose blast radius is bounded. +- **Deploy-only** — the adopter sets these on the server-module defaults at deploy time. Security-sensitive bind/policy fields are intentionally _not_ reachable from the wire-protocol init payload (e.g., to avoid LLM-driven init payloads widening the network attack surface or weakening the DNS-rebinding mitigation). `port` lives on the init side because pinning a specific port is a legitimate IDE concern with a local blast radius; `host` lives on the deploy side because letting an init payload widen the bind from loopback would re-open the DNS-rebinding attack pattern. + +### Init-controllable options + +| Option | Type | Default | Description | +| ------------------------- | ------------------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `port` | `number` | `0` (random) | Port the MCP HTTP server listens on. The resolved port is reported back via the `InitializeResult` and the stdout marker. See [Deployment Model](#deployment-model) for guidance on random vs. fixed. | +| `route` | `string` | `'/mcp'` | HTTP route path for the MCP endpoint. | +| `name` | `string` | `'glsp'` | Name reported in the MCP server handshake. Adopters typically override (e.g. `'glsp-workflow'`) so multiple GLSP-based MCP servers group together in the IDE's MCP server list. | +| `options.dataMode` | `'resources' \| 'tools'` | `'tools'` | How data handlers are exposed to the MCP client. See [Resource vs. Tool Mode](#resource-vs-tool-mode). | +| `options.agentPersona` | `string` | _built-in_ | The agent-persona instructions sent to connecting MCP clients via the MCP server's `instructions` field. | +| `options.eventStoreLimit` | `number` | `10000` | Maximum SSE events retained per session in the in-memory event store (LRU-evicted). Must exceed the worst-case in-flight event count for `Last-Event-ID`-based resume to work. | + +### Deploy-only options + +Adopters set these on the server module's defaults binding (see [Integrating into a GLSP Server](./README.md#integrating-into-a-glsp-server)). They are sourced _only_ from the adopter defaults — same-named fields supplied via the init payload are ignored. + +| Option | Type | Default | Description | +| ---------------- | ---------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `host` | `string` | `'127.0.0.1'` | Host/interface the MCP HTTP server binds to. Pinned to loopback by default; widening it is an explicit deploy-time decision. | +| `allowedHosts` | `string[]` | `['127.0.0.1', 'localhost']` | Allowlist for the HTTP `Host` header — requests whose `Host` is not on this list get `403 Forbidden`. Spec MUST per the Streamable HTTP transport's DNS-rebinding mitigation; widen only if `host` itself was widened. | +| `allowedOrigins` | `string[]` | — | Allowlist for the HTTP `Origin` header. Leave unset for desktop-IDE MCP clients (which omit `Origin`); set explicitly when fronted by a browser-based MCP client. | + +### Discovering the resolved URL + +Once started, the resolved URL is reported back in the `InitializeResult` under `result.mcpServer`: + +```typescript +interface McpServerResult { + name: string; + type: 'http'; // transport-type discriminator + url: string; // e.g. "http://127.0.0.1:54321/mcp" + headers?: Record; // extension point: a transport subclass that + // adds auth (or an adopter fronting the endpoint with a proxy) populates this + // so AI clients include the required headers when connecting. Empty by default. +} +``` + +The shape aligns with the configuration formats used by IDE-side MCP integrations (e.g., VS Code's `.vscode/mcp.json`, Theia's `RemoteMCPServerDescription`) so the result can be mapped directly into a client-side MCP-client configuration. + +In addition, the server logs a tagged line to stdout when ready, mirroring the GLSP server's own startup announcement: + +``` +[GLSP-MCP-Server]:Ready. {"name":"glsp","url":"http://127.0.0.1:54321/mcp","route":"/mcp"} +``` + +The tag is exported as `MCP_SERVER_READY_MSG` so IDE integrations can parse the line and surface the URL automatically. + +### Transport + +The MCP server uses the **Streamable HTTP transport** (`StreamableHTTPServerTransport` from the MCP SDK). HTTP `POST` carries client → server JSON-RPC; `GET` returns the server → client SSE stream (with `Last-Event-ID` resumability); `DELETE` terminates a session. Sessions are multiplexed on a single endpoint via the `mcp-session-id` header. + +A periodic server-initiated `ping` keeps the SSE GET stream alive across chat-idle periods, so client-side read timeouts (e.g. undici's 5-min `bodyTimeout`) don't force a reconnect cycle. + +--- + +## Deployment Model + +The MCP server runs **inside each GLSP server** (which is per connecting app, not per process). One GLSP server = one MCP server = one TCP port. All diagrams in that GLSP server are surfaced through its MCP server, multiplexed via `clientSessionId`. A process hosting multiple connected apps therefore hosts multiple GLSP servers and multiple MCP servers on distinct ports; typical desktop-IDE deployments connect one app per process, so the 1:1 case is what most adopters see. + +### Single-process scenarios (the common case) + +- **Standalone**: `node app.js` → one GLSP process, one MCP server. +- **VS Code per-window**: each VS Code window spawns its own GLSP process. Within a window, all diagrams share that process. No port conflict. +- **Theia per-frontend**: each Theia frontend has its own GLSP process; same shape. + +### Multi-process: not auto-supported + +If two GLSP processes start on the same machine at the same time and both request the same fixed port, the second one's MCP server fails to bind with `EADDRINUSE`. The error message names the offending host:port and points at the override path. To run multiple GLSP processes concurrently on one machine, the adopter must pass distinct ports per process. We deliberately do **not** ship a cross-process aggregator, a discovery daemon, or a filesystem-based instance directory — the multi-application case is left to adopter configuration. + +A few alternatives were considered and rejected: + +- **Discovery files + standalone aggregator daemon.** Adds a long-lived companion process and a new shipping artifact with its own versioning, lifecycle, and security model — substantial cost for a use case (multi-window-on-one-machine) that's rare in practice. +- **Shared launcher process across applications.** Would require IDE integrations (e.g. the GLSP VS Code integration) to detect-or-spawn rather than always-spawn their GLSP child process. The detect-or-spawn pattern doesn't exist in the upstream integrations today, so this is conditional on prior work landing there. +- **In-process multi-application MCP.** Re-binding the MCP launcher above the per-`GLSPServer` containers so one process could host multiple applications behind one MCP server. Doesn't apply under the current per-window-process deployment, which yields one `GLSPServer` per process — there's nothing to multiplex inside the process. Only meaningful as a follow-up to shared-launcher mode. + +### Choosing a port: random vs. fixed + +Both the GLSP server itself and this MCP server default to **random port allocation** (`port: 0`). The chosen port is announced via the resolved `InitializeResult.mcpServer.url` and the stdout `[GLSP-MCP-Server]:Ready.` marker. + +- **Random (default)** is correct when the IDE integration consumes the resolved URL programmatically (e.g., reads the stdout marker and registers the URL with its native MCP infrastructure). It avoids `EADDRINUSE` entirely. +- **Fixed** is correct when an external MCP client (Claude Desktop, web client, etc.) is configured statically with the URL. The adopter pins `port` per init call so the URL is stable across restarts. + +### Connecting MCP clients + +Two paths, depending on the client class: + +- **IDE-internal MCP clients** (VS Code Copilot chat, Theia AI, etc.) should consume the resolved URL programmatically. The GLSP IDE integration knows it via the `MCP_SERVER_READY_MSG` stdout marker and is best placed to register that URL with the IDE's native MCP infrastructure. _This automatic registration is intended follow-up work in the GLSP VS Code and Theia integrations_ — at the time of writing, adopters wire it up manually using the URL surfaced in `InitializeResult.mcpServer`. +- **External MCP clients** (Claude Desktop, web clients, etc.) are configured separately by the user with a stable URL. For these, pick a fixed port and document it in the adopter's setup guide. + +--- + +## Resource vs. Tool Mode + +`options.dataMode` controls how data handlers are surfaced. Two values: + +- `'tools'` (default) — handlers register as MCP tools. Most in-the-wild MCP clients support tools more reliably than resources, so this is the safer default. +- `'resources'` — handlers register as URI-addressable resources (the spec-aligned form). Use this when the client is known to handle resources well. + +Handlers that don't represent URI-addressable data (text-only reads, all write tools) are always plain tools regardless of `dataMode`. + +Adopters who want their own URI-addressable read endpoints extend the resource handler base and set `toolAlternativeInputSchema` on the handler to also opt into the tool fallback — a GLSP-specific affordance for clients that don't speak resources, not an MCP spec feature. The shipped diagram-render handlers (PNG / SVG) are the canonical examples of this pattern. + +--- + +## ID Aliasing + +Verbose real IDs (UUIDs, structural paths) consume LLM tokens and clutter prompts. The MCP integration replaces them on the wire with short, sequential aliases. The aliasing is **per GLSP client session**, so aliases issued in one session don't bleed into another. + +Resolution is not transparent: handlers convert IDs at the boundary — alias outgoing IDs, look up incoming ones. The mint loop records every real id that flows through `alias()` and skips counter values that would collide, so the convention is self-protecting as long as it's followed. Custom handlers that emit raw real IDs without going through the alias service can re-open a corner case where the alias counter mints, say, `42` for some real id, and a later real model element with id `42` becomes ambiguous on lookup. Keep alias-at-boundary in adopter code. + +A pass-through implementation (no aliasing, useful for diagnostics) ships alongside the default; adopters can swap in their own strategy via the `bindIdAliasService()` hook on the diagram module. + +--- + +## Extending with Custom Handlers + +Handler bases come in three flavors, picked by where the handler lives: + +| Base class | Scope | When to use | +| ----------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------ | +| `AbstractMcpToolHandler` | Server-scope | Tools that don't target a specific diagram session — listing all sessions, querying server-wide state. | +| `AbstractMcpDiagramToolHandler` | Per GLSP client session | Read-style tools that operate on one diagram. Don't dispatch operations. | +| `OperationMcpDiagramToolHandler` | Per GLSP client session | Write-style tools that dispatch a model-mutating GLSP `Operation`. Includes the read-only-mode gate. | + +Resource and prompt handlers follow the same server/diagram split: `AbstractMcpResourceHandler` / `AbstractMcpDiagramResourceHandler`, and `AbstractMcpPromptHandler` / `AbstractMcpDiagramPromptHandler`. + +Adopters declare handler metadata as **fields** (`name`, `description`, `inputSchema`, optionally `outputSchema`/`title`) and implement `createResult(params)` — the framework reads those fields, adopters never override `registerTool` / `registerResource` themselves. The generic `T` describes the parsed input shape; specialize it with `z.infer` for type-safe destructuring. Common per-handler injections include `McpLogger` (routes to both the GLSP `Logger` and the connected MCP client) and `McpProgressReporter` (best-effort beat for long-running tools, no-op when the client doesn't opt in via `_meta.progressToken`). Cross-references in prompt text and tool descriptions reference `OtherHandler.NAME` (the static const on each handler) instead of literal strings, so a future rename is a compile-time error rather than silent rot. + +A minimal example showing the field-driven shape and the binding hook: + +```typescript +@injectable() +export class SessionCountMcpToolHandler extends AbstractMcpToolHandler { + static readonly NAME = 'session-count'; + readonly name = SessionCountMcpToolHandler.NAME; + readonly description = 'Count active GLSP sessions, optionally filtered by diagram type.'; + readonly inputSchema = SessionCountInputSchema; + override readonly outputSchema = SessionCountOutputSchema; + + @inject(ClientSessionManager) protected clientSessionManager: ClientSessionManager; + @inject(McpLogger) protected logger: McpLogger; + + protected createResult({ diagramType }: SessionCountInput): McpToolResult { + const sessions = this.clientSessionManager.getSessions(); + const filtered = diagramType ? sessions.filter(s => s.diagramType === diagramType) : sessions; + this.logger.debug(`session-count → ${filtered.length}`); + return this.success(`${filtered.length} session(s)`, { count: filtered.length, diagramType }); + } +} + +class MyMcpServerModule extends DefaultMcpServerModule { + protected override configureToolHandlers(binding: McpHandlerMultiBinding): void { + super.configureToolHandlers(binding); + binding.add(SessionCountMcpToolHandler); + } +} +``` + +The same pattern carries over to the other handler kinds: + +- **Operation tools** dispatch a GLSP `Operation` from `createResult` and inherit a read-only-mode gate. Set `override readonly destructiveHint = true;` for irreversible operations so MCP clients can warn before invocation. A `requestAction(action, timeoutMs)` helper wraps `RequestAction` round-trips with consistent timeout/error handling. +- **Resources** declare `mimeType` + `uri` (string or templated `{ template: 'glsp://…' }`); the per-session base ships sensible defaults for `list()` and `complete()` covering the common single-resource-per-session case. Setting `toolAlternativeInputSchema` opts the resource into the tool fallback used in `dataMode: 'tools'`. +- **Prompts** declare `argsSchema` and return `messages` from `createResult`. + +For **single-instance services** (model serializer, alias service, …), override the matching `bind*` hook on your module subclass and return the replacement class. For **multi-binding handlers**, use `binding.rebind(StandardHandler, MyHandler)` inside the relevant `configure*Handlers` hook. + +The workflow example (`examples/workflow-server`) is the canonical reference — see `workflow-mcp-module.ts` for the binding shape and the workflow-specific serializer / label provider for override examples. + +> **Note on operations.** server-mcp does **not** ship a generic `apply-operation` tool or a contribution registry that auto-generates tools from operation kinds. Adopters subclass the operation tool base directly. Both alternatives — a freeform tool and an explicit registry — were considered and rejected: freeform is unsafe (the LLM can smuggle arbitrary kinds, no per-op schema, no guardrails), and a registry doesn't earn its abstraction (real operations almost always need alias-id resolution, custom error mapping, or result enrichment, so adopters graduate to handcrafted handlers anyway). + +> **Per-element-type creation hints (roadmap).** Today the create-`*` and modify-`*` tools accept a free-form `args` payload, and per-type guidance flows through `ElementTypeEntry.description`. A declarative `argsShape` so adopters can wire LLM-discoverable per-type creation hints is on the roadmap if a real adopter need surfaces — file an issue if your diagram type would benefit. diff --git a/packages/server-mcp/README.md b/packages/server-mcp/README.md new file mode 100644 index 0000000..6cdcc05 --- /dev/null +++ b/packages/server-mcp/README.md @@ -0,0 +1,57 @@ +# @eclipse-glsp/server-mcp + +> **Status: Experimental.** The MCP integration is under active development. Option names, schema shapes, and handler contracts MAY change in minor releases until the feature graduates from experimental status. Pin the package version in adopter projects; track release notes for breaking changes. + +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. + +## What it provides + +- **Tools** — Read/query and read/write operations to inspect, create, modify, delete, validate, and navigate diagram elements (and the active sessions themselves). +- **Resources** — URI-addressable read-only data; in the default ship-set this is `diagram-png` only (the rendered diagram screenshot, useful as embeddable image content). Other read endpoints ship as plain tools because in-the-wild MCP clients support tools more reliably than resources — see [Resource vs. Tool Mode](./ARCHITECTURE.md#resource-vs-tool-mode). +- **Prompts** — User-invokable templates (slash-command-style) that frame multi-step agent tasks against the diagram. + +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. + +## Installation + +```bash +yarn add @eclipse-glsp/server-mcp +# or +npm install @eclipse-glsp/server-mcp +``` + +## Integrating into a GLSP Server + +Load the MCP container modules in your GLSP server's DI configuration: + +```typescript +import { GModelStorage, WebSocketServerLauncher, createAppModule } from '@eclipse-glsp/server/node'; +import { Container } from 'inversify'; +import { DefaultMcpDiagramModule, DefaultMcpServerModule } from '@eclipse-glsp/server-mcp'; + +const appContainer = new Container(); +appContainer.load(createAppModule(options)); + +// Per-session bindings — must be part of `configureDiagramModule`. +const mcpDiagramModule = new DefaultMcpDiagramModule(); +const serverModule = new MyServerModule().configureDiagramModule(new MyDiagramModule(() => GModelStorage), mcpDiagramModule); + +const launcher = appContainer.resolve(WebSocketServerLauncher); +// Launcher-level bindings — must not be part of `configureDiagramModule`. +launcher.configure(serverModule, new DefaultMcpServerModule()); +``` + +The two modules are deliberately separate because they bind into different container scopes: + +- `DefaultMcpDiagramModule` is mounted inside `configureDiagramModule`, so each `ClientSession.container` gets its own per-session services (`McpIdAliasService`, `McpModelSerializer`, the diagram-scope handler registries). +- `DefaultMcpServerModule` is mounted at the launcher container, so the MCP HTTP server, the option holder, and the server-scope tool/resource handlers live as launcher singletons. + +The MCP server itself is started lazily on the first GLSP `InitializeAction` that carries an `mcpServer` configuration. + +## Further reading + +See [ARCHITECTURE.md](./ARCHITECTURE.md) for the architecture, security model, configuration surface, deployment guidance, and the extension cookbook. The workflow example (`examples/workflow-server`) is the canonical reference for adopter wiring; each shipped handler carries an LLM-facing `description` field that doubles as developer-facing documentation. + +## License + +EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 diff --git a/packages/server-mcp/package.json b/packages/server-mcp/package.json new file mode 100644 index 0000000..1efd5ca --- /dev/null +++ b/packages/server-mcp/package.json @@ -0,0 +1,62 @@ +{ + "name": "@eclipse-glsp/server-mcp", + "version": "2.7.0-next", + "description": "Extension of the GLSP Node Server for the Model Context Protocol", + "keywords": [ + "eclipse", + "graphics", + "diagram", + "modeling", + "visualization", + "glsp", + "diagram editor", + "mcp" + ], + "homepage": "https://www.eclipse.org/glsp/", + "bugs": "https://github.com/eclipse-glsp/glsp/issues", + "repository": { + "type": "git", + "url": "https://github.com/eclipse-glsp/glsp-server-node.git" + }, + "license": "(EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0)", + "author": { + "name": "Eclipse GLSP" + }, + "contributors": [ + { + "name": "Eclipse GLSP Project", + "email": "glsp-dev@eclipse.org", + "url": "https://projects.eclipse.org/projects/ecd.glsp" + } + ], + "main": "lib/index", + "types": "lib/index", + "files": [ + "lib", + "src" + ], + "scripts": { + "build": "tsc -b", + "clean": "rimraf lib *.tsbuildinfo coverage .nyc_output", + "generate:index": "glsp generateIndex src -f -s", + "lint": "eslint --ext .ts,.tsx ./src", + "test": "mocha --config ../../.mocharc \"./src/**/*.spec.?(ts|tsx)\"", + "test:ci": "yarn test --reporter mocha-ctrf-json-reporter", + "test:coverage": "nyc yarn test", + "watch": "tsc -w" + }, + "dependencies": { + "@eclipse-glsp/server": "2.7.0-next", + "@modelcontextprotocol/sdk": "^1.29.0", + "express": "^5.2.1" + }, + "devDependencies": { + "@types/express": "^5.0.6" + }, + "peerDependencies": { + "inversify": "^6.1.3" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/server-mcp/src/index.ts b/packages/server-mcp/src/index.ts new file mode 100644 index 0000000..1b2c22d --- /dev/null +++ b/packages/server-mcp/src/index.ts @@ -0,0 +1,24 @@ +/******************************************************************************** + * Copyright (c) 2025-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 './prompts'; +export * from './resources'; +export * from './server'; +export * from './tools'; +export * from './util'; +// Module classes — exported here (not from `./server`) to avoid circular imports through +// the handler barrels which the modules consume. +export * from './server/mcp-diagram-module'; +export * from './server/mcp-server-module'; diff --git a/packages/server-mcp/src/prompts/handlers/describe-diagram-mcp-prompt-handler.spec.ts b/packages/server-mcp/src/prompts/handlers/describe-diagram-mcp-prompt-handler.spec.ts new file mode 100644 index 0000000..4152994 --- /dev/null +++ b/packages/server-mcp/src/prompts/handlers/describe-diagram-mcp-prompt-handler.spec.ts @@ -0,0 +1,83 @@ +/******************************************************************************** + * 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, NullLogger } from '@eclipse-glsp/server'; +import { expect } from 'chai'; +import { Container, ContainerModule } from 'inversify'; +import { McpPromptResult, McpToolError } from '../../server'; +import { DescribeDiagramArgs, DescribeDiagramMcpPromptHandler } from './describe-diagram-mcp-prompt-handler'; + +/** Build a handler with a {@link ClientSessionManager} stub that returns the supplied session ids. */ +function buildHandler(sessionIds: string[]): DescribeDiagramMcpPromptHandler { + const container = new Container(); + container.load( + new ContainerModule(bind => { + bind(Logger).toConstantValue(new NullLogger()); + bind(ClientSessionManager).toConstantValue({ + getSessions: () => sessionIds.map(id => ({ id })) + } as unknown as ClientSessionManager); + bind(DescribeDiagramMcpPromptHandler).toSelf(); + }) + ); + return container.get(DescribeDiagramMcpPromptHandler); +} + +function callCreateResult(handler: DescribeDiagramMcpPromptHandler, args: DescribeDiagramArgs): McpPromptResult { + // Result type is `MaybePromise` on the abstract base; the concrete handler returns synchronously. + return (handler as unknown as { createResult: (a: DescribeDiagramArgs) => McpPromptResult }).createResult(args); +} + +describe('DescribeDiagramMcpPromptHandler', () => { + it('substitutes an explicit sessionId into the prompt text and emits a single user-role message', () => { + const handler = buildHandler(['unique-session-zzz', 'other-session']); + + const result = callCreateResult(handler, { sessionId: 'unique-session-zzz' }); + + expect(result.messages).to.have.lengthOf(1); + expect(result.messages[0].role).to.equal('user'); + const content = result.messages[0].content as { type: 'text'; text: string }; + expect(content.type).to.equal('text'); + // Use a uniquely-shaped sessionId so this test fails if the value is hardcoded rather than substituted. + expect(content.text).to.include('unique-session-zzz'); + }); + + it('defaults to the single open session when sessionId is omitted', () => { + const handler = buildHandler(['solo-session']); + + const result = callCreateResult(handler, {}); + + const content = result.messages[0].content as { type: 'text'; text: string }; + expect(content.text).to.include('solo-session'); + }); + + it('throws McpToolError listing the open sessions when ambiguous', () => { + const handler = buildHandler(['session-a', 'session-b']); + + expect(() => callCreateResult(handler, {})).to.throw(McpToolError, /Multiple sessions open.*session-a.*session-b/); + }); + + it('throws McpToolError when no sessions are open', () => { + const handler = buildHandler([]); + + expect(() => callCreateResult(handler, {})).to.throw(McpToolError, /No open diagram sessions/); + }); + + it('throws McpToolError when an explicit sessionId does not match any open session', () => { + const handler = buildHandler(['real-session']); + + expect(() => callCreateResult(handler, { sessionId: 'ghost' })).to.throw(McpToolError, /Unknown sessionId: ghost/); + }); +}); diff --git a/packages/server-mcp/src/prompts/handlers/describe-diagram-mcp-prompt-handler.ts b/packages/server-mcp/src/prompts/handlers/describe-diagram-mcp-prompt-handler.ts new file mode 100644 index 0000000..8f19ce7 --- /dev/null +++ b/packages/server-mcp/src/prompts/handlers/describe-diagram-mcp-prompt-handler.ts @@ -0,0 +1,89 @@ +/******************************************************************************** + * 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 } from '@eclipse-glsp/server'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { AbstractMcpPromptHandler, McpPromptResult, resolveActiveSessionId } from '../../server'; +import { + CountElementsMcpToolHandler, + DiagramModelMcpToolHandler, + ElementTypesMcpToolHandler, + QueryElementsMcpToolHandler, + SetSelectionMcpToolHandler, + SetViewMcpToolHandler +} from '../../tools'; + +export const DescribeDiagramArgsSchema = z.object({ + sessionId: z + .string() + .optional() + .describe('GLSP client session id (open diagram). Defaults to the only open session; required when multiple are open.') +}); +export type DescribeDiagramArgs = z.infer; + +/** + * Prompt template that instructs the agent to produce a structured description of a diagram. + * Pre-baked starting point invokable from the MCP-client UI; the agent then orchestrates the + * necessary tool calls on its own. + * + * Server-scope on purpose — the prompt itself has no per-session state, so we avoid forcing + * the user to type a sessionId in the common single-diagram case. + */ +@injectable() +export class DescribeDiagramMcpPromptHandler extends AbstractMcpPromptHandler { + static readonly NAME = 'describe-diagram'; + readonly name = DescribeDiagramMcpPromptHandler.NAME; + override readonly title = 'Describe Diagram'; + readonly description = + 'Produce a structured, skim-friendly description of an open diagram. ' + + 'The agent picks the right tools to gather data (overview, element-type breakdown, structure, notable elements) ' + + 'and writes the description in its own words. ' + + 'Use this as a starting point when the user asks "what does this diagram show?" or wants a documentation-style summary. ' + + '`sessionId` is optional — defaults to the only open session.'; + readonly argsSchema = DescribeDiagramArgsSchema; + + @inject(ClientSessionManager) protected clientSessionManager: ClientSessionManager; + + override referencedToolNames(): string[] { + return [ + CountElementsMcpToolHandler.NAME, + ElementTypesMcpToolHandler.NAME, + DiagramModelMcpToolHandler.NAME, + QueryElementsMcpToolHandler.NAME, + SetSelectionMcpToolHandler.NAME, + SetViewMcpToolHandler.NAME + ]; + } + + protected createResult(args: DescribeDiagramArgs): McpPromptResult { + const sessionId = resolveActiveSessionId(this.clientSessionManager, args.sessionId); + const text = + `Describe the diagram for session \`${sessionId}\`. Include:\n\n` + + `1. **Overview** — diagram type and total element count (use \`${CountElementsMcpToolHandler.NAME}\`).\n` + + `2. **Element-type breakdown** — what kinds of elements are present and how many of each ` + + `(use \`${ElementTypesMcpToolHandler.NAME}\` and \`${CountElementsMcpToolHandler.NAME}\`).\n` + + `3. **Structure** — load the model with \`${DiagramModelMcpToolHandler.NAME}\` and summarize the ` + + `hierarchy and major connections.\n` + + `4. **Notable elements** — call out anything that stands out (unconnected nodes, deeply ` + + `nested groups, missing labels). Use \`${QueryElementsMcpToolHandler.NAME}\` to locate specific cases.\n\n` + + `Keep the description concise and skim-friendly. ` + + `When mentioning an element, prefer its label or type, with the alias appended in parens — e.g. ` + + `"the 'Brew' task (#7)" or "the decision node (#9)" — never the bare alias alone, since aliases mean nothing to the user. ` + + `Use \`${SetSelectionMcpToolHandler.NAME}\` or \`${SetViewMcpToolHandler.NAME} → 'center-on-elements'\` to draw the user's attention.`; + return { messages: [{ role: 'user', content: { type: 'text', text } }] }; + } +} diff --git a/packages/server-mcp/src/prompts/handlers/suggest-improvements-mcp-prompt-handler.spec.ts b/packages/server-mcp/src/prompts/handlers/suggest-improvements-mcp-prompt-handler.spec.ts new file mode 100644 index 0000000..89a7ff3 --- /dev/null +++ b/packages/server-mcp/src/prompts/handlers/suggest-improvements-mcp-prompt-handler.spec.ts @@ -0,0 +1,60 @@ +/******************************************************************************** + * 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, NullLogger } from '@eclipse-glsp/server'; +import { expect } from 'chai'; +import { Container, ContainerModule } from 'inversify'; +import { McpPromptResult } from '../../server'; +import { SuggestImprovementsArgs, SuggestImprovementsMcpPromptHandler } from './suggest-improvements-mcp-prompt-handler'; + +/** Build a handler with a {@link ClientSessionManager} stub that returns the supplied session ids. */ +function buildHandler(sessionIds: string[]): SuggestImprovementsMcpPromptHandler { + const container = new Container(); + container.load( + new ContainerModule(bind => { + bind(Logger).toConstantValue(new NullLogger()); + bind(ClientSessionManager).toConstantValue({ + getSessions: () => sessionIds.map(id => ({ id })) + } as unknown as ClientSessionManager); + bind(SuggestImprovementsMcpPromptHandler).toSelf(); + }) + ); + return container.get(SuggestImprovementsMcpPromptHandler); +} + +function callCreateResult(handler: SuggestImprovementsMcpPromptHandler, args: SuggestImprovementsArgs): McpPromptResult { + return (handler as unknown as { createResult: (a: SuggestImprovementsArgs) => McpPromptResult }).createResult(args); +} + +// The shared session-resolution error paths (no sessions / ambiguous / unknown sessionId) are +// covered by `describe-diagram-mcp-prompt-handler.spec.ts` against the same `AbstractMcpPromptHandler` +// base. This file only verifies the suggest-improvements-specific prompt-template substitution. +describe('SuggestImprovementsMcpPromptHandler', () => { + it('substitutes the resolved sessionId into the suggest-improvements prompt template', () => { + const handler = buildHandler(['unique-session-yyy', 'other-session']); + + const result = callCreateResult(handler, { sessionId: 'unique-session-yyy' }); + + expect(result.messages).to.have.lengthOf(1); + expect(result.messages[0].role).to.equal('user'); + const content = result.messages[0].content as { type: 'text'; text: string }; + expect(content.type).to.equal('text'); + expect(content.text).to.include('unique-session-yyy'); + // Prompt-template marker unique to suggest-improvements (not present in describe-diagram). + // Catches a regression where the wrong template is wired up. + expect(content.text).to.include('must-fix'); + }); +}); diff --git a/packages/server-mcp/src/prompts/handlers/suggest-improvements-mcp-prompt-handler.ts b/packages/server-mcp/src/prompts/handlers/suggest-improvements-mcp-prompt-handler.ts new file mode 100644 index 0000000..6166732 --- /dev/null +++ b/packages/server-mcp/src/prompts/handlers/suggest-improvements-mcp-prompt-handler.ts @@ -0,0 +1,86 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { ClientSessionManager } from '@eclipse-glsp/server'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { AbstractMcpPromptHandler, McpPromptResult, resolveActiveSessionId } from '../../server'; +import { + DiagramModelMcpToolHandler, + QueryElementsMcpToolHandler, + SetSelectionMcpToolHandler, + ValidateDiagramMcpToolHandler +} from '../../tools'; + +export const SuggestImprovementsArgsSchema = z.object({ + sessionId: z + .string() + .optional() + .describe('GLSP client session id (open diagram). Defaults to the only open session; required when multiple are open.') +}); +export type SuggestImprovementsArgs = z.infer; + +/** + * Prompt template that asks the agent to review the diagram and propose concrete improvements + * (validation issues, structural smells, missing labels, etc.). The agent orchestrates the + * tool calls; this prompt only frames the task. + * + * Server-scope on purpose — the prompt itself has no per-session state, so we avoid forcing + * the user to type a sessionId in the common single-diagram case. + */ +@injectable() +export class SuggestImprovementsMcpPromptHandler extends AbstractMcpPromptHandler { + static readonly NAME = 'suggest-improvements'; + readonly name = SuggestImprovementsMcpPromptHandler.NAME; + override readonly title = 'Suggest Diagram Improvements'; + readonly description = + 'Review an open diagram and propose concrete improvements grouped by severity ' + + '(must-fix vs. nice-to-have). The agent runs validation, checks connectivity, looks for unclear labels, ' + + 'and flags structural inconsistencies, then names the specific element ids for each suggestion so the user ' + + 'can act on them. Read-only by intent — the prompt instructs the agent not to modify the diagram, only propose. ' + + '`sessionId` is optional — defaults to the only open session.'; + readonly argsSchema = SuggestImprovementsArgsSchema; + + @inject(ClientSessionManager) protected clientSessionManager: ClientSessionManager; + + override referencedToolNames(): string[] { + return [ + ValidateDiagramMcpToolHandler.NAME, + DiagramModelMcpToolHandler.NAME, + QueryElementsMcpToolHandler.NAME, + SetSelectionMcpToolHandler.NAME + ]; + } + + protected createResult(args: SuggestImprovementsArgs): McpPromptResult { + const sessionId = resolveActiveSessionId(this.clientSessionManager, args.sessionId); + const text = + `Review the diagram for session \`${sessionId}\` and propose concrete improvements. Focus on:\n\n` + + `1. **Validation issues** — run \`${ValidateDiagramMcpToolHandler.NAME}\` and surface any markers.\n` + + `2. **Connectivity** — load the model (\`${DiagramModelMcpToolHandler.NAME}\`) and flag unconnected ` + + `nodes or orphaned subgraphs.\n` + + `3. **Labelling** — use \`${QueryElementsMcpToolHandler.NAME}\` to find nodes that lack a meaningful ` + + `label, and edges with missing or unclear text.\n` + + `4. **Structure** — call out elements whose type or placement looks inconsistent with ` + + `the rest of the diagram.\n\n` + + `Group findings by severity (must-fix vs. nice-to-have). ` + + `When naming elements, prefer their label or type with the alias in parens — e.g. ` + + `"the 'Brew' task (#7)" or "the decision node (#9)" — never the bare alias alone, since aliases mean nothing to the user. ` + + `Point at each suggestion via \`${SetSelectionMcpToolHandler.NAME}\` so the user can navigate. ` + + `Do not modify the diagram — only propose.`; + return { messages: [{ role: 'user', content: { type: 'text', text } }] }; + } +} diff --git a/packages/server-mcp/src/prompts/index.ts b/packages/server-mcp/src/prompts/index.ts new file mode 100644 index 0000000..0a876b8 --- /dev/null +++ b/packages/server-mcp/src/prompts/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 './handlers/describe-diagram-mcp-prompt-handler'; +export * from './handlers/suggest-improvements-mcp-prompt-handler'; diff --git a/packages/server-mcp/src/resources/handlers/diagram-png-mcp-resource-handler.ts b/packages/server-mcp/src/resources/handlers/diagram-png-mcp-resource-handler.ts new file mode 100644 index 0000000..6dc505f --- /dev/null +++ b/packages/server-mcp/src/resources/handlers/diagram-png-mcp-resource-handler.ts @@ -0,0 +1,181 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { ClientActionKinds, isGBoundsAware, RequestExportAction } from '@eclipse-glsp/server'; +import { Role } from '@modelcontextprotocol/sdk/types.js'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { + AbstractMcpDiagramResourceHandler, + McpDiagramScopedInputSchema, + McpMimeType, + McpProgressReporter, + McpResourceContent, + McpResourceUri, + McpToolError +} from '../../server'; + +export const DiagramPngInputSchema = McpDiagramScopedInputSchema.extend({ + scale: z + .number() + .min(0.1) + .max(4) + .optional() + .describe( + "Multiplier applied to the diagram's natural extent (range 0.1..4). Use values >1 for " + + 'sharper-than-default renders, <1 for thumbnails. Ignored when `width` or `height` is supplied.' + ), + width: z + .number() + .int() + .min(1) + .max(8192) + .optional() + .describe('Override the rendered width in pixels. When given alone, the height is derived from the diagram aspect ratio.'), + height: z + .number() + .int() + .min(1) + .max(8192) + .optional() + .describe('Override the rendered height in pixels. When given alone, the width is derived from the diagram aspect ratio.'), + timeoutMs: z + .number() + .int() + .min(100) + .max(60000) + .optional() + .describe('Override the default render timeout in milliseconds (100–60000). Useful for very large diagrams.') +}); +export type DiagramPngInput = z.infer; + +/** Rendering lives on the client — `RequestExportAction('png')` routes to the registered PNG exporter, which returns base64 via `ExportResultAction`. */ +@injectable() +export class DiagramPngMcpResourceHandler extends AbstractMcpDiagramResourceHandler { + /** Default timeout (in ms) used when the call doesn't override `timeoutMs`. Override via subclass + rebind. */ + protected readonly defaultTimeoutMs: number = 5000; + + /** + * Default `scale` multiplier when the caller doesn't pin `width`/`height`/`scale`. `1` means + * "render at the diagram's natural extent" — sharp because the client rasterises the SVG + * at the requested size. Adopters override (e.g. to `2` for a high-DPI deployment) via + * subclass + rebind. + */ + protected readonly defaultScale: number = 1; + + static readonly NAME = 'diagram-png'; + readonly name = DiagramPngMcpResourceHandler.NAME; + override readonly title = 'Diagram Model PNG'; + readonly description = + 'Render the session diagram as a base64-encoded PNG screenshot of its current visible state. ' + + 'Includes all nodes, edges, and their styling — useful when a visual answer would help the agent ' + + '(layout reasoning, confirming a recently-created element looks right, sharing the diagram with the user). ' + + "Defaults to a sharp render at the diagram's natural extent; pass `scale` to multiply, " + + 'or `width`/`height` to pin specific dimensions. ' + + 'Requires a connected frontend client to perform the render and is subject to a timeout if no response arrives. ' + + 'For a structured-text view of the model use `diagram-model` instead; for vector output use `diagram-svg`.'; + readonly mimeType: McpMimeType = 'image/png'; + readonly uri: McpResourceUri = { template: 'glsp://diagrams/{sessionId}/png' }; + /** Both the user (visual artifact) and the assistant (visual reasoning) consume the rendered diagram. */ + override readonly audience: Role[] = ['user', 'assistant']; + /** Useful when relevant, but not always-relevant — clients may skip when text-only reasoning suffices. */ + override readonly priority = 0.6; + // `lastModified` intentionally omitted: we have no cheap, accurate freshness signal for the + // rendered PNG (the model state doesn't track a "rendered at" timestamp), and a stale value + // would mislead clients. + override readonly toolAlternativeInputSchema = DiagramPngInputSchema; + + @inject(McpProgressReporter) protected progress: McpProgressReporter; + + @inject(ClientActionKinds) protected clientActionKinds: Set; + + /** Skip-bind when the GLSP client doesn't speak `RequestExportAction` — every read would otherwise time out. */ + override canRegister(): boolean { + return this.clientActionKinds.has(RequestExportAction.KIND); + } + + protected async createResult({ scale, width, height, timeoutMs }: DiagramPngInput): Promise { + const resolved = this.resolveSize(scale, width, height); + // Best-effort beat for clients that opted in via `_meta.progressToken`; no-ops otherwise. + await this.progress.emit({ progress: 0, message: 'Awaiting client-side PNG render…' }); + const response = await this.requestAction( + RequestExportAction.create('png', { formatOptions: { width: resolved.width, height: resolved.height } }), + timeoutMs ?? this.defaultTimeoutMs + ); + // A misbehaving exporter strategy returning text-encoded data here would silently corrupt the blob; fail loudly instead. + if (response.encoding !== 'base64') { + throw new McpToolError(`PNG export returned unexpected encoding '${response.encoding}'; expected 'base64'.`); + } + return { blob: response.data }; + } + + /** + * Resolve the final render size with this precedence: + * - `width` and `height` both given → use as-is. + * - only one of `width`/`height` given → derive the other from the diagram aspect ratio. + * - neither dimension given → natural × (`scale` ?? {@link defaultScale}). + */ + protected resolveSize( + scale: number | undefined, + width: number | undefined, + height: number | undefined + ): { width: number; height: number } { + if (width !== undefined && height !== undefined) { + return { width, height }; + } + const natural = this.computeNaturalSize(); + if (width !== undefined) { + return { width, height: Math.max(1, Math.round(width * (natural.height / natural.width))) }; + } + if (height !== undefined) { + return { width: Math.max(1, Math.round(height * (natural.width / natural.height))), height }; + } + const factor = scale ?? this.defaultScale; + return { + width: Math.max(1, Math.round(natural.width * factor)), + height: Math.max(1, Math.round(natural.height * factor)) + }; + } + + /** + * Compute the diagram's natural extent as the bounding box of every {@link isGBoundsAware} + * element with both `position` and `size`. Throws when the model has no positioned elements + * — a 1×1 PNG of an empty diagram is technically renderable but useless, and surfacing it + * as a self-correctable error lets the LLM react (e.g. wait for layout, ask the user). + */ + protected computeNaturalSize(): { width: number; height: number } { + let maxX = 0; + let maxY = 0; + for (const id of this.modelState.index.allIds()) { + const element = this.modelState.index.get(id); + if (!element || !isGBoundsAware(element)) { + continue; + } + const position = element.position; + const size = element.size; + if (position && size && size.width > 0 && size.height > 0) { + maxX = Math.max(maxX, position.x + size.width); + maxY = Math.max(maxY, position.y + size.height); + } + } + if (maxX <= 0 || maxY <= 0) { + throw new McpToolError( + 'Diagram has no positioned elements yet — render once after layout completes, or pass `width` / `height` explicitly.' + ); + } + return { width: Math.ceil(maxX), height: Math.ceil(maxY) }; + } +} diff --git a/packages/server-mcp/src/resources/handlers/diagram-svg-mcp-resource-handler.ts b/packages/server-mcp/src/resources/handlers/diagram-svg-mcp-resource-handler.ts new file mode 100644 index 0000000..056609e --- /dev/null +++ b/packages/server-mcp/src/resources/handlers/diagram-svg-mcp-resource-handler.ts @@ -0,0 +1,89 @@ +/******************************************************************************** + * 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 { ClientActionKinds, RequestExportAction } from '@eclipse-glsp/server'; +import { Role } from '@modelcontextprotocol/sdk/types.js'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { + AbstractMcpDiagramResourceHandler, + McpDiagramScopedInputSchema, + McpMimeType, + McpProgressReporter, + McpResourceContent, + McpResourceUri, + McpToolError +} from '../../server'; + +export const DiagramSvgInputSchema = McpDiagramScopedInputSchema.extend({ + timeoutMs: z + .number() + .int() + .min(100) + .max(60000) + .optional() + .describe('Override the default render timeout in milliseconds (100–60000). Useful for very large diagrams.') +}); +export type DiagramSvgInput = z.infer; + +/** + * Vector counterpart of {@link DiagramPngMcpResourceHandler}. Emits the diagram as raw SVG + * markup (text-encoded). No `width`/`height` knobs because SVG is vector — clients (and + * downstream rasterisers) scale it freely. + */ +@injectable() +export class DiagramSvgMcpResourceHandler extends AbstractMcpDiagramResourceHandler { + /** Default timeout (in ms) used when the call doesn't override `timeoutMs`. Override via subclass + rebind. */ + protected readonly defaultTimeoutMs: number = 5000; + + static readonly NAME = 'diagram-svg'; + readonly name = DiagramSvgMcpResourceHandler.NAME; + override readonly title = 'Diagram Model SVG'; + readonly description = + 'Render the session diagram as SVG markup of its current visible state. ' + + 'Vector format — scales without quality loss, ideal for embedding in documentation, ' + + 'further programmatic processing, or post-export rasterisation at arbitrary resolutions. ' + + 'Requires a connected frontend client to perform the render and is subject to a timeout if no response arrives. ' + + 'Use `diagram-png` instead when you need a ready-to-display raster image; ' + + '`diagram-model` is the structured-text alternative for content-only reasoning.'; + readonly mimeType: McpMimeType = 'image/svg+xml'; + readonly uri: McpResourceUri = { template: 'glsp://diagrams/{sessionId}/svg' }; + /** Both the user (visual artifact) and the assistant (visual reasoning) consume the rendered diagram. */ + override readonly audience: Role[] = ['user', 'assistant']; + /** Useful when relevant, but not always-relevant — clients may skip when text-only reasoning suffices. */ + override readonly priority = 0.6; + override readonly toolAlternativeInputSchema = DiagramSvgInputSchema; + + @inject(McpProgressReporter) protected progress: McpProgressReporter; + + @inject(ClientActionKinds) protected clientActionKinds: Set; + + /** Skip-bind when the GLSP client doesn't speak `RequestExportAction`. */ + override canRegister(): boolean { + return this.clientActionKinds.has(RequestExportAction.KIND); + } + + protected async createResult({ timeoutMs }: DiagramSvgInput): Promise { + // Best-effort beat for clients that opted in via `_meta.progressToken`; no-ops otherwise. + await this.progress.emit({ progress: 0, message: 'Awaiting client-side SVG render…' }); + const response = await this.requestAction(RequestExportAction.create('svg'), timeoutMs ?? this.defaultTimeoutMs); + // SVG is text-encoded — base64 encoding here would mean an exporter mis-classified its payload. + if (response.encoding !== 'text') { + throw new McpToolError(`SVG export returned unexpected encoding '${response.encoding}'; expected 'text'.`); + } + return { text: response.data }; + } +} diff --git a/packages/server-mcp/src/resources/index.ts b/packages/server-mcp/src/resources/index.ts new file mode 100644 index 0000000..5ed2451 --- /dev/null +++ b/packages/server-mcp/src/resources/index.ts @@ -0,0 +1,20 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +export * from './handlers/diagram-png-mcp-resource-handler'; +export * from './handlers/diagram-svg-mcp-resource-handler'; +export * from './services/element-types-provider'; +export * from './services/mcp-model-serializer'; diff --git a/packages/server-mcp/src/resources/services/element-types-provider.ts b/packages/server-mcp/src/resources/services/element-types-provider.ts new file mode 100644 index 0000000..1fdcf4a --- /dev/null +++ b/packages/server-mcp/src/resources/services/element-types-provider.ts @@ -0,0 +1,108 @@ +/******************************************************************************** + * 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 { CreateEdgeOperation, CreateNodeOperation } from '@eclipse-glsp/protocol'; +import { + CreateEdgeOperationHandler, + CreateNodeOperationHandler, + CreateOperationHandler, + Logger, + OperationHandlerRegistry +} from '@eclipse-glsp/server'; +import { inject, injectable } from 'inversify'; + +/** + * A discoverable creatable element type emitted by the `element-types` tool handler. + * + * Required fields (`id`, `label`) are produced by every provider, including the default + * registry-scrape impl. The optional fields are populated by adopter-specific providers that + * have richer semantic info — `description` adds an LLM-facing explanation, `acceptsText` + * tells the LLM whether the `text` arg on create-* / modify-* tools is meaningful for this + * type. Custom adopters MAY add further passthrough fields beyond these (the schema is + * `loose()` on the tool handler's structured output). + */ +export interface ElementTypeEntry { + id: string; + /** Human-readable display name for the element TYPE (e.g. `Manual Task`). */ + label: string; + /** Adopter-supplied human-readable description; absent on the default scrape impl. */ + description?: string; + /** Whether create-* / modify-* tools should pass a `text` arg for this type. Absent ⇒ unknown. */ + acceptsText?: boolean; +} + +/** Node + edge types creatable in a given diagram type. */ +export interface ElementTypes { + nodeTypes: ElementTypeEntry[]; + edgeTypes: ElementTypeEntry[]; +} + +/** + * Per-diagram-type provider of creatable element types. Bound on the diagram (per-GLSP-session) + * container so each diagram type can supply its own list. Adopters with explicit type info + * typically rebind to a constant-value provider; the default scrapes + * {@link OperationHandlerRegistry} for backwards compatibility with adopters that haven't + * declared their types yet. + * + * @experimental + */ +export interface ElementTypesProvider { + get(): ElementTypes; +} +export const ElementTypesProvider = Symbol('ElementTypesProvider'); + +/** + * Default {@link ElementTypesProvider} that scrapes the per-session {@link OperationHandlerRegistry} + * for {@link CreateNodeOperationHandler} / {@link CreateEdgeOperationHandler} instances and reads + * their `elementTypeIds`. + */ +@injectable() +export class DefaultElementTypesProvider implements ElementTypesProvider { + @inject(OperationHandlerRegistry) protected operationHandlerRegistry: OperationHandlerRegistry; + + @inject(Logger) protected logger: Logger; + + /** Already-warned operation types, deduped so the warn fires once per type. */ + protected readonly warnedTypes = new Set(); + + get(): ElementTypes { + const nodeTypes: ElementTypeEntry[] = []; + const edgeTypes: ElementTypeEntry[] = []; + for (const handler of this.operationHandlerRegistry.getAll()) { + if (CreateNodeOperationHandler.is(handler)) { + handler.elementTypeIds.forEach(id => nodeTypes.push({ id, label: handler.label })); + } else if (CreateEdgeOperationHandler.is(handler)) { + handler.elementTypeIds.forEach(id => edgeTypes.push({ id, label: handler.label })); + } else if (CreateOperationHandler.is(handler)) { + this.warnUnrecognizedOperationType(handler); + } + } + return { nodeTypes, edgeTypes }; + } + + /** Once-per-type warn when a `CreateOperationHandler` carries an unrecognized `operationType`. */ + protected warnUnrecognizedOperationType(handler: CreateOperationHandler): void { + const operationType = handler.operationType; + if (this.warnedTypes.has(operationType)) { + return; + } + this.warnedTypes.add(operationType); + this.logger.warn( + `DefaultElementTypesProvider: ignoring CreateOperationHandler with operationType '${operationType}' — ` + + `expected '${CreateNodeOperation.KIND}' or '${CreateEdgeOperation.KIND}'. Rebind ElementTypesProvider for custom operation 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 new file mode 100644 index 0000000..488bbfd --- /dev/null +++ b/packages/server-mcp/src/resources/services/mcp-model-serializer.ts @@ -0,0 +1,212 @@ +/******************************************************************************** + * 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 { Dimension, GModelSerializer, Logger, Point } from '@eclipse-glsp/server'; +import { inject, injectable, postConstruct } from 'inversify'; +import { McpStructuredContent } from '../../server/mcp-handler-shared'; +import { McpIdAliasService } from '../../server/mcp-id-alias-service'; +import { objectArrayToMarkdownTable } from '../../util'; + +export const McpModelSerializer = Symbol('McpModelSerializer'); + +/** Loose JSON-object shape mirroring `GModelSerializer.createSchema` output — open-keyed because + * GModel attributes vary per element type. Reads must narrow before use. */ +export interface SerializedElement { + [key: string]: unknown; +} + +/** + * Transforms a graphical model into a representation suitable for LLM consumption. Markdown and + * JSON are both reasonable formats; the default impl ({@link MarkdownMcpModelSerializer}) emits + * Markdown for {@link serialize}/{@link serializeArray} and an object payload for + * {@link serializeStructured} (the dual-emit `structuredContent` counterpart). Aliasing of element + * ids happens internally via the per-session {@link McpIdAliasService}; callers don't pass an + * alias function. + * + * @experimental + */ +export interface McpModelSerializer { + /** Serializes a single element (and its descendants). */ + serialize(element: GModelElement): string; + + /** Serializes an array of elements; duplicates introduced by hierarchy are removed. */ + serializeArray(elements: GModelElement[]): string; + + /** + * Structured-content counterpart of {@link serialize} for dual-emit + * (`CallToolResult.structuredContent`). The shape is intentionally open: `{ elements: [...] }` + * with each entry carrying `id` + `type` + adopter-specific attrs (passthrough). + */ + serializeStructured(element: GModelElement): McpStructuredContent; + + /** Structured-content counterpart of {@link serializeArray}. */ + serializeStructuredArray(elements: GModelElement[]): McpStructuredContent; +} + +/** + * Default {@link McpModelSerializer} — emits Markdown with one H1 section per element type + * followed by a table of all elements of that type. Flattens the GModel tree, drops keys with + * no LLM value (`cssClasses`, `revision`, `layout`, etc., see {@link keysToRemove}), truncates + * position+size to integers and derives a `bounds` rectangle so the LLM doesn't redo arithmetic. + * + * Generic: no control over element order or per-type attribute order, since no specific GLSP + * adopter is known. Adopters override (see workflow's `WorkflowMcpModelSerializer`) when + * semantic ordering matters. + */ +@injectable() +export class MarkdownMcpModelSerializer implements McpModelSerializer { + @inject(GModelSerializer) protected gModelSerializer: GModelSerializer; + @inject(McpIdAliasService) protected aliasService: McpIdAliasService; + @inject(Logger) protected logger: Logger; + + protected keysToRemove: string[] = [ + 'cssClasses', + 'revision', + 'layout', + 'args', + 'layoutOptions', + 'alignment', + 'children', + 'routingPoints', + 'resizeLocations', + 'parent' + ]; + + /** Warn once per binding when the bare default is used — subclasses suppress via the constructor check. */ + @postConstruct() + protected warnIfGenericDefault(): void { + if (this.constructor === MarkdownMcpModelSerializer) { + this.logger.warn( + 'Using generic MarkdownMcpModelSerializer; bind a diagram-specific subclass via ' + + 'DefaultMcpDiagramModule.bindModelSerializer() for richer LLM output.' + ); + } + } + + serialize(element: GModelElement): string { + return this.serializeArray([element]); + } + + serializeArray(elements: GModelElement[]): string { + return Object.entries(this.buildAliasedTypeBuckets(elements)) + .filter(([, bucket]) => bucket.length > 0) + .flatMap(([type, bucket]) => [`# ${type}`, objectArrayToMarkdownTable(bucket)]) + .join('\n'); + } + + serializeStructured(element: GModelElement): McpStructuredContent { + return this.serializeStructuredArray([element]); + } + + serializeStructuredArray(elements: GModelElement[]): McpStructuredContent { + return { + elements: Object.values(this.buildAliasedTypeBuckets(elements)) + .filter(bucket => bucket.length > 0) + .flat() + }; + } + + /** Common pipeline: prepare, dedupe by id, group by type, alias ids — used by both renders. */ + protected buildAliasedTypeBuckets(elements: GModelElement[]): Record { + const elementsByTypeArray = elements.map(element => this.prepareElement(element)); + const result: Record = {}; + const allKeys = new Set(elementsByTypeArray.flatMap(obj => Object.keys(obj))); + allKeys.forEach(key => { + const combined = elementsByTypeArray.flatMap(obj => obj[key] ?? []); + result[key] = Array.from(new Map(combined.map(item => [item.id, item])).values()).map(item => this.applyAlias(item)); + }); + return result; + } + + protected applyAlias(element: SerializedElement): SerializedElement { + for (const field of ['id', 'sourceId', 'targetId', 'parentId'] as const) { + const value = element[field]; + if (typeof value === 'string') { + element[field] = this.aliasService.alias(value); + } + } + return element; + } + + protected prepareElement(element: GModelElement): Record { + const schema = this.gModelSerializer.createSchema(element) as unknown as SerializedElement; + const elements = this.flattenStructure(schema, element.parent?.id); + + const result: Record = {}; + elements.forEach(elem => { + this.removeKeys(elem); + this.combinePositionAndSize(elem); + const type = typeof elem.type === 'string' ? elem.type : 'unknown'; + (result[type] ??= []).push(elem); + }); + + return result; + } + + protected flattenStructure(element: SerializedElement, parentId?: string): SerializedElement[] { + const newElement: SerializedElement = { ...element }; + const result: SerializedElement[] = [newElement]; + const children = newElement.children; + if (Array.isArray(children)) { + const ownId = typeof newElement.id === 'string' ? newElement.id : undefined; + children.forEach(child => + this.flattenStructure(child as SerializedElement, ownId).forEach(descendant => result.push(descendant)) + ); + } + newElement.parentId = parentId; + return result; + } + + protected removeKeys(element: SerializedElement): void { + for (const key of Object.keys(element)) { + if (this.keysToRemove.includes(key)) { + delete element[key]; + } + } + } + + protected combinePositionAndSize(element: SerializedElement): void { + const position = element.position; + if (!Point.is(position)) { + return; + } + const x = Math.trunc(position.x); + const y = Math.trunc(position.y); + element.position = { x, y }; + + // Omit `size` and `bounds` when an element has no explicit geometry yet (e.g., a freshly + // created node whose `.size()` has not been applied). Emitting `{width: 0, height: 0}` + // would mislead an LLM consumer into "fixing" placeholder bounds. Visual rendering on + // the GLSP/sprotty client is unaffected — those nodes are laid out via CSS regardless. + const size = element.size; + if (!Dimension.is(size) || (Math.trunc(size.width) === 0 && Math.trunc(size.height) === 0)) { + delete element.size; + delete element.bounds; + return; + } + + const width = Math.trunc(size.width); + const height = Math.trunc(size.height); + element.size = { width, height }; + element.bounds = { + left: x, + right: x + width, + top: y, + bottom: y + height + }; + } +} diff --git a/packages/server-mcp/src/server/dual-emit.spec.ts b/packages/server-mcp/src/server/dual-emit.spec.ts new file mode 100644 index 0000000..3a1cbc4 --- /dev/null +++ b/packages/server-mcp/src/server/dual-emit.spec.ts @@ -0,0 +1,182 @@ +/******************************************************************************** + * 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 { expect } from 'chai'; +import * as z from 'zod/v4'; +import { McpToolResult } from './mcp-handler-shared'; +import { CreateEdgesMcpToolHandler, CreateEdgesOutputSchema } from '../tools/handlers/create-edges-mcp-tool-handler'; +import { CreateNodesMcpToolHandler, CreateNodesOutputSchema } from '../tools/handlers/create-nodes-mcp-tool-handler'; +import { DeleteElementsMcpToolHandler, DeleteElementsOutputSchema } from '../tools/handlers/delete-elements-mcp-tool-handler'; +import { GetSelectionMcpToolHandler, GetSelectionOutputSchema } from '../tools/handlers/get-selection-mcp-tool-handler'; +import { LayoutMcpToolHandler, LayoutOutputSchema } from '../tools/handlers/layout-mcp-tool-handler'; +import { ModifyEdgesMcpToolHandler, ModifyEdgesOutputSchema } from '../tools/handlers/modify-edges-mcp-tool-handler'; +import { ModifyNodesMcpToolHandler, ModifyNodesOutputSchema } from '../tools/handlers/modify-nodes-mcp-tool-handler'; +import { QueryElementsMcpToolHandler, QueryElementsOutputSchema } from '../tools/handlers/query-elements-mcp-tool-handler'; +import { RedoMcpToolHandler, RedoOutputSchema } from '../tools/handlers/redo-mcp-tool-handler'; +import { SaveModelMcpToolHandler, SaveModelOutputSchema } from '../tools/handlers/save-model-mcp-tool-handler'; +import { SetViewMcpToolHandler, SetViewOutputSchema } from '../tools/handlers/set-view-mcp-tool-handler'; +import { UndoMcpToolHandler, UndoOutputSchema } from '../tools/handlers/undo-mcp-tool-handler'; +import { ValidateDiagramMcpToolHandler, ValidateDiagramOutputSchema } from '../tools/handlers/validate-diagram-mcp-tool-handler'; +import { AbstractMcpToolHandler } from './mcp-tool-handler'; + +// ─── Framework: BaseMcpToolHandler.success(...) and toRegistrationConfig() ──────────────────── + +class WithoutOutputSchemaHandler extends AbstractMcpToolHandler { + readonly name = 'no-schema'; + readonly description = 'no-schema'; + readonly inputSchema = z.object({}); + + protected createResult(): McpToolResult { + return this.success('plain'); + } + + /** Test exposure of the protected helper. `BaseMcpToolHandler` doesn't read `logger` here. */ + public testSuccess(message: string, structured?: Record): McpToolResult { + return this.success(message, structured); + } +} + +class WithOutputSchemaHandler extends AbstractMcpToolHandler { + readonly name = 'with-schema'; + readonly description = 'with-schema'; + readonly inputSchema = z.object({}); + override readonly outputSchema = z.object({ ok: z.boolean() }); + + protected createResult(): McpToolResult { + return this.success('with', { ok: true }); + } +} + +describe('Dual-emit framework', () => { + it('success(message) without structured payload omits structuredContent', () => { + const result = new WithoutOutputSchemaHandler().testSuccess('hi'); + expect(result.content).to.deep.equal([{ type: 'text', text: 'hi' }]); + expect(result.structuredContent).to.equal(undefined); + expect(result.isError).to.equal(false); + }); + + it('success(message, structured) emits structuredContent', () => { + const result = new WithoutOutputSchemaHandler().testSuccess('hi', { count: 3, ids: ['a'] }); + expect(result.content).to.deep.equal([{ type: 'text', text: 'hi' }]); + expect(result.structuredContent).to.deep.equal({ count: 3, ids: ['a'] }); + }); + + it('toRegistrationConfig() omits outputSchema when not declared', () => { + const config = new WithoutOutputSchemaHandler().toRegistrationConfig(); + expect(config.outputSchema).to.equal(undefined); + }); + + it('toRegistrationConfig() forwards outputSchema when declared', () => { + const config = new WithOutputSchemaHandler().toRegistrationConfig(); + expect(config.outputSchema).to.not.equal(undefined); + // The SDK accepts a full `ZodObject` (or its raw shape); we pass the wrapped object so + // strict-mode rejection of unknown keys can extend uniformly to outputs. + expect(Object.keys(config.outputSchema!.shape)).to.deep.equal(['ok']); + }); +}); + +// ─── Per-handler schema matrix ──────────────────────────────────────────────────────────────── + +/** + * Each entry binds a handler constructor to its declared `OutputSchema` + a representative + * structured payload. The expectations: + * - Constructor's `outputSchema` field matches the exported schema (same reference). + * - The schema accepts the representative payload (zod parse passes). + */ +const matrix: Array<{ + name: string; + Constructor: new () => { outputSchema?: unknown }; + schema: z.ZodObject; + sample: Record; +}> = [ + { + name: 'create-nodes', + Constructor: CreateNodesMcpToolHandler, + schema: CreateNodesOutputSchema, + sample: { createdNodes: [{ id: 'n1', elementTypeId: 'node:foo' }], errors: [], warnings: [] } + }, + { + name: 'create-edges', + Constructor: CreateEdgesMcpToolHandler, + schema: CreateEdgesOutputSchema, + sample: { createdEdges: [{ id: 'e1', elementTypeId: 'edge' }], errors: [] } + }, + { + name: 'modify-nodes', + Constructor: ModifyNodesMcpToolHandler, + schema: ModifyNodesOutputSchema, + sample: { modifiedNodes: [{ id: 'n1', elementTypeId: 'node:foo' }], dispatchedCommands: 1, warnings: [] } + }, + { + name: 'modify-edges', + Constructor: ModifyEdgesMcpToolHandler, + schema: ModifyEdgesOutputSchema, + sample: { modifiedEdges: [{ id: 'e1', elementTypeId: 'edge' }], dispatchedCommands: 1, errors: [] } + }, + { + name: 'delete-elements', + Constructor: DeleteElementsMcpToolHandler, + schema: DeleteElementsOutputSchema, + sample: { deletedElements: [{ id: 'n1', elementTypeId: 'node:foo' }], deletedCount: 3 } + }, + { name: 'undo', Constructor: UndoMcpToolHandler, schema: UndoOutputSchema, sample: { commandsUndone: 2 } }, + { name: 'redo', Constructor: RedoMcpToolHandler, schema: RedoOutputSchema, sample: { commandsRedone: 2 } }, + { name: 'layout', Constructor: LayoutMcpToolHandler, schema: LayoutOutputSchema, sample: { applied: true } }, + { + name: 'validate-diagram', + Constructor: ValidateDiagramMcpToolHandler, + schema: ValidateDiagramOutputSchema, + sample: { markers: [{ kind: 'error', label: 'bad', description: 'bad bad', elementId: 'n1' }] } + }, + { + name: 'get-selection', + Constructor: GetSelectionMcpToolHandler, + schema: GetSelectionOutputSchema, + sample: { selectedElementIds: ['n1', 'n2'] } + }, + { + name: 'query-elements', + Constructor: QueryElementsMcpToolHandler, + schema: QueryElementsOutputSchema, + sample: { mode: 'inspect', elements: [{ id: 'n1', type: 'node:foo', position: { x: 0, y: 0 } }] } + }, + { + name: 'save-model', + Constructor: SaveModelMcpToolHandler, + schema: SaveModelOutputSchema, + sample: { saved: true, fileUri: 'file:///a' } + }, + { + name: 'set-view', + Constructor: SetViewMcpToolHandler, + schema: SetViewOutputSchema, + sample: { action: 'fit-to-screen', targetIds: ['n1'] } + } +]; + +describe('Tool output schemas · per-handler matrix', () => { + matrix.forEach(({ name, Constructor, schema, sample }) => { + describe(name, () => { + it('declares outputSchema referencing the exported schema constant', () => { + expect(new Constructor().outputSchema).to.equal(schema); + }); + + it('schema accepts a representative structured payload', () => { + expect(() => schema.parse(sample)).to.not.throw(); + }); + }); + }); +}); diff --git a/packages/server-mcp/src/server/glsp-mcp-server.spec.ts b/packages/server-mcp/src/server/glsp-mcp-server.spec.ts new file mode 100644 index 0000000..8d0ba57 --- /dev/null +++ b/packages/server-mcp/src/server/glsp-mcp-server.spec.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 { McpServer, RegisteredResource } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import * as z from 'zod/v4'; +import { DefaultGLSPMcpServer } from './glsp-mcp-server'; + +describe('DefaultGLSPMcpServer', () => { + function makeServer(): { wrapper: DefaultGLSPMcpServer; sdk: McpServer } { + const sdk = new McpServer({ name: 'test', version: '1.0.0' }, { capabilities: {} }); + const wrapper = new DefaultGLSPMcpServer(sdk, { dataMode: 'tools' }); + return { wrapper, sdk }; + } + + it('tracks every registerTool call so listTools returns the registered set', () => { + const { wrapper } = makeServer(); + wrapper.registerTool('alpha', { description: 'first', inputSchema: { x: z.string() } }, async () => ({ content: [] })); + wrapper.registerTool('beta', { description: 'second' }, async () => ({ content: [] })); + + const names = wrapper.listTools().map(tool => tool.description); + expect(names).to.have.members(['first', 'second']); + expect(wrapper.listTools()).to.have.lengthOf(2); + }); + + it('tracks registerResource (static URI) and listResources reflects it by name', () => { + const { wrapper } = makeServer(); + wrapper.registerResource('my-resource', 'glsp://test', { title: 'res', mimeType: 'text/plain' }, async () => ({ + contents: [{ uri: 'glsp://test', text: 'ok' }] + })); + + const resources = wrapper.listResources(); + expect(resources).to.have.lengthOf(1); + expect((resources[0] as RegisteredResource).name).to.equal('my-resource'); + expect((resources[0] as RegisteredResource).title).to.equal('res'); + }); + + it('tracks registerPrompt and listPrompts reflects it by description', () => { + const { wrapper } = makeServer(); + wrapper.registerPrompt('describe', { description: 'desc' }, async () => ({ messages: [] })); + + const prompts = wrapper.listPrompts(); + expect(prompts).to.have.lengthOf(1); + expect(prompts[0].description).to.equal('desc'); + }); + + it('getRawServer() returns the exact SDK instance passed in (escape hatch identity)', () => { + const { wrapper, sdk } = makeServer(); + expect(wrapper.getRawServer()).to.equal(sdk); + }); + + it('dispose() invokes close() on the underlying SDK server', () => { + const { wrapper, sdk } = makeServer(); + const closeSpy = sinon.spy(sdk, 'close'); + wrapper.dispose(); + expect(closeSpy.calledOnce).to.be.true; + }); +}); diff --git a/packages/server-mcp/src/server/glsp-mcp-server.ts b/packages/server-mcp/src/server/glsp-mcp-server.ts new file mode 100644 index 0000000..b28e09b --- /dev/null +++ b/packages/server-mcp/src/server/glsp-mcp-server.ts @@ -0,0 +1,200 @@ +/******************************************************************************** + * 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 { Disposable, McpServerOptions } from '@eclipse-glsp/server'; +import { + McpServer, + RegisteredPrompt, + RegisteredResource, + RegisteredResourceTemplate, + RegisteredTool +} from '@modelcontextprotocol/sdk/server/mcp.js'; +import { EmptyResultSchema, ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; +import { injectable } from 'inversify'; + +export const GLSPMcpServer = Symbol('GLSPMcpServer'); + +/** Either a static resource or a templated resource registration. */ +export type GLSPMcpResource = RegisteredResource | RegisteredResourceTemplate; + +/** + * Cadence for server-initiated `ping` requests on the standalone SSE GET stream. Below typical + * client-side SSE read timeouts (e.g. undici's 5-min `bodyTimeout`) so the stream stays alive + * across chat-idle periods. + */ +const KEEP_ALIVE_INTERVAL_MS = 30_000; + +/** Per-ping timeout — short, since a ping with no SSE GET stream open will never resolve. */ +const KEEP_ALIVE_PING_TIMEOUT_MS = 5_000; + +/** + * Curated, per-session view onto the underlying MCP server that GLSP + * tool/resource handlers see during registration. The `register*`, + * `sendLoggingMessage`, `isConnected`, and `connect` methods delegate to + * the underlying server; the `list*` methods read from a registration log + * maintained by {@link DefaultGLSPMcpServer}. `dispose()` closes the + * underlying SDK server (called by `McpServerLauncher` on session-close + * and on overall server shutdown). + * + * The `GLSP` prefix mirrors core's convention (`GLSPServer`, + * `GLSPClientProxy`, `GLSPModule`) where it disambiguates from + * external/SDK types. Here it specifically separates `GLSPMcpServer` from + * the SDK's `McpServer`; the rest of this package uses an `Mcp` prefix + * because there's no name collision. + */ +export interface GLSPMcpServer + extends Pick, + Disposable { + readonly options: McpServerOptions; + listTools(): RegisteredTool[]; + /** True iff a tool with this exact `name` has been registered on this server instance. */ + hasTool(name: string): boolean; + listResources(): GLSPMcpResource[]; + listPrompts(): RegisteredPrompt[]; + /** + * Sends an MCP `ping` request and resolves with the empty result. Rejects on transport + * timeout (e.g. when the standalone SSE GET stream is not currently open). + */ + ping(): Promise; + /** + * Escape hatch to the underlying SDK `McpServer` for advanced APIs not covered by the + * stable {@link GLSPMcpServer} surface. The common operations ({@link connect}, + * {@link sendLoggingMessage}, the `register*` methods, {@link dispose}) are exposed + * directly — reach for the raw server only when an SDK feature is not yet wrapped. + * + * The returned value is the SDK type, not a stable GLSP surface — its API may change + * with `@modelcontextprotocol/sdk` upgrades. + */ + getRawServer(): McpServer; +} + +export const GLSPMcpServerFactory = Symbol('GLSPMcpServerFactory'); + +/** + * Factory that produces a {@link GLSPMcpServer} per MCP client session. + * Bound to {@link DefaultGLSPMcpServer} by default; rebind to swap the + * implementation across all sessions. + */ +export type GLSPMcpServerFactory = (mcpServer: McpServer, options: McpServerOptions) => GLSPMcpServer; + +@injectable() +export class DefaultGLSPMcpServer implements GLSPMcpServer { + protected readonly tools = new Map(); + protected readonly resources = new Map(); + protected readonly prompts = new Map(); + protected keepAliveTimer?: NodeJS.Timeout; + + readonly registerTool: McpServer['registerTool']; + readonly registerResource: McpServer['registerResource']; + readonly registerPrompt: McpServer['registerPrompt']; + + constructor( + protected readonly mcpServer: McpServer, + readonly options: McpServerOptions + ) { + // `register*` need an interception layer so the local registration log stays in + // sync; the Proxy preserves the SDK's generic signatures (which a wrapped method + // using `Parameters<>` would collapse, breaking adopter autocomplete on + // `inputSchema → handler arg shape`). + const { tools, resources, prompts } = this; + this.registerTool = new Proxy(mcpServer.registerTool.bind(mcpServer), { + apply(target, thisArg, args) { + const registered = Reflect.apply(target, thisArg, args); + if (typeof args[0] === 'string') { + tools.set(args[0], registered); + } + return registered; + } + }); + this.registerResource = new Proxy(mcpServer.registerResource.bind(mcpServer), { + apply(target, thisArg, args) { + const registered = Reflect.apply(target, thisArg, args); + if (typeof args[0] === 'string') { + resources.set(args[0], registered); + } + return registered; + } + }); + this.registerPrompt = new Proxy(mcpServer.registerPrompt.bind(mcpServer), { + apply(target, thisArg, args) { + const registered = Reflect.apply(target, thisArg, args); + if (typeof args[0] === 'string') { + prompts.set(args[0], registered); + } + return registered; + } + }); + } + + sendLoggingMessage(...args: Parameters): ReturnType { + return this.mcpServer.sendLoggingMessage(...args); + } + + async connect(...args: Parameters): ReturnType { + await this.mcpServer.connect(...args); + // Suppress the expected `RequestTimeout` when no SSE GET stream is open; surface other + // failures via debug. `unref` so a forgotten timer can't pin the process. + this.keepAliveTimer = setInterval( + () => + this.ping().catch(err => { + if (!(err instanceof McpError) || err.code !== ErrorCode.RequestTimeout) { + console.debug('MCP keep-alive ping failed:', err); + } + }), + KEEP_ALIVE_INTERVAL_MS + ); + this.keepAliveTimer.unref(); + } + + isConnected(): boolean { + return this.mcpServer.isConnected(); + } + + async ping(): Promise { + // Bypass `Server.ping()` to pass a per-request timeout; SDK's no-arg wrapper uses the + // 60s default, which leaves keep-alive pings dangling for a full minute when no SSE GET + // stream is open. + await this.mcpServer.server.request({ method: 'ping' }, EmptyResultSchema, { timeout: KEEP_ALIVE_PING_TIMEOUT_MS }); + } + + listTools(): RegisteredTool[] { + return [...this.tools.values()]; + } + + hasTool(name: string): boolean { + return this.tools.has(name); + } + + listResources(): GLSPMcpResource[] { + return [...this.resources.values()]; + } + + listPrompts(): RegisteredPrompt[] { + return [...this.prompts.values()]; + } + + getRawServer(): McpServer { + return this.mcpServer; + } + + dispose(): void { + if (this.keepAliveTimer) { + clearInterval(this.keepAliveTimer); + this.keepAliveTimer = undefined; + } + this.mcpServer.close(); + } +} diff --git a/packages/server-mcp/src/server/index.ts b/packages/server-mcp/src/server/index.ts new file mode 100644 index 0000000..f05a0bc --- /dev/null +++ b/packages/server-mcp/src/server/index.ts @@ -0,0 +1,42 @@ +/******************************************************************************** + * 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 './glsp-mcp-server'; +export * from './mcp-diagram-handler-dispatcher'; +export * from './mcp-diagram-prompt-handler-registry'; +export * from './mcp-diagram-resource-handler-registry'; +export * from './mcp-diagram-tool-handler-registry'; +export * from './mcp-handler-shared'; +export * from './mcp-http-transport'; +export * from './mcp-id-alias-service'; +export * from './mcp-input-schemas'; +export * from './mcp-label-provider'; +export * from './mcp-log-level-registry'; +export * from './mcp-logger'; +export * from './mcp-mime-types'; +export * from './mcp-options'; +export * from './mcp-progress-reporter'; +export * from './mcp-prompt-handler'; +export * from './mcp-request-context'; +export * from './mcp-resource-handler'; +export * from './mcp-server-launcher'; +export * from './mcp-session'; +export * from './mcp-tool-handler'; + +// `mcp-diagram-module` and `mcp-server-module` are intentionally not re-exported here — +// they import handler classes from `../resources` / `../tools`, which would create a circular +// import chain through this barrel. They are re-exported from the package root (`src/index.ts`) +// after the handler barrels finish initializing. diff --git a/packages/server-mcp/src/server/lru-event-store.spec.ts b/packages/server-mcp/src/server/lru-event-store.spec.ts new file mode 100644 index 0000000..630c151 --- /dev/null +++ b/packages/server-mcp/src/server/lru-event-store.spec.ts @@ -0,0 +1,121 @@ +/******************************************************************************** + * 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 { Logger } from '@eclipse-glsp/server'; +import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +import { expect } from 'chai'; +import { LruEventStore } from './lru-event-store'; + +function jsonRpc(id: number): JSONRPCMessage { + return { jsonrpc: '2.0', id, result: { value: id } }; +} + +describe('LruEventStore', () => { + it('rejects an invalid limit (< 1)', () => { + expect(() => new LruEventStore(0)).to.throw(/limit must be >= 1/); + expect(() => new LruEventStore(-5)).to.throw(/limit must be >= 1/); + }); + + it('storeEvent returns a streamId-prefixed event id', async () => { + const store = new LruEventStore(); + const id = await store.storeEvent('stream-A', jsonRpc(1)); + expect(id.split('_')[0]).to.equal('stream-A'); + }); + + it('evicts the oldest event once the cap is exceeded', async () => { + const store = new LruEventStore(3); + const id1 = await store.storeEvent('s', jsonRpc(1)); + const id2 = await store.storeEvent('s', jsonRpc(2)); + await store.storeEvent('s', jsonRpc(3)); + + expect(store.size).to.equal(3); + + // Adding a 4th event evicts the oldest (id1). + const id4 = await store.storeEvent('s', jsonRpc(4)); + expect(store.size).to.equal(3); + + // Replay since id1 → can't find it (evicted) → returns ''. + const replayed: string[] = []; + const streamId = await store.replayEventsAfter(id1, { + send: async eventId => { + replayed.push(eventId); + } + }); + expect(streamId).to.equal(''); // id1 has been evicted + expect(replayed).to.have.lengthOf(0); + + // Replay since id2 still works — id3 (still in store) and id4 are sent in order. + const replayedFromId2: string[] = []; + await store.replayEventsAfter(id2, { + send: async eventId => { + replayedFromId2.push(eventId); + } + }); + expect(replayedFromId2).to.have.lengthOf(2); + expect(replayedFromId2[1]).to.equal(id4); + }); + + it('replays only events from the same stream after the lastEventId', async () => { + const store = new LruEventStore(); + const a1 = await store.storeEvent('A', jsonRpc(1)); + await store.storeEvent('B', jsonRpc(2)); // different stream — must be skipped + const a3 = await store.storeEvent('A', jsonRpc(3)); + const a4 = await store.storeEvent('A', jsonRpc(4)); + + const replayed: Array<{ id: string; msg: JSONRPCMessage }> = []; + const streamId = await store.replayEventsAfter(a1, { + send: async (id, msg) => { + replayed.push({ id, msg }); + } + }); + + expect(streamId).to.equal('A'); + expect(replayed.map(e => e.id)).to.deep.equal([a3, a4]); + }); + + it('returns "" when lastEventId is unknown', async () => { + const store = new LruEventStore(); + await store.storeEvent('A', jsonRpc(1)); + + const streamId = await store.replayEventsAfter('A_999_xxx', { + send: async () => { + throw new Error('should not be called'); + } + }); + + expect(streamId).to.equal(''); + }); + + it('logs a cap-eviction warn when replaying an evicted last-event-id', async () => { + const warns: string[] = []; + const logger = { warn: (message: string) => warns.push(message) } as unknown as Logger; + const store = new LruEventStore(2, logger); + const evictedId = await store.storeEvent('s', jsonRpc(1)); + await store.storeEvent('s', jsonRpc(2)); + await store.storeEvent('s', jsonRpc(3)); + + const streamId = await store.replayEventsAfter(evictedId, { + send: async () => { + throw new Error('should not be called'); + } + }); + + expect(streamId).to.equal(''); + expect(warns).to.have.lengthOf(1); + expect(warns[0]).to.match(/Replay miss/); + expect(warns[0]).to.include('Cap is 2'); + }); +}); diff --git a/packages/server-mcp/src/server/lru-event-store.ts b/packages/server-mcp/src/server/lru-event-store.ts new file mode 100644 index 0000000..d999970 --- /dev/null +++ b/packages/server-mcp/src/server/lru-event-store.ts @@ -0,0 +1,112 @@ +/******************************************************************************** + * 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 { Logger } from '@eclipse-glsp/server'; +import { EventId, EventStore, StreamId } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; + +/** + * In-memory `EventStore` with a bounded LRU cap, replacing the SDK's `InMemoryEventStore` + * (which is unbounded and explicitly intended only for examples). + * + * Resumability via `Last-Event-ID` requires that historical events stay reachable. Without a + * cap, the underlying `Map` grows linearly with `MCP message volume × server uptime` — fine + * for local-dev, a slow leak for daemonised / multi-user / CI deployments. + * + * Eviction shape: insert-order LRU. JS `Map` already preserves insertion order, so newer + * events sit at the tail and `keys().next()` returns the oldest. On overflow we delete the + * oldest — no timer, no periodic sweep, no `dispose()` plumbing required. The cap must be + * comfortably larger than the worst-case in-flight event count (per-client × concurrent-clients + * × disconnect-window), or a client reconnecting with a stale `Last-Event-ID` will find its + * resume point already evicted. + * + * The event-id format mirrors the SDK's example impl (`__`) so + * `replayEventsAfter` can extract the stream id with the same `split('_')[0]` trick. + */ +export class LruEventStore implements EventStore { + static readonly DEFAULT_LIMIT = 10_000; + + protected readonly events = new Map(); + protected readonly limit: number; + protected readonly logger?: Logger; + + constructor(limit: number = LruEventStore.DEFAULT_LIMIT, logger?: Logger) { + if (limit < 1) { + throw new Error(`LruEventStore limit must be >= 1, got ${limit}`); + } + this.limit = limit; + this.logger = logger; + } + + /** Current event count. Exposed for tests. */ + get size(): number { + return this.events.size; + } + + async storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise { + // Format: `__<8-char-base36-random>`. The leading `streamId` lets + // `replayEventsAfter` recover the stream via `split('_')[0]` without a side table; the + // timestamp + random suffix make ids globally unique across concurrent emits. + const eventId = `${streamId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; + this.events.set(eventId, { streamId, message }); + if (this.events.size > this.limit) { + const oldest = this.events.keys().next().value; + if (oldest !== undefined) { + this.events.delete(oldest); + } + } + return eventId; + } + + async replayEventsAfter( + lastEventId: EventId, + { send }: { send: (eventId: EventId, message: JSONRPCMessage) => Promise } + ): Promise { + if (!lastEventId) { + return ''; + } + if (!this.events.has(lastEventId)) { + // Cap-eviction race: warn so adopters can tune `eventStoreLimit` upward. + this.logger?.warn( + `[LruEventStore] Replay miss for last-event-id '${lastEventId}'. ` + + `Cap is ${this.limit}; consider increasing \`eventStoreLimit\` if this fires under normal load.` + ); + return ''; + } + const streamId = lastEventId.split('_')[0]; + if (!streamId) { + return ''; + } + + // Map iteration is insertion-order — equivalent to the SDK example's `localeCompare` + // sort under our id format, but cheaper and not dependent on the lexicographic-vs- + // chronological coincidence of the timestamp segment. + let foundLast = false; + for (const [eventId, { streamId: evtStreamId, message }] of this.events) { + if (evtStreamId !== streamId) { + continue; + } + if (eventId === lastEventId) { + foundLast = true; + continue; + } + if (foundLast) { + await send(eventId, message); + } + } + return streamId; + } +} diff --git a/packages/server-mcp/src/server/mcp-diagram-handler-dispatcher.spec.ts b/packages/server-mcp/src/server/mcp-diagram-handler-dispatcher.spec.ts new file mode 100644 index 0000000..409a23a --- /dev/null +++ b/packages/server-mcp/src/server/mcp-diagram-handler-dispatcher.spec.ts @@ -0,0 +1,231 @@ +/******************************************************************************** + * 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 { CallToolResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; +import { expect } from 'chai'; +import { GLSPMcpServer } from './glsp-mcp-server'; +import { DefaultMcpDiagramHandlerDispatcher, DiagramTypeCatalog } from './mcp-diagram-handler-dispatcher'; + +/** + * Tests the SDK-callback dispatch error path covered by `runWithToolErrorEnvelope`. The + * concern is that pre-handler routing errors (missing/unknown sessionId, unknown handler name) + * that the launcher's tool path `throw McpToolError` are converted into `{ isError: true }` tool + * results by the wrap, instead of bubbling up as JSON-RPC `-32603 Internal error`. + * + * The prompt path is intentionally NOT wrapped — prompt errors propagate as JSON-RPC errors per + * MCP spec semantics. This test pins that asymmetry too. + */ + +type ToolCallback = (params: unknown, extra: unknown) => Promise; +type PromptCallback = (args: unknown, extra: unknown) => Promise; +type StaticResourceCallback = (uri: URL, extra: unknown) => Promise; + +class CapturingMcpServer { + readonly tools = new Map(); + readonly prompts = new Map(); + readonly staticResources = new Map(); + + registerTool(name: string, _config: unknown, callback: ToolCallback): unknown { + this.tools.set(name, callback); + return undefined; + } + + registerPrompt(name: string, _config: unknown, callback: PromptCallback): unknown { + this.prompts.set(name, callback); + return undefined; + } + + registerResource(name: string, uriOrTemplate: unknown, _config: unknown, callback: unknown): unknown { + if (typeof uriOrTemplate === 'string') { + this.staticResources.set(name, callback as StaticResourceCallback); + } + return undefined; + } +} + +function makeDispatcher( + catalog: DiagramTypeCatalog, + sessions: Array<{ id: string; container?: unknown; diagramType?: string }> = [] +): DefaultMcpDiagramHandlerDispatcher { + const dispatcher = new DefaultMcpDiagramHandlerDispatcher(); + (dispatcher as unknown as { diagramCatalogs: DiagramTypeCatalog[] }).diagramCatalogs = [catalog]; + (dispatcher as unknown as { clientSessionManager: unknown }).clientSessionManager = { + getSessions: () => sessions, + getSession: (id: string) => sessions.find(session => session.id === id) + }; + return dispatcher; +} + +class FakeToolHandlerCtor { + name = 'fake-tool'; + title = 'Fake'; + description = 'A test tool'; + inputSchema = { strict: () => ({}) }; + toRegistrationConfig(): unknown { + return { title: this.title, description: this.description, inputSchema: {} }; + } +} + +class FakePromptHandlerCtor { + name = 'fake-prompt'; + title = 'FakePrompt'; + description = 'A test prompt'; + toRegistrationConfig(): unknown { + return { title: this.title, description: this.description }; + } +} + +class FakeResourceAsToolHandlerCtor { + name = 'fake-resource'; + title = 'FakeResource'; + description = 'A test resource exposed as tool'; + mimeType = 'text/plain'; + uri = 'glsp://static/fake'; + toolAlternativeInputSchema = { strict: () => ({}) }; + toolAlternativeOutputSchema = undefined; + toAnnotations(): undefined { + return undefined; + } +} + +class FakeStaticResourceHandlerCtor { + name = 'fake-static-resource'; + title = 'FakeStatic'; + description = 'A test static-URI resource'; + mimeType = 'text/plain'; + uri = 'glsp://static/fake'; + toAnnotations(): undefined { + return undefined; + } +} + +describe('DefaultMcpDiagramHandlerDispatcher · SDK-callback dispatch error envelope', () => { + it('tool callback returns isError envelope when sessionId is missing', async () => { + const dispatcher = makeDispatcher({ + diagramType: 'test', + toolConstructors: [FakeToolHandlerCtor as unknown as DiagramTypeCatalog['toolConstructors'][number]], + resourceConstructors: [], + promptConstructors: [] + }); + const captured = new CapturingMcpServer(); + dispatcher.registerAll(captured as unknown as GLSPMcpServer, false); + + const result = await captured.tools.get('fake-tool')!({}, {}); + expect(result.isError).to.equal(true); + expect((result.content as Array<{ text: string }>)[0].text).to.match(/sessionId/); + }); + + it('tool callback returns isError envelope when sessionId is unknown', async () => { + const dispatcher = makeDispatcher( + { + diagramType: 'test', + toolConstructors: [FakeToolHandlerCtor as unknown as DiagramTypeCatalog['toolConstructors'][number]], + resourceConstructors: [], + promptConstructors: [] + }, + [] // no open sessions + ); + const captured = new CapturingMcpServer(); + dispatcher.registerAll(captured as unknown as GLSPMcpServer, false); + + const result = await captured.tools.get('fake-tool')!({ sessionId: 'unknown-glsp-id' }, {}); + expect(result.isError).to.equal(true); + expect((result.content as Array<{ text: string }>)[0].text).to.match(/Session not found/); + }); + + it('tool callback returns isError envelope when registered handler is absent for the session', async () => { + const sessionContainer = { + get: () => ({ + get: () => undefined // registry returns no handler + }) + }; + const dispatcher = makeDispatcher( + { + diagramType: 'test', + toolConstructors: [FakeToolHandlerCtor as unknown as DiagramTypeCatalog['toolConstructors'][number]], + resourceConstructors: [], + promptConstructors: [] + }, + [{ id: 'session-1', container: sessionContainer, diagramType: 'test' }] + ); + const captured = new CapturingMcpServer(); + dispatcher.registerAll(captured as unknown as GLSPMcpServer, false); + + const result = await captured.tools.get('fake-tool')!({ sessionId: 'session-1' }, {}); + expect(result.isError).to.equal(true); + expect((result.content as Array<{ text: string }>)[0].text).to.match(/No tool handler/); + }); + + it('resource-as-tool callback returns isError envelope when sessionId is missing', async () => { + const dispatcher = makeDispatcher({ + diagramType: 'test', + toolConstructors: [], + resourceConstructors: [FakeResourceAsToolHandlerCtor as unknown as DiagramTypeCatalog['resourceConstructors'][number]], + promptConstructors: [] + }); + const captured = new CapturingMcpServer(); + // dataMode='tools' (resourcesAsResources=false) → resource registers as tool fallback. + dispatcher.registerAll(captured as unknown as GLSPMcpServer, false); + + const result = await captured.tools.get('fake-resource')!({}, {}); + expect(result.isError).to.equal(true); + expect((result.content as Array<{ text: string }>)[0].text).to.match(/sessionId/); + }); + + it('static-URI resource read throws McpToolError when no GLSP session is open', async () => { + const dispatcher = makeDispatcher( + { + diagramType: 'test', + toolConstructors: [], + resourceConstructors: [FakeStaticResourceHandlerCtor as unknown as DiagramTypeCatalog['resourceConstructors'][number]], + promptConstructors: [] + }, + [] // no open sessions + ); + const captured = new CapturingMcpServer(); + // dataMode='resources' (resourcesAsResources=true) → resource registers as URI-addressable resource. + dispatcher.registerAll(captured as unknown as GLSPMcpServer, true); + + let rejection: unknown; + try { + await captured.staticResources.get('fake-static-resource')!(new URL('glsp://static/fake'), {}); + } catch (err: unknown) { + rejection = err; + } + expect(rejection).to.exist; + expect((rejection as Error).message).to.match(/No open GLSP session/); + }); + + it('prompt callback rejects with McpToolError when sessionId is missing (no envelope wrap — by design)', async () => { + const dispatcher = makeDispatcher({ + diagramType: 'test', + toolConstructors: [], + resourceConstructors: [], + promptConstructors: [FakePromptHandlerCtor as unknown as DiagramTypeCatalog['promptConstructors'][number]] + }); + const captured = new CapturingMcpServer(); + dispatcher.registerAll(captured as unknown as GLSPMcpServer, false); + + let rejection: unknown; + try { + await captured.prompts.get('fake-prompt')!({}, {}); + } catch (err: unknown) { + rejection = err; + } + expect(rejection).to.exist; + expect((rejection as Error).message).to.match(/sessionId/); + }); +}); diff --git a/packages/server-mcp/src/server/mcp-diagram-handler-dispatcher.ts b/packages/server-mcp/src/server/mcp-diagram-handler-dispatcher.ts new file mode 100644 index 0000000..01e5d24 --- /dev/null +++ b/packages/server-mcp/src/server/mcp-diagram-handler-dispatcher.ts @@ -0,0 +1,435 @@ +/******************************************************************************** + * 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, + DiagramModules, + InjectionContainer, + Logger, + TEMPORARY_CLIENT_ID, + createClientSessionModule +} from '@eclipse-glsp/server'; +import { CompleteResourceTemplateCallback, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { Variables } from '@modelcontextprotocol/sdk/shared/uriTemplate.js'; +import { CallToolResult, GetPromptResult, ListResourcesResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; +import { Container, ContainerModule, inject, injectable } from 'inversify'; +import { GLSPMcpServer } from './glsp-mcp-server'; +import { McpDiagramPromptHandlerRegistry } from './mcp-diagram-prompt-handler-registry'; +import { McpDiagramResourceHandlerRegistry } from './mcp-diagram-resource-handler-registry'; +import { McpDiagramToolHandlerRegistry } from './mcp-diagram-tool-handler-registry'; +import { McpMissingParamError, McpSessionNotFoundError, McpToolError, runWithToolErrorEnvelope } from './mcp-handler-shared'; +import { McpDiagramScopedInput } from './mcp-input-schemas'; +import { AbstractMcpDiagramPromptHandler, McpDiagramPromptHandlerConstructor } from './mcp-prompt-handler'; +import { mcpRequestContext } from './mcp-request-context'; +import { AbstractMcpDiagramResourceHandler, McpDiagramResourceHandlerConstructor, toParams } from './mcp-resource-handler'; +import { BaseMcpDiagramToolHandler, McpDiagramToolHandlerConstructor } from './mcp-tool-handler'; + +/** + * Per-diagram-type catalog of constructor lists, harvested at MCP-server start by loading each + * diagram type's modules onto a temporary child container — same pattern as + * `DefaultGlobalActionProvider` (`packages/server/src/common/actions/global-action-provider.ts:36-48`). + * The constructors stay across MCP-session inits; the temporary container lives just long enough + * for `getAll(...)` to read the `InstanceMultiBinding` constant value. + */ +export interface DiagramTypeCatalog { + readonly diagramType: string; + readonly toolConstructors: McpDiagramToolHandlerConstructor[]; + readonly resourceConstructors: McpDiagramResourceHandlerConstructor[]; + readonly promptConstructors: McpDiagramPromptHandlerConstructor[]; +} + +export const McpDiagramHandlerDispatcher = Symbol('McpDiagramHandlerDispatcher'); + +/** + * Owns diagram-scope handler discovery, SDK registration, and per-MCP-call dispatch routing. + * Extracted from {@link McpServerLauncher} so adopters can `rebind(McpDiagramHandlerDispatcher)` + * to a subclass to customize registration without subclassing the entire launcher lifecycle. + * + * Responsibilities: + * - Harvest per-diagram-type constructor catalogs from the diagram modules ({@link harvest}). + * - Register one SDK tool/resource/prompt per metadata `name` against a per-MCP-session + * {@link GLSPMcpServer}, deduping across diagram types ({@link registerAll}). + * - Dispatch each registered SDK callback to the per-GLSP-session handler instance, resolved + * from the GLSP session container by the {@link McpDiagramScopedInput.sessionId} input. + */ +@injectable() +export class DefaultMcpDiagramHandlerDispatcher { + @inject(InjectionContainer) protected serverContainer: Container; + @inject(DiagramModules) protected diagramModules: Map; + @inject(ClientSessionManager) protected clientSessionManager: ClientSessionManager; + @inject(Logger) protected logger: Logger; + + protected diagramCatalogs: DiagramTypeCatalog[] = []; + + /** + * Builds {@link diagramCatalogs} once per dispatcher lifetime by loading each diagram type's + * modules onto a temporary child container and reading out the constructor lists. Pure + * metadata — no instances are created here. + * + * Loaded with the synthetic {@link TEMPORARY_CLIENT_ID} so session-scoped `@inject` + * dependencies resolve. Diagram modules' `configure()` therefore must not have side effects + * keyed on `ClientId`. Catalogs are harvested once and never re-read; call {@link reset} to + * invalidate before re-harvest. + */ + harvest(): void { + if (this.diagramCatalogs.length > 0) { + return; + } + const catalogs: DiagramTypeCatalog[] = []; + const placeholderSessionModule = createClientSessionModule({ + clientId: TEMPORARY_CLIENT_ID, + glspClient: { process: () => {} }, + clientActionKinds: [] + }); + for (const [diagramType, modules] of this.diagramModules) { + const tempContainer = this.serverContainer.createChild(); + tempContainer.load(...modules, placeholderSessionModule); + const tools = tempContainer.isBound(McpDiagramToolHandlerConstructor) + ? tempContainer.get(McpDiagramToolHandlerConstructor) + : []; + const resources = tempContainer.isBound(McpDiagramResourceHandlerConstructor) + ? tempContainer.get(McpDiagramResourceHandlerConstructor) + : []; + const prompts = tempContainer.isBound(McpDiagramPromptHandlerConstructor) + ? tempContainer.get(McpDiagramPromptHandlerConstructor) + : []; + tempContainer.unbindAll(); + catalogs.push({ diagramType, toolConstructors: tools, resourceConstructors: resources, promptConstructors: prompts }); + } + this.diagramCatalogs = catalogs; + } + + /** True when at least one diagram type has at least one tool handler bound. */ + hasDiagramTools(): boolean { + return this.diagramCatalogs.some(catalog => catalog.toolConstructors.length > 0); + } + + /** True when at least one diagram type has at least one resource handler bound. */ + hasDiagramResources(): boolean { + return this.diagramCatalogs.some(catalog => catalog.resourceConstructors.length > 0); + } + + /** True when at least one diagram type has at least one prompt handler bound. */ + hasDiagramPrompts(): boolean { + return this.diagramCatalogs.some(catalog => catalog.promptConstructors.length > 0); + } + + /** + * Registers all diagram-scope tools/resources/prompts on the supplied per-MCP-session + * {@link GLSPMcpServer}. Resource registration shape depends on `dataMode`: `resources` + * registers them as URI-addressable resources; `tools` (default) registers each + * `toolAlternativeInputSchema`-bearing resource as a tool. + */ + registerAll(glspMcpServer: GLSPMcpServer, resourcesAsResources: boolean): void { + this.registerDiagramScopeTools(glspMcpServer); + this.registerDiagramScopeResources(glspMcpServer, resourcesAsResources); + this.registerDiagramScopePrompts(glspMcpServer); + } + + protected registerDiagramScopeTools(glspMcpServer: GLSPMcpServer): void { + const seen = new Map(); + for (const catalog of this.diagramCatalogs) { + for (const Constructor of catalog.toolConstructors) { + const probe = new Constructor(); + const fingerprint = { + description: probe.description, + inputKeys: Object.keys(probe.inputSchema?.shape ?? {}) + .sort() + .join(','), + diagramType: catalog.diagramType + }; + const existing = seen.get(probe.name); + if (existing) { + if (existing.description !== fingerprint.description || existing.inputKeys !== fingerprint.inputKeys) { + this.logger.warn( + `Diagram-scope tool '${probe.name}' is registered by multiple diagram types with diverging schemas: ` + + `first registered by '${existing.diagramType}', shadowed registration from '${catalog.diagramType}' ` + + 'is silently dropped. Either align schemas (description + inputSchema.shape keys must match) or ' + + 'pick distinct tool names.' + ); + } + continue; + } + seen.set(probe.name, fingerprint); + glspMcpServer.registerTool(probe.name, probe.toRegistrationConfig(), (params, extra) => + mcpRequestContext.run(extra, () => this.dispatchDiagramTool(probe.name, params)) + ); + } + } + } + + protected registerDiagramScopeResources(glspMcpServer: GLSPMcpServer, resourcesAsResources: boolean): void { + const seenNames = new Set(); + for (const catalog of this.diagramCatalogs) { + for (const Constructor of catalog.resourceConstructors) { + const probe = new Constructor(); + if (seenNames.has(probe.name)) { + continue; + } + seenNames.add(probe.name); + if (resourcesAsResources) { + this.registerOneDiagramResource(glspMcpServer, probe); + } else if (probe.toolAlternativeInputSchema) { + this.registerOneDiagramResourceAsTool(glspMcpServer, probe); + } + } + } + } + + protected registerOneDiagramResource( + glspMcpServer: GLSPMcpServer, + probe: AbstractMcpDiagramResourceHandler + ): void { + const name = probe.name; + const annotations = probe.toAnnotations(); + const config = { + title: probe.title, + description: probe.description, + mimeType: probe.mimeType, + ...(annotations ? { annotations } : {}) + }; + if (typeof probe.uri === 'string') { + const uri = probe.uri; + glspMcpServer.registerResource(name, uri, config, (_uri, extra) => + mcpRequestContext.run(extra, () => this.dispatchStaticDiagramRead(name, uri)) + ); + } else { + const template = this.buildAggregatingResourceTemplate(probe.uri.template, name); + glspMcpServer.registerResource(name, template, config, (uri, params, extra) => + mcpRequestContext.run(extra, () => this.dispatchTemplatedDiagramRead(name, uri, params)) + ); + } + } + + protected registerOneDiagramResourceAsTool( + glspMcpServer: GLSPMcpServer, + probe: AbstractMcpDiagramResourceHandler + ): void { + const name = probe.name; + const inputSchema = probe.toolAlternativeInputSchema!; + // `.strict()` matches the tool-handler policy — see `BaseMcpToolHandler.toRegistrationConfig` + // for the full rationale (LLM-typoed fields surface as JSON-RPC validation errors instead + // of being silently stripped). + glspMcpServer.registerTool( + name, + { + title: probe.title, + description: probe.description, + inputSchema: inputSchema.strict(), + outputSchema: probe.toolAlternativeOutputSchema + }, + (params, extra) => mcpRequestContext.run(extra, () => this.dispatchDiagramResourceAsTool(name, params)) + ); + } + + protected registerDiagramScopePrompts(glspMcpServer: GLSPMcpServer): void { + const seenNames = new Set(); + for (const catalog of this.diagramCatalogs) { + for (const Constructor of catalog.promptConstructors) { + const probe = new Constructor(); + if (seenNames.has(probe.name)) { + continue; + } + seenNames.add(probe.name); + // Prompt errors propagate as JSON-RPC errors per spec — no `runWithToolErrorEnvelope` wrap. + glspMcpServer.registerPrompt(probe.name, probe.toRegistrationConfig(), (args, extra) => + mcpRequestContext.run(extra, () => this.dispatchDiagramPrompt(probe.name, args)) + ); + } + } + } + + /** + * Builds an SDK `ResourceTemplate` whose `list`/`complete` walk all open GLSP sessions and + * aggregate the per-session handler results. The cross-GLSP-session-pollution guard for + * `complete` (auto-empty for non-matching sessions) is applied at the handler base level + * via {@link AbstractMcpDiagramResourceHandler.glspSessionScopedComplete}. + */ + protected buildAggregatingResourceTemplate(uriTemplate: string, name: string): ResourceTemplate { + return new ResourceTemplate(uriTemplate, { + list: extra => mcpRequestContext.run(extra, () => this.aggregateList(name)), + complete: this.buildAggregatedCompleters(name) + }); + } + + protected async aggregateList(name: string): Promise { + const aggregated: ListResourcesResult['resources'] = []; + const seenUris = new Set(); + for (const sessionId of this.clientSessionManager.getSessions().map(session => session.id)) { + const handler = this.findDiagramResourceHandler(name, sessionId); + const partial = await handler?.list?.(); + if (!partial) { + continue; + } + for (const entry of partial.resources) { + if (!seenUris.has(entry.uri)) { + seenUris.add(entry.uri); + aggregated.push(entry); + } + } + } + return { resources: aggregated }; + } + + protected buildAggregatedCompleters(name: string): Record { + const variableNames = this.collectCompleterVariableNames(name); + const completers: Record = {}; + for (const variable of variableNames) { + completers[variable] = async (value, ctx) => { + const aggregated = new Set(); + for (const sessionId of this.clientSessionManager.getSessions().map(session => session.id)) { + const handler = this.findDiagramResourceHandler(name, sessionId); + if (!handler) { + continue; + } + const sessionScoped = handler.glspSessionScopedComplete(); + const completer = sessionScoped[variable]; + if (!completer) { + continue; + } + const partial = await completer(value, ctx); + partial.forEach(item => aggregated.add(item)); + } + return [...aggregated]; + }; + } + return completers; + } + + /** Probe each diagram type's resource constructor for `complete()` keys to wire SDK-side. */ + protected collectCompleterVariableNames(name: string): Set { + const variables = new Set(); + for (const catalog of this.diagramCatalogs) { + for (const Constructor of catalog.resourceConstructors) { + const probe = new Constructor(); + if (probe.name !== name) { + continue; + } + Object.keys(probe.complete?.() ?? {}).forEach(key => variables.add(key)); + } + } + return variables; + } + + // ── Dispatch entry points ────────────────────────────────────────────────── + + /** SDK-validated `params` against the registered Zod schema before this callback fires. */ + protected dispatchDiagramTool(name: string, params: unknown): Promise { + return runWithToolErrorEnvelope(async () => { + const handler = this.requireDiagramToolHandler(name, params); + return handler.handle(params as McpDiagramScopedInput); + }); + } + + protected dispatchStaticDiagramRead(name: string, uri: string): Promise { + // Static URI on a diagram-scope resource doesn't differentiate sessions; pick the first + // open one and surface a clear error when none is available. + const sessionId = this.clientSessionManager.getSessions()[0]?.id; + if (!sessionId) { + throw new McpToolError(`No open GLSP session can serve resource '${name}'.`); + } + const handler = this.requireDiagramResourceHandler(name, sessionId); + return handler.handleRead(uri, { sessionId }); + } + + protected dispatchTemplatedDiagramRead(name: string, uri: URL, params: Variables): Promise { + const flat = toParams(params); + const handler = this.requireDiagramResourceHandler(name, flat.sessionId); + return handler.handleRead(uri.toString(), flat as McpDiagramScopedInput); + } + + protected dispatchDiagramResourceAsTool(name: string, params: unknown): Promise { + return runWithToolErrorEnvelope(async () => { + const handler = this.requireDiagramResourceHandler(name, this.extractSessionId(params)); + return handler.handleAsTool(params as McpDiagramScopedInput); + }); + } + + protected dispatchDiagramPrompt(name: string, args: unknown): Promise { + const handler = this.requireDiagramPromptHandler(name, args); + return handler.handle(args as McpDiagramScopedInput); + } + + // ── Handler resolution ───────────────────────────────────────────────────── + + /** Reads `sessionId` from a JSON-shaped tool/resource/prompt input without committing to the full schema. */ + protected extractSessionId(params: unknown): string | undefined { + return (params as Partial).sessionId; + } + + /** Resolves the per-GLSP-session tool handler for `params.sessionId`, throwing on miss. */ + protected requireDiagramToolHandler(name: string, params: unknown): BaseMcpDiagramToolHandler { + const session = this.requireGlspSession(this.extractSessionId(params)); + const registry = session.container.get(McpDiagramToolHandlerRegistry); + const handler = registry.get(name); + if (!handler) { + throw new McpToolError(`No tool handler '${name}' registered for diagram type '${session.diagramType}'.`); + } + return handler; + } + + /** Resolves the per-GLSP-session resource handler for `sessionId`, throwing on miss. */ + protected requireDiagramResourceHandler( + name: string, + sessionId: string | undefined + ): AbstractMcpDiagramResourceHandler { + const session = this.requireGlspSession(sessionId); + const registry = session.container.get(McpDiagramResourceHandlerRegistry); + const handler = registry.get(name); + if (!handler) { + throw new McpToolError(`No resource handler '${name}' registered for diagram type '${session.diagramType}'.`); + } + return handler; + } + + /** Looks up the per-GLSP-session resource handler — returns `undefined` for an unknown id. */ + protected findDiagramResourceHandler( + name: string, + sessionId: string + ): AbstractMcpDiagramResourceHandler | undefined { + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + return undefined; + } + return session.container.get(McpDiagramResourceHandlerRegistry).get(name); + } + + protected requireDiagramPromptHandler(name: string, args: unknown): AbstractMcpDiagramPromptHandler { + const session = this.requireGlspSession(this.extractSessionId(args)); + const registry = session.container.get(McpDiagramPromptHandlerRegistry); + const handler = registry.get(name); + if (!handler) { + throw new McpToolError(`No prompt handler '${name}' registered for diagram type '${session.diagramType}'.`); + } + return handler; + } + + protected requireGlspSession(sessionId: string | undefined): { container: Container; diagramType: string } { + if (!sessionId) { + throw new McpMissingParamError('sessionId'); + } + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + throw new McpSessionNotFoundError(sessionId); + } + return session; + } + + /** Disposes the catalog so a subsequent {@link harvest} call re-reads the diagram modules. */ + reset(): void { + this.diagramCatalogs = []; + } +} diff --git a/packages/server-mcp/src/server/mcp-diagram-module.ts b/packages/server-mcp/src/server/mcp-diagram-module.ts new file mode 100644 index 0000000..9916a8d --- /dev/null +++ b/packages/server-mcp/src/server/mcp-diagram-module.ts @@ -0,0 +1,248 @@ +/******************************************************************************** + * 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 { applyBindingTarget, BindingTarget, ClientSessionInitializer, GLSPModule, InstanceMultiBinding } from '@eclipse-glsp/server'; +import { interfaces } from 'inversify'; +import { DiagramPngMcpResourceHandler, DiagramSvgMcpResourceHandler } from '../resources'; +import { DefaultElementTypesProvider, ElementTypesProvider } from '../resources/services/element-types-provider'; +import { MarkdownMcpModelSerializer, McpModelSerializer } from '../resources/services/mcp-model-serializer'; +import { + CountElementsMcpToolHandler, + CreateEdgesMcpToolHandler, + CreateNodesMcpToolHandler, + DeleteElementsMcpToolHandler, + DiagramModelMcpToolHandler, + GetSelectionMcpToolHandler, + LayoutMcpToolHandler, + ModifyEdgesMcpToolHandler, + ModifyNodesMcpToolHandler, + QueryElementsMcpToolHandler, + RedoMcpToolHandler, + SaveModelMcpToolHandler, + SetSelectionMcpToolHandler, + SetViewMcpToolHandler, + UndoMcpToolHandler, + ValidateDiagramMcpToolHandler +} from '../tools'; +import { + McpDiagramPromptHandlerFactory, + McpDiagramPromptHandlerRegistry, + McpDiagramPromptHandlerRegistryInitializer +} from './mcp-diagram-prompt-handler-registry'; +import { + McpDiagramResourceHandlerFactory, + McpDiagramResourceHandlerRegistry, + McpDiagramResourceHandlerRegistryInitializer +} from './mcp-diagram-resource-handler-registry'; +import { + McpDiagramToolHandlerFactory, + McpDiagramToolHandlerRegistry, + McpDiagramToolHandlerRegistryInitializer +} from './mcp-diagram-tool-handler-registry'; +import { DefaultMcpIdAliasService, McpIdAliasService } from './mcp-id-alias-service'; +import { McpDiagramScopedInput } from './mcp-input-schemas'; +import { DefaultMcpLabelProvider, McpLabelProvider } from './mcp-label-provider'; +import { AbstractMcpDiagramPromptHandler, McpDiagramPromptHandlerConstructor } from './mcp-prompt-handler'; +import { AbstractMcpDiagramResourceHandler, McpDiagramResourceHandlerConstructor } from './mcp-resource-handler'; +import { BaseMcpDiagramToolHandler, McpDiagramToolHandlerConstructor } from './mcp-tool-handler'; + +/** + * Per-GLSP-client-session DI module for the MCP server. Loaded inside `configureDiagramModule` + * so each `ClientSession.container` gets its own instance of session-scoped services. + * + * Binds the {@link McpIdAliasService}, the {@link McpModelSerializer}, the + * {@link ElementTypesProvider}, and the diagram-scope handler-constructor multi-bindings + * (`McpDiagram*HandlerConstructor`). Adopters subclass and override the `bind*` hooks to swap + * single-instance services and the `configure*` hooks to extend the multi-binding handler sets. + * + * @example + * ```ts + * class WorkflowMcpDiagramModule extends DefaultMcpDiagramModule { + * // Single-instance binding: return the class. + * protected override bindModelSerializer(): BindingTarget { + * return WorkflowMcpModelSerializer; + * } + * // Multi-binding: extend or rebind the default set. + * protected override configureToolHandlers(binding) { + * super.configureToolHandlers(binding); + * binding.add(WorkflowExtraTool); + * binding.rebind(CreateNodesMcpToolHandler, WorkflowCreateNodesMcpToolHandler); + * } + * } + * new WorkflowServerModule().configureDiagramModule( + * new WorkflowDiagramModule(...), + * elkLayoutModule, + * new WorkflowMcpDiagramModule() + * ); + * ``` + */ +export abstract class AbstractMcpDiagramModule extends GLSPModule { + protected bind!: interfaces.Bind; + protected rebind!: interfaces.Rebind; + + protected override configure( + bind: interfaces.Bind, + _unbind: interfaces.Unbind, + isBound: interfaces.IsBound, + rebind: interfaces.Rebind + ): void { + this.bind = bind; + this.rebind = rebind; + const context = { bind, isBound }; + applyBindingTarget(context, McpIdAliasService, this.bindIdAliasService()).inSingletonScope(); + applyBindingTarget(context, McpLabelProvider, this.bindLabelProvider()).inSingletonScope(); + applyBindingTarget(context, McpModelSerializer, this.bindModelSerializer()).inSingletonScope(); + applyBindingTarget(context, ElementTypesProvider, this.bindElementTypesProvider()).inSingletonScope(); + this.configureMultiBinding(new InstanceMultiBinding(McpDiagramToolHandlerConstructor), binding => + this.configureToolHandlers(binding as InstanceMultiBinding) + ); + this.configureMultiBinding( + new InstanceMultiBinding(McpDiagramResourceHandlerConstructor), + binding => this.configureResourceHandlers(binding as InstanceMultiBinding) + ); + this.configureMultiBinding( + new InstanceMultiBinding(McpDiagramPromptHandlerConstructor), + binding => this.configurePromptHandlers(binding as InstanceMultiBinding) + ); + this.configureHandlerRegistries(); + } + + /** + * Bind the per-GLSP-session handler registries, factories, and {@link ClientSessionInitializer}s + * that populate the registries at session-open. Adopters typically don't override. + * + * Each kind (tool / resource / prompt) follows the same shape: + * - `Registry` — singleton-per-session, holds instantiated handlers keyed by `name`. + * - `Factory` — `dynamicValue` that calls `ctx.container.resolve(constructor)`, so the + * handler's `@inject(...)` fields resolve against the live `ClientSession.container`. + * - `RegistryInitializer` — at session-open, reads the constructor multi-binding (added by + * adopter `configure*Handlers` overrides) and instantiates each via the factory. + * Picked up by Inversify alongside core's own `ClientSessionInitializer`s + * (`OperationHandlerRegistryInitializer` etc.). + */ + protected configureHandlerRegistries(): void { + this.bind(McpDiagramToolHandlerRegistry).toSelf().inSingletonScope(); + this.bind(McpDiagramToolHandlerFactory).toDynamicValue( + ctx => (constructor: McpDiagramToolHandlerConstructor) => + ctx.container.resolve>(constructor) + ); + this.bind(McpDiagramToolHandlerRegistryInitializer).toSelf().inSingletonScope(); + this.bind(ClientSessionInitializer).toService(McpDiagramToolHandlerRegistryInitializer); + + this.bind(McpDiagramResourceHandlerRegistry).toSelf().inSingletonScope(); + this.bind(McpDiagramResourceHandlerFactory).toDynamicValue( + ctx => (constructor: McpDiagramResourceHandlerConstructor) => + ctx.container.resolve>(constructor) + ); + this.bind(McpDiagramResourceHandlerRegistryInitializer).toSelf().inSingletonScope(); + this.bind(ClientSessionInitializer).toService(McpDiagramResourceHandlerRegistryInitializer); + + this.bind(McpDiagramPromptHandlerRegistry).toSelf().inSingletonScope(); + this.bind(McpDiagramPromptHandlerFactory).toDynamicValue( + ctx => (constructor: McpDiagramPromptHandlerConstructor) => + ctx.container.resolve>(constructor) + ); + this.bind(McpDiagramPromptHandlerRegistryInitializer).toSelf().inSingletonScope(); + this.bind(ClientSessionInitializer).toService(McpDiagramPromptHandlerRegistryInitializer); + } + + /** + * {@link McpIdAliasService} binding. Override to swap in a custom alias strategy. Bind the + * shipped `NullMcpIdAliasService` (passthrough) to expose raw GLSP ids on the wire — useful + * for diagnostic readability or when token cost isn't a concern. + */ + protected bindIdAliasService(): BindingTarget { + return DefaultMcpIdAliasService; + } + + /** + * {@link McpLabelProvider} binding. Override to teach MCP about adopter-specific label + * locations (e.g., labels nested in header components or compartments). One override here + * covers every read-side echo and every write-side label-edit operation. + */ + protected bindLabelProvider(): BindingTarget { + return DefaultMcpLabelProvider; + } + + /** + * {@link McpModelSerializer} binding. Override to swap in a diagram-type-specific serializer + * (e.g. one that emits JSON, or knows the adopter's element schema). Per-session scope means + * each diagram type provides its own serializer without a server-level rebind. + */ + protected bindModelSerializer(): BindingTarget { + return MarkdownMcpModelSerializer; + } + + /** + * {@link ElementTypesProvider} binding. Override to ship a constant-value list of creatable + * element types for the adopter's diagram type instead of the default impl that scrapes + * {@link OperationHandlerRegistry} (noisy for non-trivial diagrams). + */ + protected bindElementTypesProvider(): BindingTarget { + return DefaultElementTypesProvider; + } + + /** + * Override to add or replace diagram-scope tool handlers. Adopters typically + * `super.configureToolHandlers(binding)` to keep the defaults, then `binding.add(MyTool)` + * for additions or `binding.rebind(StandardTool, MyTool)` for overrides. + */ + protected configureToolHandlers(binding: InstanceMultiBinding): void { + binding.add(CreateNodesMcpToolHandler); + binding.add(CreateEdgesMcpToolHandler); + binding.add(DeleteElementsMcpToolHandler); + binding.add(SaveModelMcpToolHandler); + binding.add(ValidateDiagramMcpToolHandler); + binding.add(DiagramModelMcpToolHandler); + binding.add(QueryElementsMcpToolHandler); + binding.add(CountElementsMcpToolHandler); + binding.add(ModifyNodesMcpToolHandler); + binding.add(ModifyEdgesMcpToolHandler); + binding.add(UndoMcpToolHandler); + binding.add(RedoMcpToolHandler); + binding.add(GetSelectionMcpToolHandler); + binding.add(SetSelectionMcpToolHandler); + binding.add(SetViewMcpToolHandler); + // Auto-skips at session-open via `canRegister()` when no `LayoutEngine` is bound. + binding.add(LayoutMcpToolHandler); + } + + /** See {@link configureToolHandlers}. */ + protected configureResourceHandlers(binding: InstanceMultiBinding): void { + binding.add(DiagramPngMcpResourceHandler); + binding.add(DiagramSvgMcpResourceHandler); + } + + /** + * See {@link configureToolHandlers}. No diagram-scope prompts ship by default — the shipped + * `describe-diagram` and `suggest-improvements` prompts are diagram-type-agnostic and bound + * server-scope. Adopters add their own diagram-type-specific prompts here. + */ + protected configurePromptHandlers(_binding: InstanceMultiBinding): void { + // empty by default + } +} + +/** + * Default {@link AbstractMcpDiagramModule} entry point. Adopters who don't need overrides + * use it directly: `new DefaultMcpDiagramModule()`. Adopters with customizations subclass + * `AbstractMcpDiagramModule` (or this class) and override the `bind*` / `configure*` hooks. + * + * @experimental The MCP integration is under active development. Option names, schema shapes, + * and handler contracts MAY change in minor releases until the feature graduates from + * experimental status. + */ +export class DefaultMcpDiagramModule extends AbstractMcpDiagramModule {} diff --git a/packages/server-mcp/src/server/mcp-diagram-prompt-handler-registry.ts b/packages/server-mcp/src/server/mcp-diagram-prompt-handler-registry.ts new file mode 100644 index 0000000..32e1dd8 --- /dev/null +++ b/packages/server-mcp/src/server/mcp-diagram-prompt-handler-registry.ts @@ -0,0 +1,59 @@ +/******************************************************************************** + * 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 { Args, ClientSessionInitializer, Registry } from '@eclipse-glsp/server'; +import { inject, injectable, optional } from 'inversify'; +import { McpDiagramScopedInput } from './mcp-input-schemas'; +import { AbstractMcpDiagramPromptHandler, McpDiagramPromptHandlerConstructor } from './mcp-prompt-handler'; + +/** See {@link McpDiagramToolHandlerFactory} — same lifecycle pattern, applied to prompt handlers. */ +export type McpDiagramPromptHandlerFactory = ( + constructor: McpDiagramPromptHandlerConstructor +) => AbstractMcpDiagramPromptHandler; +export const McpDiagramPromptHandlerFactory = Symbol('McpDiagramPromptHandlerFactory'); + +/** See {@link McpDiagramToolHandlerRegistry} — same lifecycle pattern, applied to prompt handlers. */ +@injectable() +export class McpDiagramPromptHandlerRegistry extends Registry> { + registerHandler(handler: AbstractMcpDiagramPromptHandler): boolean { + return this.register(handler.name, handler); + } +} + +/** See {@link McpDiagramToolHandlerRegistryInitializer} — same lifecycle pattern, applied to prompt handlers. */ +@injectable() +export class McpDiagramPromptHandlerRegistryInitializer implements ClientSessionInitializer { + @inject(McpDiagramPromptHandlerFactory) protected factory: McpDiagramPromptHandlerFactory; + + @inject(McpDiagramPromptHandlerConstructor) + @optional() + protected constructors: McpDiagramPromptHandlerConstructor[] = []; + + @inject(McpDiagramPromptHandlerRegistry) protected registry: McpDiagramPromptHandlerRegistry; + + initialize(_args?: Args): void { + for (const constructor of this.constructors) { + try { + this.registry.registerHandler(this.factory(constructor)); + } catch (err: unknown) { + throw new Error( + `Failed to instantiate MCP diagram prompt handler '${constructor.name}': ${(err as Error).message}. ` + + 'Check your DiagramModule bindings.' + ); + } + } + } +} diff --git a/packages/server-mcp/src/server/mcp-diagram-resource-handler-registry.ts b/packages/server-mcp/src/server/mcp-diagram-resource-handler-registry.ts new file mode 100644 index 0000000..7aebb0a --- /dev/null +++ b/packages/server-mcp/src/server/mcp-diagram-resource-handler-registry.ts @@ -0,0 +1,73 @@ +/******************************************************************************** + * 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 { Args, ClientId, ClientSessionInitializer, Logger, Registry, TEMPORARY_CLIENT_ID } from '@eclipse-glsp/server'; +import { inject, injectable, optional } from 'inversify'; +import { McpDiagramScopedInput } from './mcp-input-schemas'; +import { AbstractMcpDiagramResourceHandler, McpDiagramResourceHandlerConstructor } from './mcp-resource-handler'; + +/** See {@link McpDiagramToolHandlerFactory} — same lifecycle pattern, applied to resource handlers. */ +export type McpDiagramResourceHandlerFactory = ( + constructor: McpDiagramResourceHandlerConstructor +) => AbstractMcpDiagramResourceHandler; +export const McpDiagramResourceHandlerFactory = Symbol('McpDiagramResourceHandlerFactory'); + +/** See {@link McpDiagramToolHandlerRegistry} — same lifecycle pattern, applied to resource handlers. */ +@injectable() +export class McpDiagramResourceHandlerRegistry extends Registry> { + registerHandler(handler: AbstractMcpDiagramResourceHandler): boolean { + return this.register(handler.name, handler); + } +} + +/** See {@link McpDiagramToolHandlerRegistryInitializer} — same lifecycle pattern, applied to resource handlers. */ +@injectable() +export class McpDiagramResourceHandlerRegistryInitializer implements ClientSessionInitializer { + @inject(McpDiagramResourceHandlerFactory) protected factory: McpDiagramResourceHandlerFactory; + + @inject(McpDiagramResourceHandlerConstructor) + @optional() + protected constructors: McpDiagramResourceHandlerConstructor[] = []; + + @inject(McpDiagramResourceHandlerRegistry) protected registry: McpDiagramResourceHandlerRegistry; + + @inject(ClientId) protected clientId: string; + + @inject(Logger) protected logger: Logger; + + initialize(_args?: Args): void { + // See {@link McpDiagramToolHandlerRegistryInitializer.initialize} for the probe rationale. + const isProbe = this.clientId === TEMPORARY_CLIENT_ID; + for (const constructor of this.constructors) { + let handler: AbstractMcpDiagramResourceHandler; + try { + handler = this.factory(constructor); + } catch (err: unknown) { + throw new Error( + `Failed to instantiate MCP diagram resource handler '${constructor.name}': ${(err as Error).message}. ` + + 'Check your DiagramModule bindings.' + ); + } + if (!handler.canRegister()) { + if (!isProbe) { + this.logger.warn(`Skipping MCP diagram resource handler '${handler.name}': canRegister() returned false.`); + } + continue; + } + this.registry.registerHandler(handler); + } + } +} diff --git a/packages/server-mcp/src/server/mcp-diagram-tool-handler-registry.ts b/packages/server-mcp/src/server/mcp-diagram-tool-handler-registry.ts new file mode 100644 index 0000000..ae42746 --- /dev/null +++ b/packages/server-mcp/src/server/mcp-diagram-tool-handler-registry.ts @@ -0,0 +1,97 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { Args, ClientId, ClientSessionInitializer, Logger, Registry, TEMPORARY_CLIENT_ID } from '@eclipse-glsp/server'; +import { inject, injectable, optional } from 'inversify'; +import { McpDiagramScopedInput } from './mcp-input-schemas'; +import { BaseMcpDiagramToolHandler, McpDiagramToolHandlerConstructor } from './mcp-tool-handler'; + +/** + * Per-GLSP-session factory that resolves a {@link McpDiagramToolHandlerConstructor} against the + * per-session Inversify container. Calls `container.resolve(ctor)` so `@inject(...)` fields on + * the handler are filled from the live `ClientSession.container` (alias service, model state, + * action dispatcher, etc.). + * + * Mirrors core's {@link OperationHandlerFactory} pattern in `DiagramModule`. + */ +export type McpDiagramToolHandlerFactory = ( + constructor: McpDiagramToolHandlerConstructor +) => BaseMcpDiagramToolHandler; +export const McpDiagramToolHandlerFactory = Symbol('McpDiagramToolHandlerFactory'); + +/** + * Per-GLSP-session registry holding one {@link BaseMcpDiagramToolHandler} instance per + * `metadata.name`. Populated at GLSP-session-open by {@link McpDiagramToolHandlerRegistryInitializer}, + * read at MCP-tool-call time by the launcher's dispatcher (looks up the right session, gets its + * registry, resolves the handler by name, invokes `createResult`). + * + * Bound singleton-per-session on the diagram container. + */ +@injectable() +export class McpDiagramToolHandlerRegistry extends Registry> { + /** Convenience that derives the key from `handler.name` so callers don't repeat themselves. */ + registerHandler(handler: BaseMcpDiagramToolHandler): boolean { + return this.register(handler.name, handler); + } +} + +/** + * {@link ClientSessionInitializer} that runs at GLSP-session-open. Reads the per-session + * constructor multi-binding ({@link McpDiagramToolHandlerConstructor}) and instantiates each + * handler via the {@link McpDiagramToolHandlerFactory}, registering the instance with the + * per-session {@link McpDiagramToolHandlerRegistry}. + * + * Same shape as core's `OperationHandlerRegistryInitializer`. + */ +@injectable() +export class McpDiagramToolHandlerRegistryInitializer implements ClientSessionInitializer { + @inject(McpDiagramToolHandlerFactory) protected factory: McpDiagramToolHandlerFactory; + + @inject(McpDiagramToolHandlerConstructor) + @optional() + protected constructors: McpDiagramToolHandlerConstructor[] = []; + + @inject(McpDiagramToolHandlerRegistry) protected registry: McpDiagramToolHandlerRegistry; + + @inject(ClientId) protected clientId: string; + + @inject(Logger) protected logger: Logger; + + initialize(_args?: Args): void { + // Suppress the canRegister-false warn during core's `DefaultGlobalActionProvider` + // startup probe — that probe runs on a throwaway container with empty action-kind set, + // so `canRegister()` returning false there is structural, not a real opt-out. + const isProbe = this.clientId === TEMPORARY_CLIENT_ID; + for (const constructor of this.constructors) { + let handler: BaseMcpDiagramToolHandler; + try { + handler = this.factory(constructor); + } catch (err: unknown) { + throw new Error( + `Failed to instantiate MCP diagram tool handler '${constructor.name}': ${(err as Error).message}. ` + + 'Check your DiagramModule bindings.' + ); + } + if (!handler.canRegister()) { + if (!isProbe) { + this.logger.warn(`Skipping MCP diagram tool handler '${handler.name}': canRegister() returned false.`); + } + continue; + } + this.registry.registerHandler(handler); + } + } +} diff --git a/packages/server-mcp/src/server/mcp-handler-shared.ts b/packages/server-mcp/src/server/mcp-handler-shared.ts new file mode 100644 index 0000000..a487716 --- /dev/null +++ b/packages/server-mcp/src/server/mcp-handler-shared.ts @@ -0,0 +1,247 @@ +/******************************************************************************** + * Copyright (c) 2025-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 { ActionDispatcher, ClientSessionManager, RequestAction, ResponseAction } from '@eclipse-glsp/server'; +import { CallToolResult, GetPromptResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types'; + +/** + * **Note on terminology** — "session" in this file always refers to a **GLSP client session** + * (one open diagram, tracked by core's `ClientSessionManager`). It is unrelated to the + * **MCP session** concept used in {@link mcp-session.ts} and {@link mcp-http-transport.ts} + * (one MCP client connection to the HTTP endpoint). The two have independent lifetimes — see + * the docstring at the top of `mcp-session.ts` for the full disambiguation. + * + * Diagram-scope handler bases ({@link AbstractMcpDiagramToolHandler} et al.) inject their per-session + * services directly per GLSP session. The launcher's dispatcher resolves the + * {@link McpDiagramScopedInput.sessionId} input field via `ClientSessionManager` to route a + * tool/resource/prompt call to the right per-session handler instance. + */ + +// ─── MCP-protocol type aliases (Mcp prefix on GLSP-side) ───────────────────── + +/** Result returned from `tools/call`. Aliased so handler signatures read in GLSP terms. */ +export type McpToolResult = CallToolResult; +/** Result returned from `resources/read`. Aliased so handler signatures read in GLSP terms. */ +export type McpResourceResult = ReadResourceResult; +/** Result returned from `prompts/get`. Aliased so handler signatures read in GLSP terms. */ +export type McpPromptResult = GetPromptResult; +/** One content part of a {@link McpToolResult}. Tool results are an array of these. */ +export type McpToolResultContent = CallToolResult['content'][number]; +/** One content part of a {@link McpResourceResult}. Resource reads are an array of these. */ +export type McpResourceResultContent = ReadResourceResult['contents'][number]; + +/** + * Single-content body returned by a session-scope resource handler. The base wraps it with + * `uri` + the handler's declared `mimeType` to produce the SDK `ReadResourceResult`. + * + * `structured` is dual-emit overflow used only when the resource is exposed as a tool + * (`toolAlternativeInputSchema` set) AND the handler also declares `toolAlternativeOutputSchema`. + * The framework forwards it to `CallToolResult.structuredContent`. Resource-protocol reads ignore + * it — the spec has no equivalent slot on `ReadResourceResult`. + */ +export type McpResourceContent = ({ text: string } | { blob: string }) & { structured?: McpStructuredContent }; + +/** Structured payload for `CallToolResult.structuredContent`. The MCP spec requires an object. */ +export interface McpStructuredContent { + [key: string]: unknown; +} + +// ─── Errors ────────────────────────────────────────────────────────────────── + +/** + * Throw inside `createResult` to surface an expected, user-facing error to the MCP client. + * The base class catches it and emits an `isError: true` result. Unexpected errors + * (non-`McpToolError`) are logged and their extracted message is surfaced too. + * + * Use one of the named subclasses (`McpMissingParamError`, `McpSessionNotFoundError`, + * `McpReadOnlyError`, `McpElementsNotFoundError`, `McpRequestTimeoutError`) where they fit; + * otherwise throw `new McpToolError('context-specific message')`. + */ +export class McpToolError extends Error { + constructor( + message: string, + readonly cause?: unknown + ) { + super(message); + this.name = 'McpToolError'; + } +} + +/** Thrown when a required input parameter is missing or empty. */ +export class McpMissingParamError extends McpToolError { + constructor(readonly paramName: string) { + super(`No '${paramName}' provided.`); + this.name = 'McpMissingParamError'; + } +} + +/** Thrown when no GLSP client session matches the provided id. */ +export class McpSessionNotFoundError extends McpToolError { + constructor(readonly sessionId: string) { + super(`Session not found: ${sessionId}`); + this.name = 'McpSessionNotFoundError'; + } +} + +/** Thrown when a write-style operation targets a read-only model. */ +export class McpReadOnlyError extends McpToolError { + constructor() { + super('Model is read-only.'); + this.name = 'McpReadOnlyError'; + } +} + +/** Thrown by handlers that look up elements by id and find some absent from the model. */ +export class McpElementsNotFoundError extends McpToolError { + constructor(readonly missingIds: readonly string[]) { + super(`Element(s) not found: ${missingIds.join(', ')}`); + this.name = 'McpElementsNotFoundError'; + } +} + +/** Thrown when a request/response round-trip (`ActionDispatcher.requestUntil`) exceeds its timeout. */ +export class McpRequestTimeoutError extends McpToolError { + constructor( + readonly operation: string, + readonly timeoutMs: number + ) { + super(`${operation} timed out after ${timeoutMs}ms.`); + this.name = 'McpRequestTimeoutError'; + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Catch `McpToolError` thrown by pre-handler routing (e.g. `requireDiagramToolHandler`) and + * convert it to an `isError: true` tool result. Without this, those throws surface as + * JSON-RPC `-32603` instead of a self-correctable tool error. + */ +export async function runWithToolErrorEnvelope(producer: () => Promise): Promise { + try { + return await producer(); + } catch (err: unknown) { + if (err instanceof McpToolError) { + return toolErrorResult(err.message, errorCodeFor(err)); + } + throw err; + } +} + +/** Stable codes surfaced in `CallToolResult.structuredContent.code` so the LLM can self-correct on a known taxonomy. */ +export const McpToolErrorCodes = { + /** GLSP session disposed mid-call (server shutdown or session disposal). */ + SessionDisposed: 'session-disposed' +} as const; +export type McpToolErrorCode = (typeof McpToolErrorCodes)[keyof typeof McpToolErrorCodes]; + +/** True when `err` is a GLSP-session-disposed rejection from the action dispatcher's dispose hooks. */ +export function isSessionDisposedError(err: unknown): boolean { + if (!(err instanceof Error)) { + return false; + } + return /ActionDispatcher disposed|cancelled: dispatcher disposed/.test(err.message); +} + +/** Resolve a known `McpToolErrorCode` for an error, or `undefined` if it is generic. */ +export function errorCodeFor(err: unknown): McpToolErrorCode | undefined { + if (isSessionDisposedError(err)) { + return McpToolErrorCodes.SessionDisposed; + } + return undefined; +} + +/** Build an `isError: true` tool result, attaching `structuredContent: { code }` when known. */ +export function toolErrorResult(message: string, code?: McpToolErrorCode): CallToolResult { + return { + isError: true, + content: [{ type: 'text', text: message }], + ...(code ? { structuredContent: { code } } : {}) + }; +} + +/** Returns a useful message for a value caught from a `throw`. */ +export function extractErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + return String(error); +} + +/** + * Dispatch a request action and await its response, surfacing failures uniformly. + * + * The `ActionDispatcher.requestUntil` API today returns `undefined` on timeout (default + * `rejectOnTimeout: false`) and throws when the client emits a `RejectAction`. Handlers + * that consume it have to branch on both. This helper consolidates that branching: + * + * - On client-side `RejectAction` → throws `McpToolError` with `${label} failed: `. + * - On timeout (undefined return) → throws `McpRequestTimeoutError(label, timeoutMs)`. + * - Otherwise → returns the typed response. + * + * Prefer the base-class methods (`OperationMcpDiagramToolHandler.requestAction` / + * `AbstractMcpDiagramResourceHandler.requestAction`) over calling this directly — they default + * the label to the handler's `name` field and pass `this.actionDispatcher`. This free + * function is the canonical implementation behind both. + */ +export async function requestActionOrFail( + dispatcher: ActionDispatcher, + request: RequestAction, + timeoutMs: number, + label: string +): Promise { + let response: R | undefined; + try { + response = await dispatcher.requestUntil(request, timeoutMs); + } catch (err: unknown) { + // Preserve the original rejection error as `cause` so adopters whose `RejectAction` + // sets `detail` (which core flattens into the message via `${message}: ${detail}`) can + // still inspect the underlying object — both for diagnostic logging and for an LLM + // surface that wants to disambiguate transient vs. permanent failures. + throw new McpToolError(`${label} failed: ${extractErrorMessage(err)}`, err); + } + if (!response) { + throw new McpRequestTimeoutError(label, timeoutMs); + } + return response; +} + +/** + * Pick a target session for server-scope handlers (e.g. prompts) where `sessionId` is optional. + * Resolution: explicit id (validated to exist) → single open session → throw with the available + * ids when ambiguous, throw when none open. Keeps user-invoked entry points like slash-command + * prompts ergonomic in the common single-diagram case while staying explicit when not. + */ +export function resolveActiveSessionId(clientSessionManager: ClientSessionManager, explicitSessionId: string | undefined): string { + const sessions = clientSessionManager.getSessions(); + if (explicitSessionId) { + if (!sessions.some(session => session.id === explicitSessionId)) { + throw new McpToolError(`Unknown sessionId: ${explicitSessionId}`); + } + return explicitSessionId; + } + if (sessions.length === 0) { + throw new McpToolError('No open diagram sessions to target.'); + } + if (sessions.length === 1) { + return sessions[0].id; + } + const ids = sessions.map(session => `'${session.id}'`).join(', '); + throw new McpToolError(`Multiple sessions open (${ids}); pass \`sessionId\` to disambiguate.`); +} diff --git a/packages/server-mcp/src/server/mcp-http-transport-e2e.spec.ts b/packages/server-mcp/src/server/mcp-http-transport-e2e.spec.ts new file mode 100644 index 0000000..ba9af71 --- /dev/null +++ b/packages/server-mcp/src/server/mcp-http-transport-e2e.spec.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 { Logger, NullLogger } from '@eclipse-glsp/server'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { expect } from 'chai'; +import { Container, ContainerModule } from 'inversify'; +import * as z from 'zod/v4'; +import { McpHttpTransport } from './mcp-http-transport'; +import { McpServerOptions } from './mcp-options'; +import { rawHttpRequest } from './raw-http.test-util'; + +function buildTransport(): McpHttpTransport { + const container = new Container(); + container.load( + new ContainerModule(bind => { + bind(Logger).toConstantValue(new NullLogger()); + const opts = new McpServerOptions(); + opts.values = {}; + bind(McpServerOptions).toConstantValue(opts); + bind(McpHttpTransport).toSelf().inSingletonScope(); + }) + ); + return container.get(McpHttpTransport); +} + +/** + * WN-058 — End-to-end smoke test that boots {@link McpHttpTransport}, + * connects a real SDK MCP client over Streamable HTTP, and exercises a tool + * round-trip (`echo`). Validates that the transport implements the protocol + * conformantly without depending on the GLSP handler stack. + */ +describe('McpHttpTransport (WN-058 e2e — real MCP SDK client over HTTP)', () => { + let httpServer: McpHttpTransport | undefined; + let client: Client | undefined; + + /** + * Idempotent client close: a test that already tore the SDK client down (e.g. after a + * spec-mandated DELETE on the same session) leaves the local `client` ref pointing at a + * closed instance. `Client.close()` is safe to call twice on the SDK side, but we still + * swallow any throw so afterEach is robust under partial-progress failures too. + */ + async function safeClose(target: Client | undefined): Promise { + if (!target) return; + try { + await target.close(); + } catch { + /* ignore — best effort */ + } + } + + afterEach(async () => { + await safeClose(client); + client = undefined; + httpServer?.dispose(); + httpServer = undefined; + }); + + it('round-trips tools/list and tools/call against a registered echo tool', async () => { + httpServer = buildTransport(); + + // Wire one fresh `McpServer` per accepted session, register an `echo` tool, + // and let the server attach to the transport. Mirrors what `McpServerLauncher` + // does; kept inline here so the test exercises only the transport path. + httpServer.onSessionInitialized(session => { + const mcpServer = new McpServer({ name: 'glsp-test', version: '1.0.0' }, { capabilities: {} }); + mcpServer.registerTool( + 'echo', + { description: 'Returns the supplied message verbatim.', inputSchema: { message: z.string() } }, + async ({ message }) => ({ content: [{ type: 'text', text: message }] }) + ); + mcpServer.connect(session); + }); + + const endpoint = await httpServer.start({ + port: 0, + host: '127.0.0.1', + route: '/mcp', + name: 'glsp-test', + options: { dataMode: 'tools' } + }); + expect(endpoint.url, 'transport must report a URL after start()').to.be.a('string'); + + client = new Client({ name: 'wn-058-test-client', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(new URL(endpoint.url!))); + + const tools = await client.listTools(); + expect(tools.tools.map(tool => tool.name)).to.include('echo'); + + const result = await client.callTool({ name: 'echo', arguments: { message: 'hello GLSP' } }); + expect(result.isError).to.not.equal(true); + expect(result.content).to.have.lengthOf(1); + const [block] = result.content as Array<{ type: string; text?: string }>; + expect(block.type).to.equal('text'); + expect(block.text).to.equal('hello GLSP'); + }); + + it('DELETE /mcp terminates the session; subsequent POSTs with the same id return 404 (§ #5)', async () => { + httpServer = buildTransport(); + httpServer.onSessionInitialized(session => { + const mcpServer = new McpServer({ name: 'glsp-test', version: '1.0.0' }, { capabilities: {} }); + mcpServer.connect(session); + }); + const endpoint = await httpServer.start({ + port: 0, + host: '127.0.0.1', + route: '/mcp', + name: 'glsp-test', + options: { dataMode: 'tools' } + }); + + client = new Client({ name: 'delete-smoke-test', version: '1.0.0' }); + const clientTransport = new StreamableHTTPClientTransport(new URL(endpoint.url!)); + await client.connect(clientTransport); + const sessionId = clientTransport.sessionId; + expect(sessionId, 'SDK transport should expose the minted session id after initialize').to.be.a('string'); + + const { port } = await httpServer.getAddress(); + + // 1. Spec § Session Management #5 — DELETE terminates the session. + const deleteRes = await rawHttpRequest(port, 'DELETE', { 'mcp-session-id': sessionId! }); + expect(deleteRes.status, 'DELETE should succeed for an active session').to.be.lessThan(300); + + // 2. After termination, the same id MUST be rejected with 404 (§ #3) — proves the + // session was actually removed, not just acked. + const followUp = await rawHttpRequest( + port, + 'POST', + { 'mcp-session-id': sessionId! }, + { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} } + ); + expect(followUp.status).to.equal(404); + const payload = JSON.parse(followUp.body); + expect(payload.error.code).to.equal(-32001); + }); +}); diff --git a/packages/server-mcp/src/server/mcp-http-transport.spec.ts b/packages/server-mcp/src/server/mcp-http-transport.spec.ts new file mode 100644 index 0000000..eb37b01 --- /dev/null +++ b/packages/server-mcp/src/server/mcp-http-transport.spec.ts @@ -0,0 +1,385 @@ +/******************************************************************************** + * 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 { Logger, NullLogger } from '@eclipse-glsp/server'; +import { expect } from 'chai'; +import { Container, ContainerModule } from 'inversify'; +import { McpHttpTransport } from './mcp-http-transport'; +import { McpServerOptions } from './mcp-options'; +import { rawHttpRequest } from './raw-http.test-util'; + +/** + * Builds a transport instance with a configurable `McpServerOptions` binding so tests can + * exercise the SDK-level host allowlist (forwarded via `createMcpExpressApp`) and our own + * Origin allowlist (installed in `configureExpressApp`). + */ +function buildTransport(optionValues: McpServerOptions['values'] = {}): McpHttpTransport { + const container = new Container(); + container.load( + new ContainerModule(bind => { + bind(Logger).toConstantValue(new NullLogger()); + const opts = new McpServerOptions(); + opts.values = optionValues; + bind(McpServerOptions).toConstantValue(opts); + bind(McpHttpTransport).toSelf().inSingletonScope(); + }) + ); + return container.get(McpHttpTransport); +} + +describe('McpHttpTransport (startup smoke test)', () => { + let httpServer: McpHttpTransport | undefined; + + afterEach(() => { + // Always tear down so a failing assertion does not leak the listening socket. + httpServer?.dispose(); + httpServer = undefined; + }); + + it('binds an HTTP server on a resolvable port and 127.0.0.1 host', async () => { + httpServer = buildTransport(); + + const endpoint = await httpServer.start({ + port: 0, + host: '127.0.0.1', + route: '/mcp', + name: 'test', + options: { dataMode: 'tools' } + }); + + expect(endpoint.url).to.match(/^http:\/\/127\.0\.0\.1:\d+\/mcp$/); + + const address = await httpServer.getAddress(); + expect(address.address).to.equal('127.0.0.1'); + expect(address.port).to.be.a('number'); + expect(address.port).to.be.greaterThan(0); + }); + + it('exposes the underlying http.Server and Express app after start', async () => { + httpServer = buildTransport(); + + await httpServer.start({ + port: 0, + host: '127.0.0.1', + route: '/mcp', + name: 'test', + options: { dataMode: 'tools' } + }); + + expect(httpServer.app).to.not.be.undefined; + expect(httpServer.server).to.not.be.undefined; + expect(httpServer.server!.listening).to.equal(true); + }); + + it('closes the underlying http.Server on dispose()', async () => { + const local = buildTransport(); + + await local.start({ + port: 0, + host: '127.0.0.1', + route: '/mcp', + name: 'test', + options: { dataMode: 'tools' } + }); + const underlying = local.server!; + expect(underlying.listening).to.equal(true); + + local.dispose(); + + // `Server.close()` is asynchronous — wait for the actual close event. + await new Promise(resolve => { + if (!underlying.listening) { + resolve(); + return; + } + underlying.once('close', () => resolve()); + }); + expect(underlying.listening).to.equal(false); + }); + + it('rejects start() with an actionable error when the requested port is already in use', async () => { + // First transport: bind a random port so we know exactly which port is taken. + const first = buildTransport(); + await first.start({ + port: 0, + host: '127.0.0.1', + route: '/mcp', + name: 'first', + options: { dataMode: 'tools' } + }); + const taken = (await first.getAddress()).port; + + // Second transport: try to bind the same port → EADDRINUSE. + const second = buildTransport(); + try { + await expectStartToReject(second, taken); + } finally { + first.dispose(); + second.dispose(); + } + }); + + async function expectStartToReject(transport: McpHttpTransport, takenPort: number): Promise { + let error: Error | undefined; + try { + await transport.start({ + port: takenPort, + host: '127.0.0.1', + route: '/mcp', + name: 'second', + options: { dataMode: 'tools' } + }); + } catch (err: unknown) { + error = err as Error; + } + expect(error, 'expected start() to reject').to.not.equal(undefined); + // The actionable hint must name the offending host:port AND point at the override path. + expect(error!.message).to.include(`127.0.0.1:${takenPort}`); + expect(error!.message).to.include('mcpServer.port'); + expect(error!.message).to.match(/already in use/); + } +}); + +describe('McpHttpTransport (Origin/Host validation — DNS-rebinding mitigation)', () => { + let httpServer: McpHttpTransport | undefined; + + afterEach(() => { + httpServer?.dispose(); + httpServer = undefined; + }); + + it('rejects requests with a Host header outside the allowlist', async () => { + httpServer = buildTransport({ allowedHosts: ['127.0.0.1', 'localhost'] }); + await httpServer.start({ port: 0, host: '127.0.0.1', route: '/mcp', name: 'test', options: {} }); + const port = (await httpServer.getAddress()).port; + + const res = await rawHttpRequest(port, 'POST', { Host: 'evil.example' }, {}); + + expect(res.status).to.equal(403); + expect(res.body).to.match(/Host/); + }); + + it('accepts requests whose Host header matches the allowlist', async () => { + httpServer = buildTransport({ allowedHosts: ['127.0.0.1', 'localhost'] }); + await httpServer.start({ port: 0, host: '127.0.0.1', route: '/mcp', name: 'test', options: {} }); + const port = (await httpServer.getAddress()).port; + + // No `mcp-session-id` header on a non-init body → session-id gate rejects with 400 + // (not the middleware's 403). What matters here is that we got *past* the middleware. + const res = await rawHttpRequest(port, 'POST', { Host: `127.0.0.1:${port}` }, {}); + + expect(res.status).to.not.equal(403); + }); + + it('rejects requests whose Origin header is outside an explicit allowlist', async () => { + httpServer = buildTransport({ + allowedHosts: ['127.0.0.1', 'localhost'], + allowedOrigins: ['https://my-frontend.example'] + }); + await httpServer.start({ port: 0, host: '127.0.0.1', route: '/mcp', name: 'test', options: {} }); + const port = (await httpServer.getAddress()).port; + + const res = await rawHttpRequest(port, 'POST', { Host: '127.0.0.1', Origin: 'https://evil.example' }, {}); + + expect(res.status).to.equal(403); + expect(res.body).to.match(/Origin/); + }); + + it('skips Origin checks when allowedOrigins is undefined (desktop-IDE default)', async () => { + httpServer = buildTransport({ allowedHosts: ['127.0.0.1', 'localhost'] }); + await httpServer.start({ port: 0, host: '127.0.0.1', route: '/mcp', name: 'test', options: {} }); + const port = (await httpServer.getAddress()).port; + + // With allowedOrigins undefined, ANY Origin (or none) is allowed past the middleware. + const res = await rawHttpRequest(port, 'POST', { Host: '127.0.0.1', Origin: 'https://anything.example' }, {}); + + expect(res.status).to.not.equal(403); + }); +}); + +describe('McpHttpTransport (session-id validation per MCP Streamable HTTP § Session Management)', () => { + let httpServer: McpHttpTransport | undefined; + + afterEach(() => { + httpServer?.dispose(); + httpServer = undefined; + }); + + async function startTransport(): Promise { + httpServer = buildTransport({ allowedHosts: ['127.0.0.1', 'localhost'] }); + await httpServer.start({ port: 0, host: '127.0.0.1', route: '/mcp', name: 'test', options: {} }); + return (await httpServer.getAddress()).port; + } + + it('rejects a non-initialize POST without an Mcp-Session-Id header with 400 (§ #2)', async () => { + const port = await startTransport(); + + const res = await rawHttpRequest(port, 'POST', {}, { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); + + expect(res.status).to.equal(400); + const payload = JSON.parse(res.body); + expect(payload.jsonrpc).to.equal('2.0'); + // eslint-disable-next-line no-null/no-null -- JSON-RPC 2.0 § 5 mandates `null` for unattributable error responses. + expect(payload.id).to.equal(null); + expect(payload.error.code).to.equal(-32000); + expect(payload.error.message).to.match(/No valid session ID/); + }); + + it('rejects a POST with an unknown Mcp-Session-Id with 404 (§ #3)', async () => { + const port = await startTransport(); + + const res = await rawHttpRequest( + port, + 'POST', + { 'mcp-session-id': 'no-such-session' }, + { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} } + ); + + expect(res.status).to.equal(404); + const payload = JSON.parse(res.body); + expect(payload.jsonrpc).to.equal('2.0'); + // eslint-disable-next-line no-null/no-null -- JSON-RPC 2.0 § 5 mandates `null` for unattributable error responses. + expect(payload.id).to.equal(null); + expect(payload.error.code).to.equal(-32001); + expect(payload.error.message).to.match(/Session not found/i); + }); + + it('rejects an initialize POST that carries an unknown Mcp-Session-Id with 404 (§ #3 — must not silently mint)', async () => { + const port = await startTransport(); + + const initBody = { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'test', version: '0.0.1' } + } + }; + + const res = await rawHttpRequest(port, 'POST', { 'mcp-session-id': 'stale-session' }, initBody); + + expect(res.status).to.equal(404); + const payload = JSON.parse(res.body); + expect(payload.error.code).to.equal(-32001); + }); + + it('rejects a GET (SSE stream) with an unknown Mcp-Session-Id with the JSON-RPC envelope', async () => { + const port = await startTransport(); + + const res = await rawHttpRequest(port, 'GET', { 'mcp-session-id': 'no-such-session' }); + + expect(res.status).to.equal(404); + const payload = JSON.parse(res.body); + expect(payload.error.code).to.equal(-32001); + // eslint-disable-next-line no-null/no-null -- JSON-RPC 2.0 § 5 mandates `null` for unattributable error responses. + expect(payload.id).to.equal(null); + }); + + it('rejects a DELETE without an Mcp-Session-Id header with the JSON-RPC envelope', async () => { + const port = await startTransport(); + + const res = await rawHttpRequest(port, 'DELETE', {}); + + expect(res.status).to.equal(400); + const payload = JSON.parse(res.body); + expect(payload.error.code).to.equal(-32000); + // eslint-disable-next-line no-null/no-null -- JSON-RPC 2.0 § 5 mandates `null` for unattributable error responses. + expect(payload.id).to.equal(null); + }); + + // Happy path (initialize POST without session id ⇒ new session) is exercised end-to-end + // by `mcp-http-transport-e2e.spec.ts` against a real SDK client; not duplicated here + // because asserting it inline requires wiring up an `McpServer` to actually respond. +}); + +describe('McpHttpTransport (MCP-Protocol-Version header validation)', () => { + let httpServer: McpHttpTransport | undefined; + + afterEach(() => { + httpServer?.dispose(); + httpServer = undefined; + }); + + async function startTransport(): Promise { + httpServer = buildTransport({ allowedHosts: ['127.0.0.1', 'localhost'] }); + await httpServer.start({ port: 0, host: '127.0.0.1', route: '/mcp', name: 'test', options: {} }); + return (await httpServer.getAddress()).port; + } + + it('rejects a non-initialize POST whose MCP-Protocol-Version is unsupported with HTTP 400', async () => { + const port = await startTransport(); + + const res = await rawHttpRequest( + port, + 'POST', + { 'mcp-session-id': 'doesnt-matter', 'mcp-protocol-version': '1999-01-01' }, + { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} } + ); + + expect(res.status).to.equal(400); + const payload = JSON.parse(res.body); + expect(payload.jsonrpc).to.equal('2.0'); + // eslint-disable-next-line no-null/no-null -- JSON-RPC 2.0 § 5 mandates `null` for unattributable error responses. + expect(payload.id).to.equal(null); + expect(payload.error.code).to.equal(-32000); + expect(payload.error.message).to.match(/Unsupported MCP-Protocol-Version/); + expect(payload.error.message).to.match(/1999-01-01/); + expect(payload.error.message).to.match(/Supported versions/); + }); + + it('rejects a GET whose MCP-Protocol-Version is unsupported with HTTP 400 (header validated before session lookup)', async () => { + const port = await startTransport(); + + const res = await rawHttpRequest(port, 'GET', { 'mcp-session-id': 'any', 'mcp-protocol-version': 'bogus' }); + + expect(res.status).to.equal(400); + const payload = JSON.parse(res.body); + expect(payload.error.message).to.match(/Unsupported MCP-Protocol-Version/); + }); + + it('passes a non-initialize POST through when the MCP-Protocol-Version header is absent (spec defaults to 2025-03-26)', async () => { + // Without the header, the protocol-version middleware must let the request through to + // the next layer (which then enforces session-id rules). Asserting we hit the 400 + // session-id error — not the 400 protocol-version error — proves the middleware + // didn't short-circuit. + const port = await startTransport(); + + const res = await rawHttpRequest(port, 'POST', {}, { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); + + expect(res.status).to.equal(400); + const payload = JSON.parse(res.body); + expect(payload.error.message).to.match(/No valid session ID/); + }); + + it('passes a non-initialize POST through when the MCP-Protocol-Version is one of the supported versions', async () => { + // Header is supported → middleware passes → we hit the 400 session-id check, not the + // 400 protocol-version check. + const port = await startTransport(); + + const res = await rawHttpRequest( + port, + 'POST', + { 'mcp-protocol-version': '2025-06-18' }, + { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} } + ); + + expect(res.status).to.equal(400); + const payload = JSON.parse(res.body); + expect(payload.error.message).to.match(/No valid session ID/); + }); +}); diff --git a/packages/server-mcp/src/server/mcp-http-transport.ts b/packages/server-mcp/src/server/mcp-http-transport.ts new file mode 100644 index 0000000..80da592 --- /dev/null +++ b/packages/server-mcp/src/server/mcp-http-transport.ts @@ -0,0 +1,364 @@ +/******************************************************************************** + * Copyright (c) 2025-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 { Deferred, Disposable, Emitter, Logger } from '@eclipse-glsp/server'; +import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { SUPPORTED_PROTOCOL_VERSIONS, isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; +import type { Express } from 'express'; +import * as express from 'express'; +import * as http from 'http'; +import { inject, injectable } from 'inversify'; +import { AddressInfo } from 'net'; +import { randomUUID } from 'node:crypto'; +import { LruEventStore } from './lru-event-store'; +import { McpServerOptions } from './mcp-options'; +import type { FullMcpServerConfiguration } from './mcp-server-launcher'; +import { McpSession, McpSessionId, WithSessionId } from './mcp-session'; + +/** + * Where this transport can be reached. Network transports populate `url`; + * future in-process or stdio transports would leave it undefined. + */ +export interface TransportEndpoint { + url?: string; + headers?: Record; +} + +@injectable() +export class McpHttpTransport implements Disposable { + protected _app?: Express; + protected _server?: http.Server; + protected _addressInfo = new Deferred(); + + protected sessions = new Map(); + protected onSessionInitializedEmitter = new Emitter(); + onSessionInitialized = this.onSessionInitializedEmitter.event; + protected onSessionClosedEmitter = new Emitter(); + onSessionClosed = this.onSessionClosedEmitter.event; + + @inject(McpServerOptions) protected serverOptions: McpServerOptions; + + constructor(@inject(Logger) protected logger: Logger) {} + + get app(): Express | undefined { + return this._app; + } + + get server(): http.Server | undefined { + return this._server; + } + + getAddress(): Promise { + return this._addressInfo.promise; + } + + async start(config: FullMcpServerConfiguration): Promise { + const { route, host, port } = config; + // `createMcpExpressApp` gives us (a) a base Express app, (b) `express.json()` body + // parsing — load-bearing; without it `req.body` is undefined and `isInitializeRequest` + // can't tell init from non-init — and (c) DNS-rebinding host-header validation for + // the configured allowlist. We forward our `allowedHosts` so the SDK's validator and + // any explicit policy share one source of truth. + this._app = createMcpExpressApp({ host, allowedHosts: this.serverOptions.values.allowedHosts }); + // Allow subclasses to install Express middleware (auth, CORS, rate-limiting, + // request logging) before the MCP routes are registered. Default: origin allowlist. + this.configureExpressApp(this._app); + // MCP-Protocol-Version validation runs after subclass middleware so adopter-installed + // gates (auth, CORS) get first cut, but before the SDK route handlers so an unsupported + // header rejects with HTTP 400 cleanly per spec. + this._app.use(route, this.validateProtocolVersionHeader.bind(this)); + this._app.post(route, this.handlePostRequest.bind(this)); + this._app.get(route, this.handleGetRequest.bind(this)); + this._app.delete(route, this.handleDeleteRequest.bind(this)); + this._server = this._app.listen(port, host); + // Disable the per-request timeout so long-lived SSE GET streams aren't killed during + // chat idle periods. From Node's perspective an SSE response is a single in-progress + // request that lasts as long as the client stays connected, so the default 5-minute + // `requestTimeout` (Node 18.1+) terminates the socket whenever no events flow for + // ≥5 min — the client surfaces this as `TypeError: terminated`. We rely on the MCP + // session-id handshake + `onclose` to detect gone clients. + this._server.requestTimeout = 0; + this._server.on('listening', () => this.listening()); + // Pre-listen errors (typically `EADDRINUSE`) fire on the http.Server. Without a + // listener the deferred address never resolves and `start()` hangs; with it we + // surface an actionable message naming the offending port + the override path. + this._server.on('error', err => this.handleListenError(err, host, port)); + const addressInfo = await this.getAddress(); + return { url: this.toServerUrl(addressInfo, route) }; + } + + /** + * Translate a pre-listen failure into an actionable error and reject the address-info + * deferred so `start()` propagates it to the caller. `EADDRINUSE` gets a tailored hint + * about overriding via `mcpServer.port`; other codes pass through unchanged. + */ + protected handleListenError(err: NodeJS.ErrnoException, host: string, port: number): void { + if (err.code === 'EADDRINUSE') { + const portLabel = port === 0 ? 'requested address' : `${host}:${port}`; + this._addressInfo.reject( + new Error( + `MCP server cannot bind ${portLabel}: address already in use. ` + + 'Pass a different `mcpServer.port` in the GLSP `initialize` call, or omit the port to get a random one.' + ) + ); + return; + } + this._addressInfo.reject(err); + } + + /** + * Hook for subclasses to register middleware on the Express app before the MCP routes + * are mounted. Called once during {@link start}, after the app is created and before + * `POST` / `GET` / `DELETE` handlers are added. + * + * Default behavior: install an Origin allowlist if one is configured. Host-header + * validation is already wired by the SDK's `createMcpExpressApp` (using the same + * `allowedHosts` we forward in {@link start}); we don't duplicate it here. Subclasses + * that override SHOULD `super.configureExpressApp(app)` to keep the origin gate in place; + * pre-existing security middleware can run before or after by calling super at the + * appropriate point. + */ + protected configureExpressApp(app: Express): void { + const allowedOrigins = this.serverOptions.values.allowedOrigins; + if (!allowedOrigins) { + return; + } + app.use((req, res, next) => { + const origin = req.headers.origin; + if (origin && !allowedOrigins.includes(origin)) { + res.status(403).json({ error: `Forbidden: Origin '${origin}' not allowed` }); + return; + } + next(); + }); + } + + /** + * Validate the `MCP-Protocol-Version` header per the Streamable HTTP transport spec. + * Initialize POSTs negotiate the version in the body — the header isn't expected there. + * For every other request: absent header → pass through (the spec mandates the server + * default to `2025-03-26`); present-but-unsupported → respond `400` with a JSON-RPC error + * envelope so the client knows which versions to retry with. + */ + protected validateProtocolVersionHeader(req: express.Request, res: express.Response, next: express.NextFunction): void { + if (req.method === 'POST' && isInitializeRequest(req.body)) { + return next(); + } + const headerValue = req.headers['mcp-protocol-version']; + const version = Array.isArray(headerValue) ? headerValue[0] : headerValue; + if (version === undefined) { + return next(); + } + if (!SUPPORTED_PROTOCOL_VERSIONS.includes(version)) { + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: + `Unsupported MCP-Protocol-Version: '${version}'. ` + + `Supported versions: ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')}.` + }, + id: JSON_RPC_NULL_ID + }); + return; + } + next(); + } + + protected toServerUrl({ address, family, port }: AddressInfo, route: string, protocol = 'http'): string { + const host = address === '::' || address === '0.0.0.0' ? 'localhost' : family === 'IPv6' ? `[${address}]` : address; + return `${protocol}://${host}:${port}${route}`; + } + + protected listening(): void { + const addressInfo = this.server?.address(); + if (!addressInfo) { + this.logger.error('Could not resolve MCP Server address info. Shutting down.'); + this._server?.close(); + return; + } else if (typeof addressInfo === 'string') { + this.logger.error(`MCP Server is unexpectedly listening to pipe or domain socket "${addressInfo}". Shutting down.`); + this._server?.close(); + return; + } + this._addressInfo.resolve(addressInfo); + } + + protected async handlePostRequest(req: express.Request, res: express.Response): Promise { + const client = this.getOrCreateClient(req, res); + if (!client) { + return; + } + this.logger.debug(`Handling POST request for session ${client.sessionId}`); + try { + await client.handleRequest(req, res, req.body); + } catch (err: unknown) { + this.logger.error('Error handling MCP request:', err); + if (!res.headersSent) { + res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error' }, id: JSON_RPC_NULL_ID }); + } + } + } + + /** + * Handle GET requests for SSE streams (using built-in support from StreamableHTTP) + */ + protected async handleGetRequest(req: express.Request, res: express.Response): Promise { + const client = this.getClient(req, res); + if (!client) { + return; + } + + // Check for Last-Event-ID header for resumability + const lastEventId = req.headers['last-event-id'] as string | undefined; + if (lastEventId) { + this.logger.info(`Client reconnecting with Last-Event-ID: ${lastEventId}`); + } else { + this.logger.info(`Establishing new SSE stream for session ${client.sessionId}`); + } + await client.handleRequest(req, res); + } + + /** + * Handle DELETE requests for session termination (according to MCP spec). + */ + protected async handleDeleteRequest(req: express.Request, res: express.Response): Promise { + const client = this.getClient(req, res); + if (!client) { + return; + } + + this.logger.info(`Received session termination request for session ${client.sessionId}`); + try { + // SDK transport closes the session as part of handleRequest. + await client.handleRequest(req, res); + } catch (err: unknown) { + this.logger.error('Error handling session termination:', err); + if (!res.headersSent) { + res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error' }, id: JSON_RPC_NULL_ID }); + } + } + } + + protected getOrCreateClient(req: express.Request, res: express.Response): StreamableHTTPServerTransport | undefined { + // A brand-new session is born on an initialize POST that doesn't assert a session id. + // Every other case falls through to `getClient`, which enforces the spec-mandated + // 400/404 errors — including the case where an initialize POST carries an unknown + // session id (§ #3 — must not silently mint a replacement). + if (!getSessionIdHeader(req) && isInitializeRequest(req.body)) { + return this.createClient(); + } + return this.getClient(req, res); + } + + protected getClient(req: express.Request, res: express.Response): StreamableHTTPServerTransport | undefined { + const sessionId = getSessionIdHeader(req); + if (!sessionId) { + // MCP Streamable HTTP § Session Management #2: a non-initialize request without + // a session id MUST be rejected with HTTP 400. + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Bad Request: No valid session ID provided' }, + id: JSON_RPC_NULL_ID + }); + return undefined; + } + const client = this.sessions.get(sessionId); + if (!client) { + // MCP Streamable HTTP § Session Management #3: requests bearing an unknown or + // terminated session id MUST be answered with HTTP 404 so the client knows to + // re-initialize. + res.status(404).json({ + jsonrpc: '2.0', + error: { code: -32001, message: 'Session not found' }, + id: JSON_RPC_NULL_ID + }); + return undefined; + } + return client; + } + + protected createClient(): StreamableHTTPServerTransport { + const client = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + // Bounded LRU store so resumability via `Last-Event-ID` works without leaking + // memory in long-running deployments. Cap configurable via `eventStoreLimit`. + eventStore: new LruEventStore(this.serverOptions.values.eventStoreLimit, this.logger), + onsessioninitialized: sessionId => { + // Store the transport by session ID when session is initialized + // This avoids race conditions where requests might come in before the session is stored + this.logger.info(`Session initialized with ID: ${sessionId}`); + this.sessions.set(sessionId, client); + this.onSessionInitializedEmitter.fire(client as WithSessionId); + } + }); + client.onclose = () => this.closeClient(client.sessionId); + // Surface transport errors to the GLSP logger. SDK 1.27.1 routes previously-swallowed + // errors here; without an explicit handler they go undiagnosed. + client.onerror = err => this.logger.error(`MCP transport error (session ${client.sessionId ?? ''}):`, err); + return client; + } + + protected closeClient(sessionId?: string): void { + if (!sessionId) { + return; + } + const client = this.sessions.get(sessionId); + if (client) { + this.sessions.delete(sessionId); + client.close(); + this.logger.info(`Closed and removed client with session ID ${sessionId}`); + this.onSessionClosedEmitter.fire(sessionId); + } + } + + dispose(): void { + // Close session transports first so their SSE responses end cleanly. `http.Server.close()` + // only stops accepting new connections — existing sockets stay open until they drain — so + // closing the server first would leave streams hanging until the per-session `client.close()` + // catches up. + Array.from(this.sessions.values()).forEach(client => client.close()); + this.sessions.clear(); + this._server?.close(); + // Reset transient state so a subsequent `start()` call boots cleanly. Required because + // the transport is bound `inSingletonScope()` — without the reset, dispose-then-restart + // (e.g., GLSP server shutdown followed by a fresh `initializeServer`) would reuse the + // dead `_addressInfo` deferred and the closed Express app. + this._app = undefined; + this._server = undefined; + this._addressInfo = new Deferred(); + this.logger.info('Server shutdown complete'); + } +} + +/** + * Read the `mcp-session-id` header. Node's `IncomingHttpHeaders` types unknown headers as + * `string | string[] | undefined`; if a misbehaving client sends the header twice we pick + * the first value rather than coercing the array to `"a,b"` and silently failing the lookup. + */ +function getSessionIdHeader(req: express.Request): string | undefined { + const value = req.headers['mcp-session-id']; + return Array.isArray(value) ? value[0] : value; +} + +/** + * JSON-RPC 2.0 § 5 mandates `null` for error responses where the request id cannot be + * determined (e.g., parse errors, batch-level rejection, missing session id). Centralised so + * the unavoidable `null` literal lives behind one eslint exception instead of many. + */ +// eslint-disable-next-line no-null/no-null +const JSON_RPC_NULL_ID = null; diff --git a/packages/server-mcp/src/server/mcp-id-alias-service.spec.ts b/packages/server-mcp/src/server/mcp-id-alias-service.spec.ts new file mode 100644 index 0000000..eb1da6c --- /dev/null +++ b/packages/server-mcp/src/server/mcp-id-alias-service.spec.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 { expect } from 'chai'; +import { DefaultMcpIdAliasService, NullMcpIdAliasService } from './mcp-id-alias-service'; + +describe('DefaultMcpIdAliasService', () => { + it('returns the same alias on repeated calls with the same id (round-trip stability)', () => { + const service = new DefaultMcpIdAliasService(); + + const aliasA = service.alias('uuid-a'); + const aliasA2 = service.alias('uuid-a'); + const aliasB = service.alias('uuid-b'); + + expect(aliasA).to.equal(aliasA2); + expect(aliasA).to.not.equal(aliasB); + // Aliases are integer strings, starting at "1". + expect(aliasA).to.match(/^\d+$/); + expect(aliasB).to.match(/^\d+$/); + }); + + it('lookup(alias) returns the original id (round-trip)', () => { + const service = new DefaultMcpIdAliasService(); + + const alias = service.alias('uuid-foo'); + expect(service.lookup(alias)).to.equal('uuid-foo'); + }); + + it('lookup(unknown) returns the input verbatim (best-effort fallback)', () => { + const service = new DefaultMcpIdAliasService(); + + // Unknown ids may come from manual user input, copy-paste, or earlier server-side + // state. The service passes them through; downstream existence checks decide. + expect(service.lookup('never-issued')).to.equal('never-issued'); + }); + + it('skips alias candidates that collide with a known real id (no shadowing)', () => { + // Regression: pre-fix, alias() always handed out sequential integers starting at "1". + // A model element whose actual id was "1" would then be shadowed — `lookup("1")` + // resolved to the *aliased* real id rather than passing through to the real "1". + // The service now records every real id seen via alias() and skips counter values + // that would collide. + const service = new DefaultMcpIdAliasService(); + const aliasOf1 = service.alias('1'); + const aliasOfX = service.alias('uuid-other'); + + expect(aliasOf1).to.not.equal('1'); + expect(aliasOfX).to.not.equal('1'); + expect(service.lookup('1')).to.equal('1'); + expect(service.lookup(aliasOf1)).to.equal('1'); + expect(service.lookup(aliasOfX)).to.equal('uuid-other'); + + // Multiple pre-known real ids: every issued alias is outside the known-real set. + const service2 = new DefaultMcpIdAliasService(); + const knownReals = ['1', '2', '3']; + knownReals.forEach(id => service2.alias(id)); + const fresh = service2.alias('uuid-fresh'); + expect(knownReals).to.not.include(fresh); + }); + + it('keeps independent counters and maps across separate instances (per-session isolation)', () => { + // Adopters get one instance per GLSP session; aliases must not bleed between sessions. + const sessionA = new DefaultMcpIdAliasService(); + const sessionB = new DefaultMcpIdAliasService(); + + const aliasA1 = sessionA.alias('uuid-x'); + const aliasA2 = sessionA.alias('uuid-y'); + const aliasB1 = sessionB.alias('uuid-z'); + + // Counters are independent: B's first alias is "1", same as A's first. + expect(aliasA1).to.equal('1'); + expect(aliasA2).to.equal('2'); + expect(aliasB1).to.equal('1'); + + // Bleed-isolation: alias "1" in B resolves to uuid-z, NOT to uuid-x (A's "1"). + expect(sessionA.lookup('1')).to.equal('uuid-x'); + expect(sessionB.lookup('1')).to.equal('uuid-z'); + + // Unknown ids in either session fall through to the input verbatim. + expect(sessionA.lookup('3')).to.equal('3'); + expect(sessionB.lookup('2')).to.equal('2'); + }); +}); + +describe('NullMcpIdAliasService', () => { + it('alias and lookup return the argument unchanged', () => { + const service = new NullMcpIdAliasService(); + + expect(service.alias('uuid-foo')).to.equal('uuid-foo'); + expect(service.lookup('uuid-foo')).to.equal('uuid-foo'); + expect(service.lookup('never-seen')).to.equal('never-seen'); + }); +}); 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..fa1a7f2 --- /dev/null +++ b/packages/server-mcp/src/server/mcp-id-alias-service.ts @@ -0,0 +1,104 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { injectable } from 'inversify'; + +export const McpIdAliasService = Symbol('McpIdAliasService'); + +/** + * Maps real GLSP element ids to a shorter, more LLM-friendly form on the wire. Tool/resource + * handlers `alias` real ids before emitting them and `lookup` incoming aliases before passing + * them to the model. + * + * Bound per GLSP client session — alias state is shared across MCP clients connected to the + * same GLSP session (id↔alias round-tripping must be consistent across clients). Adopters + * who want raw ids on the wire bind {@link NullMcpIdAliasService} via + * {@link DefaultMcpDiagramModule.bindIdAliasService}. + * + * @experimental + */ +export interface McpIdAliasService { + /** + * Returns the alias for the given real id, allocating one on first call. Stable across + * subsequent calls within the same session. + */ + alias(realId: string): string; + /** + * Resolves an alias back to its real id. Falls back to the input verbatim when no mapping + * exists, so callers can pass either an alias or a real id without branching: aliased ids + * round-trip; real ids (manual user input, copy-paste, ids surfaced from earlier + * server-side state) pass through. Downstream existence checks + * (`modelState.index.find`, the operation handler) decide validity. + */ + lookup(aliasOrRealId: string): string; +} + +/** + * Default {@link McpIdAliasService} — sequential integer aliases per session. + * + * Collision avoidance: every real id passed to {@link alias} is also recorded in a known-real + * set. When minting, candidate alias strings that collide with a known real id are skipped, so + * a model element whose actual id is e.g. `"1"` cannot be shadowed by an alias mapped to a + * different real id. This is conditional on the existing convention that every real id + * surfaced to the LLM flows through `alias()` at least once before the LLM can refer to it — + * adopter-written handlers that bypass `alias()` re-open the corner case. + */ +@injectable() +export class DefaultMcpIdAliasService implements McpIdAliasService { + protected idToAlias = new Map(); + protected aliasToId = new Map(); + protected realIds = new Set(); + protected counter = 1; + + alias(realId: string): string { + this.realIds.add(realId); + const existingAlias = this.idToAlias.get(realId); + if (existingAlias) { + return existingAlias; + } + + let candidate = this.counter.toString(); + while (this.realIds.has(candidate)) { + this.counter += 1; + candidate = this.counter.toString(); + } + this.counter += 1; + + this.idToAlias.set(realId, candidate); + this.aliasToId.set(candidate, realId); + + return candidate; + } + + lookup(aliasOrRealId: string): string { + return this.aliasToId.get(aliasOrRealId) ?? aliasOrRealId; + } +} + +/** + * Null-object {@link McpIdAliasService} — passes ids through unchanged. Bind this in + * {@link DefaultMcpDiagramModule.bindIdAliasService} to expose raw GLSP ids on the wire. + */ +@injectable() +export class NullMcpIdAliasService implements McpIdAliasService { + alias(realId: string): string { + return realId; + } + + lookup(aliasOrRealId: string): string { + return aliasOrRealId; + } +} diff --git a/packages/server-mcp/src/server/mcp-input-schemas.ts b/packages/server-mcp/src/server/mcp-input-schemas.ts new file mode 100644 index 0000000..7081915 --- /dev/null +++ b/packages/server-mcp/src/server/mcp-input-schemas.ts @@ -0,0 +1,82 @@ +/******************************************************************************** + * 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 * as z from 'zod/v4'; + +/** + * Common Zod input-schema fragments shared across MCP tool / resource / prompt handlers. + * Adopters compose these via `z.object({ ... })` or `McpDiagramScopedInputSchema.extend(...)` + * — Zod's modifiers (`.describe`, `.optional`, `.min`, ...) return new schemas without mutating + * the exports, so the shared fragments can be safely adapted at the call site. + * + * @example + * ```ts + * import { McpDiagramScopedInputSchema, elementIds } from '@eclipse-glsp/server-mcp'; + * + * // Use as-is — the tool's `description` field already conveys the action context: + * const inputSchema = McpDiagramScopedInputSchema.extend({ elementIds }); + * + * // Override `describe` only when there's genuinely new info (defaults, conditional applicability): + * const inputSchema = McpDiagramScopedInputSchema.extend({ + * elementIds: elementIds.optional().describe('If not provided, validates entire model.') + * }); + * ``` + */ + +/** GLSP client session id (open diagram). Resolved by the launcher dispatcher via `ClientSessionManager`. */ +export const sessionId = z.string().describe('GLSP client session id (open diagram).'); + +/** Single element id — alias or real; handlers translate via `resolveIds` on the diagram base. */ +export const elementId = z.string(); + +/** One or more element ids. Empty arrays are rejected. */ +export const elementIds = z.array(z.string()).min(1); + +/** Cartesian position used by node-creation / -modification tools. */ +export const position = z + .object({ + x: z.number().describe('X coordinate in diagram space'), + y: z.number().describe('Y coordinate in diagram space') + }) + .strict(); + +/** + * Base schema for diagram-scope tool / prompt / resource-tool-alternative input. Adopter + * schemas extend this via {@link z.ZodObject.extend} and add their tool-specific fields. + */ +export const McpDiagramScopedInputSchema = z.object({ sessionId }); + +/** + * Compact identity echoed by mutating tools (create / modify / delete) so the LLM has enough + * context to refer to the element by label or type in user-facing prose without a follow-up + * `query-elements`. The `id` is the alias. + */ +export const ElementIdentitySchema = z.object({ + id: z.string().describe('Aliased element id.'), + elementTypeId: z.string().describe('Element type id (e.g. `node:foo`, `edge`).'), + label: z.string().optional().describe('Primary label text, when the element has one.') +}); + +/** Inferred shape of {@link ElementIdentitySchema} — see its docstring for usage. */ +export type ElementIdentity = z.infer; + +/** + * Inferred shape of {@link McpDiagramScopedInputSchema} — `{ sessionId: string }`. Any + * adopter input schema that extends `McpDiagramScopedInputSchema` infers a type structurally + * assignable to this; used as the upper bound on diagram-scope handler generics + * (``). + */ +export type McpDiagramScopedInput = z.infer; diff --git a/packages/server-mcp/src/server/mcp-label-provider.ts b/packages/server-mcp/src/server/mcp-label-provider.ts new file mode 100644 index 0000000..5afd47e --- /dev/null +++ b/packages/server-mcp/src/server/mcp-label-provider.ts @@ -0,0 +1,52 @@ +/******************************************************************************** + * 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 { injectable } from 'inversify'; + +export const McpLabelProvider = Symbol('McpLabelProvider'); + +/** + * Locates the {@link GLabel} that primarily represents an element to a user. Centralises the + * label-lookup logic so both LLM-facing reads (label-text echoes in tool results, + * `query-elements` rendering) and writes (`ApplyLabelEditOperation` from `create-nodes` / + * `modify-nodes`) share one override point. + * + * Diagram-scope: bound per GLSP session via {@link DefaultMcpDiagramModule.bindLabelProvider}. + * Adopters with non-trivial label structures (nested headers, compartments) override the + * {@link DefaultMcpLabelProvider} once instead of per-handler. + * + * @experimental + */ +export interface McpLabelProvider { + /** + * The {@link GLabel} primarily representing this element to a user. Returns `undefined` + * when the element has no label. + */ + getLabel(element: GModelElement): GLabel | undefined; +} + +/** + * Default {@link McpLabelProvider}: returns the first direct {@link GLabel} child. Adopters + * whose elements wrap labels in intermediary container nodes (headers, compartments) subclass + * and override {@link getLabel}. + */ +@injectable() +export class DefaultMcpLabelProvider implements McpLabelProvider { + getLabel(element: GModelElement): GLabel | undefined { + return element.children.find((child): child is GLabel => child instanceof GLabel); + } +} diff --git a/packages/server-mcp/src/server/mcp-log-level-registry.spec.ts b/packages/server-mcp/src/server/mcp-log-level-registry.spec.ts new file mode 100644 index 0000000..a48ff66 --- /dev/null +++ b/packages/server-mcp/src/server/mcp-log-level-registry.spec.ts @@ -0,0 +1,75 @@ +/******************************************************************************** + * 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 { expect } from 'chai'; +import { DefaultMcpLogLevelRegistry, passesLogThreshold } from './mcp-log-level-registry'; + +describe('passesLogThreshold (G4 severity gate)', () => { + it('passes a level whose severity is at or above the threshold (RFC 5424: lower number = more severe)', () => { + // Threshold 'warning' (4) keeps emergency..warning, drops notice..debug. + expect(passesLogThreshold('emergency', 'warning')).to.equal(true); + expect(passesLogThreshold('error', 'warning')).to.equal(true); + expect(passesLogThreshold('warning', 'warning')).to.equal(true); + expect(passesLogThreshold('notice', 'warning')).to.equal(false); + expect(passesLogThreshold('info', 'warning')).to.equal(false); + expect(passesLogThreshold('debug', 'warning')).to.equal(false); + }); + + it('passes everything when the threshold is debug (the default)', () => { + for (const level of ['emergency', 'alert', 'critical', 'error', 'warning', 'notice', 'info', 'debug'] as const) { + expect(passesLogThreshold(level, 'debug')).to.equal(true); + } + }); + + it('passes only emergency when the threshold is emergency', () => { + expect(passesLogThreshold('emergency', 'emergency')).to.equal(true); + expect(passesLogThreshold('alert', 'emergency')).to.equal(false); + expect(passesLogThreshold('debug', 'emergency')).to.equal(false); + }); +}); + +describe('DefaultMcpLogLevelRegistry', () => { + it('returns the default level for an unknown session id', () => { + const registry = new DefaultMcpLogLevelRegistry(); + expect(registry.getLevel('never-set')).to.equal(DefaultMcpLogLevelRegistry.DEFAULT_LEVEL); + }); + + it('returns the default level when sessionId is undefined (out-of-band logger calls)', () => { + const registry = new DefaultMcpLogLevelRegistry(); + expect(registry.getLevel(undefined)).to.equal(DefaultMcpLogLevelRegistry.DEFAULT_LEVEL); + }); + + it('persists the most recent setLevel value per session and isolates across sessions', () => { + const registry = new DefaultMcpLogLevelRegistry(); + registry.setLevel('A', 'warning'); + registry.setLevel('B', 'error'); + expect(registry.getLevel('A')).to.equal('warning'); + expect(registry.getLevel('B')).to.equal('error'); + + registry.setLevel('A', 'info'); + expect(registry.getLevel('A')).to.equal('info'); + expect(registry.getLevel('B')).to.equal('error'); + }); + + it('clear(sessionId) drops the entry so a recycled session id starts at the default', () => { + const registry = new DefaultMcpLogLevelRegistry(); + registry.setLevel('reused', 'error'); + expect(registry.getLevel('reused')).to.equal('error'); + + registry.clear('reused'); + expect(registry.getLevel('reused')).to.equal(DefaultMcpLogLevelRegistry.DEFAULT_LEVEL); + }); +}); diff --git a/packages/server-mcp/src/server/mcp-log-level-registry.ts b/packages/server-mcp/src/server/mcp-log-level-registry.ts new file mode 100644 index 0000000..32f21a2 --- /dev/null +++ b/packages/server-mcp/src/server/mcp-log-level-registry.ts @@ -0,0 +1,90 @@ +/******************************************************************************** + * 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 { LoggingLevel } from '@modelcontextprotocol/sdk/types.js'; +import { injectable } from 'inversify'; + +export const McpLogLevelRegistry = Symbol('McpLogLevelRegistry'); + +/** + * Per-MCP-session minimum-severity threshold for `notifications/message`. Updated by the + * server's `logging/setLevel` request handler (registered in {@link McpServerLauncher} on + * session-init); read by {@link McpLogger} to gate message delivery. + * + * Bound as a server-scope singleton: one registry shared across MCP sessions, keyed by + * session id. On session-close, the entry is cleared so a recycled session id does not + * inherit a stale threshold. + */ +export interface McpLogLevelRegistry { + /** Update the minimum severity for a session. Called by the SDK setLevel request handler. */ + setLevel(sessionId: string, level: LoggingLevel): void; + /** Resolve the active threshold for a session, falling back to the default for unknown ids. */ + getLevel(sessionId: string | undefined): LoggingLevel; + /** Drop the per-session entry on session-close. */ + clear(sessionId: string): void; +} + +/** + * RFC 5424 severity numbering, mirroring the MCP `LoggingLevel` enum. Lower number = more + * severe; the threshold compares numerically. + */ +const SEVERITY: Record = { + emergency: 0, + alert: 1, + critical: 2, + error: 3, + warning: 4, + notice: 5, + info: 6, + debug: 7 +}; + +/** + * Returns `true` iff a message of severity {@link level} should be delivered given the + * session's current {@link threshold}. A `setLevel('warning')` call drops `notice`, `info`, + * and `debug`; `setLevel('debug')` keeps everything (the default). + */ +export function passesLogThreshold(level: LoggingLevel, threshold: LoggingLevel): boolean { + return SEVERITY[level] <= SEVERITY[threshold]; +} + +@injectable() +export class DefaultMcpLogLevelRegistry implements McpLogLevelRegistry { + /** + * Default threshold used when the client has not sent `logging/setLevel`. `'debug'` is the + * MCP-spec-permitted "send everything" mode (the spec lets the server decide if no + * setLevel was received). Set wide so adopters who never wire setLevel see the same + * verbose behavior the server had before this registry existed. + */ + static readonly DEFAULT_LEVEL: LoggingLevel = 'debug'; + + protected readonly levels = new Map(); + + setLevel(sessionId: string, level: LoggingLevel): void { + this.levels.set(sessionId, level); + } + + getLevel(sessionId: string | undefined): LoggingLevel { + if (sessionId === undefined) { + return DefaultMcpLogLevelRegistry.DEFAULT_LEVEL; + } + return this.levels.get(sessionId) ?? DefaultMcpLogLevelRegistry.DEFAULT_LEVEL; + } + + clear(sessionId: string): void { + this.levels.delete(sessionId); + } +} diff --git a/packages/server-mcp/src/server/mcp-logger.spec.ts b/packages/server-mcp/src/server/mcp-logger.spec.ts new file mode 100644 index 0000000..f1d566f --- /dev/null +++ b/packages/server-mcp/src/server/mcp-logger.spec.ts @@ -0,0 +1,227 @@ +/******************************************************************************** + * 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 { Logger, NullLogger } from '@eclipse-glsp/server'; +import { ServerNotification } from '@modelcontextprotocol/sdk/types.js'; +import { expect } from 'chai'; +import { Container, ContainerModule } from 'inversify'; +import { DefaultMcpLogLevelRegistry, McpLogLevelRegistry } from './mcp-log-level-registry'; +import { McpLogger } from './mcp-logger'; +import { McpRequestExtra, mcpRequestContext } from './mcp-request-context'; + +interface RecordedLog { + level: string; + message: string; +} + +class RecordingLogger extends NullLogger { + readonly entries: RecordedLog[] = []; + override info(message: string): void { + this.entries.push({ level: 'info', message }); + } + override warn(message: string): void { + this.entries.push({ level: 'warn', message }); + } + override error(message: string): void { + this.entries.push({ level: 'error', message }); + } + override debug(message: string): void { + this.entries.push({ level: 'debug', message }); + } +} + +function buildLogger(): { logger: McpLogger; glspLogger: RecordingLogger; levelRegistry: DefaultMcpLogLevelRegistry } { + const container = new Container(); + const glspLogger = new RecordingLogger(); + const levelRegistry = new DefaultMcpLogLevelRegistry(); + container.load( + new ContainerModule(bind => { + bind(Logger).toConstantValue(glspLogger); + bind(McpLogLevelRegistry).toConstantValue(levelRegistry); + bind(McpLogger).toSelf().inSingletonScope(); + }) + ); + return { logger: container.get(McpLogger), glspLogger, levelRegistry }; +} + +/** + * Build a stub `RequestHandlerExtra` with a recording `sendNotification`. Only `sendNotification` + * is exercised by `McpLogger`; the remaining fields are never read here, so we cast rather + * than fabricate the full SDK shape. + */ +function buildExtra(sessionId?: string): { extra: McpRequestExtra; sent: ServerNotification[] } { + const sent: ServerNotification[] = []; + const extra = { + sessionId, + sendNotification: async (n: ServerNotification) => { + sent.push(n); + } + } as unknown as McpRequestExtra; + return { extra, sent }; +} + +describe('McpLogger', () => { + describe('outside a request context (no MCP client to deliver to)', () => { + it('routes info/warn/error/debug to the GLSP Logger only', () => { + const { logger, glspLogger } = buildLogger(); + + logger.info('hello'); + logger.warn('careful'); + logger.error('boom'); + logger.debug('trace'); + + expect(glspLogger.entries).to.deep.equal([ + { level: 'info', message: 'hello' }, + { level: 'warn', message: 'careful' }, + { level: 'error', message: 'boom' }, + { level: 'debug', message: 'trace' } + ]); + }); + }); + + describe('inside an mcpRequestContext.run frame', () => { + it('emits notifications/message to the bound MCP client AND the GLSP Logger', async () => { + const { logger, glspLogger } = buildLogger(); + const { extra, sent } = buildExtra(); + + await mcpRequestContext.run(extra, async () => { + logger.info('one'); + logger.warn('two'); + logger.error('three'); + logger.debug('four'); + // Allow the fire-and-forget `.catch` chain in McpLogger.notify to settle. + await new Promise(resolve => setImmediate(resolve)); + }); + + expect(glspLogger.entries.map(e => e.message)).to.deep.equal(['one', 'two', 'three', 'four']); + expect( + sent.map(n => ({ + method: n.method, + level: (n.params as { level: string }).level, + data: (n.params as { data: string }).data + })) + ).to.deep.equal([ + { method: 'notifications/message', level: 'info', data: 'one' }, + { method: 'notifications/message', level: 'warning', data: 'two' }, // GLSP warn → MCP warning + { method: 'notifications/message', level: 'error', data: 'three' }, + { method: 'notifications/message', level: 'debug', data: 'four' } + ]); + }); + + it('swallows transport failures so a broken MCP send never breaks the producing tool', async () => { + const { logger, glspLogger } = buildLogger(); + const failingExtra = { + sendNotification: async () => { + throw new Error('transport closed'); + } + } as unknown as McpRequestExtra; + + await mcpRequestContext.run(failingExtra, async () => { + expect(() => logger.error('still works')).to.not.throw(); + await new Promise(resolve => setImmediate(resolve)); + }); + + // Server-side log still fired. + expect(glspLogger.entries).to.deep.equal([{ level: 'error', message: 'still works' }]); + }); + }); + + describe('concurrent request contexts', () => { + it('keeps each request frame isolated via AsyncLocalStorage', async () => { + const { logger } = buildLogger(); + const { extra: extraA, sent: sentA } = buildExtra(); + const { extra: extraB, sent: sentB } = buildExtra(); + + await Promise.all([ + mcpRequestContext.run(extraA, async () => { + logger.info('A'); + await new Promise(resolve => setImmediate(resolve)); + logger.info('A-after-yield'); + }), + mcpRequestContext.run(extraB, async () => { + logger.info('B'); + await new Promise(resolve => setImmediate(resolve)); + logger.info('B-after-yield'); + }) + ]); + + expect(sentA.map(n => (n.params as { data: string }).data)).to.deep.equal(['A', 'A-after-yield']); + expect(sentB.map(n => (n.params as { data: string }).data)).to.deep.equal(['B', 'B-after-yield']); + }); + }); + + describe('logging/setLevel threshold gate (G4)', () => { + it('drops messages below the per-session threshold; the GLSP-side log still fires', async () => { + const { logger, glspLogger, levelRegistry } = buildLogger(); + const { extra, sent } = buildExtra('session-X'); + // Client opted down to "warning" — info and debug must be dropped on the MCP side. + levelRegistry.setLevel('session-X', 'warning'); + + await mcpRequestContext.run(extra, async () => { + logger.info('chatty'); + logger.debug('verbose'); + logger.warn('important'); + logger.error('critical'); + await new Promise(resolve => setImmediate(resolve)); + }); + + // GLSP-side log path is independent of the MCP threshold — keeps adopter logs intact. + expect(glspLogger.entries.map(entry => entry.message)).to.deep.equal(['chatty', 'verbose', 'important', 'critical']); + // MCP side: only warn + error survive. + expect(sent.map(n => (n.params as { level: string }).level)).to.deep.equal(['warning', 'error']); + }); + + it('isolates thresholds across sessions (different setLevel per session id)', async () => { + const { logger, levelRegistry } = buildLogger(); + const { extra: extraA, sent: sentA } = buildExtra('session-A'); + const { extra: extraB, sent: sentB } = buildExtra('session-B'); + levelRegistry.setLevel('session-A', 'error'); + levelRegistry.setLevel('session-B', 'debug'); + + await Promise.all([ + mcpRequestContext.run(extraA, async () => { + logger.info('A-info'); + logger.error('A-error'); + await new Promise(resolve => setImmediate(resolve)); + }), + mcpRequestContext.run(extraB, async () => { + logger.info('B-info'); + logger.error('B-error'); + await new Promise(resolve => setImmediate(resolve)); + }) + ]); + + expect(sentA.map(n => (n.params as { data: string }).data)).to.deep.equal(['A-error']); + expect(sentB.map(n => (n.params as { data: string }).data).sort()).to.deep.equal(['B-error', 'B-info']); + }); + + it('default threshold (no setLevel sent) lets every level through (preserves prior behavior)', async () => { + const { logger } = buildLogger(); + const { extra, sent } = buildExtra('session-default'); + // No setLevel call → default threshold = 'debug' → everything emitted. + + await mcpRequestContext.run(extra, async () => { + logger.debug('d'); + logger.info('i'); + logger.warn('w'); + logger.error('e'); + await new Promise(resolve => setImmediate(resolve)); + }); + + expect(sent.map(n => (n.params as { level: string }).level)).to.deep.equal(['debug', 'info', 'warning', 'error']); + }); + }); +}); diff --git a/packages/server-mcp/src/server/mcp-logger.ts b/packages/server-mcp/src/server/mcp-logger.ts new file mode 100644 index 0000000..62bc0b0 --- /dev/null +++ b/packages/server-mcp/src/server/mcp-logger.ts @@ -0,0 +1,91 @@ +/******************************************************************************** + * 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 { Logger } from '@eclipse-glsp/server'; +import type { LoggingLevel } from '@modelcontextprotocol/sdk/types.js'; +import { inject, injectable } from 'inversify'; +import { McpLogLevelRegistry, passesLogThreshold } from './mcp-log-level-registry'; +import { mcpRequestContext } from './mcp-request-context'; + +/** + * Logger that writes to BOTH the GLSP-side server log and the connected MCP client. + * + * Mirrors the {@link Logger} shape so handlers can drop-in switch from `@inject(Logger)` to + * `@inject(McpLogger)`. The server-side route always fires; the MCP-side route fires only when + * a call is made from inside an MCP request callback (tracked via {@link mcpRequestContext}). + * + * We deliberately do NOT auto-forward arbitrary GLSP `Logger.info` calls to MCP clients — + * that would leak unrelated server-wide log lines into every connected LLM. Adopters opt in + * per-handler by injecting `McpLogger` instead of `Logger`. + * + * Level mapping (GLSP → MCP, RFC 5424 names per the MCP spec): + * - `info` → `info` + * - `warn` → `warning` + * - `error` → `error` + * - `debug` → `debug` + * + * Shared across MCP clients on the same GLSP session; per-client routing is handled by the + * active `mcpRequestContext` frame, and the per-MCP-session `logging/setLevel` threshold is + * stored in {@link McpLogLevelRegistry}. + * + * @experimental + */ +@injectable() +export class McpLogger { + @inject(Logger) protected glspLogger: Logger; + + @inject(McpLogLevelRegistry) protected levelRegistry: McpLogLevelRegistry; + + info(message: string, ...meta: unknown[]): void { + this.glspLogger.info(message, ...meta); + this.notify('info', message); + } + + warn(message: string, ...meta: unknown[]): void { + this.glspLogger.warn(message, ...meta); + this.notify('warning', message); + } + + error(message: string, ...meta: unknown[]): void { + this.glspLogger.error(message, ...meta); + this.notify('error', message); + } + + debug(message: string, ...meta: unknown[]): void { + this.glspLogger.debug(message, ...meta); + this.notify('debug', message); + } + + /** + * Send a `notifications/message` to the connected MCP client when invoked inside an active + * request context AND the message passes the session's `logging/setLevel` threshold. + * Outside a request context (init contributions, background timers) this is a no-op so the + * same logger can be used everywhere without orphan-notification leaks. + * + * Failures to deliver are swallowed — a broken transport must not break the producing tool. + */ + protected notify(level: LoggingLevel, data: string): void { + const extra = mcpRequestContext.getStore(); + if (!extra) { + return; + } + const threshold = this.levelRegistry.getLevel(extra.sessionId); + if (!passesLogThreshold(level, threshold)) { + return; + } + extra.sendNotification({ method: 'notifications/message', params: { level, data } }).catch(() => undefined); + } +} diff --git a/packages/server-mcp/src/server/mcp-mime-types.ts b/packages/server-mcp/src/server/mcp-mime-types.ts new file mode 100644 index 0000000..c192241 --- /dev/null +++ b/packages/server-mcp/src/server/mcp-mime-types.ts @@ -0,0 +1,31 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { ProposalString } from '@eclipse-glsp/protocol'; + +/** + * MIME type for an MCP resource. Annotating a `mimeType` field with this type prompts the IDE + * to suggest the listed common values; any other string the MCP SDK accepts is also valid (the + * `(string & {})` part of `ProposalString` keeps the field free-form). + * + * Unlike `MarkersReason` / `EditMode` (closed sets of framework-defined enum values), MIME + * types are open and IANA-defined — the listed literals are common, not exhaustive. There's + * no companion const object: at the call site, adopters write the string literal directly + * (`readonly mimeType: McpMimeType = 'image/png';`). + */ +export type McpMimeType = ProposalString< + 'text/plain' | 'text/markdown' | 'text/html' | 'application/json' | 'image/png' | 'image/jpeg' | 'image/svg+xml' +>; diff --git a/packages/server-mcp/src/server/mcp-options.ts b/packages/server-mcp/src/server/mcp-options.ts new file mode 100644 index 0000000..7663b93 --- /dev/null +++ b/packages/server-mcp/src/server/mcp-options.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 + ********************************************************************************/ + +import { McpServerOptions as McpServerOptionsType } from '@eclipse-glsp/protocol'; +import { injectable } from 'inversify'; + +/** + * Holds the launcher-scoped MCP options. The launcher merges adopter-provided defaults + * (passed to the {@link AbstractMcpServerModule} constructor, bound as + * {@link McpServerDefaults}) with deployment-time overrides from the GLSP `initialize` request, + * then writes the merged result to `values`. Consumers `@inject(McpServerOptions)` and read + * `.values.` directly. + * + * Bound as a singleton on the server container — the shared reference means server-scope + * singletons constructed before init still observe populated values once init runs. + * + * @experimental + */ +@injectable() +export class McpServerOptions { + values: McpServerOptionsType = {}; +} + +/** + * DI binding identifier for adopter-provided default options. Supplied via the + * {@link AbstractMcpServerModule} constructor and merged with init-time options by + * `McpServerLauncher.initializeServer` — init-time wins per field. + */ +export const McpServerDefaults = Symbol('McpServerDefaults'); +export type McpServerDefaults = McpServerOptionsType; diff --git a/packages/server-mcp/src/server/mcp-progress-reporter.spec.ts b/packages/server-mcp/src/server/mcp-progress-reporter.spec.ts new file mode 100644 index 0000000..755ac7d --- /dev/null +++ b/packages/server-mcp/src/server/mcp-progress-reporter.spec.ts @@ -0,0 +1,93 @@ +/******************************************************************************** + * 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 { ServerNotification } from '@modelcontextprotocol/sdk/types.js'; +import { expect } from 'chai'; +import { McpProgressReporter } from './mcp-progress-reporter'; +import { McpRequestExtra, mcpRequestContext } from './mcp-request-context'; + +function buildExtra(progressToken?: string | number): { extra: McpRequestExtra; sent: ServerNotification[] } { + const sent: ServerNotification[] = []; + const extra = { + sendNotification: async (n: ServerNotification) => { + sent.push(n); + }, + _meta: progressToken === undefined ? undefined : { progressToken } + } as unknown as McpRequestExtra; + return { extra, sent }; +} + +describe('McpProgressReporter', () => { + it('no-ops inside a request that has no progressToken (client did not opt in)', async () => { + const reporter = new McpProgressReporter(); + const { extra, sent } = buildExtra(undefined); + + await mcpRequestContext.run(extra, async () => { + await reporter.emit({ progress: 0, message: 'starting' }); + }); + + expect(sent).to.have.lengthOf(0); + }); + + it('emits notifications/progress when the request carries a progressToken', async () => { + const reporter = new McpProgressReporter(); + const { extra, sent } = buildExtra('tok-42'); + + await mcpRequestContext.run(extra, async () => { + await reporter.emit({ progress: 0, message: 'starting' }); + await reporter.emit({ progress: 1, total: 3, message: 'step 1/3' }); + }); + + expect(sent).to.have.lengthOf(2); + expect(sent[0]).to.deep.equal({ + method: 'notifications/progress', + params: { progressToken: 'tok-42', progress: 0, message: 'starting' } + }); + expect(sent[1]).to.deep.equal({ + method: 'notifications/progress', + params: { progressToken: 'tok-42', progress: 1, total: 3, message: 'step 1/3' } + }); + }); + + it('accepts numeric progress tokens', async () => { + const reporter = new McpProgressReporter(); + const { extra, sent } = buildExtra(7); + + await mcpRequestContext.run(extra, async () => { + await reporter.emit({ progress: 0 }); + }); + + expect(sent).to.have.lengthOf(1); + expect(sent[0].params).to.deep.equal({ progressToken: 7, progress: 0 }); + }); + + it('swallows transport failures so a broken send never breaks the producing tool', async () => { + const reporter = new McpProgressReporter(); + const failingExtra = { + sendNotification: async () => { + throw new Error('transport closed'); + }, + _meta: { progressToken: 'tok-1' } + } as unknown as McpRequestExtra; + + await mcpRequestContext.run(failingExtra, async () => { + // Must complete without throwing. If we re-threw, every PNG export would hard-fail + // when the client's SSE stream blipped — the opposite of progress reporting being + // a UX nicety. + await reporter.emit({ progress: 0, message: 'starting' }); + }); + }); +}); diff --git a/packages/server-mcp/src/server/mcp-progress-reporter.ts b/packages/server-mcp/src/server/mcp-progress-reporter.ts new file mode 100644 index 0000000..ec2953f --- /dev/null +++ b/packages/server-mcp/src/server/mcp-progress-reporter.ts @@ -0,0 +1,67 @@ +/******************************************************************************** + * 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'; +import { mcpRequestContext } from './mcp-request-context'; + +/** + * Per-call shape of a `notifications/progress` emission. Mirrors the SDK's + * `ProgressNotificationParams` minus the `progressToken` field (the reporter pulls that from + * the active request context — the caller's job is just to describe the progress beat). + */ +export interface McpProgressBeat { + /** Monotonic progress count. Spec is loose; convention: increment from 0 toward `total` if known, or use seconds-elapsed otherwise. */ + progress: number; + /** Total when known (e.g. number of nodes to process). Omit when bounded only by a timeout. */ + total?: number; + /** Short user-facing description; surfaces in compatible clients' UI. */ + message?: string; +} + +/** + * Emits `notifications/progress` to the connected MCP client when the active request carries a + * `progressToken` in its `_meta`. Built on the same {@link mcpRequestContext} as + * {@link McpLogger}; handlers don't need to thread `extra` through their own signatures. + * + * Behaviour: + * - Outside a request context (init, background): no-op. + * - Inside a request context with no `progressToken` (client didn't opt in): no-op. + * Universally supported per spec — clients that don't render progress simply omit the token. + * - Failures to deliver are swallowed: a broken transport must not break the producing tool. + * + * Shared across MCP clients on the same GLSP session; per-client routing is handled by the + * active `mcpRequestContext` frame. + * + * @experimental + */ +@injectable() +export class McpProgressReporter { + async emit(beat: McpProgressBeat): Promise { + const extra = mcpRequestContext.getStore(); + const progressToken = extra?._meta?.progressToken; + if (extra === undefined || progressToken === undefined) { + return; + } + try { + await extra.sendNotification({ + method: 'notifications/progress', + params: { progressToken, ...beat } + }); + } catch { + // Fire-and-forget — never propagate transport errors into tool execution. + } + } +} diff --git a/packages/server-mcp/src/server/mcp-prompt-handler.ts b/packages/server-mcp/src/server/mcp-prompt-handler.ts new file mode 100644 index 0000000..b5911f2 --- /dev/null +++ b/packages/server-mcp/src/server/mcp-prompt-handler.ts @@ -0,0 +1,157 @@ +/******************************************************************************** + * Copyright (c) 2025-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 { ClientId, Logger, MaybePromise, ModelState } from '@eclipse-glsp/server'; +import { inject, injectable, interfaces } from 'inversify'; +import { ZodObject, ZodRawShape } from 'zod/v4'; +import { GLSPMcpServer } from './glsp-mcp-server'; +import { McpPromptResult, McpToolError, extractErrorMessage } from './mcp-handler-shared'; +import { McpIdAliasService } from './mcp-id-alias-service'; +import { McpDiagramScopedInput } from './mcp-input-schemas'; +import { mcpRequestContext } from './mcp-request-context'; + +/** + * Multi-binding key for **server-scope** prompt handlers — singletons that don't target a + * specific GLSP client session. Diagram-scope prompts (one instance per open diagram) use the + * separate {@link McpDiagramPromptHandlerConstructor} multi-binding instead. + * + * Prompts are LLM-facing message templates the AI client may invoke. No prompts ship by default; + * the surface exists for adopters. + * + * @experimental + */ +export interface McpPromptHandler { + /** Prompt identifier exposed to the MCP client; matches the abstract base's field. */ + readonly name: string; + registerPrompt(server: GLSPMcpServer): void; + /** Tool names this prompt's text references via `${OtherHandler.NAME}`; default: empty. */ + referencedToolNames(): string[]; +} +export const McpPromptHandler = Symbol('McpPromptHandler'); + +/** Shared infrastructure for both server- and diagram-scope prompt handlers. */ +@injectable() +abstract class BaseMcpPromptHandler { + @inject(Logger) protected logger: Logger; + + /** + * Prompt identifier exposed to the MCP client. Also used to reference this prompt from + * other handlers' prompt or description text — wire via `static readonly NAME = '…'` and + * `readonly name = ClassName.NAME` so those cross-references survive renames. + */ + abstract readonly name: string; + /** LLM-facing explanation surfaced in the prompt catalog. Keep concise — clients pass this verbatim to the model. */ + abstract readonly description: string; + /** Adopter writes `z.object({ ... })`; the base passes `.shape` to the SDK. */ + abstract readonly argsSchema: ZodObject; + /** Optional human-friendly display name for UIs that render a friendlier label than `name`. */ + readonly title?: string; + + /** SDK-facing registration config; consumed by both registration paths. */ + toRegistrationConfig(): { title?: string; description: string; argsSchema: ZodRawShape } { + return { title: this.title, description: this.description, argsSchema: this.argsSchema.shape }; + } + + /** + * Optional list of tool names this prompt's text refers to via `${OtherHandler.NAME}` + * substitutions. Default: empty. Override in concrete subclasses to declare references so + * the launcher can warn at registration time when an adopter unbinds a referenced tool — + * the prompt would otherwise still register and silently produce text pointing at a tool + * that no longer exists. Pure declarative metadata; never invoked at request time. + */ + referencedToolNames(): string[] { + return []; + } + + /** + * Catches `McpToolError` (→ surfaced as a `user`-role assistant message) and unexpected + * errors (→ logged + extracted message). Errors map to a single text-content message so + * the LLM sees the failure in the prompt response. + */ + protected async execute(producer: () => MaybePromise): Promise { + try { + return await producer(); + } catch (err: unknown) { + if (err instanceof McpToolError) { + return this.errorResult(err.message); + } + const message = extractErrorMessage(err); + this.logger.error(`Unexpected error in prompt '${this.name}': ${message}`, err); + return this.errorResult(message); + } + } + + protected errorResult(message: string): McpPromptResult { + return { messages: [{ role: 'user', content: { type: 'text', text: message } }] }; + } +} + +/** + * Server-scope prompt base — for prompts that don't target a specific GLSP client session + * (e.g., a prompt that summarizes the system's state). Bound under {@link McpPromptHandler}; + * the launcher invokes `registerPrompt(server)` once per MCP session. + * + * @experimental + */ +@injectable() +export abstract class AbstractMcpPromptHandler> extends BaseMcpPromptHandler implements McpPromptHandler { + /** Throw {@link McpToolError} for expected errors; the base wraps. */ + protected abstract createResult(args: T): MaybePromise; + + registerPrompt(server: GLSPMcpServer): void { + server.registerPrompt(this.name, this.toRegistrationConfig(), async (args, extra) => + mcpRequestContext.run(extra, () => this.execute(() => this.createResult(args as T))) + ); + } +} + +/** + * Diagram-scope prompt base — for prompts whose argument schema carries a `sessionId` + * (e.g., a `describe-diagram` prompt for one open diagram). + * + * The launcher's dispatcher resolves `args.sessionId` to the right GLSP session and invokes + * {@link handle} on this session's per-instance handler. From the handler's perspective the + * session is implicit — `this.clientId`, `this.modelState`, `this.aliasService` (and any + * adopter `@inject(...)` fields) all resolve to that GLSP session's container. + * + * @experimental + */ +@injectable() +export abstract class AbstractMcpDiagramPromptHandler< + T extends McpDiagramScopedInput = McpDiagramScopedInput +> extends BaseMcpPromptHandler { + @inject(ClientId) protected clientId: string; + @inject(ModelState) protected modelState: ModelState; + @inject(McpIdAliasService) protected aliasService: McpIdAliasService; + + /** Throw {@link McpToolError} for expected errors; the base wraps. */ + protected abstract createResult(args: T): MaybePromise; + + /** + * Public dispatch entry point invoked by {@link McpServerLauncher}'s SDK callback. + */ + handle(args: T): Promise { + return this.execute(() => this.createResult(args)); + } +} + +/** + * Multi-binding identifier for diagram-scope prompt handler constructors. See + * {@link McpDiagramToolHandlerConstructor} for the lifecycle pattern — same shape, applied to + * prompt handlers. + */ +export type McpDiagramPromptHandlerConstructor = interfaces.Newable>; +export const McpDiagramPromptHandlerConstructor = Symbol('McpDiagramPromptHandlerConstructor'); diff --git a/packages/server-mcp/src/server/mcp-request-context.ts b/packages/server-mcp/src/server/mcp-request-context.ts new file mode 100644 index 0000000..401800a --- /dev/null +++ b/packages/server-mcp/src/server/mcp-request-context.ts @@ -0,0 +1,39 @@ +/******************************************************************************** + * 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 { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; +import { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types.js'; +import { AsyncLocalStorage } from 'node:async_hooks'; + +export type McpRequestExtra = RequestHandlerExtra; + +/** + * Module-level `AsyncLocalStorage` carrying the SDK's per-request `extra` (notification sender, + * progress token, request id, session id) for the duration of a tool/resource/prompt callback. + * + * The handler bases (tool / resource / prompt) wrap each registered SDK callback in + * `mcpRequestContext.run(extra, () => …)`. Anything inside the handler — and any await chain + * branching off it — can read the active context via {@link mcpRequestContext.getStore}. + * + * This lets {@link McpLogger} forward logs to the MCP client without every handler having to + * thread `extra` through its own signature, and lets future progress-emission code (P1f / PNG + * export) reach the same channel from inside `requestUntil` chains. + * + * Concurrent requests on the same MCP session each get their own AsyncLocalStorage frame — + * no cross-talk. Code that runs OUTSIDE a request (init contributions, background timers) + * sees `undefined` from `getStore()`. + */ +export const mcpRequestContext = new AsyncLocalStorage(); diff --git a/packages/server-mcp/src/server/mcp-resource-handler.ts b/packages/server-mcp/src/server/mcp-resource-handler.ts new file mode 100644 index 0000000..d11de2e --- /dev/null +++ b/packages/server-mcp/src/server/mcp-resource-handler.ts @@ -0,0 +1,398 @@ +/******************************************************************************** + * Copyright (c) 2025-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 { ActionDispatcher, ClientId, Logger, MaybePromise, ModelState, RequestAction, ResponseAction } from '@eclipse-glsp/server'; +import { CompleteResourceTemplateCallback, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { Variables } from '@modelcontextprotocol/sdk/shared/uriTemplate.js'; +import { Annotations, ListResourcesResult, Role } from '@modelcontextprotocol/sdk/types.js'; +import { inject, injectable, interfaces } from 'inversify'; +import { ZodObject, ZodRawShape } from 'zod/v4'; +import { GLSPMcpServer } from './glsp-mcp-server'; +import { + McpResourceContent, + McpResourceResult, + McpResourceResultContent, + McpToolError, + McpToolResult, + extractErrorMessage, + requestActionOrFail +} from './mcp-handler-shared'; +import { McpIdAliasService } from './mcp-id-alias-service'; +import { McpDiagramScopedInput } from './mcp-input-schemas'; +import { McpMimeType } from './mcp-mime-types'; +import { mcpRequestContext } from './mcp-request-context'; + +/** + * Multi-binding key for **server-scope** resource handlers — singletons that don't target a + * specific GLSP client session (e.g. a hypothetical adopter-supplied "global config" resource + * that returns the same data regardless of which diagram is open). Adopters extend + * {@link AbstractMcpResourceHandler} and bind their subclass against this symbol; the + * launcher invokes `registerResource(server)` once per MCP session. + * + * Resources may also be exposed as tools for clients that don't speak the resources protocol — + * setting `toolAlternativeInputSchema` (a zod object) opts the resource into the tool fallback. + * The launcher picks one or the other depending on `McpServerOptions.resources`. + * + * Diagram-scope resources (one instance per open diagram) use the separate + * {@link McpDiagramResourceHandlerConstructor} multi-binding instead — see `mcp-session.ts` for + * the MCP-session vs GLSP-session terminology. + * + * @experimental + */ +export interface McpResourceHandler { + registerResource(server: GLSPMcpServer): void; + registerToolAlternative?(server: GLSPMcpServer): void; +} +export const McpResourceHandler = Symbol('McpResourceHandler'); + +/** Static URI (string) or templated URI (`{ template: string }`). The base branches on shape. */ +export type McpResourceUri = string | { template: string }; + +/** Shared infrastructure for both server- and diagram-scope resource handlers. */ +@injectable() +abstract class BaseMcpResourceHandler { + @inject(Logger) protected logger: Logger; + + /** + * Resource identifier exposed to the MCP client. Also used to reference this resource from + * other handlers' prompt or description text — wire via `static readonly NAME = '…'` and + * `readonly name = ClassName.NAME` so those cross-references survive renames. + */ + abstract readonly name: string; + /** LLM-facing explanation surfaced in the resource catalog. Keep concise — clients pass this verbatim to the model. */ + abstract readonly description: string; + /** MIME type of the resource body. Adopters typically use one of {@link McpMimeType}'s common values; any string the MCP SDK accepts is valid. */ + abstract readonly mimeType: McpMimeType; + /** Static URI string for fixed resources, or `{ template: string }` for templated URIs (e.g. `glsp://diagrams/{sessionId}/model`). */ + abstract readonly uri: McpResourceUri; + /** Optional human-friendly display name for UIs that render a friendlier label than `name`. */ + readonly title?: string; + + // ─── Resource annotations (MCP spec: server/resources#annotations) ──────────── + // Surfaced as flat fields rather than a nested `annotations` object so adopters can + // override one hint with a one-line `override readonly priority = 0.8;` instead of + // re-declaring the whole triple. Mirror the {@link BaseMcpToolHandler} pattern. + // **Untrusted** unless from a trusted server — clients MUST treat these as advisory. + + /** + * Intended audience(s) — `"user"` (display the rendered content), `"assistant"` (use as model + * context), or both. Clients use it to filter/route the resource. + */ + readonly audience?: Role[]; + /** Importance, 0.0–1.0. Clients use it to prioritize inclusion in context. */ + readonly priority?: number; + /** ISO 8601 timestamp of last meaningful change. Omit when the server has no clean freshness signal. */ + readonly lastModified?: string; + + /** + * Assembles the {@link Annotations} object the SDK expects from the flat-field surface. + * Returns `undefined` when no field is set so `resources/list` entries don't carry an + * empty `annotations: {}`. + */ + toAnnotations(): Annotations | undefined { + if (this.audience === undefined && this.priority === undefined && this.lastModified === undefined) { + return undefined; + } + return { + ...(this.audience !== undefined ? { audience: this.audience } : {}), + ...(this.priority !== undefined ? { priority: this.priority } : {}), + ...(this.lastModified !== undefined ? { lastModified: this.lastModified } : {}) + }; + } + + /** + * Set to a `z.object({...})` schema to also expose the resource as a tool fallback (for MCP + * clients that don't speak the resources protocol). Field shape mirrors `inputSchema` on + * tool handlers — declarative, no extra method. + */ + readonly toolAlternativeInputSchema?: ZodObject; + + /** + * Optional dual-emit output schema applied **only** in tool-alternative mode (when + * {@link toolAlternativeInputSchema} is set). When declared, the handler should populate + * `structured` on the returned {@link McpResourceContent} so the framework can forward it + * to `CallToolResult.structuredContent`. Resource-protocol reads ignore this — the spec + * has no equivalent slot on `ReadResourceResult`. + */ + readonly toolAlternativeOutputSchema?: ZodObject; + + /** Override for templated URIs — enumerate matching resources. */ + list?(): MaybePromise; + /** Override for templated URIs — completers per template variable. */ + complete?(): Record; + + /** Resolve `this.uri` (which may be a fixed string or a template object) to a template string. */ + protected uriTemplate(): string { + return typeof this.uri === 'string' ? this.uri : this.uri.template; + } + + /** + * Replace `{key}` placeholders in the URI template with the supplied values, single-pass + * (no re-expansion of substituted text) and `encodeURIComponent`-escaped. Unmatched keys + * pass through verbatim so the caller can spot them. + */ + protected expandUriTemplate(vars: Record): string { + return this.uriTemplate().replace(/\{(\w+)\}/g, (placeholder, key) => { + const value = vars[key]; + return value === undefined ? placeholder : encodeURIComponent(value); + }); + } + + /** Catches `McpToolError` (→ surfaced as text content + isError) and unexpected errors. */ + protected async execute(producer: () => MaybePromise): Promise { + try { + return { ok: true, body: await producer() }; + } catch (err: unknown) { + if (err instanceof McpToolError) { + return { ok: false, message: err.message }; + } + const message = extractErrorMessage(err); + this.logger.error(`Unexpected error in resource '${this.name}': ${message}`, err); + return { ok: false, message }; + } + } + + /** Wraps the body returned by {@link createResult} with `uri` + `mimeType` for the SDK. */ + toResourceResult(uri: string, result: ResourceExecutionResult): McpResourceResult { + if (!result.ok) { + return { contents: [{ uri, mimeType: 'text/plain', text: result.message }], isError: true }; + } + const content: McpResourceResultContent = + 'text' in result.body + ? { uri, mimeType: this.mimeType, text: result.body.text } + : { uri, mimeType: this.mimeType, blob: result.body.blob }; + return { contents: [content], isError: false }; + } + + /** Converts the body to a `CallToolResult` for tool-alternative mode. Image MIMEs render as `image` content. */ + toToolResult(result: ResourceExecutionResult): McpToolResult { + if (!result.ok) { + return { isError: true, content: [{ type: 'text', text: result.message }] }; + } + const structuredContent = result.body.structured; + const baseContent: McpToolResult = + 'text' in result.body + ? { isError: false, content: [{ type: 'text', text: result.body.text }] } + : { isError: false, content: [{ type: 'image', data: result.body.blob, mimeType: this.mimeType }] }; + return structuredContent ? { ...baseContent, structuredContent } : baseContent; + } + + /** Builds the SDK `ResourceTemplate` for templated URIs (server-scope path). */ + protected buildResourceTemplate(template: string): ResourceTemplate { + return new ResourceTemplate(template, { + list: this.list ? async extra => mcpRequestContext.run(extra, () => this.list!()) : undefined, + complete: this.complete?.() + }); + } +} + +export type ResourceExecutionResult = { ok: true; body: McpResourceContent } | { ok: false; message: string }; + +/** + * Server-scope resource base — for resources that don't target a specific GLSP client session + * (e.g., a hypothetical adopter-supplied "global config" resource that returns the same data + * regardless of which diagram is open). The instance exists at boot, so `list`/`complete` can + * `@inject` server-scope deps directly. Bound under {@link McpResourceHandler}; the launcher + * invokes `registerResource(server)` once per MCP session. + * + * @experimental + */ +@injectable() +export abstract class AbstractMcpResourceHandler> extends BaseMcpResourceHandler implements McpResourceHandler { + /** Throw {@link McpToolError} for expected errors; the base wraps. */ + protected abstract createResult(params: T): MaybePromise; + + registerResource(server: GLSPMcpServer): void { + const annotations = this.toAnnotations(); + const config = { + title: this.title, + description: this.description, + mimeType: this.mimeType, + ...(annotations ? { annotations } : {}) + }; + if (typeof this.uri === 'string') { + const uri = this.uri; + server.registerResource(this.name, uri, config, async (_uri, extra) => + mcpRequestContext.run(extra, async () => this.toResourceResult(uri, await this.execute(() => this.createResult({} as T)))) + ); + } else { + server.registerResource(this.name, this.buildResourceTemplate(this.uri.template), config, async (uri, params, extra) => + mcpRequestContext.run(extra, async () => + this.toResourceResult(uri.toString(), await this.execute(() => this.createResult(toParams(params) as T))) + ) + ); + } + } + + /** No-op when no {@link toolAlternativeInputSchema} is declared; otherwise registers as a tool. */ + registerToolAlternative(server: GLSPMcpServer): void { + if (!this.toolAlternativeInputSchema) { + return; + } + this.doRegisterToolAlternative(server); + } + + protected doRegisterToolAlternative(server: GLSPMcpServer): void { + const inputSchema = this.toolAlternativeInputSchema!; + // `.strict()` matches the tool-handler policy — see `BaseMcpToolHandler.toRegistrationConfig` + // for the full rationale (LLM-typoed fields surface as JSON-RPC validation errors instead + // of being silently stripped). + server.registerTool( + this.name, + { + title: this.title, + description: this.description, + inputSchema: inputSchema.strict(), + outputSchema: this.toolAlternativeOutputSchema + }, + async (params, extra) => + mcpRequestContext.run(extra, async () => this.toToolResult(await this.execute(() => this.createResult(params as T)))) + ); + } +} + +/** + * Diagram-scope resource base — for resources whose URI templates carry a `sessionId` (e.g., + * `glsp://diagrams/{sessionId}/model`). + * + * Unlike the server-scope base, the diagram-scope handler does not register itself with the + * SDK. The launcher's dispatcher reads the per-diagram-type constructor list, registers a + * single resource entry per `name`, and routes incoming reads to the matching per-GLSP-session + * handler instance via {@link handleRead}. For `list`/`complete`, the launcher walks all open + * GLSP sessions and aggregates each instance's slice — see {@link glspSessionScopedComplete} + * for the cross-GLSP-session-pollution auto-guard. + * + * @experimental + */ +@injectable() +export abstract class AbstractMcpDiagramResourceHandler< + T extends McpDiagramScopedInput = McpDiagramScopedInput +> extends BaseMcpResourceHandler { + @inject(ClientId) protected clientId: string; + @inject(ModelState) protected modelState: ModelState; + @inject(McpIdAliasService) protected aliasService: McpIdAliasService; + @inject(ActionDispatcher) protected actionDispatcher: ActionDispatcher; + + /** Throw {@link McpToolError} for expected errors; the base wraps. */ + protected abstract createResult(params: T): MaybePromise; + + /** + * Convenience for resource handlers that fulfil a read by initiating a `RequestAction` + * round-trip to the client (today: `diagram-png` → `RequestExportAction`). Wraps + * {@link requestActionOrFail} with `this.actionDispatcher` and a default label of + * `this.name`. Pass an explicit label only to disambiguate between multiple round-trips. + */ + protected requestAction(request: RequestAction, timeoutMs: number, label: string = this.name): Promise { + return requestActionOrFail(this.actionDispatcher, request, timeoutMs, label); + } + + /** Override to opt out of registration when a runtime dependency is missing. Default: `true`. */ + canRegister(): boolean { + return true; + } + + /** + * Default `list()` for the per-session-single-resource case (diagram-model, diagram-png). + * Emits one entry resolved against the URI template, named with the handler's title and + * the GLSP session id, described with the handler's `description`. Multi-resource + * handlers (e.g. `element-types`, which lists once per diagram type) override. + */ + override list(): ListResourcesResult { + return { resources: [this.toListingEntry()] }; + } + + /** + * Default `complete()` for templated URIs that include `{sessionId}`. Returns a single + * completer that resolves to the current GLSP session's id. Handlers with other template + * variables (e.g. `{diagramType}` on `element-types`) override. + */ + override complete(): Record { + if (this.uriTemplate().includes('{sessionId}')) { + return { sessionId: async () => [this.clientId] }; + } + return {}; + } + + /** Default per-session entry built from the handler's metadata. Override to customize. */ + protected toListingEntry(): ListResourcesResult['resources'][number] { + const annotations = this.toAnnotations(); + return { + uri: this.expandUriTemplate({ sessionId: this.clientId }), + name: `${this.title ?? this.name}: ${this.clientId}`, + description: this.description, + mimeType: this.mimeType, + ...(annotations ? { annotations } : {}) + }; + } + + /** + * Public dispatch entry point invoked by {@link McpServerLauncher}'s SDK callback for + * resource reads. The launcher passes the URI it received from the SDK plus the URI-template + * variable values normalized into a flat record. + */ + async handleRead(uri: string, params: T): Promise { + return this.toResourceResult(uri, await this.execute(() => this.createResult(params))); + } + + /** + * Public dispatch entry point invoked by the launcher when the resource is exposed as a + * tool fallback (`McpServerOptions.resources === false`). + */ + async handleAsTool(params: T): Promise { + return this.toToolResult(await this.execute(() => this.createResult(params))); + } + + /** + * Wraps adopter-provided {@link complete} callbacks with the cross-GLSP-session-pollution + * auto-guard: when a templated URI carries `{sessionId}` and the LLM has bound a specific + * session id, completers for OTHER variables should not leak data from sessions whose ids + * don't match. The wrapper auto-returns `[]` from a GLSP session whose id doesn't match + * `ctx.arguments.sessionId`. Adopters write completers as if they only see their own GLSP + * session's data — the framework enforces the guard. + * + * Invoked by the launcher's aggregator, not by adopters directly. + */ + glspSessionScopedComplete(): Record { + const raw = this.complete?.() ?? {}; + const myId = this.clientId; + const isGlspSessionScoped = typeof this.uri === 'object' && this.uri.template.includes('{sessionId}'); + const wrapped: Record = {}; + for (const [variable, completer] of Object.entries(raw)) { + wrapped[variable] = + variable === 'sessionId' || !isGlspSessionScoped + ? completer + : async (value, ctx) => (ctx?.arguments?.sessionId === myId ? completer(value, ctx) : []); + } + return wrapped; + } +} + +/** Normalizes SDK `Variables` (each value is `string | string[]`) to a flat `Record`. */ +export function toParams(variables: Variables): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(variables)) { + out[key] = Array.isArray(value) ? value[0] ?? '' : value; + } + return out; +} + +/** + * Multi-binding identifier for diagram-scope resource handler constructors. See + * {@link McpDiagramToolHandlerConstructor} for the lifecycle pattern — same shape, applied to + * resource handlers. + */ +export type McpDiagramResourceHandlerConstructor = interfaces.Newable>; +export const McpDiagramResourceHandlerConstructor = Symbol('McpDiagramResourceHandlerConstructor'); diff --git a/packages/server-mcp/src/server/mcp-server-launcher.spec.ts b/packages/server-mcp/src/server/mcp-server-launcher.spec.ts new file mode 100644 index 0000000..6c21d2e --- /dev/null +++ b/packages/server-mcp/src/server/mcp-server-launcher.spec.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 { McpServerInitOptions } from '@eclipse-glsp/protocol'; +import { expect } from 'chai'; +import { version as packageVersion } from '../../package.json'; +import { McpServerLauncher, SERVER_VERSION, assertLoopbackOrAcknowledged, isLoopbackHost, pickInitOptions } from './mcp-server-launcher'; + +describe('McpServerLauncher · SERVER_VERSION', () => { + it('matches the package.json version (no stale literal)', () => { + // Regression guard: the launcher used to hard-code '1.0.0'. Pull from package.json so + // adopters and MCP clients can tell builds apart via the `serverInfo.version` handshake + // field. + expect(SERVER_VERSION).to.equal(packageVersion); + }); +}); + +describe('McpServerLauncher · buildCapabilities', () => { + /** + * Sidestep DI: build a stub whose shape matches the fields `buildCapabilities` reads, then + * invoke the prototype method against it. The method is protected, so we cast through. + */ + function buildCaps( + args: { + toolHandlers?: unknown[]; + promptHandlers?: unknown[]; + resourceHandlers?: unknown[]; + hasDiagramTools?: boolean; + hasDiagramPrompts?: boolean; + hasDiagramResources?: boolean; + }, + resourcesAsResources: boolean + ): Record { + const stub = { + toolHandlers: args.toolHandlers ?? [], + promptHandlers: args.promptHandlers ?? [], + resourceHandlers: args.resourceHandlers ?? [], + dispatcher: { + hasDiagramTools: () => args.hasDiagramTools ?? false, + hasDiagramPrompts: () => args.hasDiagramPrompts ?? false, + hasDiagramResources: () => args.hasDiagramResources ?? false + } + }; + const proto = McpServerLauncher.prototype as unknown as { + buildCapabilities(this: typeof stub, resourcesAsResources: boolean): Record; + }; + return proto.buildCapabilities.call(stub, resourcesAsResources); + } + + it('omits `tools`, `resources`, and `prompts` when nothing is bound (regression: resources/list -32601)', () => { + const caps = buildCaps({}, /* resourcesAsResources */ true); + expect(caps).to.have.property('logging'); + expect(caps).to.not.have.property('tools'); + expect(caps).to.not.have.property('resources'); + expect(caps).to.not.have.property('prompts'); + }); + + it('declares `tools` with listChanged: false when at least one tool handler binds', () => { + const caps = buildCaps({ toolHandlers: [{}] }, true); + expect(caps.tools).to.deep.equal({ listChanged: false }); + expect(caps).to.not.have.property('resources'); + expect(caps).to.not.have.property('prompts'); + }); + + it('declares `prompts` when at least one prompt handler binds (server- or diagram-scope)', () => { + expect(buildCaps({ promptHandlers: [{}] }, true).prompts).to.deep.equal({ listChanged: false }); + expect(buildCaps({ hasDiagramPrompts: true }, true).prompts).to.deep.equal({ listChanged: false }); + }); + + it('declares `resources` only in dataMode=resources; otherwise resources count toward `tools`', () => { + // Diagram-scope resources mutate per GLSP session add/remove → `listChanged: true` is honest. + const asResources = buildCaps({ hasDiagramResources: true }, true); + expect(asResources.resources).to.deep.equal({ listChanged: true }); + expect(asResources).to.not.have.property('tools'); + + const asTools = buildCaps({ hasDiagramResources: true }, false); + expect(asTools.tools).to.deep.equal({ listChanged: false }); + expect(asTools).to.not.have.property('resources'); + }); + + it('keeps resources.listChanged: false when only server-scope resources are bound (catalog static)', () => { + const caps = buildCaps({ resourceHandlers: [{}] }, true); + expect(caps.resources).to.deep.equal({ listChanged: false }); + }); +}); + +describe('McpServerLauncher · isLoopbackHost', () => { + it('treats 127.0.0.0/8, localhost, and ::1 as loopback', () => { + expect(isLoopbackHost('127.0.0.1')).to.equal(true); + expect(isLoopbackHost('127.55.0.1')).to.equal(true); + expect(isLoopbackHost('localhost')).to.equal(true); + expect(isLoopbackHost('::1')).to.equal(true); + }); + + it('treats unspecified, LAN, and public addresses as non-loopback', () => { + expect(isLoopbackHost('0.0.0.0')).to.equal(false); + expect(isLoopbackHost('::')).to.equal(false); + expect(isLoopbackHost('192.168.1.1')).to.equal(false); + expect(isLoopbackHost('10.0.0.1')).to.equal(false); + expect(isLoopbackHost('203.0.113.5')).to.equal(false); + }); +}); + +describe('McpServerLauncher · assertLoopbackOrAcknowledged (auth footgun)', () => { + it('passes silently for a loopback bind without acknowledgement', () => { + expect(() => assertLoopbackOrAcknowledged('127.0.0.1', undefined)).to.not.throw(); + expect(() => assertLoopbackOrAcknowledged('localhost', undefined)).to.not.throw(); + }); + + it('throws an actionable error for a non-loopback bind without acknowledgement', () => { + expect(() => assertLoopbackOrAcknowledged('0.0.0.0', undefined)) + .to.throw(Error) + .with.property('message') + .that.matches(/Refusing to bind/) + .and.matches(/0\.0\.0\.0/) + .and.matches(/acknowledgedNoAuth/); + }); + + it('passes for a non-loopback bind when acknowledgedNoAuth is true', () => { + expect(() => assertLoopbackOrAcknowledged('0.0.0.0', true)).to.not.throw(); + expect(() => assertLoopbackOrAcknowledged('192.168.1.50', true)).to.not.throw(); + }); + + it('still throws for a non-loopback bind when acknowledgedNoAuth is false (explicit denial)', () => { + expect(() => assertLoopbackOrAcknowledged('0.0.0.0', false)).to.throw(/Refusing to bind/); + }); +}); + +describe('McpServerLauncher · pickInitOptions (deploy/init split — defense-in-depth)', () => { + it('passes through every allowed init-side field unchanged', () => { + const picked = pickInitOptions({ dataMode: 'resources', agentPersona: 'X', eventStoreLimit: 50 }); + expect(picked).to.deep.equal({ dataMode: 'resources', agentPersona: 'X', eventStoreLimit: 50 }); + }); + + it('omits init-side fields that the caller did not set (no `undefined` sneak-through)', () => { + const picked = pickInitOptions({ dataMode: 'tools' }); + expect(picked).to.deep.equal({ dataMode: 'tools' }); + expect(picked).to.not.have.property('agentPersona'); + expect(picked).to.not.have.property('eventStoreLimit'); + }); + + it('strips deploy-only keys smuggled in via JSON wire payload (host, allowedHosts, allowedOrigins, acknowledgedNoAuth)', () => { + // Simulate a malicious/malformed wire payload: the static type rules these out, but + // JSON parsing does not, so the destructure-pick must drop them. + const wirePayload = JSON.parse(`{ + "dataMode": "tools", + "host": "0.0.0.0", + "allowedHosts": ["evil.example.com"], + "allowedOrigins": ["https://evil.example.com"], + "acknowledgedNoAuth": true + }`) as McpServerInitOptions; + + const picked = pickInitOptions(wirePayload); + expect(picked).to.deep.equal({ dataMode: 'tools' }); + expect(picked).to.not.have.property('host'); + expect(picked).to.not.have.property('allowedHosts'); + expect(picked).to.not.have.property('allowedOrigins'); + expect(picked).to.not.have.property('acknowledgedNoAuth'); + }); +}); diff --git a/packages/server-mcp/src/server/mcp-server-launcher.ts b/packages/server-mcp/src/server/mcp-server-launcher.ts new file mode 100644 index 0000000..23d516c --- /dev/null +++ b/packages/server-mcp/src/server/mcp-server-launcher.ts @@ -0,0 +1,392 @@ +/******************************************************************************** + * Copyright (c) 2025-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 { + ClientSessionListener, + ClientSessionManager, + Disposable, + DisposableCollection, + GLSPServer, + GLSPServerInitializer, + GLSPServerListener, + InitializeParameters, + InitializeResult, + Logger, + McpInitializeParameters, + McpInitializeResult, + McpServerConfiguration, + McpServerInitOptions +} from '@eclipse-glsp/server'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ServerCapabilities, SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { inject, injectable, multiInject, optional } from 'inversify'; +import { version as packageVersion } from '../../package.json'; +import { GLSPMcpServer, GLSPMcpServerFactory } from './glsp-mcp-server'; +import { DefaultMcpDiagramHandlerDispatcher, McpDiagramHandlerDispatcher } from './mcp-diagram-handler-dispatcher'; +import { McpHttpTransport } from './mcp-http-transport'; +import { McpLogLevelRegistry } from './mcp-log-level-registry'; +import { McpServerDefaults, McpServerOptions } from './mcp-options'; +import { McpPromptHandler } from './mcp-prompt-handler'; +import { McpResourceHandler } from './mcp-resource-handler'; +import { McpSession } from './mcp-session'; +import { McpToolHandler } from './mcp-tool-handler'; + +/** + * Stdout tag used to announce the started MCP server so IDE integrations can pick up the URL + * automatically. The full line is `MCP_SERVER_READY_MSG + JSON.stringify({name, url, route})`, + * mirroring how the GLSP server itself announces its port via `START_UP_COMPLETE_MSG`. + */ +export const MCP_SERVER_READY_MSG = '[GLSP-MCP-Server]:Ready. '; + +/** + * Server version reported in MCP `initialize` handshake responses (the SDK's `serverInfo.version` + * field). Sourced from the package's own `package.json` so adopters and clients can tell builds + * apart without the server author having to remember to bump a literal. + */ +export const SERVER_VERSION: string = packageVersion; + +/** + * Launcher's internal handoff shape: everything from the public {@link McpServerConfiguration} + * with all fields resolved, plus `host`. `host` is deliberately *not* in the public protocol's + * init schema — it lives on `McpServerDeployOptions` (deploy-only) rather than + * `McpServerInitOptions` (init-controllable). The launcher reads it from the adopter-supplied + * defaults via `McpServerOptions.values.host` (whose ship default lives in + * `DefaultMcpServerModule.DEFAULT_OPTIONS`). The init/deploy split limits blast radius: + * MCP clients can negotiate behavioral fields like `port` over the wire, but security-sensitive + * fields like the bind interface are settable only by the adopter at process start. + */ +export type FullMcpServerConfiguration = Required & { host: string }; + +/** + * Defense-in-depth filter for the init-side options payload. The static type already rules + * out deploy-only fields (`host`, `allowedHosts`, `allowedOrigins`, `acknowledgedNoAuth`) on + * `McpServerConfiguration.options`, but the wire payload is JSON, so a malformed or + * malicious client could smuggle extra keys. Destructure-based pick drops anything outside + * the allowed set so deploy-only fields are sourced *only* from adopter defaults. + * + * **Update this allowlist when adding a field to `McpServerInitOptions`** — the destructure + * below is the single source of truth for which init-side fields cross the wire. + * + * Exported for regression-test access only; not part of the public package surface. + */ +export function pickInitOptions(options: McpServerInitOptions): McpServerInitOptions { + const { dataMode, agentPersona, eventStoreLimit } = options; + const picked: McpServerInitOptions = {}; + if (dataMode !== undefined) picked.dataMode = dataMode; + if (agentPersona !== undefined) picked.agentPersona = agentPersona; + if (eventStoreLimit !== undefined) picked.eventStoreLimit = eventStoreLimit; + return picked; +} + +/** + * Returns true iff `host` is a loopback bind: `localhost`, `::1`, or any IPv4 in + * `127.0.0.0/8`. Any other value (`0.0.0.0`, `::`, LAN/public addresses) is non-loopback. + * Used by {@link assertLoopbackOrAcknowledged} for the auth-footgun runtime check. + */ +export function isLoopbackHost(host: string): boolean { + return host === 'localhost' || host === '::1' || /^127\./.test(host); +} + +/** + * Auth-footgun check: refuse to start the MCP HTTP server on a non-loopback host unless the + * operator has explicitly acknowledged that traffic is authenticated by an external + * mechanism (reverse proxy, mTLS, network ACL, etc.). The MCP server has no built-in + * authentication, so the default loopback bind is the only safe configuration without + * external fronting; this assertion catches the careless `host: '0.0.0.0'` for "easier dev + * access" case before it exposes an unauthenticated endpoint to the network. + * + * Exported for regression-test access only; not part of the public package surface. + */ +export function assertLoopbackOrAcknowledged(host: string, acknowledgedNoAuth: boolean | undefined): void { + if (isLoopbackHost(host) || acknowledgedNoAuth === true) { + return; + } + throw new Error( + `Refusing to bind MCP server to non-loopback host '${host}' without authentication. ` + + 'The MCP server has no built-in auth; binding to a non-loopback interface exposes an ' + + 'unauthenticated MCP endpoint to the network. If this is intentional (e.g., the endpoint ' + + 'is fronted by a reverse proxy, mTLS, or a network ACL that authenticates traffic), set ' + + '`acknowledgedNoAuth: true` on the McpServerDefaults you pass to the server module.' + ); +} + +/** + * Boots the embedded MCP HTTP server when a GLSP `initialize` call carries an `mcpServer` + * configuration. **Not a separate process runner** — despite the `*Launcher` name (which in + * core typically means a top-level process entry point such as `SocketServerLauncher`), this + * launches the MCP server *within* the GLSP server process via the + * `GLSPServerInitializer` lifecycle. The naming follows the broader sense of "launches + * something" rather than the process-runner sub-meaning specific to core. + * + * Diagram-scope handler discovery, registration, and dispatch are delegated to + * {@link DefaultMcpDiagramHandlerDispatcher} (rebindable via the {@link McpDiagramHandlerDispatcher} + * symbol). + */ +@injectable() +export class McpServerLauncher implements GLSPServerInitializer, GLSPServerListener, Disposable { + @inject(Logger) protected logger: Logger; + + @inject(McpServerOptions) protected mcpOptions: McpServerOptions; + + @inject(McpServerDefaults) protected mcpDefaults: McpServerDefaults; + + @inject(McpHttpTransport) protected transport: McpHttpTransport; + + @inject(GLSPMcpServerFactory) protected glspMcpServerFactory: GLSPMcpServerFactory; + + @inject(McpDiagramHandlerDispatcher) protected dispatcher: DefaultMcpDiagramHandlerDispatcher; + + @inject(McpLogLevelRegistry) protected logLevelRegistry: McpLogLevelRegistry; + + @inject(ClientSessionManager) protected clientSessionManager: ClientSessionManager; + + @multiInject(McpToolHandler) @optional() protected toolHandlers: McpToolHandler[] = []; + + @multiInject(McpResourceHandler) @optional() protected resourceHandlers: McpResourceHandler[] = []; + + @multiInject(McpPromptHandler) @optional() protected promptHandlers: McpPromptHandler[] = []; + + protected toDispose = new DisposableCollection(); + protected serverUrl: string | undefined; + protected serverConfig: FullMcpServerConfiguration | undefined; + + /** Per-MCP-session GLSPMcpServer registry — populated on session-init, cleared on session-close. */ + protected readonly sessionServers = new Map(); + + async initializeServer(server: GLSPServer, params: InitializeParameters, result: InitializeResult): Promise { + const mcpServerParam = McpInitializeParameters.getServerConfig(params); + if (!mcpServerParam) { + return result; + } + + // Idempotent: subsequent client sessions of the same GLSP server reuse the existing + // MCP HTTP server. Only the first call starts it. + if (this.serverUrl && this.serverConfig) { + return McpInitializeResult.attachServer(result, { name: this.serverConfig.name, url: this.serverUrl }); + } + + // Default to a random port; IDE integrations pick up the resolved URL via the stdout + // marker emitted below (mirrors `START_UP_COMPLETE_MSG` for the GLSP server's port). + // `host` is intentionally NOT in the public init-time schema (security: no DNS-rebinding + // foot-gun via the LLM-init path); the bind host comes from the server module's + // constructor-supplied defaults via `McpServerOptions`. + const { port = 0, route = '/mcp', name = 'glsp', options = {} } = mcpServerParam; + // Adopter defaults from the server module (constructor arg) merged with init-time + // overrides — init-time wins per field, *but only for fields in the init allowlist*. + // Deploy-only fields (host, allowedHosts, allowedOrigins) come exclusively from the + // adopter defaults; `pickInitOptions` strips any wire-smuggled keys before merge. The + // shared holder reference makes the result visible to every `@inject(McpServerOptions)` + // consumer, including singletons that were constructed before this call ran. + const mergedOptions = { ...this.mcpDefaults, ...pickInitOptions(options) }; + this.mcpOptions.values = mergedOptions; + const host = mergedOptions.host ?? '127.0.0.1'; + // Auth-footgun guard: refuse non-loopback bind unless the operator opted in via + // `acknowledgedNoAuth`. Runs BEFORE the transport binds the socket so a careless + // `host: '0.0.0.0'` doesn't get a chance to expose an unauthenticated endpoint. + assertLoopbackOrAcknowledged(host, mergedOptions.acknowledgedNoAuth); + const mcpServerConfig: FullMcpServerConfiguration = { port, host, route, name, options: mergedOptions }; + + this.dispatcher.harvest(); + + // Capture the per-init subscription disposables so a dispose-then-restart cycle + // (transport is `inSingletonScope()`) doesn't accumulate stale listeners. + this.toDispose.push(this.transport.onSessionInitialized(client => this.onSessionInitialized(client, mcpServerConfig))); + this.toDispose.push(this.transport.onSessionClosed(sessionId => this.onSessionClosed(sessionId))); + this.toDispose.push(this.transport); + this.installResourceListChangedNotifier(); + + const endpoint = await this.transport.start(mcpServerConfig); + this.serverUrl = endpoint.url; + this.serverConfig = mcpServerConfig; + this.logger.info( + `MCP server '${mcpServerConfig.name}' is ready to accept new client requests on: ${this.serverUrl ?? '(no network endpoint)'}` + ); + + // stdout ready-marker — IPC contract for parent processes to discover the MCP URL. + // Routed through `console.log` deliberately, NOT the GLSP Logger, so log-level / formatter + // changes by adopters can never hide it (mirrors the GLSP server's own startup announcement). + console.log(MCP_SERVER_READY_MSG + JSON.stringify({ name: mcpServerConfig.name, url: this.serverUrl, route })); + if (endpoint.url) { + return McpInitializeResult.attachServer(result, { + name: mcpServerConfig.name, + url: endpoint.url, + headers: endpoint.headers + }); + } + return result; + } + + protected onSessionInitialized(client: McpSession, config: FullMcpServerConfiguration): void { + this.logger.info(`MCP session initialized with ID: ${client.sessionId}`); + const glspMcpServer = this.createGlspMcpServer(config); + this.sessionServers.set(client.sessionId, glspMcpServer); + this.registerLogLevelHandler(glspMcpServer, client.sessionId); + // server assumes control of the connection + glspMcpServer.connect(client); + } + + protected onSessionClosed(sessionId: string): void { + const glspMcpServer = this.sessionServers.get(sessionId); + if (glspMcpServer) { + this.sessionServers.delete(sessionId); + this.logLevelRegistry.clear(sessionId); + // The transport already closes the client end; close the SDK server end too. + glspMcpServer.dispose(); + this.logger.info(`MCP session closed: ${sessionId}`); + } + } + + /** + * Fire `notifications/resources/list_changed` to every connected MCP client when a GLSP + * session opens or closes — diagram-scope resources aggregate across GLSP sessions, so the + * visible list mutates with that lifecycle. No-op when no diagram-scope resources are bound. + */ + protected installResourceListChangedNotifier(): void { + if (!this.dispatcher.hasDiagramResources()) { + return; + } + const listener: ClientSessionListener = { + sessionCreated: () => this.broadcastResourceListChanged(), + sessionDisposed: () => this.broadcastResourceListChanged() + }; + this.clientSessionManager.addListener(listener); + this.toDispose.push(Disposable.create(() => this.clientSessionManager.removeListener(listener))); + } + + /** Best-effort fan-out — failures on individual MCP sessions (e.g. transport mid-close) are swallowed. */ + protected broadcastResourceListChanged(): void { + for (const glspMcpServer of this.sessionServers.values()) { + glspMcpServer + .getRawServer() + .server.sendResourceListChanged() + .catch(err => this.logger.debug('sendResourceListChanged failed:', err)); + } + } + + /** Register `logging/setLevel` so a connected MCP client can adjust its message severity threshold. */ + protected registerLogLevelHandler(glspMcpServer: GLSPMcpServer, sessionId: string): void { + glspMcpServer.getRawServer().server.setRequestHandler(SetLevelRequestSchema, async request => { + this.logLevelRegistry.setLevel(sessionId, request.params.level); + return {}; + }); + } + + protected createGlspMcpServer({ name, options }: FullMcpServerConfiguration): GLSPMcpServer { + const resourcesAsResources = options.dataMode === 'resources'; + const server = new McpServer( + { name, version: SERVER_VERSION }, + { + capabilities: this.buildCapabilities(resourcesAsResources), + instructions: options.agentPersona + } + ); + const glspMcpServer = this.glspMcpServerFactory(server, options); + this.registerHandlers(glspMcpServer, resourcesAsResources); + return glspMcpServer; + } + + /** + * Build the MCP server-capabilities map from what's actually bound. The SDK wires + * `tools/list`, `resources/list`, `prompts/list` request handlers lazily — on the *first* + * corresponding `register*()` call. Declaring a capability the host never registers any + * handlers for produces `-32601 Method not found` on `/list` while `initialize` happily + * advertises support. So we only declare a key when at least one binding contributes to it, + * and pin `listChanged: false` on the keys we do include (the catalog is fixed at + * session-init; no `notifications//list_changed` is ever emitted). + * + * Resources surfaced as tools (`dataMode === 'tools'`) count toward `tools`, not `resources`. + */ + protected buildCapabilities(resourcesAsResources: boolean): ServerCapabilities { + const hasStaticTools = this.toolHandlers.length > 0; + const hasStaticPrompts = this.promptHandlers.length > 0; + const hasStaticResources = this.resourceHandlers.length > 0; + const hasDiagramTools = this.dispatcher.hasDiagramTools(); + const hasDiagramPrompts = this.dispatcher.hasDiagramPrompts(); + const hasDiagramResources = this.dispatcher.hasDiagramResources(); + const anyResources = hasStaticResources || hasDiagramResources; + + const hasTools = hasStaticTools || hasDiagramTools || (!resourcesAsResources && anyResources); + const hasPrompts = hasStaticPrompts || hasDiagramPrompts; + const hasResources = resourcesAsResources && anyResources; + + return { + logging: {}, + ...(hasTools ? { tools: { listChanged: false } } : {}), + // `resources.listChanged: true` iff the catalog contains diagram-scope resources — + // those aggregate across open GLSP sessions, so the visible list mutates with + // session add/remove. Server-scope-only catalogs are static, so the flag stays + // honest at `false` (the SDK reads it; clients refetch only when notified). + ...(hasResources ? { resources: { listChanged: hasDiagramResources } } : {}), + ...(hasPrompts ? { prompts: { listChanged: false } } : {}) + }; + } + + /** + * Registers tool/resource/prompt handlers against the per-MCP-session GLSP MCP server. Two + * sources flow into the catalog: + * + * 1. **Server-scope handlers**: singletons bound under `McpToolHandler` / + * `McpResourceHandler` / `McpPromptHandler`. Registered via their `register*(server)` + * methods — they're already-instantiated objects that close over their own state. + * + * 2. **Diagram-scope handlers**: registered by {@link McpDiagramHandlerDispatcher}, which + * walks the catalogs harvested at server start and dispatches each registered SDK + * callback by `params.sessionId` → per-GLSP-session container → registry lookup. + */ + protected registerHandlers(glspMcpServer: GLSPMcpServer, resourcesAsResources: boolean): void { + this.toolHandlers.forEach(handler => handler.registerTool(glspMcpServer)); + this.promptHandlers.forEach(handler => handler.registerPrompt(glspMcpServer)); + if (resourcesAsResources) { + this.resourceHandlers.forEach(handler => handler.registerResource(glspMcpServer)); + } else { + this.resourceHandlers.forEach(handler => handler.registerToolAlternative?.(glspMcpServer)); + } + + this.dispatcher.registerAll(glspMcpServer, resourcesAsResources); + this.validatePromptToolReferences(glspMcpServer); + } + + /** + * Warn when a server-scope prompt's {@link AbstractMcpPromptHandler.referencedToolNames} + * contains a name not registered on this MCP session — catches adopters who unbind a tool + * a shipped prompt references via `${OtherHandler.NAME}`. + */ + protected validatePromptToolReferences(glspMcpServer: GLSPMcpServer): void { + for (const handler of this.promptHandlers) { + const missing = handler.referencedToolNames().filter(name => !glspMcpServer.hasTool(name)); + if (missing.length > 0) { + this.logger.warn( + `Prompt '${handler.name}' references unbound tool(s): ${missing.join(', ')}. ` + + 'The prompt will still register but its text points at tools the LLM cannot invoke.' + ); + } + } + } + + serverShutDown(server: GLSPServer): void { + this.dispose(); + } + + dispose(): void { + this.sessionServers.forEach(glspMcpServer => glspMcpServer.dispose()); + this.sessionServers.clear(); + this.toDispose.dispose(); + this.toDispose.clear(); + this.serverUrl = undefined; + this.serverConfig = undefined; + this.dispatcher.reset(); + } +} diff --git a/packages/server-mcp/src/server/mcp-server-module.ts b/packages/server-mcp/src/server/mcp-server-module.ts new file mode 100644 index 0000000..0406f3c --- /dev/null +++ b/packages/server-mcp/src/server/mcp-server-module.ts @@ -0,0 +1,285 @@ +/******************************************************************************** + * 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 as McpServerOptionsType } from '@eclipse-glsp/protocol'; +import { BindingContext } from '@eclipse-glsp/protocol/lib/di'; +import { + AbstractMultiBinding, + applyBindingTarget, + BindingTarget, + GLSPModule, + GLSPServerInitializer, + GLSPServerListener +} from '@eclipse-glsp/server'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { interfaces } from 'inversify'; +import { DescribeDiagramMcpPromptHandler, SuggestImprovementsMcpPromptHandler } from '../prompts'; +import { ElementTypesMcpToolHandler, SessionInfoMcpToolHandler } from '../tools'; +import { DefaultGLSPMcpServer, GLSPMcpServerFactory } from './glsp-mcp-server'; +import { DefaultMcpDiagramHandlerDispatcher, McpDiagramHandlerDispatcher } from './mcp-diagram-handler-dispatcher'; +import { DefaultMcpLogLevelRegistry, McpLogLevelRegistry } from './mcp-log-level-registry'; +import { LruEventStore } from './lru-event-store'; +import { McpHttpTransport } from './mcp-http-transport'; +import { McpLogger } from './mcp-logger'; +import { McpServerDefaults, McpServerOptions } from './mcp-options'; +import { McpProgressReporter } from './mcp-progress-reporter'; +import { McpPromptHandler } from './mcp-prompt-handler'; +import { McpResourceHandler } from './mcp-resource-handler'; +import { McpServerLauncher } from './mcp-server-launcher'; +import { McpToolHandler } from './mcp-tool-handler'; + +/** + * GLSP-generic default agent persona — adopters typically pass a product-specific persona to + * the {@link DefaultMcpServerModule} constructor (e.g. workflow-server might say "You are the + * Workflow Modeling Agent..."). + * + * **Spec note.** This is wired to the MCP `instructions` field, which the spec describes as + * "concise instructions". This persona is intentionally verbose (~700 chars of behavior rules) + * — within the spec's "free-form server-supplied instructions" allowance and adopter-overridable + * via `mcpDefaults.agentPersona`. Don't trim on autopilot if a "concise" interpretation drifts + * back: the verbose form materially improves LLM tool-use compliance for graphical modelling. + */ +const DEFAULT_AGENT_PERSONA = ` +You are the GLSP Modeling Agent. Your primary goal is to assist in the creation and modification of graphical models using the +GLSP MCP server. You have to adhere to the following principles: +- MCP-Interaction: Any modeling related activity has to occur using the MCP server. +- Real Data: The diagram model is the ground truth regarding the existing graphical model. Always query it before modifying the diagram. +- Real Creation: Consult the available element types before creating elements. +- Visual Proof: An image of the graphical model can be created, if you deem it useful for calculating or verifying layout decisions. +- Precision: All IDs and types must be exact. +- Visualization: When creating nodes, suggest sensible default positions and avoid visual overlapping. +- Careful: Under no circumstances save the model without explicit instruction. If you deem it sensible, you may ask the user for permission. + The same goes for Undo/Redo operations. +- Layouting: If available, make use of automatic layouting when not given explicit custom layouting requirements. +- Human-friendly references: When mentioning an element in user-visible prose, prefer its label, then its element type + (the bare ids returned by the tools are internal aliases that mean nothing to the user). Append the alias in parentheses + so the user can correlate it with follow-up tools. Use the \`set-selection\` tool — or the \`set-view\` tool with + \`action: "center-on-elements"\` — to point the user at the elements you reference. +`; + +/** + * Multi-binding helper for MCP handler classes. Singleton-scoped sibling of core's + * `MultiBinding` — same `binding.add(...)` / `binding.rebind(old, new)` adopter shape, but + * core's plain `MultiBinding` binds in transient scope, while MCP handlers cache deferred + * resolvers (selection/PNG round-trips) and need singleton scope to keep their state. + */ +export class McpHandlerMultiBinding extends AbstractMultiBinding> { + override applyBindings(context: BindingContext): void { + this.bindings.forEach(handlerClass => { + if (!context.isBound(handlerClass)) { + context.bind(handlerClass).toSelf().inSingletonScope(); + } + context.bind(this.identifier).toService(handlerClass); + }); + } +} + +/** + * Server-scope DI module for the MCP server. Adopters subclass {@link DefaultMcpServerModule} + * and override the `bind*` hooks to swap single-instance services or the `configure*` hooks to + * add/replace handlers in the multi-bindings. Mirrors the `DiagramModule` pattern from core + * (`bindGModelSerializer()`, `configureActionHandlers(binding)`, etc.). + * + * Adopter-provided default option values flow through the constructor — pass a + * `Partial` to override individual fields. The launcher then merges these + * defaults with init-time options from the GLSP `initialize` request (init-time wins per field). + * + * @example + * ```ts + * // Drop-in: GLSP defaults. + * launcher.configure(serverModule, new DefaultMcpServerModule()); + * + * // Drop-in with a product-specific override. + * launcher.configure(serverModule, new DefaultMcpServerModule({ dataMode: 'resources' })); + * + * // Subclass when handler customization is needed. + * class WorkflowMcpServerModule extends DefaultMcpServerModule { + * constructor() { super({ agentPersona: WORKFLOW_PERSONA }); } + * protected override configureToolHandlers(binding) { + * super.configureToolHandlers(binding); + * binding.add(WorkflowSpecificTool); + * } + * } + * ``` + */ +export abstract class AbstractMcpServerModule extends GLSPModule { + protected bind!: interfaces.Bind; + protected rebind!: interfaces.Rebind; + + constructor(protected readonly defaultOptions: McpServerDefaults = {}) { + super(); + } + + protected override configure( + bind: interfaces.Bind, + _unbind: interfaces.Unbind, + isBound: interfaces.IsBound, + rebind: interfaces.Rebind + ): void { + this.bind = bind; + this.rebind = rebind; + const context = { bind, isBound }; + applyBindingTarget(context, McpServerLauncher, this.bindMcpServerLauncher()).inSingletonScope(); + // The launcher is bound under two additional service identifiers so core's existing + // multi-bindings pick it up alongside the rest of the server's contributions/listeners. + bind(GLSPServerInitializer).toService(McpServerLauncher); + bind(GLSPServerListener).toService(McpServerLauncher); + applyBindingTarget(context, McpHttpTransport, this.bindMcpHttpTransport()).inSingletonScope(); + applyBindingTarget(context, McpDiagramHandlerDispatcher, this.bindMcpDiagramHandlerDispatcher()).inSingletonScope(); + applyBindingTarget(context, McpServerOptions, this.bindMcpServerOptions()).inSingletonScope(); + applyBindingTarget(context, McpServerDefaults, this.bindMcpServerDefaults()); + applyBindingTarget(context, McpLogger, this.bindMcpLogger()).inSingletonScope(); + applyBindingTarget(context, McpLogLevelRegistry, this.bindMcpLogLevelRegistry()).inSingletonScope(); + applyBindingTarget(context, McpProgressReporter, this.bindMcpProgressReporter()).inSingletonScope(); + applyBindingTarget(context, GLSPMcpServerFactory, this.bindGLSPMcpServerFactory()); + this.configureMultiBinding(new McpHandlerMultiBinding(McpToolHandler), binding => + this.configureToolHandlers(binding as McpHandlerMultiBinding) + ); + this.configureMultiBinding(new McpHandlerMultiBinding(McpResourceHandler), binding => + this.configureResourceHandlers(binding as McpHandlerMultiBinding) + ); + this.configureMultiBinding(new McpHandlerMultiBinding(McpPromptHandler), binding => + this.configurePromptHandlers(binding as McpHandlerMultiBinding) + ); + } + + /** + * {@link McpServerLauncher} binding. Bound as a singleton AND aliased to + * `GLSPServerInitializer` + `GLSPServerListener` (the launcher implements both). + * Override to swap in a custom launcher impl. + */ + protected bindMcpServerLauncher(): BindingTarget { + return McpServerLauncher; + } + + /** {@link McpHttpTransport} binding. Override to swap to a different transport implementation. */ + protected bindMcpHttpTransport(): BindingTarget { + return McpHttpTransport; + } + + /** + * {@link McpDiagramHandlerDispatcher} binding. Owns diagram-scope handler discovery, + * SDK registration, and per-call dispatch routing. Override (or `rebind` to a subclass) + * to customize registration without subclassing the launcher itself. + */ + protected bindMcpDiagramHandlerDispatcher(): BindingTarget { + return DefaultMcpDiagramHandlerDispatcher; + } + + /** {@link McpServerOptions} holder binding. Mutated at init by the launcher. */ + protected bindMcpServerOptions(): BindingTarget { + return McpServerOptions; + } + + /** + * {@link McpServerDefaults} binding — adopter-supplied default option values flow through + * the constructor and land here as a constant. The launcher merges these defaults with + * init-time options at server init (init-time wins per field). + */ + protected bindMcpServerDefaults(): BindingTarget { + return { constantValue: this.defaultOptions }; + } + + /** + * {@link McpLogger} binding. Bound on the server container; per-session containers inherit + * it, so handlers at any scope can inject it; routes through the active MCP request via + * {@link mcpRequestContext}. + */ + protected bindMcpLogger(): BindingTarget { + return McpLogger; + } + + /** {@link McpProgressReporter} binding. Same scope/lifecycle story as {@link bindMcpLogger}. */ + protected bindMcpProgressReporter(): BindingTarget { + return McpProgressReporter; + } + + /** + * {@link McpLogLevelRegistry} binding — holds per-MCP-session `logging/setLevel` thresholds + * read by {@link McpLogger} when filtering `notifications/message`. + */ + protected bindMcpLogLevelRegistry(): BindingTarget { + return DefaultMcpLogLevelRegistry; + } + + /** + * {@link GLSPMcpServerFactory} binding — produces a fresh {@link DefaultGLSPMcpServer} per + * MCP-session-init call. Override to wrap the SDK `McpServer` differently (e.g. add custom + * middleware, swap the Proxy strategy). + */ + protected bindGLSPMcpServerFactory(): BindingTarget { + return { + dynamicValue: + () => + (mcpServer: McpServer, options: McpServerOptionsType): DefaultGLSPMcpServer => + new DefaultGLSPMcpServer(mcpServer, options) + }; + } + + /** + * Override to add or replace tool handlers. Adopters typically `super.configureToolHandlers(binding)` + * to keep the defaults, then `binding.add(MyTool)` for additions or + * `binding.rebind(StandardTool, MyTool)` for overrides. + */ + protected configureToolHandlers(binding: McpHandlerMultiBinding): void { + binding.add(SessionInfoMcpToolHandler); + binding.add(ElementTypesMcpToolHandler); + } + + /** See {@link configureToolHandlers}. No server-scope resources ship by default. */ + protected configureResourceHandlers(_binding: McpHandlerMultiBinding): void { + // empty by default + } + + /** + * See {@link configureToolHandlers}. Server-scope by default because the shipped prompts + * (`describe-diagram`, `suggest-improvements`) are diagram-type-agnostic and resolve their + * target session at invocation time — diagram-scope adopters add prompts via + * {@link DefaultMcpDiagramModule.configurePromptHandlers}. + */ + protected configurePromptHandlers(binding: McpHandlerMultiBinding): void { + binding.add(DescribeDiagramMcpPromptHandler); + binding.add(SuggestImprovementsMcpPromptHandler); + } +} + +/** + * Default {@link AbstractMcpServerModule} entry point. Ships GLSP-default option values (see + * {@link DEFAULT_OPTIONS}) on top of the abstract module's hook defaults. Adopter-provided + * overrides via the constructor merge on top. + * + * @experimental The MCP integration is under active development. Option names, schema shapes, + * and handler contracts MAY change in minor releases until the feature graduates from + * experimental status. + */ +export class DefaultMcpServerModule extends AbstractMcpServerModule { + static readonly DEFAULT_OPTIONS: McpServerDefaults = { + host: '127.0.0.1', + allowedHosts: ['127.0.0.1', 'localhost'], + // `allowedOrigins` deliberately undefined: accept absent Origin (typical for desktop-IDE + // MCP clients) and rely on Host validation to gate DNS-rebinding. Adopters whose + // deployment is browser-fronted set this explicitly to their frontend's origin. + dataMode: 'tools', + agentPersona: DEFAULT_AGENT_PERSONA, + // 10K events per session is generous for typical workloads (a few MB) and large enough + // that disconnects within seconds recover via `Last-Event-ID` resumability. + eventStoreLimit: LruEventStore.DEFAULT_LIMIT + }; + + constructor(overrides: McpServerDefaults = {}) { + super({ ...DefaultMcpServerModule.DEFAULT_OPTIONS, ...overrides }); + } +} diff --git a/packages/server-mcp/src/server/mcp-session.ts b/packages/server-mcp/src/server/mcp-session.ts new file mode 100644 index 0000000..d18556f --- /dev/null +++ b/packages/server-mcp/src/server/mcp-session.ts @@ -0,0 +1,45 @@ +/******************************************************************************** + * 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 type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; + +/** + * **Note on terminology** — the word "session" is overloaded in this codebase. Two + * independent concepts exist and must not be conflated: + * + * 1. **MCP session** (this file): one MCP client connection to the MCP HTTP endpoint, tracked by + * {@link McpHttpTransport.sessions} keyed by the SDK-issued `mcp-session-id` HTTP header. + * Created in `onsessioninitialized`, supports resumability across reconnects via + * `Last-Event-ID`. Each {@link McpSession} corresponds to one underlying SDK `McpServer` + * instance — multiple LLM clients can connect simultaneously, each with its own MCP session + * and its own SDK server. + * + * 2. **GLSP client session** (core): one open diagram, tracked by core's `ClientSessionManager` + * and represented by `ClientSession`. Tools/resources/prompts that target a specific diagram + * receive the GLSP session id via `params.sessionId` in their input schema. + * + * The two are independent in lifetime and cardinality: a single MCP session sees ALL open GLSP + * sessions; a single GLSP session is visible to every connected MCP session. Names in this + * package distinguish them: anything with the `Mcp` prefix in {@link mcp-session.ts} or + * `mcp-http-transport.ts` is MCP-side; anything talking about diagrams (`AbstractMcpDiagram*`, + * `requireGlspSession`, `GlspSession`) is GLSP-side. + */ +export type McpSessionId = string; + +export type WithSessionId = T & { get sessionId(): McpSessionId }; + +/** SDK {@link Transport} widened with the session-id accessor every concrete MCP transport carries post-handshake. */ +export type McpSession = WithSessionId; diff --git a/packages/server-mcp/src/server/mcp-tool-handler.ts b/packages/server-mcp/src/server/mcp-tool-handler.ts new file mode 100644 index 0000000..49f2f6c --- /dev/null +++ b/packages/server-mcp/src/server/mcp-tool-handler.ts @@ -0,0 +1,431 @@ +/******************************************************************************** + * Copyright (c) 2025-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 { + ActionDispatcher, + ClientId, + GModelElement, + Logger, + MaybePromise, + ModelState, + RequestAction, + ResponseAction +} from '@eclipse-glsp/server'; +import { ToolAnnotations } from '@modelcontextprotocol/sdk/types'; +import { inject, injectable, interfaces } from 'inversify'; +import { ZodObject, ZodRawShape } from 'zod/v4'; +import { GLSPMcpServer } from './glsp-mcp-server'; +import { + McpElementsNotFoundError, + McpReadOnlyError, + McpStructuredContent, + McpToolError, + McpToolErrorCode, + McpToolErrorCodes, + McpToolResult, + errorCodeFor, + extractErrorMessage, + requestActionOrFail, + toolErrorResult +} from './mcp-handler-shared'; +import { McpIdAliasService } from './mcp-id-alias-service'; +import { ElementIdentity, McpDiagramScopedInput } from './mcp-input-schemas'; +import { McpLabelProvider } from './mcp-label-provider'; +import { mcpRequestContext } from './mcp-request-context'; + +/** + * Multi-binding key for **server-scope** tool handlers — singletons that don't target a + * specific GLSP client session. Adopters extend {@link AbstractMcpToolHandler} and bind their + * subclass against this symbol; the launcher invokes `registerTool(server)` on each at + * MCP-session-init. + * + * Diagram-scope tools (one instance per open diagram) use the separate + * {@link McpDiagramToolHandlerConstructor} multi-binding instead — see `mcp-session.ts` for + * the MCP-session vs GLSP-session terminology. + * + * @experimental + */ +export interface McpToolHandler { + registerTool(server: GLSPMcpServer): void; +} +export const McpToolHandler = Symbol('McpToolHandler'); + +/** + * Shared infrastructure for both server- and diagram-scope tool handlers — exported so adopters + * who want to factor common helpers across server-scope and diagram-scope tools can extend a + * single ancestor instead of duplicating logic. Most adopters extend the more specific siblings + * ({@link AbstractMcpToolHandler}, {@link AbstractMcpDiagramToolHandler}, + * {@link OperationMcpDiagramToolHandler}); reach for this base only when a helper is genuinely + * scope-agnostic. + * + * @experimental + */ +@injectable() +export abstract class BaseMcpToolHandler { + @inject(Logger) protected logger: Logger; + + /** + * Tool identifier exposed to the MCP client. Also used to reference this tool from other + * handlers' prompt or description text — wire via `static readonly NAME = '…'` and + * `readonly name = ClassName.NAME` so those cross-references survive renames. + */ + abstract readonly name: string; + /** LLM-facing explanation surfaced in the tool catalog — clients pass this verbatim to the model. Keep concise and behavioral. */ + abstract readonly description: string; + /** Adopter writes `z.object({ ... })`; the base passes `.shape` to the SDK. */ + abstract readonly inputSchema: ZodObject; + /** + * Optional dual-emit schema. When set, pass the matching `structured` payload to + * {@link success} so the framework forwards it as `structuredContent` alongside the + * human-readable text. The MCP spec says clients SHOULD validate `structuredContent` + * against the declared schema, so the two MUST stay in sync. + */ + readonly outputSchema?: ZodObject; + /** Optional human-friendly display name for UIs that render a friendlier label than `name`. */ + readonly title?: string; + + // ─── Tool annotations (MCP spec: server/tools) ─────────────────────────────── + // Surfaced as flat fields rather than a nested `annotations` object so adopters can + // override one hint with a one-line `override readonly destructiveHint = true;` instead of + // re-declaring the whole quartet. **Untrusted** unless from a trusted server — clients + // MUST treat these as advisory. + + /** + * Tool does not modify its environment. Defaults to `true` here on the common base; + * overridden to `false` on {@link OperationMcpDiagramToolHandler} since write-style tools + * dispatch model-mutating operations. + */ + readonly readOnlyHint: boolean = true; + /** + * Tool may perform destructive *updates* (irreversible deletion, data loss). Only meaningful + * when `readOnlyHint: false`. Set explicitly on the concrete handler when it applies (e.g. + * `delete-elements`). + */ + readonly destructiveHint?: boolean; + /** + * Repeated calls with the same arguments have no additional effect. Only meaningful when + * `readOnlyHint: false`. + */ + readonly idempotentHint?: boolean; + /** + * Tool interacts with an "open world" of external entities (web search, external APIs). + * Default in this codebase: `false` — diagram ops are bounded to the GLSP client. Set + * `true` for tools that reach off-process. + */ + readonly openWorldHint: boolean = false; + + /** + * SDK-facing registration config; consumed by both the server-scope `registerTool` flow and + * the launcher's diagram-scope dispatcher. Assembles the {@link ToolAnnotations} object from + * the flat-field surface so adopters compose annotations via field overrides, not by + * redeclaring the whole annotations literal. + */ + toRegistrationConfig(): { + title?: string; + description: string; + inputSchema: ZodObject; + outputSchema?: ZodObject; + annotations: ToolAnnotations; + } { + // `.strict()` rejects unknown keys at the SDK boundary instead of silently stripping them + // — turns an LLM mis-typed field name into a self-correctable JSON-RPC validation error + // rather than a misleading "no-op success". + // + // The `as unknown as ZodObject` bridge is needed because `.strict()` returns + // a `ZodObject<…, $strict>` whose `$strict` marker generic is not assignable to the + // shape-only `ZodObject` expected here. The SDK accepts both shapes + // structurally, so the cast is type-only — no runtime mismatch. + return { + title: this.title, + description: this.description, + inputSchema: this.inputSchema.strict() as unknown as ZodObject, + outputSchema: this.outputSchema as ZodObject | undefined, + annotations: { + readOnlyHint: this.readOnlyHint, + destructiveHint: this.destructiveHint, + idempotentHint: this.idempotentHint, + openWorldHint: this.openWorldHint + } + }; + } + + protected error(message: string, code?: McpToolErrorCode): McpToolResult { + return toolErrorResult(message, code); + } + + /** + * Convention: `message` is a short, referenceable summary (ids, types, counts); `structured` + * carries the full payload. The two complement rather than duplicate — balancing token usage + * across the heterogeneous client landscape. + * + * The MCP spec (2025-06-18) recommends mirroring `structuredContent` into a TextContent + * block, but in-flight discussion is softening that — `content` and `structuredContent` are + * increasingly model-oriented vs. machine-oriented surfaces that should be semantically + * equivalent, not byte-identical. + * + * Client behavior is uneven, so summary-in-content + payload-in-structured is a deliberate + * hedge: some clients only forward `structuredContent`, some only `content`, some forward + * both verbatim and double the per-call context budget. + * + * Pass `structured` whenever {@link outputSchema} is declared (the spec says clients SHOULD + * validate against the declared shape). Omit for plain text-only responses. + */ + protected success(message: string, structured?: McpStructuredContent): McpToolResult { + return { isError: false, content: [{ type: 'text', text: message }], structuredContent: structured }; + } + + /** Catches `McpToolError` (→ `isError: true` result) and unexpected errors; tags known errors via {@link errorCodeFor}. */ + protected async execute(producer: () => MaybePromise): Promise { + try { + return await producer(); + } catch (err: unknown) { + const code = errorCodeFor(err); + if (err instanceof McpToolError) { + return this.error(err.message, code); + } + const message = extractErrorMessage(err); + // Session-disposed races aren't programming errors — log at warn so they don't drown the error feed. + if (code === McpToolErrorCodes.SessionDisposed) { + this.logger.warn(`Session disposed mid-call in tool '${this.name}': ${message}`); + } else { + this.logger.error(`Unexpected error in tool '${this.name}': ${message}`, err); + } + return this.error(message, code); + } + } +} + +/** + * Server-scope tool base — for tools that don't target a specific GLSP client session + * (e.g., listing all sessions). Bound under {@link McpToolHandler} as a server-scope singleton; + * the launcher invokes `registerTool(server)` once per MCP session. + * + * @experimental + */ +@injectable() +export abstract class AbstractMcpToolHandler> extends BaseMcpToolHandler implements McpToolHandler { + /** Throw {@link McpToolError} for expected, user-facing errors; the base wraps. */ + protected abstract createResult(params: T): MaybePromise; + + registerTool(server: GLSPMcpServer): void { + server.registerTool(this.name, this.toRegistrationConfig(), async (params, extra) => + mcpRequestContext.run(extra, () => this.execute(() => this.createResult(params as T))) + ); + } +} + +/** + * Shared per-session implementation for diagram-scope tool handlers. Adopters don't extend this + * directly — extend {@link AbstractMcpDiagramToolHandler} (read) or {@link OperationMcpDiagramToolHandler} + * (write). Exported only as the upper bound for the {@link McpDiagramToolHandlerConstructor} + * multi-binding. + * + * `@inject(...)` fields resolve against the live `ClientSession.container` because instances are + * created at GLSP-session-open by `McpDiagramToolHandlerRegistryInitializer`. + * + * Read-side handlers access `modelState.index` directly. Synchronous reads are atomic, but a + * concurrent tool that awaits `actionDispatcher.dispatch(...)` may expose a half-committed + * model — write-then-read tool sequences must serialize the write before the read. + * + * @experimental + */ +@injectable() +export abstract class BaseMcpDiagramToolHandler extends BaseMcpToolHandler { + @inject(ClientId) protected clientId: string; + @inject(ModelState) protected modelState: ModelState; + @inject(McpIdAliasService) protected aliasService: McpIdAliasService; + @inject(McpLabelProvider) protected labelProvider: McpLabelProvider; + + /** Throw {@link McpToolError} for expected errors; the base wraps. */ + protected abstract createResult(params: T): MaybePromise; + + /** + * Public dispatch entry point invoked by {@link McpServerLauncher}'s registered SDK + * callback. Each sibling sets its own policy — {@link AbstractMcpDiagramToolHandler} + * passes through; {@link OperationMcpDiagramToolHandler} enforces the readonly gate. + * Adopters don't call this directly. + */ + abstract handle(params: T): Promise; + + /** Override to opt out of registration when a runtime dependency is missing. Default: `true`. */ + canRegister(): boolean { + return true; + } + + /** Translates (alias-or-real) ids to real ids; partitions ids absent from the model into `missingIds`. */ + protected resolveIds(ids: string[]): { realIds: string[]; missingIds: string[] } { + const realIds: string[] = []; + const missingIds: string[] = []; + for (const id of ids) { + const realId = this.aliasService.lookup(id); + if (this.modelState.index.find(realId)) { + realIds.push(realId); + } else { + missingIds.push(id); + } + } + return { realIds, missingIds }; + } + + /** Like {@link resolveIds} but throws {@link McpElementsNotFoundError} when any input id is absent. */ + protected resolveExistingIds(ids: string[] | undefined): string[] { + if (!ids || ids.length === 0) { + return []; + } + const { realIds, missingIds } = this.resolveIds(ids); + if (missingIds.length > 0) { + throw new McpElementsNotFoundError(missingIds); + } + return realIds; + } + + /** + * Resolves a list of structured inputs against the model: each input's id is looked up via the + * alias service and the resolved element is retrieved from the index. Throws + * {@link McpElementsNotFoundError} if any input's id is absent. The returned tuples preserve + * caller's input/element pairing for downstream processing (e.g. type-checks per kind). + */ + protected lookupElements(inputs: I[], extractId: (input: I) => string): Array<[I, GModelElement]> { + const found: Array<[I, GModelElement]> = []; + const missing: string[] = []; + for (const input of inputs) { + const inputId = extractId(input); + const realId = this.aliasService.lookup(inputId); + const element = this.modelState.index.find(realId); + if (element) { + found.push([input, element]); + } else { + missing.push(inputId); + } + } + if (missing.length > 0) { + throw new McpElementsNotFoundError(missing); + } + return found; + } + + /** Encodes real ids to alias ids (passthrough when {@link NullMcpIdAliasService} is bound). */ + protected encodeIds(ids: string[]): string[] { + return ids.map(id => this.aliasService.alias(id)); + } + + /** + * Compact identity for an element — `{ id, elementTypeId, label? }`. Mutating tools (create + * / modify / delete) echo this so the LLM can refer to the element by label or type in + * user-facing prose without a follow-up `query-elements` call. Returns `undefined` when + * the element is no longer in the model (e.g. just deleted). + */ + protected describeElement(aliasOrRealId: string): ElementIdentity | undefined { + const realId = this.aliasService.lookup(aliasOrRealId); + const element = this.modelState.index.find(realId); + return element ? this.describeResolvedElement(element) : undefined; + } + + /** Describe an already-resolved {@link GModelElement} — skips the model-lookup round-trip. */ + protected describeResolvedElement(element: GModelElement): ElementIdentity { + const label = this.labelProvider.getLabel(element)?.text; + // Conditional spread keeps `label` absent (not `undefined`) when the element has none — + // matches Zod's `.optional()` semantics so the structured payload is clean. + return { id: this.aliasService.alias(element.id), elementTypeId: element.type, ...(label !== undefined ? { label } : {}) }; + } +} + +/** + * Diagram-scope tool base for **query-style** tools that read the model without mutating it + * (validate, get-selection, set-view, query-elements). Sibling of + * {@link OperationMcpDiagramToolHandler} — extend that one when the tool dispatches a + * model-mutating Operation. + * + * @experimental + */ +@injectable() +export abstract class AbstractMcpDiagramToolHandler< + T extends McpDiagramScopedInput = McpDiagramScopedInput +> extends BaseMcpDiagramToolHandler { + handle(params: T): Promise { + return this.execute(() => this.createResult(params)); + } +} + +/** + * Diagram-scope tool base for **operation-style** tools that mutate the model by dispatching a + * GLSP `Operation` (or other model-mutating `Action` like `UndoAction` / `RedoAction`) on + * behalf of the LLM — create, modify, delete, undo, redo. Sibling of + * {@link AbstractMcpDiagramToolHandler}. + * + * The base bakes in two pieces beyond the read sibling: + * - `@inject(ActionDispatcher)`. Every adopter extending this base dispatches, so the + * dispatcher belongs on the base, not in per-handler boilerplate. + * - Throws {@link McpReadOnlyError} when `modelState.isReadonly`, surfacing a hard failure + * to the LLM. The MCP-side gate is necessary even though core's `OperationActionHandler` + * checks readonly itself: core's gate is a *soft* warning (returns a `MessageAction`, the + * dispatch resolves successfully and the tool body would otherwise report success while + * nothing changed), and `UndoRedoActionHandler` doesn't gate readonly at all. + * + * Parallels core's `OperationHandler` (sibling to `ActionHandler`, not a refinement) — but + * note the role flip: core's `OperationHandler` is downstream of dispatch and only needs + * `ModelState`; ours is upstream of dispatch (the LLM-side handler that triggers the Operation) + * and so additionally injects `ActionDispatcher`. + * + * @experimental + */ +@injectable() +export abstract class OperationMcpDiagramToolHandler< + T extends McpDiagramScopedInput = McpDiagramScopedInput +> extends BaseMcpDiagramToolHandler { + @inject(ActionDispatcher) protected actionDispatcher: ActionDispatcher; + + // Operation tools mutate the model — flip the read defaults. Concrete handlers override + // `destructiveHint` / `idempotentHint` (one line each) where it applies; the explicit `false` + // defaults below override the MCP spec's "true if unset" semantics so non-destructive, + // non-idempotent writes don't trigger overzealous client-side confirmation prompts. + override readonly readOnlyHint = false; + override readonly destructiveHint: boolean = false; + override readonly idempotentHint: boolean = false; + + handle(params: T): Promise { + return this.execute(() => { + if (this.modelState.isReadonly) { + throw new McpReadOnlyError(); + } + return this.createResult(params); + }); + } + + /** + * Convenience for tools that initiate a `RequestAction` round-trip (rather than the + * fire-and-forget operation dispatch this base is named for) — wraps + * {@link requestActionOrFail} with `this.actionDispatcher` and a default label of + * `this.name`. Pass an explicit label only when the tool handles multiple distinct + * round-trips and wants to disambiguate them in error messages. + */ + protected requestAction(request: RequestAction, timeoutMs: number, label: string = this.name): Promise { + return requestActionOrFail(this.actionDispatcher, request, timeoutMs, label); + } +} + +/** + * Multi-binding identifier for diagram-scope tool handler constructors — covers both + * {@link AbstractMcpDiagramToolHandler} and {@link OperationMcpDiagramToolHandler}. Bound via + * `AbstractMcpDiagramModule.configureToolHandlers`; the per-GLSP-session registry initializer + * reads the list at session-open and resolves each constructor against the session container. + * + * Mirrors core's `OperationHandlerConstructor` pattern: instance fields (`readonly name = '…'`) + * are read off `new Constructor()` at MCP-session-init for SDK catalog registration, the same + * trick `bindOperations` uses to read `operationType`. + */ +export type McpDiagramToolHandlerConstructor = interfaces.Newable>; +export const McpDiagramToolHandlerConstructor = Symbol('McpDiagramToolHandlerConstructor'); diff --git a/packages/server-mcp/src/server/raw-http.test-util.ts b/packages/server-mcp/src/server/raw-http.test-util.ts new file mode 100644 index 0000000..574622c --- /dev/null +++ b/packages/server-mcp/src/server/raw-http.test-util.ts @@ -0,0 +1,59 @@ +/******************************************************************************** + * 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 * as http from 'http'; + +export interface RawHttpResponse { + status: number; + contentType: string | undefined; + body: string; +} + +/** + * Issue a raw HTTP request to a transport's listening port. Used by spec files that need + * to bypass the SDK Client to assert wire-level behavior (status codes, error envelopes). + */ +export function rawHttpRequest( + port: number, + method: 'POST' | 'GET' | 'DELETE', + headers: http.OutgoingHttpHeaders, + body?: unknown +): Promise { + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: '127.0.0.1', + port, + path: '/mcp', + method, + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', ...headers } + }, + res => { + const chunks: Buffer[] = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('end', () => + resolve({ + status: res.statusCode ?? 0, + contentType: res.headers['content-type'], + body: Buffer.concat(chunks).toString('utf8') + }) + ); + } + ); + req.on('error', reject); + req.end(body !== undefined ? JSON.stringify(body) : undefined); + }); +} diff --git a/packages/server-mcp/src/tools/handlers/count-elements-mcp-tool-handler.spec.ts b/packages/server-mcp/src/tools/handlers/count-elements-mcp-tool-handler.spec.ts new file mode 100644 index 0000000..d647989 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/count-elements-mcp-tool-handler.spec.ts @@ -0,0 +1,99 @@ +/******************************************************************************** + * 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 { ClientId, GModelElement, Logger, ModelState, NullLogger } from '@eclipse-glsp/server'; +import { expect } from 'chai'; +import { Container, ContainerModule } from 'inversify'; +import { DefaultMcpLabelProvider, McpIdAliasService, McpLabelProvider, McpToolResult } from '../../server'; +import { CountElementsMcpToolHandler } from './count-elements-mcp-tool-handler'; + +function makeElement(id: string, type: string): GModelElement { + return { id, type, children: [] } as unknown as GModelElement; +} + +function makeModelState(elements: GModelElement[]): ModelState { + const byId = new Map(elements.map(el => [el.id, el])); + return { + index: { + allIds: () => [...byId.keys()], + get: (id: string) => byId.get(id), + find: (id: string) => byId.get(id) + } + } as unknown as ModelState; +} + +function buildHandler(elements: GModelElement[]): CountElementsMcpToolHandler { + const container = new Container(); + container.load( + new ContainerModule(bind => { + bind(Logger).toConstantValue(new NullLogger()); + bind(ClientId).toConstantValue('test-session'); + bind(ModelState).toConstantValue(makeModelState(elements)); + bind(McpIdAliasService).toConstantValue({ + lookup: (id: string) => id, + alias: (id: string) => id + } as McpIdAliasService); + bind(McpLabelProvider).to(DefaultMcpLabelProvider); + bind(CountElementsMcpToolHandler).toSelf(); + }) + ); + return container.get(CountElementsMcpToolHandler); +} + +function callCreateResult(handler: CountElementsMcpToolHandler): Promise { + return (handler as unknown as { createResult: (params: { sessionId: string }) => Promise }).createResult({ + sessionId: 'test-session' + }); +} + +describe('CountElementsMcpToolHandler', () => { + it('aggregates element counts grouped by `type`', async () => { + const handler = buildHandler([ + makeElement('n1', 'task:manual'), + makeElement('n2', 'task:manual'), + makeElement('n3', 'task:automated'), + makeElement('e1', 'edge') + ]); + + const result = await callCreateResult(handler); + expect(result.structuredContent).to.deep.equal({ + total: 4, + countsByType: { 'task:manual': 2, 'task:automated': 1, edge: 1 } + }); + }); + + it('orders rendered rows by count desc, then type alpha asc on ties', async () => { + const handler = buildHandler([ + makeElement('e1', 'edge'), + makeElement('e2', 'edge'), + makeElement('n1', 'task:automated'), + makeElement('n2', 'task:manual'), + makeElement('n3', 'task:manual') + ]); + + const result = await callCreateResult(handler); + const text = (result.content[0] as { text: string }).text; + // Counts: edge=2, task:manual=2, task:automated=1. + // Expected order: edge before task:manual (alpha tiebreak on count=2), then task:automated. + const expectedOrder = ['edge', 'task:manual', 'task:automated']; + const indices = expectedOrder.map(type => text.indexOf(`- ${type}: `)); + expect( + indices.every(i => i >= 0), + 'expected all type rows to be rendered' + ).to.equal(true); + expect(indices).to.deep.equal([...indices].sort((a, b) => a - b)); + }); +}); diff --git a/packages/server-mcp/src/tools/handlers/count-elements-mcp-tool-handler.ts b/packages/server-mcp/src/tools/handlers/count-elements-mcp-tool-handler.ts new file mode 100644 index 0000000..3506493 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/count-elements-mcp-tool-handler.ts @@ -0,0 +1,66 @@ +/******************************************************************************** + * 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'; +import * as z from 'zod/v4'; +import { McpDiagramScopedInputSchema, AbstractMcpDiagramToolHandler, McpToolResult } from '../../server'; + +export const CountElementsInputSchema = McpDiagramScopedInputSchema; +export type CountElementsInput = z.infer; + +export const CountElementsOutputSchema = z.object({ + total: z.number().int().describe('Total element count across the diagram (root included).'), + countsByType: z.record(z.string(), z.number().int()).describe('Element count grouped by `GModelElement.type`.') +}); + +/** + * Counts elements in the diagram, grouped by type. Cheap alternative to dumping the full + * `diagram-model` resource when the agent only needs to know "how big is this" or "do any + * elements of type X exist". + */ +@injectable() +export class CountElementsMcpToolHandler extends AbstractMcpDiagramToolHandler { + static readonly NAME = 'count-elements'; + readonly name = CountElementsMcpToolHandler.NAME; + override readonly title = 'Count Diagram Elements'; + readonly description = + 'Count the elements in the session diagram, grouped by element type. ' + + 'Cheap sizing primitive — useful before deciding whether to load the full model with `diagram-model` ' + + '(expensive on large diagrams) or whether a filtered `query-elements` call would suffice. ' + + 'Returns total count plus a per-type breakdown.'; + readonly inputSchema = CountElementsInputSchema; + override readonly outputSchema = CountElementsOutputSchema; + + protected async createResult(_params: CountElementsInput): Promise { + const countsByType: Record = {}; + let total = 0; + for (const id of this.modelState.index.allIds()) { + const element = this.modelState.index.get(id); + if (!element) { + continue; + } + countsByType[element.type] = (countsByType[element.type] ?? 0) + 1; + total += 1; + } + return this.success(this.renderMarkdown(total, countsByType), { total, countsByType }); + } + + protected renderMarkdown(total: number, countsByType: Record): string { + const sorted = Object.entries(countsByType).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); + const rows = sorted.map(([type, count]) => `- ${type}: ${count}`).join('\n'); + return `Total elements: ${total}\n\nBy type:\n${rows}`; + } +} diff --git a/packages/server-mcp/src/tools/handlers/create-edges-mcp-tool-handler.spec.ts b/packages/server-mcp/src/tools/handlers/create-edges-mcp-tool-handler.spec.ts new file mode 100644 index 0000000..3d488b2 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/create-edges-mcp-tool-handler.spec.ts @@ -0,0 +1,196 @@ +/******************************************************************************** + * 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, + ClientId, + DiagramConfiguration, + EdgeCreationChecker, + EdgeTypeHint, + GModelElement, + Logger, + ModelState, + NullLogger +} from '@eclipse-glsp/server'; +import { expect } from 'chai'; +import { Container, ContainerModule } from 'inversify'; +import { DefaultMcpLabelProvider, McpIdAliasService, McpLabelProvider, McpToolResult } from '../../server'; +import { CreateEdgesInput, CreateEdgesMcpToolHandler } from './create-edges-mcp-tool-handler'; + +function makeElement(id: string, type: string): GModelElement { + return { id, type, children: [] } as unknown as GModelElement; +} + +function makeModelState(elements: GModelElement[]): ModelState { + const byId = new Map(elements.map(el => [el.id, el])); + return { + index: { + allIds: () => [...byId.keys()], + get: (id: string) => byId.get(id), + find: (id: string) => byId.get(id) + }, + isReadonly: false + } as unknown as ModelState; +} + +class StubEdgeCreationChecker implements EdgeCreationChecker { + public lastTargetCall?: { edgeType: string; source: GModelElement; target: GModelElement }; + constructor(private readonly result: boolean) {} + isValidSource(): boolean { + return this.result; + } + isValidTarget(edgeType: string, source: GModelElement, target: GModelElement): boolean { + this.lastTargetCall = { edgeType, source, target }; + return this.result; + } +} + +interface BuildArgs { + elements: GModelElement[]; + edgeTypeHints: Pick[]; + checker?: EdgeCreationChecker; +} + +/** Records dispatched actions so create-mode tests can assert no dispatch happened. */ +class RecordingDispatcher { + public dispatched: Action[] = []; + async dispatch(action: Action): Promise { + this.dispatched.push(action); + } + async dispatchAll(): Promise { + // not used by create-edges + } + dispatchAfterNextUpdate(): void { + // not used + } +} + +function buildHandler({ elements, edgeTypeHints, checker }: BuildArgs): { + handler: CreateEdgesMcpToolHandler; + dispatcher: RecordingDispatcher; +} { + const dispatcher = new RecordingDispatcher(); + const container = new Container(); + container.load( + new ContainerModule(bind => { + bind(Logger).toConstantValue(new NullLogger()); + bind(ClientId).toConstantValue('test-session'); + bind(ModelState).toConstantValue(makeModelState(elements)); + bind(McpIdAliasService).toConstantValue({ + lookup: (id: string) => id, + alias: (id: string) => id + } as McpIdAliasService); + bind(DiagramConfiguration).toConstantValue({ edgeTypeHints } as unknown as DiagramConfiguration); + bind(ActionDispatcher).toConstantValue(dispatcher as unknown as ActionDispatcher); + if (checker) { + bind(EdgeCreationChecker).toConstantValue(checker); + } + bind(McpLabelProvider).to(DefaultMcpLabelProvider); + bind(CreateEdgesMcpToolHandler).toSelf(); + }) + ); + return { handler: container.get(CreateEdgesMcpToolHandler), dispatcher }; +} + +function callCreateResult(handler: CreateEdgesMcpToolHandler, params: CreateEdgesInput): Promise { + return (handler as unknown as { createResult: (p: CreateEdgesInput) => Promise }).createResult(params); +} + +interface DryRunStructured { + createdEdges: { id: string; elementTypeId: string; label?: string }[]; + errors: string[]; + validationResults: { edgeType: string; sourceElementId: string; targetElementId: string; isValid: boolean; reason?: string }[]; +} + +describe('CreateEdgesMcpToolHandler · dryRun', () => { + const baseInput = (overrides: Partial = {}): CreateEdgesInput => ({ + sessionId: 's', + dryRun: true, + edges: [{ elementTypeId: 'edge:dynamic', sourceElementId: 's', targetElementId: 't' }], + ...overrides + }); + + it('returns isValid:true with a "no dynamic hint" reason when the edgeType has no dynamic hint', async () => { + const { handler, dispatcher } = buildHandler({ + elements: [makeElement('s', 'task'), makeElement('t', 'task')], + edgeTypeHints: [{ elementTypeId: 'edge:static', dynamic: false }] + }); + + const result = await callCreateResult( + handler, + baseInput({ edges: [{ elementTypeId: 'edge:static', sourceElementId: 's', targetElementId: 't' }] }) + ); + + const structured = result.structuredContent as unknown as DryRunStructured; + expect(structured.validationResults).to.have.lengthOf(1); + expect(structured.validationResults[0].isValid).to.equal(true); + expect(structured.validationResults[0].reason).to.match(/no dynamic edge-type hint/i); + expect(structured.createdEdges).to.deep.equal([]); + expect(dispatcher.dispatched).to.have.lengthOf(0); + }); + + it('flags isValid:false with a config-gap reason when edgeType declares a dynamic hint but no checker is bound', async () => { + const { handler, dispatcher } = buildHandler({ + elements: [makeElement('s', 'task'), makeElement('t', 'task')], + edgeTypeHints: [{ elementTypeId: 'edge:dynamic', dynamic: true }] + }); + + const result = await callCreateResult(handler, baseInput()); + + const structured = result.structuredContent as unknown as DryRunStructured; + expect(structured.validationResults[0].isValid).to.equal(false); + expect(structured.validationResults[0].reason).to.include('EdgeCreationChecker is not bound'); + expect(dispatcher.dispatched).to.have.lengthOf(0); + }); + + it('delegates to EdgeCreationChecker.isValidTarget when target + dynamic hint + checker are present', async () => { + const sourceElement = makeElement('s', 'task'); + const targetElement = makeElement('t', 'task'); + const checker = new StubEdgeCreationChecker(false); + const { handler, dispatcher } = buildHandler({ + elements: [sourceElement, targetElement], + edgeTypeHints: [{ elementTypeId: 'edge:dynamic', dynamic: true }], + checker + }); + + const result = await callCreateResult(handler, baseInput()); + + expect(checker.lastTargetCall).to.deep.equal({ + edgeType: 'edge:dynamic', + source: sourceElement, + target: targetElement + }); + const structured = result.structuredContent as unknown as DryRunStructured; + expect(structured.validationResults[0].isValid).to.equal(false); + expect(dispatcher.dispatched).to.have.lengthOf(0); + }); + + it('reports source/target not-found inline as a per-edge validation failure (does not throw)', async () => { + const { handler } = buildHandler({ + elements: [makeElement('s', 'task')], + edgeTypeHints: [{ elementTypeId: 'edge:dynamic', dynamic: true }] + }); + + const result = await callCreateResult( + handler, + baseInput({ edges: [{ elementTypeId: 'edge:dynamic', sourceElementId: 's', targetElementId: 'missing' }] }) + ); + const structured = result.structuredContent as unknown as DryRunStructured; + expect(structured.validationResults[0].isValid).to.equal(false); + expect(structured.validationResults[0].reason).to.include('Target element not found'); + }); +}); diff --git a/packages/server-mcp/src/tools/handlers/create-edges-mcp-tool-handler.ts b/packages/server-mcp/src/tools/handlers/create-edges-mcp-tool-handler.ts new file mode 100644 index 0000000..96a68d3 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/create-edges-mcp-tool-handler.ts @@ -0,0 +1,205 @@ +/******************************************************************************** + * 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, CreateEdgeOperation, DiagramConfiguration, EdgeCreationChecker } from '@eclipse-glsp/server'; +import { inject, injectable, optional } from 'inversify'; +import * as z from 'zod/v4'; +import { + ElementIdentity, + ElementIdentitySchema, + McpDiagramScopedInputSchema, + McpToolResult, + OperationMcpDiagramToolHandler, + position +} from '../../server'; +import { formatNoticeList } from '../../util'; + +/** Single edge-creation entry. Strict so an LLM-typoed field surfaces as a validation error instead of being silently dropped. */ +export const CreateEdgeSpecSchema = z.strictObject({ + elementTypeId: z.string().describe('Edge type ID (e.g., `edge`, `transition`). Use the `element-types` tool to discover valid IDs.'), + sourceElementId: z.string().describe('ID of the source element (must exist in the diagram)'), + targetElementId: z.string().describe('ID of the target element (must exist in the diagram)'), + routingPoints: z.array(position).optional().describe('Optional array of routing point coordinates that allow for a complex edge path.'), + // `args` stays open (`record(...)`) — adopter-specific extension surface for per-edge-type creation hints. + args: z.record(z.string(), z.any()).optional().describe('Additional type-specific arguments for edge creation (varies by edge type)') +}); + +export const CreateEdgesInputSchema = McpDiagramScopedInputSchema.extend({ + edges: z.array(CreateEdgeSpecSchema).min(1).describe('Array of edges to create. Must include at least one edge.'), + dryRun: z + .boolean() + .optional() + .describe( + 'When true, validate each edge against the type-hint rules without creating anything; returns per-edge `validationResults`.' + ) +}); +export type CreateEdgesInput = z.infer; + +export const CreateEdgesValidationResultSchema = z.object({ + edgeType: z.string(), + sourceElementId: z.string(), + targetElementId: z.string(), + isValid: z.boolean(), + reason: z.string().optional().describe('Brief reason explaining the verdict (always present when `isValid: false`).') +}); + +export const CreateEdgesOutputSchema = z.object({ + createdEdges: z.array(ElementIdentitySchema).describe('Identity of each edge successfully created. Empty in `dryRun` mode.'), + errors: z.array(z.string()).describe('Per-input failure messages; absent or empty when every input succeeded.'), + validationResults: z + .array(CreateEdgesValidationResultSchema) + .optional() + .describe('Per-input validation results. Present only in `dryRun` mode.') +}); + +type EdgeInput = CreateEdgesInput['edges'][number]; +type ValidationResult = z.infer; + +@injectable() +export class CreateEdgesMcpToolHandler extends OperationMcpDiagramToolHandler { + static readonly NAME = 'create-edges'; + readonly name = CreateEdgesMcpToolHandler.NAME; + override readonly title = 'Create Diagram Edges'; + readonly description = + 'Create one or multiple new edges connecting two elements in the diagram. ' + + 'Set `dryRun: true` to validate proposed edges (per the diagram-type type-hint rules) ' + + 'without creating anything; the result then carries per-edge `validationResults`. ' + + 'Without `dryRun`, this operation modifies the diagram state and requires user approval. ' + + 'Use the `element-types` tool to discover valid edge type IDs.'; + readonly inputSchema = CreateEdgesInputSchema; + override readonly outputSchema = CreateEdgesOutputSchema; + + @inject(DiagramConfiguration) protected diagramConfiguration: DiagramConfiguration; + @inject(EdgeCreationChecker) @optional() protected edgeCreationChecker?: EdgeCreationChecker; + + protected async createResult({ edges, dryRun }: CreateEdgesInput): Promise { + if (dryRun) { + return this.runDryRun(edges); + } + return this.runCreate(edges); + } + + protected runDryRun(edges: EdgeInput[]): McpToolResult { + const validationResults: ValidationResult[] = edges.map(edge => this.validateEdge(edge)); + const validCount = validationResults.filter(result => result.isValid).length; + const summary = + `Dry run: validated ${edges.length} edge(s); ${validCount} would be accepted, ${edges.length - validCount} rejected.\n` + + validationResults + .map( + result => + `- ${result.edgeType} ${result.sourceElementId} → ${result.targetElementId}: ` + + `${result.isValid ? 'valid' : `invalid (${result.reason})`}` + ) + .join('\n'); + return this.success(summary, { createdEdges: [], errors: [], validationResults }); + } + + protected async runCreate(edges: EdgeInput[]): Promise { + let beforeIds = this.modelState.index.allIds(); + + const errors: string[] = []; + const createdEdges: ElementIdentity[] = []; + let dispatchedOperations = 0; + // Sequential — each iteration must isolate its own creation in the post-dispatch diff. + for (const edge of edges) { + const { elementTypeId, routingPoints, args } = edge; + const sourceElementId = this.aliasService.lookup(edge.sourceElementId); + const targetElementId = this.aliasService.lookup(edge.targetElementId); + + const source = this.modelState.index.find(sourceElementId); + if (!source) { + errors.push(`Source element not found: ${edge.sourceElementId}`); + continue; + } + const target = this.modelState.index.find(targetElementId); + if (!target) { + errors.push(`Target element not found: ${edge.targetElementId}`); + continue; + } + + const operation = CreateEdgeOperation.create({ elementTypeId, sourceElementId, targetElementId, args }); + await this.actionDispatcher.dispatch(operation); + dispatchedOperations++; + + const afterIds = this.modelState.index.allIds(); + const newIds = afterIds.filter(id => !beforeIds.includes(id)); + const newElements = newIds.map(id => this.modelState.index.find(id)).filter(element => element?.type === elementTypeId); + const newElement = newElements[0]; + if (newElements.length > 1) { + this.logger.warn('More than 1 new element created'); + } + beforeIds = afterIds; + + // Operations don't surface failure directly — infer from absence of a new id of the requested type. + if (!newElement) { + errors.push(`Edge creation likely failed because no new element ID was found for input: ${JSON.stringify(edge)}`); + continue; + } + + if (routingPoints) { + const routingPointsOperation = ChangeRoutingPointsOperation.create([ + { elementId: newElement.id, newRoutingPoints: routingPoints } + ]); + await this.actionDispatcher.dispatch(routingPointsOperation); + dispatchedOperations++; + } + + createdEdges.push(this.describeResolvedElement(newElement)); + } + + const successListStr = createdEdges.map(({ id, elementTypeId }) => `- ${elementTypeId} (#${id})`).join('\n'); + // Per-input errors are surfaced in `errors`; the call itself still succeeds — rolling back partial creates would require operation-level transactions. + return this.success( + `Successfully created ${createdEdges.length} edge(s) (in ${dispatchedOperations} commands):\n${successListStr}${formatNoticeList('errors', errors)}`, + { createdEdges, errors } + ); + } + + /** Mirrors `RequestCheckEdgeAction`: existence check, then dynamic-hint → checker; static or unknown edgeType → valid. */ + protected validateEdge(edge: EdgeInput): ValidationResult { + const { elementTypeId } = edge; + const sourceRealId = this.aliasService.lookup(edge.sourceElementId); + const targetRealId = this.aliasService.lookup(edge.targetElementId); + const echo: Pick = { + edgeType: elementTypeId, + sourceElementId: edge.sourceElementId, + targetElementId: edge.targetElementId + }; + + const source = this.modelState.index.find(sourceRealId); + if (!source) { + return { ...echo, isValid: false, reason: `Source element not found: ${edge.sourceElementId}` }; + } + const target = this.modelState.index.find(targetRealId); + if (!target) { + return { ...echo, isValid: false, reason: `Target element not found: ${edge.targetElementId}` }; + } + + const hasDynamicHint = this.diagramConfiguration.edgeTypeHints.some(hint => hint.elementTypeId === elementTypeId && hint.dynamic); + if (!hasDynamicHint) { + return { ...echo, isValid: true, reason: 'no dynamic edge-type hint — static hints apply' }; + } + if (!this.edgeCreationChecker) { + return { + ...echo, + isValid: false, + reason: `EdgeCreationChecker is not bound although edge type '${elementTypeId}' declares a dynamic hint.` + }; + } + const isValid = this.edgeCreationChecker.isValidTarget(elementTypeId, source, target); + return { ...echo, isValid, ...(isValid ? {} : { reason: 'rejected by EdgeCreationChecker' }) }; + } +} diff --git a/packages/server-mcp/src/tools/handlers/create-nodes-mcp-tool-handler.ts b/packages/server-mcp/src/tools/handlers/create-nodes-mcp-tool-handler.ts new file mode 100644 index 0000000..cd9419e --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/create-nodes-mcp-tool-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 { ApplyLabelEditOperation, CreateNodeOperation } from '@eclipse-glsp/server'; +import { injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { + ElementIdentity, + ElementIdentitySchema, + McpDiagramScopedInputSchema, + McpToolResult, + OperationMcpDiagramToolHandler, + position +} from '../../server'; +import { formatNoticeList } from '../../util'; + +/** Single node-creation entry. Strict so an LLM-typoed field surfaces as a validation error instead of being silently dropped. */ +export const CreateNodeSpecSchema = z.strictObject({ + elementTypeId: z + .string() + .describe('Element type ID (e.g., `task:manual`, `task:automated`). Use the `element-types` tool to discover valid IDs.'), + position: position.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` stays open (`record(...)`) — adopter-specific extension surface for per-element-type creation hints. + args: z.record(z.string(), z.any()).optional().describe('Additional type-specific arguments for node creation (varies by element type)') +}); + +export const CreateNodesInputSchema = McpDiagramScopedInputSchema.extend({ + nodes: z.array(CreateNodeSpecSchema).min(1).describe('Array of nodes to create. Must include at least one node.') +}); +export type CreateNodesInput = z.infer; + +export const CreateNodesOutputSchema = z.object({ + createdNodes: z + .array(ElementIdentitySchema) + .describe('Nodes successfully created, in input order. `label` is only present when the type actually accepted text.'), + errors: z.array(z.string()).describe('Per-input hard-failure messages — the creation could not complete.'), + warnings: z + .array(z.string()) + .describe( + 'Soft notices for inputs that succeeded with caveats (e.g. `text` supplied for a type whose elements have no editable label).' + ) +}); + +@injectable() +export class CreateNodesMcpToolHandler extends OperationMcpDiagramToolHandler { + static readonly NAME = 'create-nodes'; + readonly name = CreateNodesMcpToolHandler.NAME; + override readonly title = 'Create Diagram Nodes'; + readonly description = + 'Create one or multiple new nodes in the diagram at the specified positions. ' + + 'When creating new nodes absolutely consider the visual alignment with existing nodes — call ' + + '`query-elements` (or `count-elements` for a quick overview) first to avoid overlap. ' + + 'Each node descriptor needs an `elementTypeId` (from `element-types`) and a `position`; ' + + '`text`, `containerId`, and per-type `args` are optional. ' + + 'This operation modifies the diagram state and requires user approval.'; + readonly inputSchema = CreateNodesInputSchema; + override readonly outputSchema = CreateNodesOutputSchema; + + protected async createResult({ nodes }: CreateNodesInput): Promise { + let beforeIds = this.modelState.index.allIds(); + + const errors: string[] = []; + const warnings: string[] = []; + const createdNodes: ElementIdentity[] = []; + let dispatchedOperations = 0; + // Sequential — each iteration must isolate its own creation in the post-dispatch diff. + for (const node of nodes) { + const { elementTypeId, position, text, args } = node; + const containerId = node.containerId ? this.aliasService.lookup(node.containerId) : undefined; + + // Surface as `position` (matches element properties) rather than core's `location` for AI-facing API consistency. + const operation = CreateNodeOperation.create(elementTypeId, { location: position, containerId, args }); + await this.actionDispatcher.dispatch(operation); + dispatchedOperations++; + + const afterIds = this.modelState.index.allIds(); + const newIds = afterIds.filter(id => !beforeIds.includes(id)); + const newElements = newIds.map(id => this.modelState.index.find(id)).filter(element => element?.type === elementTypeId); + const newElement = newElements[0]; + if (newElements.length > 1) { + this.logger.warn('More than 1 new element created'); + } + beforeIds = afterIds; + + // Operations don't surface failure directly — infer from absence of a new id of the requested type. + if (!newElement) { + errors.push(`Node creation likely failed because no new element ID was found for input: ${JSON.stringify(node)}`); + continue; + } + + if (text) { + const labelId = this.labelProvider.getLabel(newElement)?.id; + if (labelId) { + await this.actionDispatcher.dispatch(ApplyLabelEditOperation.create({ labelId, text })); + dispatchedOperations++; + } else { + warnings.push(`Ignored \`text\` for '${elementTypeId}' — this element type has no editable label.`); + } + } + + createdNodes.push(this.describeResolvedElement(newElement)); + } + + const successListStr = createdNodes + .map(({ id, elementTypeId, label }) => `- ${label ? `'${label}' ` : ''}${elementTypeId} (#${id})`) + .join('\n'); + // Per-input errors / warnings are surfaced in `errors` / `warnings`; the call itself still + // succeeds — rolling back partial creates would require operation-level transactions. + return this.success( + `Successfully created ${createdNodes.length} node(s) (in ${dispatchedOperations} commands):\n${successListStr}` + + formatNoticeList('errors', errors) + + formatNoticeList('warnings', warnings), + { createdNodes, errors, warnings } + ); + } +} diff --git a/packages/server-mcp/src/tools/handlers/delete-elements-mcp-tool-handler.ts b/packages/server-mcp/src/tools/handlers/delete-elements-mcp-tool-handler.ts new file mode 100644 index 0000000..7b6a16e --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/delete-elements-mcp-tool-handler.ts @@ -0,0 +1,73 @@ +/******************************************************************************** + * 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 { DeleteElementOperation } from '@eclipse-glsp/server'; +import { injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { + ElementIdentitySchema, + McpDiagramScopedInputSchema, + McpToolResult, + OperationMcpDiagramToolHandler, + elementIds +} from '../../server'; + +export const DeleteElementsInputSchema = McpDiagramScopedInputSchema.extend({ elementIds }); +export type DeleteElementsInput = z.infer; + +export const DeleteElementsOutputSchema = z.object({ + deletedElements: z + .array(ElementIdentitySchema) + .describe( + 'Identity of each element the LLM requested for deletion (captured before dispatch). Dependents auto-deleted by GLSP are not enumerated here.' + ), + deletedCount: z + .number() + .int() + .describe( + 'Total number of elements removed from the model. Higher than `deletedElements.length` when dependents (e.g. edges) are auto-deleted.' + ) +}); + +@injectable() +export class DeleteElementsMcpToolHandler extends OperationMcpDiagramToolHandler { + static readonly NAME = 'delete-elements'; + readonly name = DeleteElementsMcpToolHandler.NAME; + override readonly title = 'Delete Diagram Elements'; + readonly 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).'; + readonly inputSchema = DeleteElementsInputSchema; + override readonly outputSchema = DeleteElementsOutputSchema; + /** Deletion is the canonical destructive update — flip the operation-base default. */ + override readonly destructiveHint = true; + + protected async createResult({ elementIds }: DeleteElementsInput): Promise { + const realIds = this.resolveExistingIds(elementIds); + // Capture identities BEFORE dispatch — once deleted, `describeElement` returns undefined. + const deletedElements = realIds + .map(realId => this.describeElement(realId)) + .filter((entry): entry is NonNullable => entry !== undefined); + const beforeCount = this.modelState.index.allIds().length; + await this.actionDispatcher.dispatch(DeleteElementOperation.create(realIds)); + const deletedCount = beforeCount - this.modelState.index.allIds().length; + return this.success(`Successfully deleted ${deletedCount} element(s) (including dependents)`, { + deletedElements, + deletedCount + }); + } +} diff --git a/packages/server-mcp/src/tools/handlers/diagram-model-mcp-tool-handler.ts b/packages/server-mcp/src/tools/handlers/diagram-model-mcp-tool-handler.ts new file mode 100644 index 0000000..340057e --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/diagram-model-mcp-tool-handler.ts @@ -0,0 +1,66 @@ +/******************************************************************************** + * 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/server'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { McpModelSerializer } from '../../resources/services/mcp-model-serializer'; +import { McpDiagramScopedInputSchema, AbstractMcpDiagramToolHandler, McpToolResult } from '../../server'; + +export const DiagramModelInputSchema = McpDiagramScopedInputSchema; +export type DiagramModelInput = z.infer; + +/** `.loose()` lets adopter element types attach extra fields (position, size, …) without widening the schema each time. */ +export const DiagramModelElementSchema = z + .object({ + id: z.string(), + type: z.string(), + parentId: z.string().optional() + }) + .loose(); + +export const DiagramModelOutputSchema = z.object({ + sessionId: z.string(), + elements: z.array(DiagramModelElementSchema) +}); + +@injectable() +export class DiagramModelMcpToolHandler extends AbstractMcpDiagramToolHandler { + static readonly NAME = 'diagram-model'; + readonly name = DiagramModelMcpToolHandler.NAME; + override readonly title = 'Diagram Model Structure'; + readonly description = + 'Get the complete GLSP model for a session as a markdown structure. ' + + 'Includes all nodes, edges, and their relevant properties. ' + + 'For large diagrams, prefer `query-elements` (filtered listing) or `count-elements` (size summary) ' + + 'before falling back to this full dump.'; + readonly inputSchema = DiagramModelInputSchema; + override readonly outputSchema = DiagramModelOutputSchema; + + @inject(McpModelSerializer) protected serializer: McpModelSerializer; + + protected createResult({ sessionId }: DiagramModelInput): McpToolResult { + const root = this.modelState.root; + const structured = this.serializer.serializeStructured(root); + const count = Array.isArray(structured.elements) ? structured.elements.length : 0; + return this.success(this.summarizeModel(root, count), { sessionId, ...structured }); + } + + /** Builds the LLM-facing summary line. Override to customize per-adopter wording. */ + protected summarizeModel(root: GModelElement, elementCount: number): string { + return `Diagram '${this.aliasService.alias(root.id)}' (${root.type}): ${elementCount} element(s) total. Full structure in structuredContent.`; + } +} diff --git a/packages/server-mcp/src/tools/handlers/element-types-mcp-tool-handler.ts b/packages/server-mcp/src/tools/handlers/element-types-mcp-tool-handler.ts new file mode 100644 index 0000000..54b9f86 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/element-types-mcp-tool-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 { + ClientSessionInitializer, + ClientSessionManager, + DiagramModules, + InjectionContainer, + createClientSessionModule +} from '@eclipse-glsp/server'; +import { Container, ContainerModule, inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { AbstractMcpToolHandler, McpToolError, McpToolResult } from '../../server'; +import { ElementTypes, ElementTypesProvider } from '../../resources/services/element-types-provider'; + +export const ElementTypesInputSchema = z.object({ + sessionId: z.string().optional().describe('Open GLSP session id; diagram type is derived from it. Provide this OR `diagramType`.'), + diagramType: z.string().optional().describe('Diagram type to query. Provide this OR `sessionId`; useful when no session is open yet.') +}); +export type ElementTypesInput = z.infer; + +/** + * `id` + `label` are required (the default registry-scrape provides both); `description` and + * `acceptsText` are optional adopter-richer fields. `.loose()` lets adopter providers attach + * arbitrary extra fields without us having to widen the schema each time. + */ +export const ElementTypeEntrySchema = z + .object({ + id: z.string().describe('Element type id used by `create-*` tools (e.g. `task:manual`).'), + label: z.string().describe('Human-readable display name for the element TYPE (e.g. `Manual Task`).'), + description: z.string().optional().describe('Adopter-supplied human-readable description.'), + acceptsText: z + .boolean() + .optional() + .describe('Whether `create-*` / `modify-*` tools should pass a `text` arg for elements of this type. Absent ⇒ unknown.') + }) + .loose(); + +export const ElementTypesOutputSchema = z.object({ + diagramType: z.string(), + nodeTypes: z.array(ElementTypeEntrySchema), + edgeTypes: z.array(ElementTypeEntrySchema) +}); + +/** + * Server-scope so the LLM can discover types before opening a session. Lazy-harvests the + * per-diagram-type {@link ElementTypesProvider} via a temporary child container (same pattern + * as core's `DefaultGlobalActionProvider`) and caches the result for the server's lifetime — + * element-type sets are static at runtime, so we pay the harvest cost once per type. + * + * Adopters with statically-known type info bind their own {@link ElementTypesProvider} via + * `bindElementTypesProvider()`; the harvest picks it up without further overrides. + */ +@injectable() +export class ElementTypesMcpToolHandler extends AbstractMcpToolHandler { + @inject(DiagramModules) protected diagramModules: Map; + @inject(InjectionContainer) protected serverContainer: Container; + @inject(ClientSessionManager) protected clientSessionManager: ClientSessionManager; + + static readonly NAME = 'element-types'; + readonly name = ElementTypesMcpToolHandler.NAME; + override readonly title = 'Creatable Element Types'; + readonly 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 the create-nodes / create-edges tools. ' + + 'Pass `sessionId` for the diagram you are working in (recommended), or `diagramType` directly ' + + 'when no session of that type is open yet (e.g. when creating a brand-new diagram).'; + readonly inputSchema = ElementTypesInputSchema; + override readonly outputSchema = ElementTypesOutputSchema; + + protected readonly cache = new Map(); + + protected createResult({ sessionId, diagramType }: ElementTypesInput): McpToolResult { + const resolved = diagramType ?? this.resolveDiagramType(sessionId); + const { nodeTypes, edgeTypes } = this.getElementTypes(resolved); + return this.success(this.summarizeElementTypes(resolved, nodeTypes, edgeTypes), { + diagramType: resolved, + nodeTypes, + edgeTypes + }); + } + + /** Builds the LLM-facing summary. Override to customize per-adopter wording. */ + protected summarizeElementTypes( + diagramType: string, + nodeTypes: ElementTypes['nodeTypes'], + edgeTypes: ElementTypes['edgeTypes'] + ): string { + return ( + `Diagram type '${diagramType}': ${nodeTypes.length} node type(s), ${edgeTypes.length} edge type(s); ` + + `full details in structuredContent.\n` + + `- Node types: ${nodeTypes.map(entry => entry.id).join(', ') || '(none)'}\n` + + `- Edge types: ${edgeTypes.map(entry => entry.id).join(', ') || '(none)'}` + ); + } + + protected resolveDiagramType(sessionId: string | undefined): string { + if (!sessionId) { + throw new McpToolError('Either `diagramType` or `sessionId` must be provided.'); + } + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + throw new McpToolError(`Unknown sessionId: ${sessionId}`); + } + return session.diagramType; + } + + protected getElementTypes(diagramType: string): ElementTypes { + let types = this.cache.get(diagramType); + if (!types) { + types = this.harvestElementTypes(diagramType); + this.cache.set(diagramType, types); + } + return types; + } + + /** Runs session-open initializers on a throwaway container so registries (e.g. `OperationHandlerRegistry`) are populated for the provider to scrape. */ + protected harvestElementTypes(diagramType: string): ElementTypes { + const modules = this.diagramModules.get(diagramType); + if (!modules) { + throw new McpToolError(`Unknown diagram type: ${diagramType}`); + } + const tempContainer = this.serverContainer.createChild(); + try { + const placeholderSessionModule = createClientSessionModule({ + clientId: 'mcp-element-types-temp', + glspClient: { process: () => {} }, + clientActionKinds: [] + }); + tempContainer.load(...modules, placeholderSessionModule); + const initializers = tempContainer.getAll(ClientSessionInitializer); + initializers.forEach(initializer => initializer.initialize()); + return tempContainer.get(ElementTypesProvider).get(); + } finally { + tempContainer.unbindAll(); + } + } +} diff --git a/packages/server-mcp/src/tools/handlers/get-selection-mcp-tool-handler.ts b/packages/server-mcp/src/tools/handlers/get-selection-mcp-tool-handler.ts new file mode 100644 index 0000000..380f342 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/get-selection-mcp-tool-handler.ts @@ -0,0 +1,54 @@ +/******************************************************************************** + * 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 { ActionDispatcher, GetSelectionAction } from '@eclipse-glsp/server'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { McpDiagramScopedInputSchema, AbstractMcpDiagramToolHandler, McpToolResult, requestActionOrFail } from '../../server'; + +export const GetSelectionInputSchema = McpDiagramScopedInputSchema; +export type GetSelectionInput = z.infer; + +export const GetSelectionOutputSchema = z.object({ + selectedElementIds: z.array(z.string()).describe('Aliased ids of the elements currently selected on the client.') +}); + +/** Round-trips a sprotty {@link GetSelectionAction} via `ActionDispatcher.requestUntil` and awaits the matching `SelectionResult`. */ +@injectable() +export class GetSelectionMcpToolHandler extends AbstractMcpDiagramToolHandler { + /** Timeout (in ms) for awaiting the selection response from the client. Override via subclass + rebind. */ + protected readonly timeoutMs: number = 5000; + + static readonly NAME = 'get-selection'; + readonly name = GetSelectionMcpToolHandler.NAME; + override readonly title = 'Get Selected Diagram Elements'; + readonly description = + 'Get the element IDs of all elements currently selected in the user-facing diagram UI. ' + + 'Use this only when the user explicitly references their selection ("the selected node", "what I have highlighted"); ' + + 'do not call it speculatively. Pairs with `set-selection` for write-side selection control. ' + + 'Returns an empty list when nothing is selected.'; + readonly inputSchema = GetSelectionInputSchema; + override readonly outputSchema = GetSelectionOutputSchema; + + @inject(ActionDispatcher) protected actionDispatcher: ActionDispatcher; + + protected async createResult(_params: GetSelectionInput): Promise { + const response = await requestActionOrFail(this.actionDispatcher, GetSelectionAction.create(), this.timeoutMs, this.name); + const selectedElementIds = this.encodeIds(response.selectedElementsIDs); + const selectedIdsStr = selectedElementIds.map(id => `- ${id}`).join('\n'); + return this.success(`Following element IDs are selected:\n${selectedIdsStr}`, { selectedElementIds }); + } +} diff --git a/packages/server-mcp/src/tools/handlers/layout-mcp-tool-handler.ts b/packages/server-mcp/src/tools/handlers/layout-mcp-tool-handler.ts new file mode 100644 index 0000000..6f3100d --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/layout-mcp-tool-handler.ts @@ -0,0 +1,56 @@ +/******************************************************************************** + * 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 { LayoutEngine, LayoutOperation } from '@eclipse-glsp/server'; +import { inject, injectable, optional } from 'inversify'; +import * as z from 'zod/v4'; +import { McpDiagramScopedInputSchema, McpToolResult, OperationMcpDiagramToolHandler } from '../../server'; + +export const LayoutInputSchema = McpDiagramScopedInputSchema; +export type LayoutInput = z.infer; + +export const LayoutOutputSchema = z.object({ + applied: z.boolean().describe('Always true on success — surfaced for parity with other operations.') +}); + +/** Not registered by default: requires an adopter-supplied `LayoutEngine` to bind, which only some GLSP servers ship. */ +@injectable() +export class LayoutMcpToolHandler extends OperationMcpDiagramToolHandler { + static readonly NAME = 'layout'; + readonly name = LayoutMcpToolHandler.NAME; + override readonly title = 'Auto-Layout Diagram'; + readonly description = + "Trigger automatic layout computation for the given session's diagram, repositioning all nodes and " + + 'rerouting all edges according to the configured layout engine. ' + + 'Use this only when the user explicitly asks for "automatic layout" or similar — it overwrites every ' + + 'manual position in the diagram and is generally a destructive change for hand-tuned layouts. ' + + 'For targeted positional edits prefer `modify-nodes` / `modify-edges`. ' + + 'Adopters who do not bind a `LayoutEngine` will not see this tool registered.'; + readonly inputSchema = LayoutInputSchema; + override readonly outputSchema = LayoutOutputSchema; + + @inject(LayoutEngine) @optional() protected layoutEngine?: LayoutEngine; + + /** Skip-bind when no `LayoutEngine` is bound — every dispatch would otherwise no-op. */ + override canRegister(): boolean { + return this.layoutEngine !== undefined; + } + + protected async createResult(_params: LayoutInput): Promise { + await this.actionDispatcher.dispatch(LayoutOperation.create()); + return this.success('Automatic layout applied', { applied: true }); + } +} diff --git a/packages/server-mcp/src/tools/handlers/modify-edges-mcp-tool-handler.ts b/packages/server-mcp/src/tools/handlers/modify-edges-mcp-tool-handler.ts new file mode 100644 index 0000000..46e896d --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/modify-edges-mcp-tool-handler.ts @@ -0,0 +1,148 @@ +/******************************************************************************** + * 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, GEdge, ReconnectEdgeOperation } from '@eclipse-glsp/server'; +import { injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { + ElementIdentitySchema, + McpDiagramScopedInputSchema, + McpToolError, + McpToolResult, + OperationMcpDiagramToolHandler, + elementId as elementIdSchema, + position +} from '../../server'; +import { formatNoticeList } from '../../util'; + +/** Single edge-modification entry. Strict so an LLM-typoed field surfaces as a validation error instead of being silently dropped. */ +export const ModifyEdgeSpecSchema = z.strictObject({ + elementId: elementIdSchema, + 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(position) + .optional() + .describe( + 'Optional array of routing point coordinates that allow for a complex edge path. ' + + 'Using an empty array removes all routing points.' + ) +}); + +export const ModifyEdgesInputSchema = McpDiagramScopedInputSchema.extend({ + edges: z + .array(ModifyEdgeSpecSchema) + .min(1) + .describe('Array of edge changes — each entry needs `elementId` plus the fields to update. Must include at least one change.') +}); +export type ModifyEdgesInput = z.infer; + +export const ModifyEdgesOutputSchema = z.object({ + modifiedEdges: z.array(ElementIdentitySchema).describe('Identity of each edge whose change request was dispatched.'), + dispatchedCommands: z.number().int().describe('Number of underlying GLSP operations dispatched.'), + errors: z.array(z.string()).describe('Per-input failure messages; absent or empty when every input succeeded.') +}); + +@injectable() +export class ModifyEdgesMcpToolHandler extends OperationMcpDiagramToolHandler { + static readonly NAME = 'modify-edges'; + readonly name = ModifyEdgesMcpToolHandler.NAME; + override readonly title = 'Modify Diagram Edges'; + readonly description = + 'Modify one or more existing edges by reconnecting their source/target endpoints or rewriting their routing points. ' + + 'Reconnection (provide both `sourceElementId` and `targetElementId`) and routing-point edits are mutually exclusive ' + + 'per change entry — a reconnect recomputes the path from scratch and ignores `routingPoints`. ' + + 'Pass an empty `routingPoints` array to remove all routing points (snap to a straight line). ' + + 'This operation modifies the diagram state and requires user approval. ' + + 'For nodes (position/size/text), use `modify-nodes` instead.'; + readonly inputSchema = ModifyEdgesInputSchema; + override readonly outputSchema = ModifyEdgesOutputSchema; + + protected async createResult({ edges }: ModifyEdgesInput): Promise { + const elements = this.lookupElements(edges, change => change.elementId); + + // Type-validate so non-edge ids surface a clear error instead of "model element not found" + // from the dispatched operation handler. Aliases are sequential across all element kinds, + // so an LLM passing an arbitrary id may hit a node here. + const wrongType = elements + .filter(([, element]) => !GEdge.is(element)) + .map(([change, element]) => `'${change.elementId}' (type '${element.type}')`); + if (wrongType.length) { + throw new McpToolError(`modify-edges accepts edges only — got: ${wrongType.join(', ')}. Use modify-nodes for nodes.`); + } + + // Dispatch in parallel via `allSettled` so one failed edge surfaces in `errors` + // instead of rejecting the whole call and losing the other outcomes. + const dispatched: Array<{ promise: Promise; realId: string; inputId: string }> = []; + const errors: string[] = []; + const modifiedRealIds = new Set(); + elements.forEach(([change]) => { + const { routingPoints } = change; + const realId = this.aliasService.lookup(change.elementId); + const sourceElementId = change.sourceElementId ? this.aliasService.lookup(change.sourceElementId) : undefined; + const targetElementId = change.targetElementId ? this.aliasService.lookup(change.targetElementId) : undefined; + + if ((sourceElementId && !targetElementId) || (!sourceElementId && targetElementId)) { + errors.push(`Both source and target ID are required for input: ${JSON.stringify(change)}`); + return; + } + + if (sourceElementId && targetElementId) { + const source = this.modelState.index.find(sourceElementId); + if (!source) { + errors.push(`Source element not found: ${sourceElementId}`); + return; + } + const target = this.modelState.index.find(targetElementId); + if (!target) { + errors.push(`Target element not found: ${targetElementId}`); + return; + } + + const operation = ReconnectEdgeOperation.create({ edgeElementId: realId, sourceElementId, targetElementId }); + dispatched.push({ promise: this.actionDispatcher.dispatch(operation), realId, inputId: change.elementId }); + modifiedRealIds.add(realId); + // Routing-point changes are skipped during a reconnect — the edge's path is recomputed from scratch. + return; + } + + if (routingPoints) { + const operation = ChangeRoutingPointsOperation.create([{ elementId: realId, newRoutingPoints: routingPoints }]); + dispatched.push({ promise: this.actionDispatcher.dispatch(operation), realId, inputId: change.elementId }); + modifiedRealIds.add(realId); + } + }); + + const results = await Promise.allSettled(dispatched.map(entry => entry.promise)); + results.forEach((result, i) => { + if (result.status === 'rejected') { + const { realId, inputId } = dispatched[i]; + const reason = result.reason instanceof Error ? result.reason.message : String(result.reason); + errors.push(`Failed to modify edge '${inputId}': ${reason}`); + // The dispatch failed, so this id was *not* modified — drop it from the success list. + modifiedRealIds.delete(realId); + } + }); + + const modifiedEdges = [...modifiedRealIds] + .map(realId => this.describeElement(realId)) + .filter((entry): entry is NonNullable => entry !== undefined); + return this.success( + `Successfully modified ${edges.length - errors.length} edge(s) (in ${dispatched.length} commands)${formatNoticeList('errors', errors)}`, + { modifiedEdges, dispatchedCommands: dispatched.length, errors } + ); + } +} diff --git a/packages/server-mcp/src/tools/handlers/modify-nodes-mcp-tool-handler.ts b/packages/server-mcp/src/tools/handlers/modify-nodes-mcp-tool-handler.ts new file mode 100644 index 0000000..d00484f --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/modify-nodes-mcp-tool-handler.ts @@ -0,0 +1,140 @@ +/******************************************************************************** + * 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, GEdge, GShapeElement } from '@eclipse-glsp/server'; +import { injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { + ElementIdentitySchema, + McpDiagramScopedInputSchema, + McpToolError, + McpToolResult, + OperationMcpDiagramToolHandler, + elementId, + position +} from '../../server'; +import { formatNoticeList } from '../../util'; + +/** Strict — any unknown field on the size object surfaces as a validation error. */ +export const NodeSizeSchema = z.strictObject({ + width: z.number().positive().describe('Width of the element in diagram space (must be > 0).'), + height: z.number().positive().describe('Height of the element in diagram space (must be > 0).') +}); + +/** Single node-modification entry. Strict so an LLM-typoed field surfaces as a validation error instead of being silently dropped. */ +export const ModifyNodeSpecSchema = z.strictObject({ + elementId, + position: position.optional().describe('Position where the node should be moved to (absolute diagram coordinates)'), + size: NodeSizeSchema.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).") +}); + +export const ModifyNodesInputSchema = McpDiagramScopedInputSchema.extend({ + nodes: z + .array(ModifyNodeSpecSchema) + .min(1) + .describe('Array of node changes — each entry needs `elementId` plus the fields to update. Must include at least one change.') +}); +export type ModifyNodesInput = z.infer; + +export const ModifyNodesOutputSchema = z.object({ + modifiedNodes: z + .array(ElementIdentitySchema) + .describe('Identity of each node whose change request was dispatched (post-modification labels).'), + dispatchedCommands: z.number().int().describe('Number of underlying GLSP operations dispatched (a single change may yield several).'), + warnings: z + .array(z.string()) + .describe( + 'Soft notices for inputs whose change applied with caveats (e.g. `text` supplied for a node whose type has no editable label).' + ) +}); + +@injectable() +export class ModifyNodesMcpToolHandler extends OperationMcpDiagramToolHandler { + static readonly NAME = 'modify-nodes'; + readonly name = ModifyNodesMcpToolHandler.NAME; + override readonly title = 'Modify Diagram Nodes'; + readonly description = + 'Modify one or more existing nodes by changing their position, size, and/or label text. ' + + 'When modifying position or size, absolutely consider the visual alignment with other nodes — ' + + 'use `query-elements` (inspect mode) first to understand the layout. ' + + 'Each change entry can include any combination of `position`, `size`, and `text`; omitted fields keep their current value. ' + + 'This operation modifies the diagram state and requires user approval. ' + + 'For edges (reconnect / routing-points), use `modify-edges` instead.'; + readonly inputSchema = ModifyNodesInputSchema; + override readonly outputSchema = ModifyNodesOutputSchema; + + protected async createResult({ nodes }: ModifyNodesInput): Promise { + const elements = this.lookupElements(nodes, change => change.elementId); + + // Reject edge ids — they have no `position`/`size` semantics and would fail downstream + // with a misleading "model element not found" error from the operation handler. Aliases + // are sequential across all element kinds, so an LLM passing an arbitrary id may hit an edge. + const wrongType = elements.filter(([, element]) => GEdge.is(element)).map(([change]) => `'${change.elementId}'`); + if (wrongType.length) { + throw new McpToolError(`modify-nodes does not accept edges — got: ${wrongType.join(', ')}. Use modify-edges for edges.`); + } + + // Reject any other non-shape kinds (labels, compartments, ports, custom kinds) — they + // reach the index too and would silently produce no-op or undefined-bounds operations. + const nonShape = elements.filter(([, element]) => !GShapeElement.is(element)).map(([change]) => `'${change.elementId}'`); + if (nonShape.length) { + throw new McpToolError( + `modify-nodes only accepts shape elements — got: ${nonShape.join(', ')}. ` + + 'Use `query-elements` (inspect mode) to find shape ids, or pick the parent shape.' + ); + } + + // Modifications are independent of each other — dispatch in parallel. + const promises: Promise[] = []; + const warnings: string[] = []; + elements.forEach(([change, element]) => { + // Guaranteed non-null and shape-typed by the missing-elements / nonShape checks above. + const resolved = element as GShapeElement; + const { size, position, text } = change; + const realId = this.aliasService.lookup(change.elementId); + + if (size || position) { + const newSize = size ?? resolved.size; + const newPosition = position ?? resolved.position; + + const operation = ChangeBoundsOperation.create([{ elementId: realId, newSize, newPosition }]); + promises.push(this.actionDispatcher.dispatch(operation)); + } + + if (text) { + const labelId = this.labelProvider.getLabel(resolved)?.id; + if (labelId) { + promises.push(this.actionDispatcher.dispatch(ApplyLabelEditOperation.create({ labelId, text }))); + } else { + warnings.push( + `Ignored \`text\` for '${change.elementId}' (type '${resolved.type}') — this element has no editable label.` + ); + } + } + }); + + await Promise.all(promises); + + const modifiedNodes = nodes + .map(change => this.describeElement(change.elementId)) + .filter((entry): entry is NonNullable => entry !== undefined); + return this.success( + `Successfully modified ${nodes.length} node(s) (in ${promises.length} commands)` + formatNoticeList('warnings', warnings), + { modifiedNodes, dispatchedCommands: promises.length, warnings } + ); + } +} diff --git a/packages/server-mcp/src/tools/handlers/query-elements-mcp-tool-handler.spec.ts b/packages/server-mcp/src/tools/handlers/query-elements-mcp-tool-handler.spec.ts new file mode 100644 index 0000000..bde1de7 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/query-elements-mcp-tool-handler.spec.ts @@ -0,0 +1,210 @@ +/******************************************************************************** + * 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 { ClientId, GLabel, GModelElement, Logger, ModelState, NullLogger } from '@eclipse-glsp/server'; +import { expect } from 'chai'; +import { Container, ContainerModule } from 'inversify'; +import { McpModelSerializer } from '../../resources/services/mcp-model-serializer'; +import { DefaultMcpLabelProvider, McpElementsNotFoundError, McpIdAliasService, McpLabelProvider, McpToolResult } from '../../server'; +import { QueryElementsInput, QueryElementsMcpToolHandler } from './query-elements-mcp-tool-handler'; + +function makeLabel(text: string): GLabel { + // Set the prototype so `child instanceof GLabel` checks in the handler return true. + return Object.assign(Object.create(GLabel.prototype), { id: 'label', type: 'label', text, children: [] }) as GLabel; +} + +function makeElement(id: string, type: string, labelText?: string): GModelElement { + return { + id, + type, + children: labelText !== undefined ? [makeLabel(labelText)] : [] + } as unknown as GModelElement; +} + +function makeModelState(elements: GModelElement[]): ModelState { + const byId = new Map(elements.map(el => [el.id, el])); + return { + index: { + allIds: () => [...byId.keys()], + get: (id: string) => byId.get(id), + find: (id: string) => byId.get(id) + } + } as unknown as ModelState; +} + +interface CapturingSerializer extends McpModelSerializer { + /** Element arrays the handler passed to {@link serializeStructuredArray}, in call order. */ + capturedArrays: GModelElement[][]; +} + +function makeCapturingSerializer(): CapturingSerializer { + const capturedArrays: GModelElement[][] = []; + return { + capturedArrays, + serialize: () => 'serialized', + serializeStructured: () => ({}), + serializeArray: elements => elements.map(el => `- ${el.id} (${el.type})`).join('\n'), + serializeStructuredArray: elements => { + capturedArrays.push(elements); + return { elements: elements.map(el => ({ id: el.id, type: el.type })) }; + } + }; +} + +function buildHandler(elements: GModelElement[], serializer: McpModelSerializer = makeCapturingSerializer()): QueryElementsMcpToolHandler { + const container = new Container(); + container.load( + new ContainerModule(bind => { + bind(Logger).toConstantValue(new NullLogger()); + bind(ClientId).toConstantValue('test-session'); + bind(ModelState).toConstantValue(makeModelState(elements)); + bind(McpIdAliasService).toConstantValue({ + lookup: (id: string) => id, + alias: (id: string) => id + } as McpIdAliasService); + bind(McpModelSerializer).toConstantValue(serializer); + bind(McpLabelProvider).to(DefaultMcpLabelProvider); + bind(QueryElementsMcpToolHandler).toSelf(); + }) + ); + return container.get(QueryElementsMcpToolHandler); +} + +interface ListStructured { + mode: 'list'; + matches: { id: string; type: string; label?: string }[]; + truncated: boolean; +} +interface InspectStructured { + mode: 'inspect'; + elements: { id: string; type: string }[]; +} + +function callCreateResult(handler: QueryElementsMcpToolHandler, params: QueryElementsInput): Promise { + return (handler as unknown as { createResult: (p: QueryElementsInput) => Promise }).createResult(params); +} + +describe('QueryElementsMcpToolHandler', () => { + describe('list mode (no `elementIds`)', () => { + it('filters by `types` (only matching types are returned)', async () => { + const handler = buildHandler([ + makeElement('n1', 'task:manual', 'Build'), + makeElement('n2', 'task:automated', 'Deploy'), + makeElement('e1', 'edge') + ]); + + const result = await callCreateResult(handler, { sessionId: 's', types: ['task:manual'] }); + const structured = result.structuredContent as unknown as ListStructured; + expect(structured.mode).to.equal('list'); + expect(structured.matches).to.have.lengthOf(1); + expect(structured.matches[0].id).to.equal('n1'); + }); + + it('matches `labelMatch` case-insensitively against direct GLabel children', async () => { + const handler = buildHandler([ + makeElement('n1', 'task', 'Build artifact'), + makeElement('n2', 'task', 'Deploy artifact'), + makeElement('n3', 'task', 'Validate spec') + ]); + + const result = await callCreateResult(handler, { sessionId: 's', labelMatch: 'ARTIFACT' }); + const ids = (result.structuredContent as unknown as ListStructured).matches.map(m => m.id); + expect(ids).to.have.members(['n1', 'n2']); + }); + + it('combines `types` and `labelMatch` as AND', async () => { + const handler = buildHandler([ + makeElement('n1', 'task:manual', 'Build artifact'), + makeElement('n2', 'task:automated', 'Build artifact') + ]); + + const result = await callCreateResult(handler, { + sessionId: 's', + types: ['task:manual'], + labelMatch: 'build' + }); + const matches = (result.structuredContent as unknown as ListStructured).matches; + expect(matches).to.have.lengthOf(1); + expect(matches[0].id).to.equal('n1'); + }); + + it('caps results at `limit` and reports `truncated: true`', async () => { + const elements = Array.from({ length: 10 }, (_, i) => makeElement(`n${i}`, 'task')); + const handler = buildHandler(elements); + + const result = await callCreateResult(handler, { sessionId: 's', limit: 3 }); + const structured = result.structuredContent as unknown as ListStructured; + expect(structured.matches).to.have.lengthOf(3); + expect(structured.truncated).to.equal(true); + }); + + it('surfaces a no-match message and empty matches when nothing matches', async () => { + const handler = buildHandler([makeElement('n1', 'task', 'foo')]); + + const result = await callCreateResult(handler, { sessionId: 's', types: ['nonexistent'] }); + const text = (result.content[0] as { text: string }).text; + expect(text).to.include('No elements matched'); + expect((result.structuredContent as unknown as ListStructured).matches).to.deep.equal([]); + }); + }); + + describe('inspect mode (with `elementIds`)', () => { + it('resolves the requested ids and forwards the matching elements to the serializer', async () => { + const elements = [makeElement('n1', 'task:manual', 'Build'), makeElement('n2', 'task:automated', 'Deploy')]; + const serializer = makeCapturingSerializer(); + const handler = buildHandler(elements, serializer); + + const result = await callCreateResult(handler, { sessionId: 's', elementIds: ['n1', 'n2'] }); + + // The handler delegated rendering to the injected serializer with the exact + // GModelElement instances looked up from the model index — not just the ids. Inspect + // mode also probes per-element to detect container expansion before rendering, so the + // final array call is what produces the structuredContent — assert on that one. + expect(serializer.capturedArrays.length).to.be.greaterThan(0); + const finalCall = serializer.capturedArrays[serializer.capturedArrays.length - 1]; + expect(finalCall).to.deep.equal(elements); + + const structured = result.structuredContent as unknown as InspectStructured; + expect(structured.mode).to.equal('inspect'); + }); + + it('throws McpElementsNotFoundError when an id is missing from the model', async () => { + const handler = buildHandler([makeElement('n1', 'task')]); + + try { + await callCreateResult(handler, { sessionId: 's', elementIds: ['n1', 'unknown'] }); + expect.fail('expected McpElementsNotFoundError'); + } catch (err) { + expect(err).to.be.instanceOf(McpElementsNotFoundError); + } + }); + + it('ignores `types` / `labelMatch` / `limit` when `elementIds` is set', async () => { + const handler = buildHandler([makeElement('n1', 'task:manual'), makeElement('n2', 'task:automated')]); + + const result = await callCreateResult(handler, { + sessionId: 's', + elementIds: ['n1'], + types: ['task:automated'], // would exclude n1 in list mode + labelMatch: 'nope', + limit: 0 // intentionally would fail validation in list mode + }); + const structured = result.structuredContent as unknown as InspectStructured; + expect(structured.mode).to.equal('inspect'); + expect(structured.elements.map(e => e.id)).to.deep.equal(['n1']); + }); + }); +}); diff --git a/packages/server-mcp/src/tools/handlers/query-elements-mcp-tool-handler.ts b/packages/server-mcp/src/tools/handlers/query-elements-mcp-tool-handler.ts new file mode 100644 index 0000000..ebc0360 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/query-elements-mcp-tool-handler.ts @@ -0,0 +1,161 @@ +/******************************************************************************** + * 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/server'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { McpModelSerializer } from '../../resources/services/mcp-model-serializer'; +import { + McpDiagramScopedInputSchema, + AbstractMcpDiagramToolHandler, + McpElementsNotFoundError, + McpToolResult, + elementIds as elementIdsSchema +} from '../../server'; + +export const QueryElementsInputSchema = McpDiagramScopedInputSchema.extend({ + elementIds: elementIdsSchema + .optional() + .describe('Inspect-mode trigger. When set, returns rich per-element data and ignores the filter fields. Throws on unknown id.'), + types: z.array(z.string()).optional().describe('List-mode type filter. Empty/omitted = any. Ignored when `elementIds` is set.'), + labelMatch: z.string().optional().describe('List-mode case-insensitive substring on direct-child `GLabel.text`.'), + limit: z.number().int().min(1).max(1000).optional().describe('List-mode cap, 1–1000. Defaults to 100.') +}); +export type QueryElementsInput = z.infer; + +export const QueryElementMatchSchema = z.object({ + id: z.string(), + type: z.string(), + label: z.string().optional() +}); + +/** `.loose()` lets adopter element types attach extra fields (position, size, …) without widening the schema each time. */ +export const DiagramElementSchema = z + .object({ + id: z.string(), + type: z.string(), + parentId: z.string().optional() + }) + .loose(); + +export const QueryElementsOutputSchema = z.object({ + mode: z.enum(['list', 'inspect']).describe('Echoes which mode the call ran in.'), + matches: z.array(QueryElementMatchSchema).optional().describe('Present in `list` mode: slim id/type/label entries.'), + elements: z.array(DiagramElementSchema).optional().describe('Present in `inspect` mode: rich per-element detail.'), + truncated: z.boolean().optional().describe('Present in `list` mode: true when more elements matched than `limit`.'), + expandedFromContainers: z + .array(z.string()) + .optional() + .describe( + 'Present in `inspect` mode when one or more requested ids referred to containers — lists those container ids. ' + + 'The `elements` array then includes the container plus its descendants.' + ) +}); + +/** Two-mode element query — list/filter or inspect-by-id, discriminated by `elementIds` presence. */ +@injectable() +export class QueryElementsMcpToolHandler extends AbstractMcpDiagramToolHandler { + static readonly NAME = 'query-elements'; + readonly name = QueryElementsMcpToolHandler.NAME; + override readonly title = 'Query Diagram Elements'; + readonly description = + 'Find or inspect elements in the session diagram. Pass `elementIds` to inspect specific ' + + 'elements in detail (rich per-element data). Pass `types` and/or `labelMatch` to search by ' + + 'filter (slim id/type/label summaries with truncation). Useful as a precursor to the ' + + 'create/modify/delete tools, and a cheaper alternative to `diagram-model` on large diagrams.'; + readonly inputSchema = QueryElementsInputSchema; + override readonly outputSchema = QueryElementsOutputSchema; + + @inject(McpModelSerializer) protected serializer: McpModelSerializer; + + /** List-mode result cap when the call doesn't override `limit`. Override via subclass. */ + protected readonly defaultLimit: number = 100; + + protected async createResult(params: QueryElementsInput): Promise { + return params.elementIds && params.elementIds.length > 0 ? this.inspect(params.elementIds) : this.list(params); + } + + protected inspect(inputIds: string[]): McpToolResult { + const { realIds, missingIds } = this.resolveIds(inputIds); + if (missingIds.length > 0) { + throw new McpElementsNotFoundError(missingIds); + } + const elements: GModelElement[] = realIds.map(id => this.modelState.index.get(id)!); + // Per-input serialize lets us identify which inputs caused expansion: the serializer + // walks descendants for containers, so a 1-input call may produce N output entries. + // The structural-children check the previous version used overstated this — a leaf node + // with a label child counts as having `children.length > 0` but doesn't produce extra + // entries because the serializer's adjuster drops labels. + const expandedFromContainers = elements + .filter(element => { + // `serializeStructuredArray` returns an open `Record` per the + // `McpStructuredContent` contract; narrow before reading `.elements.length`. + const entries = this.serializer.serializeStructuredArray([element]).elements; + return Array.isArray(entries) && entries.length > 1; + }) + .map(element => this.aliasService.alias(element.id)); + // Serializer's `serializeStructuredArray` already returns `{ elements: [...] }`; spread to keep one source of truth. + return this.success(this.summarizeInspect(elements, expandedFromContainers), { + mode: 'inspect', + ...this.serializer.serializeStructuredArray(elements), + ...(expandedFromContainers.length > 0 ? { expandedFromContainers } : {}) + }); + } + + /** Builds the LLM-facing summary for inspect mode. Override to customize per-adopter wording. */ + protected summarizeInspect(elements: GModelElement[], expandedFromContainers: string[]): string { + const lines = elements.map(element => `- ${this.aliasService.alias(element.id)} (${element.type})`); + const expansionNote = + expandedFromContainers.length > 0 ? `\nContainer(s) ${expandedFromContainers.join(', ')} expanded to include descendants.` : ''; + return `Inspected ${elements.length} element(s); full data in structuredContent.\n${lines.join('\n')}${expansionNote}`; + } + + protected list({ types, labelMatch, limit }: QueryElementsInput): McpToolResult { + const cap = limit ?? this.defaultLimit; + const typeFilter = types && types.length > 0 ? new Set(types) : undefined; + const needle = labelMatch?.toLowerCase(); + + const matches: { id: string; type: string; label?: string }[] = []; + let truncated = false; + for (const id of this.modelState.index.allIds()) { + const element = this.modelState.index.get(id); + if (!element) { + continue; + } + if (typeFilter && !typeFilter.has(element.type)) { + continue; + } + const label = this.labelProvider.getLabel(element)?.text; + if (needle !== undefined && !label?.toLowerCase().includes(needle)) { + continue; + } + if (matches.length >= cap) { + truncated = true; + break; + } + matches.push({ id: this.aliasService.alias(element.id), type: element.type, ...(label !== undefined ? { label } : {}) }); + } + + const summary = matches.length === 0 ? 'No elements matched the query.' : this.renderMarkdown(matches, truncated); + return this.success(summary, { mode: 'list', matches, truncated }); + } + + protected renderMarkdown(matches: { id: string; type: string; label?: string }[], truncated: boolean): string { + const rows = matches.map(match => `- ${match.id} (${match.type})${match.label ? ` — "${match.label}"` : ''}`).join('\n'); + const tail = truncated ? '\n\n_(truncated — increase `limit` or refine filters to see more)_' : ''; + return `Matched ${matches.length} element${matches.length === 1 ? '' : 's'}:\n${rows}${tail}`; + } +} diff --git a/packages/server-mcp/src/tools/handlers/redo-mcp-tool-handler.ts b/packages/server-mcp/src/tools/handlers/redo-mcp-tool-handler.ts new file mode 100644 index 0000000..18e2b2d --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/redo-mcp-tool-handler.ts @@ -0,0 +1,62 @@ +/******************************************************************************** + * 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 { CommandStack, RedoAction } from '@eclipse-glsp/server'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { McpDiagramScopedInputSchema, McpToolError, McpToolResult, OperationMcpDiagramToolHandler } from '../../server'; + +export const RedoInputSchema = McpDiagramScopedInputSchema.extend({ + commandsToRedo: z.number().min(1).default(1).describe('Number of commands to redo. Defaults to 1 (most recent undone command).') +}); +export type RedoInput = z.infer; + +export const RedoOutputSchema = z.object({ + commandsRedone: z.number().int().describe('Number of previously-undone commands re-applied.') +}); + +/** + * Redo a given number of the most recent undone actions on the command stack. + */ +@injectable() +export class RedoMcpToolHandler extends OperationMcpDiagramToolHandler { + static readonly NAME = 'redo'; + readonly name = RedoMcpToolHandler.NAME; + override readonly title = 'Redo Diagram Commands'; + readonly description = + 'Re-apply commands that were previously undone. Defaults to redoing one command — the most recent undo — when ' + + '`commandsToRedo` is omitted. Use the same count you passed to `undo` to revert a complete undo cycle. ' + + "Throws when there's nothing on the redo stack (e.g. nothing has been undone, or the redo stack was cleared by a " + + 'subsequent edit). Only do this on an explicit user request.'; + readonly inputSchema = RedoInputSchema; + override readonly outputSchema = RedoOutputSchema; + + @inject(CommandStack) protected commandStack: CommandStack; + + protected async createResult({ commandsToRedo }: RedoInput): Promise { + if (!this.commandStack.canRedo()) { + throw new McpToolError( + 'Nothing to redo (redo stack is empty; perform an undo first or accept that the redo history was cleared by a subsequent edit).' + ); + } + + for (let i = 0; i < commandsToRedo; i++) { + await this.actionDispatcher.dispatch(RedoAction.create()); + } + + return this.success('Redo successful', { commandsRedone: commandsToRedo }); + } +} diff --git a/packages/server-mcp/src/tools/handlers/save-model-mcp-tool-handler.ts b/packages/server-mcp/src/tools/handlers/save-model-mcp-tool-handler.ts new file mode 100644 index 0000000..10fb250 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/save-model-mcp-tool-handler.ts @@ -0,0 +1,71 @@ +/******************************************************************************** + * 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 { ActionDispatcher, CommandStack, SaveModelAction } from '@eclipse-glsp/server'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { McpDiagramScopedInputSchema, AbstractMcpDiagramToolHandler, McpLogger, McpToolResult } from '../../server'; + +export const SaveModelInputSchema = McpDiagramScopedInputSchema.extend({ + fileUri: z.string().optional().describe('Optional destination file URI. If not provided, saves to the original source model location.') +}); +export type SaveModelInput = z.infer; + +export const SaveModelOutputSchema = z.object({ + saved: z.boolean().describe('True when a save operation was dispatched; false when the model was clean and nothing was written.'), + fileUri: z + .string() + .optional() + .describe('Destination file URI when explicitly provided; absent when saving to the original source location.') +}); + +/** Doesn't extend `OperationMcpDiagramToolHandler` — saving a read-only model is a legitimate adopter scenario (e.g. "save as" to a writable location). */ +@injectable() +export class SaveModelMcpToolHandler extends AbstractMcpDiagramToolHandler { + static readonly NAME = 'save-model'; + readonly name = SaveModelMcpToolHandler.NAME; + override readonly title = 'Save Diagram Model'; + readonly 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.'; + readonly inputSchema = SaveModelInputSchema; + override readonly outputSchema = SaveModelOutputSchema; + /** + * Saving writes to disk — that's a mutation of the environment even though the in-memory + * model state isn't a target of an `Operation` dispatch. Drop the readOnly claim from the + * read-base default. Not destructive per the spec definition (no irreversible deletion). + */ + override readonly readOnlyHint = false; + override readonly destructiveHint: boolean = false; + override readonly idempotentHint: boolean = false; + + @inject(ActionDispatcher) protected actionDispatcher: ActionDispatcher; + @inject(CommandStack) protected commandStack: CommandStack; + @inject(McpLogger) protected mcpLogger: McpLogger; + + protected async createResult({ fileUri }: SaveModelInput): Promise { + if (!this.commandStack.isDirty) { + this.mcpLogger.info('save-model: nothing to save'); + return this.success('No changes to save', { saved: false, fileUri }); + } + + await this.actionDispatcher.dispatch(SaveModelAction.create({ fileUri })); + this.mcpLogger.info(`save-model: model saved${fileUri ? ` to ${fileUri}` : ''}`); + return this.success('Model saved successfully', { saved: true, fileUri }); + } +} diff --git a/packages/server-mcp/src/tools/handlers/session-info-mcp-tool-handler.spec.ts b/packages/server-mcp/src/tools/handlers/session-info-mcp-tool-handler.spec.ts new file mode 100644 index 0000000..535e076 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/session-info-mcp-tool-handler.spec.ts @@ -0,0 +1,152 @@ +/******************************************************************************** + * 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 { ClientSession, ClientSessionManager, CommandStack, Logger, ModelState, NullLogger } from '@eclipse-glsp/server'; +import { expect } from 'chai'; +import { Container, ContainerModule } from 'inversify'; +import { McpToolResult } from '../../server'; +import { SessionInfoMcpToolHandler } from './session-info-mcp-tool-handler'; + +function asText(result: McpToolResult): string { + const block = result.content[0]; + if (!block || block.type !== 'text') throw new Error('expected text content'); + return block.text; +} + +/** Build a {@link ClientSession} stub whose container resolves a fixed {@link ModelState} + {@link CommandStack}. */ +function makeSession( + id: string, + diagramType: string, + sourceUri: string | undefined, + isReadonly: boolean, + isDirty: boolean = false +): ClientSession { + const sessionContainer = new Container(); + const modelStateStub: Partial = { sourceUri, isReadonly }; + sessionContainer.bind(ModelState).toConstantValue(modelStateStub as ModelState); + const commandStackStub: Partial = { isDirty }; + sessionContainer.bind(CommandStack).toConstantValue(commandStackStub as CommandStack); + return { + id, + diagramType, + container: sessionContainer, + // The handler does not exercise this dispatcher, but the interface requires it. + actionDispatcher: {} as ClientSession['actionDispatcher'], + dispose: () => { + // + } + }; +} + +class StubClientSessionManager implements ClientSessionManager { + constructor(private readonly sessions: ClientSession[]) {} + + getOrCreateClientSession(): ClientSession { + throw new Error('not implemented'); + } + getSession(sessionId?: string): ClientSession | undefined { + return this.sessions.find(s => s.id === sessionId); + } + getSessions(): ClientSession[] { + return this.sessions; + } + getSessionsByType(): ClientSession[] { + return []; + } + disposeClientSession(): boolean { + return true; + } + addListener(): boolean { + return true; + } + removeListener(): boolean { + return true; + } + removeListeners(): void { + // + } +} + +function buildHandler(sessions: ClientSession[]): SessionInfoMcpToolHandler { + const container = new Container(); + container.load( + new ContainerModule(bind => { + bind(Logger).toConstantValue(new NullLogger()); + bind(ClientSessionManager).toConstantValue(new StubClientSessionManager(sessions)); + bind(SessionInfoMcpToolHandler).toSelf().inSingletonScope(); + }) + ); + return container.get(SessionInfoMcpToolHandler); +} + +interface SessionRow { + sessionId: string; + diagramType: string; + sourceUri: string; + readOnly: boolean; + dirty: boolean; +} + +describe('SessionInfoMcpToolHandler', () => { + it('summarizes each session by id and diagram type when sessions exist', () => { + const handler = buildHandler([ + makeSession('session-alpha', 'workflow-diagram', 'file:///alpha.wf', false), + makeSession('session-beta', 'state-diagram', 'file:///beta.sd', true) + ]); + + const result = handler['createResult']({}); + const text = asText(result); + // Summary lists each session's id and diagram type — the load-bearing referenceable bits. + expect(text).to.include('session-alpha'); + expect(text).to.include('session-beta'); + expect(text).to.include('workflow-diagram'); + expect(text).to.include('state-diagram'); + // Full per-session detail goes via structuredContent. + const sessions = (result.structuredContent as { sessions: SessionRow[] }).sessions; + expect(sessions.map(row => row.sessionId)).to.deep.equal(['session-alpha', 'session-beta']); + }); + + it("passes through `sourceUri` and `readOnly` from each session container's ModelState via structuredContent", () => { + const handler = buildHandler([makeSession('s1', 'workflow-diagram', 'file:///only.wf', true, /* isDirty */ false)]); + + const result = handler['createResult']({}); + const sessions = (result.structuredContent as { sessions: SessionRow[] }).sessions; + expect(sessions).to.have.lengthOf(1); + expect(sessions[0].sourceUri).to.equal('file:///only.wf'); + expect(sessions[0].readOnly).to.equal(true); + expect(sessions[0].dirty).to.equal(false); + // Read-only state is also surfaced in the text summary so content-only clients can see it. + expect(asText(result)).to.include('read-only'); + }); + + it('filters to a single session when `sessionId` matches', () => { + const handler = buildHandler([ + makeSession('session-alpha', 'workflow-diagram', 'file:///alpha.wf', false), + makeSession('session-beta', 'state-diagram', 'file:///beta.sd', true) + ]); + + const text = asText(handler['createResult']({ sessionId: 'session-beta' })); + + expect(text).to.include('session-beta'); + expect(text).to.not.include('session-alpha'); + }); + + it('throws McpToolError when the requested `sessionId` does not exist', () => { + const handler = buildHandler([makeSession('session-alpha', 'workflow-diagram', 'file:///alpha.wf', false)]); + + expect(() => handler['createResult']({ sessionId: 'unknown' })).to.throw(/Unknown sessionId: unknown/); + }); +}); diff --git a/packages/server-mcp/src/tools/handlers/session-info-mcp-tool-handler.ts b/packages/server-mcp/src/tools/handlers/session-info-mcp-tool-handler.ts new file mode 100644 index 0000000..d31c3a8 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/session-info-mcp-tool-handler.ts @@ -0,0 +1,97 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { ClientSessionManager, CommandStack, ModelState } from '@eclipse-glsp/server'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { AbstractMcpToolHandler, McpToolError, McpToolResult } from '../../server'; + +export const SessionInfoInputSchema = z.object({ + sessionId: z + .string() + .optional() + .describe('Optional filter — returns only the matching session, or throws if unknown. Omit to list all sessions.') +}); +export type SessionInfoInput = z.infer; + +export const SessionInfoRowSchema = z.object({ + sessionId: z.string(), + diagramType: z.string(), + sourceUri: z.string(), + readOnly: z.boolean(), + dirty: z.boolean().describe('True when the diagram has unsaved changes — call `save-model` to persist.') +}); +export type SessionInfoRow = z.infer; + +export const SessionInfoOutputSchema = z.object({ + sessions: z.array(SessionInfoRowSchema) +}); + +@injectable() +export class SessionInfoMcpToolHandler extends AbstractMcpToolHandler { + @inject(ClientSessionManager) protected clientSessionManager: ClientSessionManager; + + static readonly NAME = 'session-info'; + readonly name = SessionInfoMcpToolHandler.NAME; + override readonly title = 'GLSP Session Info'; + readonly description = + 'Report info on the active GLSP client sessions: session id, diagram type, source uri, and read-only status. ' + + "Pass `sessionId` to retrieve a single session's entry; omit it to list every active session. " + + 'Useful as a discovery tool — most other diagram-scoped tools take the `sessionId` returned here.'; + readonly inputSchema = SessionInfoInputSchema; + override readonly outputSchema = SessionInfoOutputSchema; + + protected createResult({ sessionId }: SessionInfoInput): McpToolResult { + const sessions = sessionId !== undefined ? this.singleSession(sessionId) : this.clientSessionManager.getSessions(); + const rows = sessions.map(session => this.buildSessionRow(session)); + return this.success(this.summarizeSessions(rows), { sessions: rows }); + } + + /** Extracts a {@link SessionInfoRow} from a {@link ClientSession}. Override to surface adopter-specific fields. */ + protected buildSessionRow(session: ReturnType[number]): SessionInfoRow { + const modelState = session.container.get(ModelState); + const commandStack = session.container.get(CommandStack); + return { + sessionId: session.id, + diagramType: session.diagramType, + // `ModelState.sourceUri` may be undefined for not-yet-persisted diagrams; render as empty string to keep the schema-required string shape. + sourceUri: modelState.sourceUri ?? '', + readOnly: modelState.isReadonly, + dirty: commandStack.isDirty + }; + } + + /** Builds the LLM-facing summary. Override to customize per-adopter wording. */ + protected summarizeSessions(rows: SessionInfoRow[]): string { + if (rows.length === 0) { + return 'No active sessions.'; + } + const lines = rows.map(row => { + const flags = [row.readOnly ? 'read-only' : undefined, row.dirty ? 'dirty' : undefined].filter(Boolean); + const suffix = flags.length > 0 ? `, ${flags.join(', ')}` : ''; + return `- ${row.sessionId} (${row.diagramType}${suffix})`; + }); + return `${rows.length} session(s); full details in structuredContent.\n${lines.join('\n')}`; + } + + protected singleSession(sessionId: string): ReturnType { + const session = this.clientSessionManager.getSession(sessionId); + if (!session) { + throw new McpToolError(`Unknown sessionId: ${sessionId}`); + } + return [session]; + } +} diff --git a/packages/server-mcp/src/tools/handlers/set-selection-mcp-tool-handler.spec.ts b/packages/server-mcp/src/tools/handlers/set-selection-mcp-tool-handler.spec.ts new file mode 100644 index 0000000..9372004 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/set-selection-mcp-tool-handler.spec.ts @@ -0,0 +1,118 @@ +/******************************************************************************** + * 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, + ClientId, + GModelElement, + Logger, + ModelState, + NullLogger, + SelectAction, + SelectAllAction +} from '@eclipse-glsp/server'; +import { expect } from 'chai'; +import { Container, ContainerModule } from 'inversify'; +import { DefaultMcpLabelProvider, McpElementsNotFoundError, McpIdAliasService, McpLabelProvider, McpToolResult } from '../../server'; +import { SetSelectionInput, SetSelectionMcpToolHandler } from './set-selection-mcp-tool-handler'; + +function makeElement(id: string, type: string): GModelElement { + return { id, type, children: [] } as unknown as GModelElement; +} + +function makeModelState(elements: GModelElement[]): ModelState { + const byId = new Map(elements.map(el => [el.id, el])); + return { + index: { + allIds: () => [...byId.keys()], + get: (id: string) => byId.get(id), + find: (id: string) => byId.get(id) + } + } as unknown as ModelState; +} + +function makeRecordingDispatcher(): { dispatcher: ActionDispatcher; dispatched: Action[] } { + const dispatched: Action[] = []; + const dispatcher = { + dispatch: async (action: Action) => { + dispatched.push(action); + } + } as unknown as ActionDispatcher; + return { dispatcher, dispatched }; +} + +function buildHandler(elements: GModelElement[], dispatcher: ActionDispatcher): SetSelectionMcpToolHandler { + const container = new Container(); + container.load( + new ContainerModule(bind => { + bind(Logger).toConstantValue(new NullLogger()); + bind(ClientId).toConstantValue('test-session'); + bind(ModelState).toConstantValue(makeModelState(elements)); + bind(McpIdAliasService).toConstantValue({ + lookup: (id: string) => id, + alias: (id: string) => id + } as McpIdAliasService); + bind(ActionDispatcher).toConstantValue(dispatcher); + bind(McpLabelProvider).to(DefaultMcpLabelProvider); + bind(SetSelectionMcpToolHandler).toSelf(); + }) + ); + return container.get(SetSelectionMcpToolHandler); +} + +function callCreateResult(handler: SetSelectionMcpToolHandler, params: SetSelectionInput): Promise { + return (handler as unknown as { createResult: (p: SetSelectionInput) => Promise }).createResult(params); +} + +describe('SetSelectionMcpToolHandler', () => { + it('dispatches SelectAllAction(false) before SelectAction when `clear: true`', async () => { + const { dispatcher, dispatched } = makeRecordingDispatcher(); + const handler = buildHandler([makeElement('n1', 'task'), makeElement('n2', 'task')], dispatcher); + + await callCreateResult(handler, { sessionId: 's', selectedElementIds: ['n1'], clear: true }); + + expect(dispatched).to.have.lengthOf(2); + expect(SelectAllAction.is(dispatched[0])).to.equal(true); + expect((dispatched[0] as SelectAllAction).select).to.equal(false); + expect(SelectAction.is(dispatched[1])).to.equal(true); + }); + + it('dispatches SelectAction with resolved selected and deselected ids', async () => { + const { dispatcher, dispatched } = makeRecordingDispatcher(); + const handler = buildHandler([makeElement('a', 'task'), makeElement('b', 'task'), makeElement('c', 'task')], dispatcher); + + await callCreateResult(handler, { sessionId: 's', selectedElementIds: ['a', 'b'], deselectedElementIds: ['c'] }); + + expect(dispatched).to.have.lengthOf(1); + const action = dispatched[0] as SelectAction; + expect(action.selectedElementsIDs).to.deep.equal(['a', 'b']); + expect(action.deselectedElementsIDs).to.deep.equal(['c']); + }); + + it('throws McpElementsNotFoundError when an id is missing from the model', async () => { + const { dispatcher } = makeRecordingDispatcher(); + const handler = buildHandler([makeElement('n1', 'task')], dispatcher); + + let error: unknown; + try { + await callCreateResult(handler, { sessionId: 's', selectedElementIds: ['n1', 'ghost'] }); + } catch (err: unknown) { + error = err; + } + expect(error).to.be.instanceOf(McpElementsNotFoundError); + }); +}); diff --git a/packages/server-mcp/src/tools/handlers/set-selection-mcp-tool-handler.ts b/packages/server-mcp/src/tools/handlers/set-selection-mcp-tool-handler.ts new file mode 100644 index 0000000..4cca77f --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/set-selection-mcp-tool-handler.ts @@ -0,0 +1,90 @@ +/******************************************************************************** + * 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 { ActionDispatcher, SelectAction, SelectAllAction } from '@eclipse-glsp/server'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { AbstractMcpDiagramToolHandler, McpDiagramScopedInputSchema, McpToolResult, elementIds } from '../../server'; + +export const SetSelectionInputSchema = McpDiagramScopedInputSchema.extend({ + selectedElementIds: elementIds + .optional() + .describe('Element IDs to select. Pass an empty array (or omit + set `clear: true`) to clear the selection.'), + deselectedElementIds: elementIds.optional().describe('Element IDs to remove from the selection. Used to subtract without replacing.'), + clear: z + .boolean() + .optional() + .describe('When true, clear the existing selection before applying `selectedElementIds`. Defaults to false (additive).') +}); +export type SetSelectionInput = z.infer; + +export const SetSelectionOutputSchema = z.object({ + selectedElementIds: z.array(z.string()).describe('Aliased ids requested for selection.'), + deselectedElementIds: z.array(z.string()).describe('Aliased ids requested for deselection.'), + cleared: z.boolean().describe('Whether the existing selection was cleared before applying.') +}); + +/** + * Pushes a selection change to the client. Counterpart to `get-selection` — useful for the + * agent to direct a human reviewer's attention to elements it just modified ("here's what I + * touched"), or to chain follow-up tools that rely on the visible selection. + * + * Read-write hint is honest: the viewport selection state is part of the client environment + * even though the underlying GModel is unaffected. + */ +@injectable() +export class SetSelectionMcpToolHandler extends AbstractMcpDiagramToolHandler { + static readonly NAME = 'set-selection'; + readonly name = SetSelectionMcpToolHandler.NAME; + override readonly title = 'Set Diagram Selection'; + readonly description = + 'Set the selection of elements on the client UI. Useful to direct a reviewer to a ' + + 'set of elements (e.g. those just created or modified). Pass `clear: true` to replace ' + + 'the existing selection rather than add to it.'; + readonly inputSchema = SetSelectionInputSchema; + override readonly outputSchema = SetSelectionOutputSchema; + override readonly readOnlyHint = false; + + @inject(ActionDispatcher) protected actionDispatcher: ActionDispatcher; + + protected async createResult({ selectedElementIds, deselectedElementIds, clear }: SetSelectionInput): Promise { + const resolvedSelected = this.resolveExistingIds(selectedElementIds); + const resolvedDeselected = this.resolveExistingIds(deselectedElementIds); + + if (clear) { + await this.actionDispatcher.dispatch(SelectAllAction.create(false)); + } + if (resolvedSelected.length > 0 || resolvedDeselected.length > 0) { + await this.actionDispatcher.dispatch( + SelectAction.create({ selectedElementsIDs: resolvedSelected, deselectedElementsIDs: resolvedDeselected }) + ); + } + + return this.success(this.summarize(resolvedSelected, resolvedDeselected, clear ?? false), { + selectedElementIds: this.encodeIds(resolvedSelected), + deselectedElementIds: this.encodeIds(resolvedDeselected), + cleared: clear ?? false + }); + } + + protected summarize(selected: string[], deselected: string[], cleared: boolean): string { + const parts: string[] = []; + if (cleared) parts.push('Cleared previous selection.'); + if (selected.length > 0) parts.push(`Selected ${selected.length} element${selected.length === 1 ? '' : 's'}.`); + if (deselected.length > 0) parts.push(`Deselected ${deselected.length} element${deselected.length === 1 ? '' : 's'}.`); + return parts.length > 0 ? parts.join(' ') : 'Selection unchanged.'; + } +} diff --git a/packages/server-mcp/src/tools/handlers/set-view-mcp-tool-handler.ts b/packages/server-mcp/src/tools/handlers/set-view-mcp-tool-handler.ts new file mode 100644 index 0000000..8c5a38b --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/set-view-mcp-tool-handler.ts @@ -0,0 +1,162 @@ +/******************************************************************************** + * 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, + CenterAction, + FitToScreenAction, + GetViewportAction, + OriginViewportAction, + Point, + SetViewportAction +} from '@eclipse-glsp/server'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { + McpDiagramScopedInputSchema, + AbstractMcpDiagramToolHandler, + McpToolError, + McpToolResult, + elementIds, + position, + requestActionOrFail +} from '../../server'; + +export const VIEWPORT_ACTIONS = ['fit-to-screen', 'center-on-elements', 'reset-viewport', 'set-viewport'] as const; +export type ViewportAction = (typeof VIEWPORT_ACTIONS)[number]; + +export const SetViewInputSchema = McpDiagramScopedInputSchema.extend({ + action: z.enum(VIEWPORT_ACTIONS).describe('The type of viewport change action to be undertaken.'), + elementIds: elementIds + .optional() + .describe( + "Elements to center on or fit (relevant for 'center-on-elements' and 'fit-to-screen'). Omit to target the whole diagram." + ), + zoom: z + .number() + .min(0.05) + .max(20) + .optional() + .describe( + "Absolute zoom level for `action: 'set-viewport'` (range 0.05..20, where 1 is 100% / native scale). " + + 'Ignored by other actions. Omit to keep the current zoom while changing scroll.' + ), + scroll: position + .optional() + .describe( + "Absolute scroll position for `action: 'set-viewport'`, in diagram coordinates (top-left of the visible area). " + + 'Ignored by other actions. Omit to keep the current scroll while changing zoom.' + ) +}); +export type SetViewInput = z.infer; + +export const SetViewOutputSchema = z.object({ + action: z.enum(VIEWPORT_ACTIONS).describe('Echo of the viewport action that was applied.'), + targetIds: z + .array(z.string()) + .optional() + .describe( + "Aliased ids the action targeted (resolved from input or fall-back of all elements). Omitted for `'set-viewport'` since that action targets coordinates, not elements." + ), + viewport: z + .object({ + scroll: position, + zoom: z.number() + }) + .optional() + .describe("Resolved final viewport when `action: 'set-viewport'` was applied (current values merged with the supplied overrides).") +}); + +@injectable() +export class SetViewMcpToolHandler extends AbstractMcpDiagramToolHandler { + /** Timeout (in ms) for the `GetViewportAction` round-trip used by `set-viewport` partial updates. Override via subclass + rebind. */ + protected readonly viewportQueryTimeoutMs: number = 5000; + + static readonly NAME = 'set-view'; + readonly name = SetViewMcpToolHandler.NAME; + override readonly title = 'Set Diagram Viewport'; + readonly description = + "Set the viewport of the session's associated UI client to focus the user's attention. " + + '`fit-to-screen` zooms+pans so all elements (or the listed `elementIds`) are visible; ' + + '`center-on-elements` pans without changing zoom, useful to highlight a specific element after creating or modifying it; ' + + '`reset-viewport` returns the camera to the origin at default zoom; ' + + '`set-viewport` applies an explicit `zoom` and/or `scroll` (omit either to preserve its current value). ' + + 'Only invoke on explicit user request or when the user clearly benefits from a viewport nudge ' + + '(e.g. just created an element off-screen). Note this changes client-side viewport state but not the model.'; + readonly inputSchema = SetViewInputSchema; + override readonly outputSchema = SetViewOutputSchema; + /** Viewport IS client-side environment; dispatching mutates it, so the read-base default doesn't honestly apply. */ + override readonly readOnlyHint = false; + + @inject(ActionDispatcher) protected actionDispatcher: ActionDispatcher; + + protected async createResult({ action, elementIds, zoom, scroll }: SetViewInput): Promise { + if (action === 'set-viewport') { + return this.applyExplicitViewport(zoom, scroll); + } + const resolvedIds = elementIds ? elementIds.map(id => this.aliasService.lookup(id)) : this.modelState.index.allIds(); + const dispatchAction = this.buildIntentAction(action, resolvedIds); + await this.actionDispatcher.dispatch(dispatchAction); + return this.success('Viewport successfully changed', { action, targetIds: this.encodeIds(resolvedIds) }); + } + + /** + * Map an intent-driven viewport action (`fit-to-screen`, `center-on-elements`, + * `reset-viewport`) to the matching sprotty action. The exhaustive switch lets TypeScript + * narrow the remaining `'set-viewport'` case out at compile time, so we don't need a + * runtime guard. + */ + protected buildIntentAction(action: Exclude, resolvedIds: string[]): Action { + switch (action) { + case 'fit-to-screen': + return FitToScreenAction.create(resolvedIds, { animate: true, padding: 20 }); + case 'center-on-elements': + return CenterAction.create(resolvedIds, { animate: true, retainZoom: true }); + case 'reset-viewport': + return OriginViewportAction.create({ animate: true }); + } + } + + /** + * Build the merged viewport for `set-viewport`: query the current viewport from the client, + * overlay the caller's `zoom` / `scroll` overrides, dispatch a {@link SetViewportAction}. + * Querying lets the LLM specify only one axis of change — the other is preserved instead of + * snapping to a placeholder default. + */ + protected async applyExplicitViewport(zoom: number | undefined, scroll: Point | undefined): Promise { + if (zoom === undefined && scroll === undefined) { + throw new McpToolError("'set-viewport' requires at least one of `zoom` or `scroll`."); + } + const rootId = this.modelState.root.id; + const current = await requestActionOrFail( + this.actionDispatcher, + GetViewportAction.create(), + this.viewportQueryTimeoutMs, + this.name + ); + // Some clients respond before layout completes — fall back to origin / 1× when fields are null. + const newViewport = { + scroll: scroll ?? (Point.isValid(current.viewport.scroll) ? current.viewport.scroll : Point.ORIGIN), + zoom: zoom ?? (Number.isFinite(current.viewport.zoom) ? current.viewport.zoom : 1) + }; + await this.actionDispatcher.dispatch(SetViewportAction.create(rootId, newViewport, { animate: true })); + return this.success(`Viewport set to scroll=(${newViewport.scroll.x}, ${newViewport.scroll.y}) zoom=${newViewport.zoom}`, { + action: 'set-viewport', + viewport: newViewport + }); + } +} diff --git a/packages/server-mcp/src/tools/handlers/undo-mcp-tool-handler.ts b/packages/server-mcp/src/tools/handlers/undo-mcp-tool-handler.ts new file mode 100644 index 0000000..036ff89 --- /dev/null +++ b/packages/server-mcp/src/tools/handlers/undo-mcp-tool-handler.ts @@ -0,0 +1,61 @@ +/******************************************************************************** + * 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 { CommandStack, UndoAction } from '@eclipse-glsp/server'; +import { inject, injectable } from 'inversify'; +import * as z from 'zod/v4'; +import { McpDiagramScopedInputSchema, McpToolError, McpToolResult, OperationMcpDiagramToolHandler } from '../../server'; + +export const UndoInputSchema = McpDiagramScopedInputSchema.extend({ + commandsToUndo: z.number().min(1).default(1).describe('Number of commands to undo. Defaults to 1 (most recent command).') +}); +export type UndoInput = z.infer; + +export const UndoOutputSchema = z.object({ + commandsUndone: z.number().int().describe('Number of commands actually reverted.') +}); + +/** + * Undo a given number of the most recent actions on the command stack. + */ +@injectable() +export class UndoMcpToolHandler extends OperationMcpDiagramToolHandler { + static readonly NAME = 'undo'; + readonly name = UndoMcpToolHandler.NAME; + override readonly title = 'Undo Diagram Commands'; + readonly description = + "Undo recent commands on the diagram's command stack. " + + 'Defaults to undoing one command — the most recent operation — when `commandsToUndo` is omitted. ' + + 'Note that some tools dispatch multiple commands per call (e.g. `create-nodes` typically dispatches a node + a label command); ' + + 'check the previous tool result for the exact count if you need to reverse more than the last one. ' + + "Throws when there's nothing on the undo stack. Only do this on an explicit user request."; + readonly inputSchema = UndoInputSchema; + override readonly outputSchema = UndoOutputSchema; + + @inject(CommandStack) protected commandStack: CommandStack; + + protected async createResult({ commandsToUndo }: UndoInput): Promise { + if (!this.commandStack.canUndo()) { + throw new McpToolError('Nothing to undo (undo stack is empty; the model is at its initial state for this session).'); + } + + for (let i = 0; i < commandsToUndo; i++) { + await this.actionDispatcher.dispatch(UndoAction.create()); + } + + return this.success('Undo successful', { commandsUndone: commandsToUndo }); + } +} diff --git a/packages/server-mcp/src/tools/handlers/validate-diagram-mcp-tool-handler.ts b/packages/server-mcp/src/tools/handlers/validate-diagram-mcp-tool-handler.ts new file mode 100644 index 0000000..987638f Binary files /dev/null and b/packages/server-mcp/src/tools/handlers/validate-diagram-mcp-tool-handler.ts differ diff --git a/packages/server-mcp/src/tools/index.ts b/packages/server-mcp/src/tools/index.ts new file mode 100644 index 0000000..707b5da --- /dev/null +++ b/packages/server-mcp/src/tools/index.ts @@ -0,0 +1,34 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +export * from './handlers/count-elements-mcp-tool-handler'; +export * from './handlers/create-edges-mcp-tool-handler'; +export * from './handlers/create-nodes-mcp-tool-handler'; +export * from './handlers/delete-elements-mcp-tool-handler'; +export * from './handlers/diagram-model-mcp-tool-handler'; +export * from './handlers/element-types-mcp-tool-handler'; +export * from './handlers/get-selection-mcp-tool-handler'; +export * from './handlers/layout-mcp-tool-handler'; +export * from './handlers/modify-edges-mcp-tool-handler'; +export * from './handlers/modify-nodes-mcp-tool-handler'; +export * from './handlers/query-elements-mcp-tool-handler'; +export * from './handlers/redo-mcp-tool-handler'; +export * from './handlers/save-model-mcp-tool-handler'; +export * from './handlers/session-info-mcp-tool-handler'; +export * from './handlers/set-selection-mcp-tool-handler'; +export * from './handlers/set-view-mcp-tool-handler'; +export * from './handlers/undo-mcp-tool-handler'; +export * from './handlers/validate-diagram-mcp-tool-handler'; diff --git a/packages/server-mcp/src/tools/tool-annotations.spec.ts b/packages/server-mcp/src/tools/tool-annotations.spec.ts new file mode 100644 index 0000000..736b650 --- /dev/null +++ b/packages/server-mcp/src/tools/tool-annotations.spec.ts @@ -0,0 +1,141 @@ +/******************************************************************************** + * 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 { expect } from 'chai'; +import { interfaces } from 'inversify'; +import { SetViewMcpToolHandler } from './handlers/set-view-mcp-tool-handler'; +import { CreateEdgesMcpToolHandler } from './handlers/create-edges-mcp-tool-handler'; +import { CreateNodesMcpToolHandler } from './handlers/create-nodes-mcp-tool-handler'; +import { DeleteElementsMcpToolHandler } from './handlers/delete-elements-mcp-tool-handler'; +import { QueryElementsMcpToolHandler } from './handlers/query-elements-mcp-tool-handler'; +import { GetSelectionMcpToolHandler } from './handlers/get-selection-mcp-tool-handler'; +import { ModifyEdgesMcpToolHandler } from './handlers/modify-edges-mcp-tool-handler'; +import { ModifyNodesMcpToolHandler } from './handlers/modify-nodes-mcp-tool-handler'; +import { RedoMcpToolHandler } from './handlers/redo-mcp-tool-handler'; +import { SaveModelMcpToolHandler } from './handlers/save-model-mcp-tool-handler'; +import { UndoMcpToolHandler } from './handlers/undo-mcp-tool-handler'; +import { ValidateDiagramMcpToolHandler } from './handlers/validate-diagram-mcp-tool-handler'; + +interface AnnotatedHandler { + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; +} + +/** + * Reads the four annotation hint fields off a freshly `new`-ed handler. Mirrors how the launcher + * reads metadata via `new Constructor()` at MCP-session-init (see + * `mcp-server-launcher.ts#registerDiagramScopeTools`); no DI wiring needed because the fields + * are plain class properties with literal initializers. + */ +function hintsOf(Constructor: interfaces.Newable): AnnotatedHandler { + const handler = new Constructor(); + return { + readOnlyHint: handler.readOnlyHint, + destructiveHint: handler.destructiveHint, + idempotentHint: handler.idempotentHint, + openWorldHint: handler.openWorldHint + }; +} + +const READ_DEFAULT: AnnotatedHandler = { + readOnlyHint: true, + destructiveHint: undefined, + idempotentHint: undefined, + openWorldHint: false +}; + +const OPERATION_DEFAULT: AnnotatedHandler = { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false +}; + +describe('Tool annotations · per-handler matrix', () => { + describe('read-style handlers (inherit base default)', () => { + const readStyleHandlers: Array<[string, interfaces.Newable]> = [ + ['query-elements', QueryElementsMcpToolHandler], + ['get-selection', GetSelectionMcpToolHandler], + ['validate-diagram', ValidateDiagramMcpToolHandler] + ]; + readStyleHandlers.forEach(([name, Constructor]) => { + it(`${name} matches the read-style default`, () => { + expect(hintsOf(Constructor)).to.deep.equal(READ_DEFAULT); + }); + }); + }); + + describe('operation-style handlers (override to write defaults)', () => { + const operationStyleHandlers: Array<[string, interfaces.Newable]> = [ + ['create-nodes', CreateNodesMcpToolHandler], + ['create-edges', CreateEdgesMcpToolHandler], + ['modify-nodes', ModifyNodesMcpToolHandler], + ['modify-edges', ModifyEdgesMcpToolHandler], + ['undo', UndoMcpToolHandler], + ['redo', RedoMcpToolHandler] + ]; + operationStyleHandlers.forEach(([name, Constructor]) => { + it(`${name} matches the operation-style default`, () => { + expect(hintsOf(Constructor)).to.deep.equal(OPERATION_DEFAULT); + }); + }); + }); + + describe('per-handler overrides', () => { + it('delete-elements is destructiveHint:true (single-flag override)', () => { + expect(hintsOf(DeleteElementsMcpToolHandler)).to.deep.equal({ ...OPERATION_DEFAULT, destructiveHint: true }); + }); + + it('save-model is NOT readOnly (writes to disk) and NOT destructive (creative)', () => { + // Spec: `readOnlyHint` means "does not modify its environment" — disk IS environment. + // `destructiveHint` is for irreversible deletion / data loss; save is creative. + // Save-model extends the read base (not the operation base) because it doesn't + // dispatch a model-mutating Operation, but we override the flat fields explicitly. + expect(hintsOf(SaveModelMcpToolHandler)).to.deep.equal({ + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false + }); + }); + + it('set-view drops the read-only claim (viewport IS environment)', () => { + // Inherits from the read base, which would claim readOnlyHint:true. Override flips + // it: dispatching a viewport action mutates client-side state, even though no + // diagram model bytes change. + expect(hintsOf(SetViewMcpToolHandler)).to.deep.equal({ + readOnlyHint: false, + destructiveHint: undefined, + idempotentHint: undefined, + openWorldHint: false + }); + }); + }); + + describe('toRegistrationConfig() assembles the annotations object the SDK expects', () => { + it('aggregates the flat fields into the SDK ToolAnnotations shape', () => { + const config = new DeleteElementsMcpToolHandler().toRegistrationConfig(); + expect(config.annotations).to.deep.equal({ + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false + }); + }); + }); +}); 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..39f84af --- /dev/null +++ b/packages/server-mcp/src/util/markdown-util.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 + ********************************************************************************/ + +/** + * 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, ''); + } + // Escape pipe characters in scalar values so they don't break the table layout + return String(value).replace(/\|/g, '\\|'); + }) + .join(' | '); + return `| ${rowString} |`; + }); + + return [headerRow, separatorRow, ...dataRows].join('\n'); +} diff --git a/packages/server-mcp/src/util/mcp-util.ts b/packages/server-mcp/src/util/mcp-util.ts new file mode 100644 index 0000000..348ab24 --- /dev/null +++ b/packages/server-mcp/src/util/mcp-util.ts @@ -0,0 +1,25 @@ +/******************************************************************************** + * Copyright (c) 2025-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 + ********************************************************************************/ + +/** + * Formats a list of per-input notices (errors, warnings, …) as a Markdown bullet list with a + * leading heading. Returns an empty string when `notices` is empty so callers can append + * unconditionally. + */ +export function formatNoticeList(kind: string, notices: string[]): string { + if (notices.length === 0) return ''; + return `\nThe following ${kind} occurred:\n${notices.map(notice => `- ${notice}`).join('\n')}`; +} diff --git a/packages/server-mcp/tsconfig.json b/packages/server-mcp/tsconfig.json new file mode 100644 index 0000000..48d476a --- /dev/null +++ b/packages/server-mcp/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@eclipse-glsp/ts-config/mocha", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + "composite": true + }, + "exclude": ["**/*.spec.ts", "**/*.test-util.ts"], + "include": ["src"], + "references": [ + { + "path": "../server" + } + ] +} diff --git a/packages/server/src/common/actions/action-dispatcher.ts b/packages/server/src/common/actions/action-dispatcher.ts index 8605ef1..223377c 100644 --- a/packages/server/src/common/actions/action-dispatcher.ts +++ b/packages/server/src/common/actions/action-dispatcher.ts @@ -219,6 +219,12 @@ export class DefaultActionDispatcher implements ActionDispatcher, Disposable { const actionHandlers = this.actionHandlerRegistry.get(action.kind); if (!handledOnClient && actionHandlers.length === 0) { + // Stale response (e.g. late `RejectAction`) for a pending request that's already + // gone — protocol signal, not a handler invocation; drop silently. + if (ResponseAction.is(action)) { + this.logger.debug(`Stale response '${action.kind}' (responseId='${action.responseId}') has no handler; dropping.`); + return; + } throw new GLSPServerError(`No handler registered for action kind: ${action.kind}`); } diff --git a/packages/server/src/common/actions/global-action-provider.ts b/packages/server/src/common/actions/global-action-provider.ts index d9eb95b..0034107 100644 --- a/packages/server/src/common/actions/global-action-provider.ts +++ b/packages/server/src/common/actions/global-action-provider.ts @@ -15,7 +15,7 @@ ********************************************************************************/ import { distinctAdd } from '@eclipse-glsp/protocol'; import { Container, ContainerModule, inject, injectable } from 'inversify'; -import { createClientSessionModule } from '../di/client-session-module'; +import { TEMPORARY_CLIENT_ID, createClientSessionModule } from '../di/client-session-module'; import { DiagramModules, InjectionContainer } from '../di/service-identifiers'; import { ClientSessionInitializer } from '../session/client-session-initializer'; import { ActionHandlerRegistry } from './action-handler-registry'; @@ -50,8 +50,7 @@ export class DefaultGlobalActionProvider implements GlobalActionProvider { createDiagramContainer(serverContainer: Container, modules: ContainerModule[]): Container { const container = serverContainer.createChild(); const clientSessionModule = createClientSessionModule({ - clientId: 'tempId', - + clientId: TEMPORARY_CLIENT_ID, glspClient: { process: () => {} }, clientActionKinds: [] }); diff --git a/packages/server/src/common/di/client-session-module.ts b/packages/server/src/common/di/client-session-module.ts index 77e9550..79b2700 100644 --- a/packages/server/src/common/di/client-session-module.ts +++ b/packages/server/src/common/di/client-session-module.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022-2024 STMicroelectronics and others. + * Copyright (c) 2022-2026 STMicroelectronics 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 @@ -24,6 +24,14 @@ export interface ClientSessionModuleOptions { clientActionKinds: string[]; } +/** + * Synthetic `ClientId` used by framework probes that load diagram modules onto a throwaway + * container — e.g. {@link DefaultGlobalActionProvider} scraping action-kind metadata at server + * startup. Adopters can detect this id in their `ClientSessionInitializer`s to suppress + * setup-time diagnostics that don't apply when no real GLSP client is connected. + */ +export const TEMPORARY_CLIENT_ID = 'tempId'; + /** * Creates the DI module that binds client session specific configuration */ diff --git a/packages/server/src/common/di/server-module.ts b/packages/server/src/common/di/server-module.ts index 209c873..6aa17a3 100644 --- a/packages/server/src/common/di/server-module.ts +++ b/packages/server/src/common/di/server-module.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022-2023 STMicroelectronics and others. + * Copyright (c) 2022-2026 STMicroelectronics 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 @@ -13,7 +13,7 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { GLSPServer, GLSPServerListener } from '@eclipse-glsp/protocol'; +import { GLSPServer, GLSPServerInitializer, GLSPServerListener } from '@eclipse-glsp/protocol'; import { ContainerModule, interfaces } from 'inversify'; import { DefaultGlobalActionProvider, GlobalActionProvider } from '../actions/global-action-provider'; import { DefaultGLSPServer } from '../protocol/glsp-server'; @@ -77,6 +77,9 @@ export class ServerModule extends GLSPModule { this.configureMultiBinding(new MultiBinding(GLSPServerListener), binding => this.configureGLSPServerListeners(binding) ); + this.configureMultiBinding(new MultiBinding(GLSPServerInitializer), binding => + this.configureGLSPServerInitializers(binding) + ); applyBindingTarget(context, GlobalActionProvider, this.bindGlobalActionProvider()).inSingletonScope(); @@ -112,4 +115,8 @@ export class ServerModule extends GLSPModule { protected configureGLSPServerListeners(binding: MultiBinding): void { binding.add({ service: ClientSessionManager }); } + + protected configureGLSPServerInitializers(binding: MultiBinding): void { + // Can be overridden to add contributions + } } diff --git a/packages/server/src/common/launch/jsonrpc-server-launcher.ts b/packages/server/src/common/launch/jsonrpc-server-launcher.ts index 8e777be..89b942f 100644 --- a/packages/server/src/common/launch/jsonrpc-server-launcher.ts +++ b/packages/server/src/common/launch/jsonrpc-server-launcher.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2023 EclipseSource and others. + * Copyright (c) 2023-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 @@ -83,6 +83,9 @@ export abstract class JsonRpcGLSPServerLauncher extends GLSPServerLauncher serverInstance.clientConnection.onNotification(JsonrpcGLSPClient.ShutdownNotification, () => this.disposeServerInstance(serverInstance) ); + // A connection may be unceremoniously be closed (e.g., closing/reloading the browser) in which + // case the server must still be disposed + serverInstance.clientConnection.onClose(() => this.disposeServerInstance(serverInstance)); this.logger.info('Starting GLSP server connection'); } diff --git a/packages/server/src/common/operations/create-operation-handler.ts b/packages/server/src/common/operations/create-operation-handler.ts index 0e2b3f2..c98a66d 100644 --- a/packages/server/src/common/operations/create-operation-handler.ts +++ b/packages/server/src/common/operations/create-operation-handler.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022-2023 STMicroelectronics and others. + * Copyright (c) 2022-2026 STMicroelectronics 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 @@ -64,3 +64,15 @@ export namespace CreateOperationHandler { ); } } + +export namespace CreateNodeOperationHandler { + export function is(object: unknown): object is CreateNodeOperationHandler { + return CreateOperationHandler.is(object) && object.operationType === CreateNodeOperation.KIND; + } +} + +export namespace CreateEdgeOperationHandler { + export function is(object: unknown): object is CreateEdgeOperationHandler { + return CreateOperationHandler.is(object) && object.operationType === CreateEdgeOperation.KIND; + } +} diff --git a/packages/server/src/common/protocol/glsp-server.ts b/packages/server/src/common/protocol/glsp-server.ts index d43f063..7cbfdea 100644 --- a/packages/server/src/common/protocol/glsp-server.ts +++ b/packages/server/src/common/protocol/glsp-server.ts @@ -19,6 +19,7 @@ import { DisposeClientSessionParameters, GLSPClientProxy, GLSPServer, + GLSPServerInitializer, GLSPServerListener, InitializeClientSessionParameters, InitializeParameters, @@ -63,7 +64,10 @@ export class DefaultGLSPServer implements GLSPServer { protected clientSessions: Map; protected serverListeners: GLSPServerListener[] = []; - constructor(@multiInject(GLSPServerListener) @optional() serverListeners: GLSPServerListener[] = []) { + constructor( + @multiInject(GLSPServerListener) @optional() serverListeners: GLSPServerListener[] = [], + @multiInject(GLSPServerInitializer) @optional() protected initializers: GLSPServerInitializer[] = [] + ) { this.clientSessions = new Map(); serverListeners.forEach(listener => this.addListener(listener)); } @@ -103,12 +107,29 @@ export class DefaultGLSPServer implements GLSPServer { let result = { protocolVersion: DefaultGLSPServer.PROTOCOL_VERSION, serverActions }; + result = await this.initializeServer(params, result); + // keep for backwards compatibility + // eslint-disable-next-line @typescript-eslint/no-deprecated result = await this.handleInitializeArgs(result, params.args); this.getListenersToNotify('serverInitialized').forEach((listener: GLSPServerListener) => listener.serverInitialized!(this)); this.initializeResult = result; return result; } + protected async initializeServer(params: InitializeParameters, result: InitializeResult): Promise { + for (const initializer of this.initializers) { + try { + result = await initializer.initializeServer(this, params, result); + } catch (error: unknown) { + this.logger.error(`Error during server initialization from ${initializer.constructor.name}:`, error); + } + } + return result; + } + + /** + * @deprecated Register a `GLSPServerInitializer` instead. + */ protected handleInitializeArgs(result: InitializeResult, args: Args | undefined): MaybePromise { return result; } diff --git a/packages/server/src/common/session/client-session-manager.ts b/packages/server/src/common/session/client-session-manager.ts index 0a3714e..1696b4a 100644 --- a/packages/server/src/common/session/client-session-manager.ts +++ b/packages/server/src/common/session/client-session-manager.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022-2023 STMicroelectronics and others. + * Copyright (c) 2022-2026 STMicroelectronics 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 @@ -47,6 +47,13 @@ export interface ClientSessionManager { */ getSession(clientSessionId: string): ClientSession | undefined; + /** + * Return all currently active {@link ClientSession}s. + * + * @returns An array of all currently active {@link ClientSession}s. + */ + getSessions(): Array; + /** * Return all currently active {@link ClientSession}s for the given diagram type. * @@ -129,6 +136,10 @@ export class DefaultClientSessionManager implements ClientSessionManager, GLSPSe return this.clientSessions.get(clientSessionId); } + getSessions(): ClientSession[] { + return Array.from(this.clientSessions.values()); + } + getSessionsByType(diagramType: string): ClientSession[] { return Array.from(this.clientSessions.values()).filter(session => session.diagramType === diagramType); } diff --git a/packages/server/src/common/test/mock-util.ts b/packages/server/src/common/test/mock-util.ts index 76914cc..0c75796 100644 --- a/packages/server/src/common/test/mock-util.ts +++ b/packages/server/src/common/test/mock-util.ts @@ -169,6 +169,10 @@ export class StubClientSessionManager implements ClientSessionManager { return undefined; } + getSessions(): ClientSession[] { + return []; + } + getSessionsByType(diagramType: string): ClientSession[] { return []; } diff --git a/packages/server/src/node/actions/action-dispatcher.spec.ts b/packages/server/src/node/actions/action-dispatcher.spec.ts index 1532b92..eba984d 100644 --- a/packages/server/src/node/actions/action-dispatcher.spec.ts +++ b/packages/server/src/node/actions/action-dispatcher.spec.ts @@ -13,7 +13,7 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { Action, Deferred, RequestAction, ResponseAction, UpdateModelAction } from '@eclipse-glsp/protocol'; +import { Action, Deferred, RejectAction, RequestAction, ResponseAction, UpdateModelAction } from '@eclipse-glsp/protocol'; import { expect } from 'chai'; import { Container, ContainerModule } from 'inversify'; import * as sinon from 'sinon'; @@ -95,6 +95,13 @@ describe('test DefaultActionDispatcher', () => { const spy_handler1_execute = sinon.stub(handler1, 'execute').returns([]); const spy_handler2_execute = sinon.stub(handler2, 'execute').returns([]); const spy_handler3_execute = sinon.stub(handler3, 'execute').returns([]); + // Stubs created at the describe-level survive sandbox.restore(), so call counts + // would accumulate across tests and break `.calledOnce` assertions. + beforeEach(() => { + spy_handler1_execute.resetHistory(); + spy_handler2_execute.resetHistory(); + spy_handler3_execute.resetHistory(); + }); const handlerMockImpl = (kind: string): ActionHandler[] => { switch (kind) { case action1: @@ -129,9 +136,9 @@ describe('test DefaultActionDispatcher', () => { // Test execution await actionDispatcher.dispatchAll([{ kind: action1 }, { kind: action2 }, { kind: action3 }]); // Check if all handlers have been called - expect(spy_handler1_execute.calledOnce); - expect(spy_handler2_execute.calledOnce); - expect(spy_handler3_execute.calledOnce); + expect(spy_handler1_execute.calledOnce).to.be.true; + expect(spy_handler2_execute.calledOnce).to.be.true; + expect(spy_handler3_execute.calledOnce).to.be.true; // Check if all handlers have been called in the right order sinon.assert.callOrder(spy_handler1_execute, spy_handler2_execute, spy_handler3_execute); }); @@ -156,9 +163,9 @@ describe('test DefaultActionDispatcher', () => { actionDispatcher.dispatch({ kind: action2 }); await actionDispatcher.dispatch({ kind: action3 }); // Check if all handlers have been called - expect(spy_handler1_execute.calledOnce); - expect(spy_handler2_execute.calledOnce); - expect(spy_handler3_execute.calledOnce); + expect(spy_handler1_execute.calledOnce).to.be.true; + expect(spy_handler2_execute.calledOnce).to.be.true; + expect(spy_handler3_execute.calledOnce).to.be.true; // Check if all handlers have been called in the right order sinon.assert.callOrder(spy_handler1_execute, spy_handler2_execute, spy_handler3_execute); }); @@ -176,8 +183,8 @@ describe('test DefaultActionDispatcher', () => { const spy_handler2_execute = sinon.spy(handler2, 'execute'); // Test execution await actionDispatcher.dispatch({ kind: action1 }); - expect(spy_handler1_execute.calledOnce); - expect(spy_handler2_execute.calledOnce); + expect(spy_handler1_execute.calledOnce).to.be.true; + expect(spy_handler2_execute.calledOnce).to.be.true; sinon.assert.callOrder(spy_handler1_execute, spy_handler2_execute); }); }); @@ -208,8 +215,8 @@ describe('test DefaultActionDispatcher', () => { // Add a delay so that the action dispatcher has time to dispatch the handler response await mock.delay(200); // Check if all handlers have been called - expect(spy_requestHandler_execute.calledOnce); - expect(spy_responseHandler_execute.calledOnce); + expect(spy_requestHandler_execute.calledOnce).to.be.true; + expect(spy_responseHandler_execute.calledOnce).to.be.true; }); it('dispatch - multiple actions & multiple response', async () => { @@ -249,10 +256,10 @@ describe('test DefaultActionDispatcher', () => { // Add a delay so that the action dispatcher has time to dispatch the handler response await mock.delay(100); // Check if all handlers have been called correctly - expect(spy_requestHandler1_execute.calledOnce); - expect(spy_requestHandler2_execute.calledOnce); - expect(spy_responseHandler1_execute.calledOnce); - expect(spy_responseHandler2_execute.calledThrice); + expect(spy_requestHandler1_execute.calledOnce).to.be.true; + expect(spy_requestHandler2_execute.calledOnce).to.be.true; + expect(spy_responseHandler1_execute.calledOnce).to.be.true; + expect(spy_responseHandler2_execute.calledThrice).to.be.true; // Check if all handlers have been called in the right order sinon.assert.callOrder(spy_requestHandler1_execute, spy_requestHandler2_execute); sinon.assert.callOrder(spy_responseHandler1_execute, spy_responseHandler2_execute); @@ -286,10 +293,10 @@ describe('test DefaultActionDispatcher', () => { await actionDispatcher.dispatch({ kind: intermediateAction }); expect(spy_postUpdateHandler_execute.called).to.be.false; await actionDispatcher.dispatch(updateModelAction); - expect(spy_postUpdateHandler_execute.calledOnce); + expect(spy_postUpdateHandler_execute.calledOnce).to.be.true; // Check that action does not get dispatched again await actionDispatcher.dispatch(updateModelAction); - expect(spy_postUpdateHandler_execute.calledOnce); + expect(spy_postUpdateHandler_execute.calledOnce).to.be.true; }); }); @@ -502,6 +509,21 @@ describe('test DefaultActionDispatcher', () => { expect(lateResponse.responseId).to.equal(''); }); + it('dispatch - drops a stale ResponseAction with no matching pending request and no handler', async () => { + // Late `RejectAction`s (and any other ResponseAction that arrives after the matching + // pending request is gone) used to throw "No handler registered" because the dispatcher + // tried to route them as regular actions. They are protocol signals, not handler + // invocations — `doDispatch` swallows them with a debug log instead. + clientActionForwarderStub.shouldForwardToClient.returns(false); + clientActionForwarderStub.handle.returns(false); + registry_get_stub.callsFake(() => []); + + const lateReject = RejectAction.create('late reject', { responseId: 'never-pending' }); + + // No throw, no client-forward — the dispatch resolves cleanly. + await actionDispatcher.dispatch(lateReject); + }); + it('request - resolves when response intercept happens from inside doDispatch', async () => { // A local handler for the request kind returns the matching response action directly. // The response is dispatched via dispatchResponses() -> dispatch() and intercepted diff --git a/tsconfig.json b/tsconfig.json index 416506b..f166222 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,9 @@ { "path": "./packages/server" }, + { + "path": "./packages/server-mcp" + }, { "path": "./examples/workflow-server" } diff --git a/yarn.lock b/yarn.lock index 5eedfcf..87626e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -223,18 +223,18 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== -"@eclipse-glsp/cli@2.7.0-next.13+90c0040": - version "2.7.0-next.13" - resolved "https://registry.yarnpkg.com/@eclipse-glsp/cli/-/cli-2.7.0-next.13.tgz#03d49ae55bb551631154d114e03e7ab32df87faf" - integrity sha512-5Rj+J5ikKDnjpkoYZc2LCIP9KaP09SXF3Ftefj3XYc3BD44/GJdngBe2uKNAxM71rSmBihBmbLoF/LxL0nr+Ow== - -"@eclipse-glsp/config-test@2.7.0-next.13+90c0040": - version "2.7.0-next.13" - resolved "https://registry.yarnpkg.com/@eclipse-glsp/config-test/-/config-test-2.7.0-next.13.tgz#f190f3f08e7d7b0ac54c851e1d2ce411a9ddbbfd" - integrity sha512-8iYAhMfEfCSVPVndqfyRCAexuXEUpdyic1zbXwuiAfAYlH5kAGVAkzCqyeXdo71Be4eX4bS43iRbi91Nmks6Dg== - dependencies: - "@eclipse-glsp/mocha-config" "2.7.0-next.13+90c0040" - "@eclipse-glsp/nyc-config" "2.7.0-next.13+90c0040" +"@eclipse-glsp/cli@2.7.0-next.16+f609ce3": + version "2.7.0-next.16" + resolved "https://registry.yarnpkg.com/@eclipse-glsp/cli/-/cli-2.7.0-next.16.tgz#998b9c3294158691ff6d076f1dd3271e6e6e9e79" + integrity sha512-YRvPN3i0KDkiTjmqr02/1Z4tLbfh1r5bCRrG8+BGuanmckdGsLoYWM0hb+2QEkk3Xjl8Pe2SFcspAV6W+ZK7Hg== + +"@eclipse-glsp/config-test@2.7.0-next.16+f609ce3": + version "2.7.0-next.16" + resolved "https://registry.yarnpkg.com/@eclipse-glsp/config-test/-/config-test-2.7.0-next.16.tgz#d0dfd8ded1d4a7ce7b18eb246d2a621492438260" + integrity sha512-tJMzKn4TvQniupbIJXKkMPuUPBKG0U8UfuE/BTE+ODQKAvZChi2hXmQgcV6aC4WcXh0qrHbwOXpgIbisd2qnTQ== + dependencies: + "@eclipse-glsp/mocha-config" "2.7.0-next.16+f609ce3" + "@eclipse-glsp/nyc-config" "2.7.0-next.16+f609ce3" "@istanbuljs/nyc-config-typescript" "^1.0.2" "@types/chai" "^4.3.7" "@types/mocha" "^10.0.2" @@ -247,14 +247,14 @@ sinon "^15.1.0" ts-node "^10.9.1" -"@eclipse-glsp/config@2.7.0-next.13+90c0040": - version "2.7.0-next.13" - resolved "https://registry.yarnpkg.com/@eclipse-glsp/config/-/config-2.7.0-next.13.tgz#56f615c0a4520d5cec48200200649d6c9d52c36d" - integrity sha512-mkqntgl3ARfHx3jMhTYSEPUlHyJ8NJaPfSu0nGiUrmOE8qcgrmc76fdynXymeSlkmbnWgS3iFmR28/kdzyAXUw== +"@eclipse-glsp/config@2.7.0-next.16+f609ce3": + version "2.7.0-next.16" + resolved "https://registry.yarnpkg.com/@eclipse-glsp/config/-/config-2.7.0-next.16.tgz#4200eeaa308d3cae24da7d3aa053563c3fb48d10" + integrity sha512-ErSeYoP8GoVdFK8At9RlCk0e3NOnuEp/mD+knk+ut37A6AqrtswUpp21duGQPd/t6TkF/u0mU5oelxaPXdvTpQ== dependencies: - "@eclipse-glsp/eslint-config" "2.7.0-next.13+90c0040" - "@eclipse-glsp/prettier-config" "2.7.0-next.13+90c0040" - "@eclipse-glsp/ts-config" "2.7.0-next.13+90c0040" + "@eclipse-glsp/eslint-config" "2.7.0-next.16+f609ce3" + "@eclipse-glsp/prettier-config" "2.7.0-next.16+f609ce3" + "@eclipse-glsp/ts-config" "2.7.0-next.16+f609ce3" "@eslint/js" "^9.0.0" "@stylistic/eslint-plugin" "^2.0.0" "@tony.ganchev/eslint-plugin-header" "^3.1.1" @@ -271,49 +271,49 @@ typescript-eslint "^8.0.0" "@eclipse-glsp/dev@next": - version "2.7.0-next.13" - resolved "https://registry.yarnpkg.com/@eclipse-glsp/dev/-/dev-2.7.0-next.13.tgz#cd29d7f8fc130602433c05b7e39c14f669aebc06" - integrity sha512-xnMKsqBtq1BZUPI2gC7/o5Le7mFP5KDZLm+tCZV08RNDkNs9o60Ijf+j8s02nLmo/IV8i0U5+dqh8SC15oUf+w== - dependencies: - "@eclipse-glsp/cli" "2.7.0-next.13+90c0040" - "@eclipse-glsp/config" "2.7.0-next.13+90c0040" - "@eclipse-glsp/config-test" "2.7.0-next.13+90c0040" - -"@eclipse-glsp/eslint-config@2.7.0-next.13+90c0040": - version "2.7.0-next.13" - resolved "https://registry.yarnpkg.com/@eclipse-glsp/eslint-config/-/eslint-config-2.7.0-next.13.tgz#8d5ff34651a397afe5a491ba53be94ca40bb440c" - integrity sha512-qaUVaG4ymXXuyPr+7UTq2X5Dbh6Jp4wIYkCwN8YxDZtPdoZKWo5AW7wNTUOpG+P1z8ishSIQI7ZzHpkyqDMXaw== - -"@eclipse-glsp/mocha-config@2.7.0-next.13+90c0040": - version "2.7.0-next.13" - resolved "https://registry.yarnpkg.com/@eclipse-glsp/mocha-config/-/mocha-config-2.7.0-next.13.tgz#0faa95ca78b61e999d37b512b26f56a0ec3f9dc8" - integrity sha512-oxTPOmn45TYJvG6GszXy9BZHRJprZuFDWRYRHhUasFd4HPNIGrchyxtxeXm8qRaVhR0bbKKTfvu14RcWx4AuCQ== - -"@eclipse-glsp/nyc-config@2.7.0-next.13+90c0040": - version "2.7.0-next.13" - resolved "https://registry.yarnpkg.com/@eclipse-glsp/nyc-config/-/nyc-config-2.7.0-next.13.tgz#19e8e28e3914a0a20008136906267600f02dc632" - integrity sha512-HY6AN3eiIM5gHCGnWnKAlGRoSh8JV1094caXw6aKfJ9BlD2lLFfK/G97Z//rlyTB2I+CIGyHPo46m65qh0FPKQ== - -"@eclipse-glsp/prettier-config@2.7.0-next.13+90c0040": - version "2.7.0-next.13" - resolved "https://registry.yarnpkg.com/@eclipse-glsp/prettier-config/-/prettier-config-2.7.0-next.13.tgz#b819c1c384ca9a0cc662727f8fa15b35ab488c59" - integrity sha512-8nbWre4W/t6gbVbVE7yz1Cf883pRA1rnWlkgtMF9OtwfhzEBeJxgpFWIe6I+nBlU8WQ+qxdZ0GOge0eJNMTSfg== + version "2.7.0-next.16" + resolved "https://registry.yarnpkg.com/@eclipse-glsp/dev/-/dev-2.7.0-next.16.tgz#785db21ec9a5105cdc0b1b63ba2c2d4634649e25" + integrity sha512-pIMZeAa55t7FQZcbZyazRJ/KI5ssniESjbWqgMMnj9i/Pxnge9mPAUkGuGg20kPMDYfKIQC+RjfvuNQ+TGdGtg== + dependencies: + "@eclipse-glsp/cli" "2.7.0-next.16+f609ce3" + "@eclipse-glsp/config" "2.7.0-next.16+f609ce3" + "@eclipse-glsp/config-test" "2.7.0-next.16+f609ce3" + +"@eclipse-glsp/eslint-config@2.7.0-next.16+f609ce3": + version "2.7.0-next.16" + resolved "https://registry.yarnpkg.com/@eclipse-glsp/eslint-config/-/eslint-config-2.7.0-next.16.tgz#5ee896ce9a114db13f55e033c45d4c33fc2fc568" + integrity sha512-nG0xf7CF+O+xMHrUJ3PVjZQwJDj4knzB0CkD5kcJdlHjT99tmdM6sweaLH6sWfdW4uokAVZiRXg8HviLJa26rg== + +"@eclipse-glsp/mocha-config@2.7.0-next.16+f609ce3": + version "2.7.0-next.16" + resolved "https://registry.yarnpkg.com/@eclipse-glsp/mocha-config/-/mocha-config-2.7.0-next.16.tgz#aff8367afe75063038eece41298a37077ffd501b" + integrity sha512-GP8/M2jB9hhiy1/V1KgAq+HoaEInuAXAhjR3Q38Uair6BQ83sAP217KxQsFU+ZZlZhU1zf2QP8am8fqc6tpd6w== + +"@eclipse-glsp/nyc-config@2.7.0-next.16+f609ce3": + version "2.7.0-next.16" + resolved "https://registry.yarnpkg.com/@eclipse-glsp/nyc-config/-/nyc-config-2.7.0-next.16.tgz#027e749b959724100aaf717b569509e0b3e42a3c" + integrity sha512-NTuy7c2LoANRuVkWyN2hNzG284KF380Qj9kpKhtLJFt/5qwZzbjKrwfBd0BmynG0xjU3gHYylo/ZCxx/yKgOsg== + +"@eclipse-glsp/prettier-config@2.7.0-next.16+f609ce3": + version "2.7.0-next.16" + resolved "https://registry.yarnpkg.com/@eclipse-glsp/prettier-config/-/prettier-config-2.7.0-next.16.tgz#13833dc1cc848f51b22ba54ba1b72847df074cf9" + integrity sha512-08Tgqwfj01nrzY32pz2ohcimpE33O75k6bK67LDUFZvgeundUuPCzbMrtYyY0bo25UvNCpN9GAMkA5gVCHmZdg== dependencies: prettier-plugin-packagejson "~2.4.6" "@eclipse-glsp/protocol@next": - version "2.7.0-next.12" - resolved "https://registry.yarnpkg.com/@eclipse-glsp/protocol/-/protocol-2.7.0-next.12.tgz#af91ae8a48ef8772a189537ada77949995710b6a" - integrity sha512-POB7bGy24sjQ5tPL4XrYZJeupLJNvDhI5jYFwKr3+wx86PZDstVz8bWydpHOa346Q8m4KKwU0vMJS+XMmyEFxQ== + version "2.7.0-next.25" + resolved "https://registry.yarnpkg.com/@eclipse-glsp/protocol/-/protocol-2.7.0-next.25.tgz#dda9362174c68bf1c3e78e50e08fde11679edbbb" + integrity sha512-vFDImSJEaAF1S2PYdwOGJCJjmhZGmzYpq8eYSH93E1/tDEsg5A1tQADx6fx9WlSWTXXno9SZBBS1AIc/fnqFPw== dependencies: sprotty-protocol "1.4.0" - uuid "~10.0.0" + uuid "~14.0.0" vscode-jsonrpc "8.2.0" -"@eclipse-glsp/ts-config@2.7.0-next.13+90c0040": - version "2.7.0-next.13" - resolved "https://registry.yarnpkg.com/@eclipse-glsp/ts-config/-/ts-config-2.7.0-next.13.tgz#9891d9dbe75dcaf099de44186b4dba6af836bb8d" - integrity sha512-EQEoM982uyxRQyjnnX5AVs54WnhcG7DiDu4cGF+2PWNZRn609wCgxYGz/SXEQCBZMJ2zGhsLX7AVmwEQZHn7Dw== +"@eclipse-glsp/ts-config@2.7.0-next.16+f609ce3": + version "2.7.0-next.16" + resolved "https://registry.yarnpkg.com/@eclipse-glsp/ts-config/-/ts-config-2.7.0-next.16.tgz#1a7fb7a382267940023910a801d5cae29c8898b1" + integrity sha512-rjqFib1tf4jSYhxxFloArQXUsfS1ekME/tdg1UfDQOQ/wwaztkIBBSz4IasXMz411fmFHuVQigCdmY5eNxZMQw== "@emnapi/core@^1.1.0": version "1.6.0" @@ -420,6 +420,11 @@ "@eslint/core" "^0.17.0" levn "^0.4.1" +"@hono/node-server@^1.19.9": + version "1.19.14" + resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.14.tgz#e30f844bc77e3ce7be442aac3b1f73ad8b58d181" + integrity sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw== + "@humanfs/core@^0.19.1": version "0.19.1" resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" @@ -808,6 +813,29 @@ yargs "17.7.2" yargs-parser "21.1.1" +"@modelcontextprotocol/sdk@^1.29.0": + version "1.29.0" + resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz#79786d8b525e269de850ac82b1f1f757f3915f44" + integrity sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ== + dependencies: + "@hono/node-server" "^1.19.9" + ajv "^8.17.1" + ajv-formats "^3.0.1" + content-type "^1.0.5" + cors "^2.8.5" + cross-spawn "^7.0.5" + eventsource "^3.0.2" + eventsource-parser "^3.0.0" + express "^5.2.1" + express-rate-limit "^8.2.1" + hono "^4.11.4" + jose "^6.1.3" + json-schema-typed "^8.0.2" + pkce-challenge "^5.0.0" + raw-body "^3.0.0" + zod "^3.25 || ^4.0" + zod-to-json-schema "^3.25.1" + "@napi-rs/wasm-runtime@0.2.4": version "0.2.4" resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.4.tgz#d27788176f250d86e498081e3c5ff48a17606918" @@ -1448,11 +1476,26 @@ dependencies: tslib "^2.4.0" +"@types/body-parser@*": + version "1.19.6" + resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.6.tgz#1859bebb8fd7dac9918a45d54c1971ab8b5af474" + integrity sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g== + dependencies: + "@types/connect" "*" + "@types/node" "*" + "@types/chai@^4.3.7": version "4.3.16" resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.16.tgz#b1572967f0b8b60bf3f87fe1d854a5604ea70c82" integrity sha512-PatH4iOdyh3MyWtmHVFXLWCCIhUbopaltqddG9BzB+gMIzee2MJrvd+jouii9Z3wzQJruGWAm7WOMjgfG8hQlQ== +"@types/connect@*": + version "3.4.38" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858" + integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== + dependencies: + "@types/node" "*" + "@types/eslint-scope@^3.7.3": version "3.7.7" resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.7.tgz#3108bd5f18b0cdb277c867b3dd449c9ed7079ac5" @@ -1479,6 +1522,30 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== +"@types/express-serve-static-core@^5.0.0": + version "5.1.1" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz#1a77faffee9572d39124933259be2523837d7eaa" + integrity sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + "@types/send" "*" + +"@types/express@^5.0.6": + version "5.0.6" + resolved "https://registry.yarnpkg.com/@types/express/-/express-5.0.6.tgz#2d724b2c990dcb8c8444063f3580a903f6d500cc" + integrity sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^5.0.0" + "@types/serve-static" "^2" + +"@types/http-errors@*": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.5.tgz#5b749ab2b16ba113423feb1a64a95dcd30398472" + integrity sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg== + "@types/json-schema@*", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.8": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" @@ -1523,6 +1590,31 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901" integrity sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA== +"@types/qs@*": + version "6.15.0" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.15.0.tgz#963ab61779843fe910639a50661b48f162bc7f79" + integrity sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow== + +"@types/range-parser@*": + version "1.2.7" + resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" + integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== + +"@types/send@*": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@types/send/-/send-1.2.1.tgz#6a784e45543c18c774c049bff6d3dbaf045c9c74" + integrity sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ== + dependencies: + "@types/node" "*" + +"@types/serve-static@^2": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-2.2.0.tgz#d4a447503ead0d1671132d1ab6bd58b805d8de6a" + integrity sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ== + dependencies: + "@types/http-errors" "*" + "@types/node" "*" + "@types/sinon@^10.0.19": version "10.0.20" resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.20.tgz#f1585debf4c0d99f9938f4111e5479fb74865146" @@ -1924,6 +2016,14 @@ abbrev@^3.0.0: resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-3.0.1.tgz#8ac8b3b5024d31464fe2a5feeea9f4536bf44025" integrity sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg== +accepts@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" + integrity sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng== + dependencies: + mime-types "^3.0.0" + negotiator "^1.0.0" + acorn-import-assertions@^1.9.0: version "1.9.0" resolved "https://registry.yarnpkg.com/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz#507276249d684797c84e0734ef84860334cfb1ac" @@ -1967,6 +2067,13 @@ aggregate-error@^3.0.0: clean-stack "^2.0.0" indent-string "^4.0.0" +ajv-formats@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-3.0.1.tgz#3d5dc762bca17679c3c2ea7e90ad6b7532309578" + integrity sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ== + dependencies: + ajv "^8.0.0" + ajv-keywords@^3.5.2: version "3.5.2" resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" @@ -1982,6 +2089,16 @@ ajv@^6.12.4, ajv@^6.12.5: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^8.0.0, ajv@^8.17.1: + version "8.20.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.20.0.tgz#304b3636add88ba7d936760dd50ece006dea95f9" + integrity sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + ansi-colors@4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" @@ -2162,6 +2279,21 @@ bl@^4.0.3: inherits "^2.0.4" readable-stream "^3.4.0" +body-parser@^2.2.1: + version "2.2.2" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.2.2.tgz#1a32cdb966beaf68de50a9dfbe5b58f83cb8890c" + integrity sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA== + dependencies: + bytes "^3.1.2" + content-type "^1.0.5" + debug "^4.4.3" + http-errors "^2.0.0" + iconv-lite "^0.7.0" + on-finished "^2.4.1" + qs "^6.14.1" + raw-body "^3.0.1" + type-is "^2.0.1" + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -2224,6 +2356,11 @@ byte-size@8.1.1: resolved "https://registry.yarnpkg.com/byte-size/-/byte-size-8.1.1.tgz#3424608c62d59de5bfda05d31e0313c6174842ae" integrity sha512-tUkzZWK0M/qdoLEqikxBWe4kumyuwjl3HO6zHTr4yEI23EojPtLYXdG1+AQY7MN0cGyNDvEaJ8wiYQm6P2bPxg== +bytes@^3.1.2, bytes@~3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + cacache@^19.0.1: version "19.0.1" resolved "https://registry.yarnpkg.com/cacache/-/cacache-19.0.1.tgz#3370cc28a758434c85c2585008bd5bdcff17d6cd" @@ -2277,6 +2414,14 @@ call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: es-errors "^1.3.0" function-bind "^1.1.2" +call-bound@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -2625,6 +2770,16 @@ console-control-strings@^1.1.0: resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ== +content-disposition@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.1.0.tgz#f3db789c752d45564cc7e9e1e0b31790d4a38e17" + integrity sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g== + +content-type@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + conventional-changelog-angular@7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz#5eec8edbff15aa9b1680a8dcfbd53e2d7eb2ba7a" @@ -2708,11 +2863,29 @@ convert-source-map@^2.0.0: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== +cookie-signature@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793" + integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== + +cookie@^0.7.1: + version "0.7.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" + integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== + core-util-is@~1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== +cors@^2.8.5: + version "2.8.6" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.6.tgz#ff5dd69bd95e547503820d29aba4f8faf8dfec96" + integrity sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw== + dependencies: + object-assign "^4" + vary "^1" + cosmiconfig@9.0.0: version "9.0.0" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-9.0.0.tgz#34c3fc58287b915f3ae905ab6dc3de258b55ad9d" @@ -2737,7 +2910,7 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" -cross-spawn@^7.0.6: +cross-spawn@^7.0.5, cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== @@ -2780,7 +2953,7 @@ debug@4, debug@4.3.4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, de dependencies: ms "2.1.2" -debug@^4.4.1, debug@^4.4.3: +debug@^4.4.0, debug@^4.4.1, debug@^4.4.3: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -2846,6 +3019,11 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== +depd@^2.0.0, depd@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + deprecation@^2.0.0: version "2.3.1" resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919" @@ -2926,6 +3104,11 @@ eastasianwidth@^0.2.0: resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + ejs@^3.1.7: version "3.1.10" resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.10.tgz#69ab8358b14e896f80cc39e62087b88500c3ac3b" @@ -2958,6 +3141,11 @@ enabled@2.0.x: resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2" integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ== +encodeurl@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" + integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + encoding@^0.1.13: version "0.1.13" resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" @@ -3058,6 +3246,11 @@ escalade@^3.1.1, escalade@^3.1.2: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.2.tgz#54076e9ab29ea5bf3d8f1ed62acffbb88272df27" integrity sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA== +escape-html@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + escape-string-regexp@4.0.0, escape-string-regexp@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" @@ -3233,6 +3426,11 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== +etag@^1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + eventemitter3@^4.0.4: version "4.0.7" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" @@ -3243,6 +3441,18 @@ events@^3.2.0: resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== +eventsource-parser@^3.0.0, eventsource-parser@^3.0.1: + version "3.0.8" + resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-3.0.8.tgz#1c792503e4080455d00701bb1f7a1d60734d0e58" + integrity sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ== + +eventsource@^3.0.2: + version "3.0.7" + resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-3.0.7.tgz#1157622e2f5377bb6aef2114372728ba0c156989" + integrity sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA== + dependencies: + eventsource-parser "^3.0.1" + execa@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/execa/-/execa-5.0.0.tgz#4029b0007998a841fbd1032e5f4de86a3c1e3376" @@ -3263,6 +3473,47 @@ exponential-backoff@^3.1.1: resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.1.tgz#64ac7526fe341ab18a39016cd22c787d01e00bf6" integrity sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw== +express-rate-limit@^8.2.1: + version "8.4.1" + resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-8.4.1.tgz#15a769dbe7b97f94581ed0db2bcead644753574f" + integrity sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw== + dependencies: + ip-address "10.1.0" + +express@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/express/-/express-5.2.1.tgz#8f21d15b6d327f92b4794ecf8cb08a72f956ac04" + integrity sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw== + dependencies: + accepts "^2.0.0" + body-parser "^2.2.1" + content-disposition "^1.0.0" + content-type "^1.0.5" + cookie "^0.7.1" + cookie-signature "^1.2.1" + debug "^4.4.0" + depd "^2.0.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + etag "^1.8.1" + finalhandler "^2.1.0" + fresh "^2.0.0" + http-errors "^2.0.0" + merge-descriptors "^2.0.0" + mime-types "^3.0.0" + on-finished "^2.4.1" + once "^1.4.0" + parseurl "^1.3.3" + proxy-addr "^2.0.7" + qs "^6.14.0" + range-parser "^1.2.1" + router "^2.2.0" + send "^1.1.0" + serve-static "^2.2.0" + statuses "^2.0.1" + type-is "^2.0.1" + vary "^1.1.2" + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -3294,6 +3545,11 @@ fast-levenshtein@^2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== +fast-uri@^3.0.1: + version "3.1.0" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.0.tgz#66eecff6c764c0df9b762e62ca7edcfb53b4edfa" + integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA== + fastest-levenshtein@^1.0.12: version "1.0.16" resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5" @@ -3344,6 +3600,18 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" +finalhandler@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-2.1.1.tgz#a2c517a6559852bcdb06d1f8bd7f51b68fad8099" + integrity sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA== + dependencies: + debug "^4.4.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + on-finished "^2.4.1" + parseurl "^1.3.3" + statuses "^2.0.1" + find-cache-dir@^3.2.0: version "3.3.2" resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b" @@ -3439,6 +3707,16 @@ form-data@^4.0.4: hasown "^2.0.2" mime-types "^2.1.12" +forwarded@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + +fresh@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4" + integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== + fromentries@^1.2.0: version "1.3.2" resolved "https://registry.yarnpkg.com/fromentries/-/fromentries-1.3.2.tgz#e4bca6808816bf8f93b52750f1127f5a6fd86e3a" @@ -3509,7 +3787,7 @@ get-func-name@^2.0.1, get-func-name@^2.0.2: resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.2.tgz#0d7cf20cd13fda808669ffa88f4ffc7a3943fc41" integrity sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ== -get-intrinsic@^1.2.6: +get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== @@ -3807,6 +4085,11 @@ he@1.2.0: resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== +hono@^4.11.4: + version "4.12.15" + resolved "https://registry.yarnpkg.com/hono/-/hono-4.12.15.tgz#50302aae9a2b8ae6e5a1bab62e722f2259f9d0fb" + integrity sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg== + hosted-git-info@^2.1.4: version "2.8.9" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" @@ -3843,6 +4126,17 @@ http-cache-semantics@^4.1.1: resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== +http-errors@^2.0.0, http-errors@^2.0.1, http-errors@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" + integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== + dependencies: + depd "~2.0.0" + inherits "~2.0.4" + setprototypeof "~1.2.0" + statuses "~2.0.2" + toidentifier "~1.0.1" + http-proxy-agent@^7.0.0: version "7.0.2" resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e" @@ -3878,6 +4172,13 @@ iconv-lite@^0.7.0: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" +iconv-lite@~0.7.0: + version "0.7.2" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.2.tgz#d0bdeac3f12b4835b7359c2ad89c422a4d1cc72e" + integrity sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + ieee754@^1.1.13: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -3939,7 +4240,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: +inherits@2, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3, inherits@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -3993,11 +4294,21 @@ inversify@^6.1.3: "@inversifyjs/common" "1.4.0" "@inversifyjs/core" "1.3.5" +ip-address@10.1.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.1.0.tgz#d8dcffb34d0e02eb241427444a6e23f5b0595aa4" + integrity sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q== + ip-address@^10.0.1: version "10.0.1" resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.0.1.tgz#a8180b783ce7788777d796286d61bce4276818ed" integrity sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA== +ipaddr.js@1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" @@ -4100,6 +4411,11 @@ is-plain-object@^2.0.4: dependencies: isobject "^3.0.1" +is-promise@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" + integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== + is-ssh@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/is-ssh/-/is-ssh-1.4.0.tgz#4f8220601d2839d8fa624b3106f8e8884f01b8b2" @@ -4271,6 +4587,11 @@ jest-worker@^27.4.5: merge-stream "^2.0.0" supports-color "^8.0.0" +jose@^6.1.3: + version "6.2.3" + resolved "https://registry.yarnpkg.com/jose/-/jose-6.2.3.tgz#0975197ad973251221c658a3cddc4b951a250c2d" + integrity sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw== + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -4328,6 +4649,16 @@ json-schema-traverse@^0.4.1: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + +json-schema-typed@^8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz#e98ee7b1899ff4a184534d1f167c288c66bbeff4" + integrity sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA== + json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" @@ -4735,6 +5066,11 @@ md5@^2.3.0: crypt "0.0.2" is-buffer "~1.1.6" +media-typer@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.0.tgz#6ab74b8f2d3320f2064b2a87a38e7931ff3a5561" + integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== + meow@^8.1.2: version "8.1.2" resolved "https://registry.yarnpkg.com/meow/-/meow-8.1.2.tgz#bcbe45bda0ee1729d350c03cffc8395a36c4e897" @@ -4752,6 +5088,11 @@ meow@^8.1.2: type-fest "^0.18.0" yargs-parser "^20.2.3" +merge-descriptors@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz#ea922f660635a2249ee565e0449f951e6b603808" + integrity sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g== + merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" @@ -4775,6 +5116,11 @@ mime-db@1.52.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== +mime-db@^1.54.0: + version "1.54.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5" + integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== + mime-types@^2.1.12, mime-types@^2.1.27: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" @@ -4782,6 +5128,13 @@ mime-types@^2.1.12, mime-types@^2.1.27: dependencies: mime-db "1.52.0" +mime-types@^3.0.0, mime-types@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-3.0.2.tgz#39002d4182575d5af036ffa118100f2524b2e2ab" + integrity sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A== + dependencies: + mime-db "^1.54.0" + mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" @@ -5326,6 +5679,23 @@ nyc@^15.1.0: test-exclude "^6.0.0" yargs "^15.0.2" +object-assign@^4: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +object-inspect@^1.13.3, object-inspect@^1.13.4: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + +on-finished@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + once@^1.3.0, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -5604,6 +5974,11 @@ parse-url@^8.1.0: dependencies: parse-path "^7.0.0" +parseurl@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + path-exists@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" @@ -5650,6 +6025,11 @@ path-to-regexp@^6.2.1: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.2.2.tgz#324377a83e5049cbecadc5554d6a63a9a4866b36" integrity sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw== +path-to-regexp@^8.0.0: + version "8.4.2" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz#795c420c4f7ca45c5b887366f622ee0c9852cccd" + integrity sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA== + path-type@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f" @@ -5702,6 +6082,11 @@ pify@^4.0.1: resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231" integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== +pkce-challenge@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/pkce-challenge/-/pkce-challenge-5.0.1.tgz#3b4446865b17b1745e9ace2016a31f48ddf6230d" + integrity sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ== + pkg-dir@^4.1.0, pkg-dir@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" @@ -5796,6 +6181,14 @@ protocols@^2.0.0, protocols@^2.0.1: resolved "https://registry.yarnpkg.com/protocols/-/protocols-2.0.1.tgz#8f155da3fc0f32644e83c5782c8e8212ccf70a86" integrity sha512-/XJ368cyBJ7fzLMwLKv1e4vLxOju2MNAIokcr7meSaNcVbWz/CPcW22cP04mwxOErdA5mwjA8Q6w/cdAQxVn7Q== +proxy-addr@^2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" + integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== + dependencies: + forwarded "0.2.0" + ipaddr.js "1.9.1" + proxy-from-env@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" @@ -5806,6 +6199,13 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== +qs@^6.14.0, qs@^6.14.1: + version "6.15.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.1.tgz#bdb55aed06bfac257a90c44a446a73fba5575c8f" + integrity sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg== + dependencies: + side-channel "^1.1.0" + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" @@ -5823,6 +6223,21 @@ randombytes@^2.1.0: dependencies: safe-buffer "^5.1.0" +range-parser@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" + integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + +raw-body@^3.0.0, raw-body@^3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.2.tgz#3e3ada5ae5568f9095d84376fd3a49b8fb000a51" + integrity sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA== + dependencies: + bytes "~3.1.2" + http-errors "~2.0.1" + iconv-lite "~0.7.0" + unpipe "~1.0.0" + react-is@^18.3.1: version "18.3.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" @@ -5955,6 +6370,11 @@ require-directory@^2.1.1: resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + require-main-filename@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" @@ -6035,6 +6455,17 @@ rimraf@^5.0.5: dependencies: glob "^10.3.7" +router@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/router/-/router-2.2.0.tgz#019be620b711c87641167cc79b99090f00b146ef" + integrity sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ== + dependencies: + debug "^4.4.0" + depd "^2.0.0" + is-promise "^4.0.0" + parseurl "^1.3.3" + path-to-regexp "^8.0.0" + run-async@^4.0.5: version "4.0.6" resolved "https://registry.yarnpkg.com/run-async/-/run-async-4.0.6.tgz#d53b86acb71f42650fe23de2b3c1b6b6b34b9294" @@ -6120,6 +6551,23 @@ semver@^7.7.2: resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== +send@^1.1.0, send@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/send/-/send-1.2.1.tgz#9eab743b874f3550f40a26867bf286ad60d3f3ed" + integrity sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ== + dependencies: + debug "^4.4.3" + encodeurl "^2.0.0" + escape-html "^1.0.3" + etag "^1.8.1" + fresh "^2.0.0" + http-errors "^2.0.1" + mime-types "^3.0.2" + ms "^2.1.3" + on-finished "^2.4.1" + range-parser "^1.2.1" + statuses "^2.0.2" + serialize-javascript@6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.0.tgz#efae5d88f45d7924141da8b5c3a7a7e663fefeb8" @@ -6134,11 +6582,26 @@ serialize-javascript@^6.0.1: dependencies: randombytes "^2.1.0" +serve-static@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-2.2.1.tgz#7f186a4a4e5f5b663ad7a4294ff1bf37cf0e98a9" + integrity sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw== + dependencies: + encodeurl "^2.0.0" + escape-html "^1.0.3" + parseurl "^1.3.3" + send "^1.2.0" + set-blocking@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== +setprototypeof@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + shallow-clone@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3" @@ -6163,6 +6626,46 @@ shell-quote@^1.8.1: resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.1.tgz#6dbf4db75515ad5bac63b4f1894c3a154c766680" integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA== +side-channel-list@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.1.tgz#c2e0b5a14a540aebee3bbc6c3f8666cc9b509127" + integrity sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + +side-channel@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + signal-exit@3.0.7, signal-exit@^3.0.2, signal-exit@^3.0.3: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" @@ -6384,6 +6887,11 @@ stack-trace@0.0.x: resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" integrity sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg== +statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" + integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== + "string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" @@ -6637,6 +7145,11 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +toidentifier@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + tree-kill@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" @@ -6745,6 +7258,15 @@ type-fest@^0.8.0, type-fest@^0.8.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== +type-is@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.0.1.tgz#64f6cf03f92fce4015c2b224793f6bdd4b068c97" + integrity sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw== + dependencies: + content-type "^1.0.5" + media-typer "^1.1.0" + mime-types "^3.0.0" + typedarray-to-buffer@^3.1.5: version "3.1.5" resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" @@ -6816,6 +7338,11 @@ universalify@^2.0.0: resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== +unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + unrs-resolver@^1.7.11, unrs-resolver@^1.9.2: version "1.11.1" resolved "https://registry.yarnpkg.com/unrs-resolver/-/unrs-resolver-1.11.1.tgz#be9cd8686c99ef53ecb96df2a473c64d304048a9" @@ -6878,10 +7405,10 @@ uuid@^8.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== -uuid@~10.0.0: - version "10.0.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" - integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== +uuid@~14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-14.0.0.tgz#0af883220163d264ffe0c084f6b8a89b9666966d" + integrity sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg== v8-compile-cache-lib@^3.0.1: version "3.0.1" @@ -6901,6 +7428,11 @@ validate-npm-package-name@6.0.2, validate-npm-package-name@^6.0.0, validate-npm- resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-6.0.2.tgz#4e8d2c4d939975a73dd1b7a65e8f08d44c85df96" integrity sha512-IUoow1YUtvoBBC06dXs8bR8B9vuA3aJfmQNKMoaPG/OFsPmoQvw8xh+6Ye25Gx9DQhoEom3Pcu9MKHerm/NpUQ== +vary@^1, vary@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + vscode-jsonrpc@8.2.0: version "8.2.0" resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz#f43dfa35fb51e763d17cd94dcca0c9458f35abf9" @@ -7288,3 +7820,13 @@ yoctocolors-cjs@^2.1.2: version "2.1.3" resolved "https://registry.yarnpkg.com/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz#7e4964ea8ec422b7a40ac917d3a344cfd2304baa" integrity sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw== + +zod-to-json-schema@^3.25.1: + version "3.25.2" + resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz#3fa799a7badd554541472fb65843fdc460b2e5aa" + integrity sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA== + +"zod@^3.25 || ^4.0": + version "4.3.6" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a" + integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==