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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@
- the web socket event carries the upgrade request next to the socket, which is the only place its headers and query are still available
- [launch] Release the server socket again when a restarted launcher is shut down [#150](https://github.com/eclipse-glsp/glsp-server-node/pull/150)
- `WebSocketServerLauncher` now also closes the HTTP server it mounts on, which `ws` leaves listening because it did not create it
- [mcp] Return results that satisfy the declared output schema, so a `create-edges` dry run returns its verdicts instead of an output-validation error [#152](https://github.com/eclipse-glsp/glsp-server-node/pull/152)
- [mcp] Stop reporting success for work that was not done [#152](https://github.com/eclipse-glsp/glsp-server-node/pull/152)
- `save-model` writes to an explicit `fileUri` even when the command stack is clean, instead of skipping a save-as
- `undo` and `redo` report how many commands they applied, not how many were requested
- `modify-nodes` and `modify-edges` report an error for entries that request no change, instead of counting them as modified
- [mcp] Reject unknown element ids in `validate-diagram` and `set-view`, which previously dropped them and returned an empty, clean-looking result [#152](https://github.com/eclipse-glsp/glsp-server-node/pull/152)
- [mcp] Keep tools out of the MCP catalog when no diagram type supports them, so `layout` is no longer advertised without a bound `LayoutEngine` [#152](https://github.com/eclipse-glsp/glsp-server-node/pull/152)
- the new `isSupportedByDiagramType()` hook on the diagram tool and resource bases covers statically bound dependencies; `canRegister()` keeps gating capabilities of the connected GLSP client
- [mcp] Align tool schemas and descriptions with what the tools actually accept and apply [#152](https://github.com/eclipse-glsp/glsp-server-node/pull/152)
- `set-selection` accepts the documented empty-array form for clearing the selection, and `undo` / `redo` require integer counts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is source-file hygiene rather than user-facing behaviour, adopters see no difference. I'd drop it from the changelog and keep it in the commit message.

- `modify-nodes` positions are parent-relative and `create-nodes` positions absolute, matching the dispatched operations
- the `create-*` tools echo the created element when its type differs from the requested `elementTypeId`, instead of reporting a creation failure

### Potentially Breaking Changes

Expand All @@ -17,6 +29,10 @@
- `applyElementAndBounds`, `applyAlignment` and `applyRoute` no longer throw for an element the index cannot resolve, they report it as not applied. `applyRoutingPoints` stays strict.
- [launch] Launchers register what `shutdown` has to release in the new `GLSPServerLauncher.registerDisposables` hook, called once per launch, rather than in their constructor [#150](https://github.com/eclipse-glsp/glsp-server-node/pull/150)
- A custom launcher that pushes into `toDispose` from its constructor keeps compiling but loses that cleanup after the first `shutdown`, because `dispose` empties the collection. Move those registrations into an override of `registerDisposables`.
- [mcp] The MCP tool handler bases take an optional output type parameter, e.g. `AbstractMcpDiagramToolHandler<I, O>`, bound to the handler's declared `outputSchema` [#152](https://github.com/eclipse-glsp/glsp-server-node/pull/152)
- The parameter defaults, so a handler without an `outputSchema` is unaffected. A subclass that passes `success()` a payload not matching the overridden handler's output schema now fails to compile, instead of producing an error result at call time.
- [mcp] `modify-nodes` rejects `position` / `size` for elements that are not a `GNode`, which core's bounds handler silently ignored while the tool reported success [#152](https://github.com/eclipse-glsp/glsp-server-node/pull/152)
- Adopters who bind a bounds handler covering more element kinds override the guard in `ModifyNodesMcpToolHandler`.

## [v2.7.0 - 01/06/2026](https://github.com/eclipse-glsp/glsp-server-node/releases/tag/v2.7.0)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ export abstract class AbstractMcpDiagramModule extends GLSPModule {
binding.add(GetSelectionMcpToolHandler);
binding.add(SetSelectionMcpToolHandler);
binding.add(SetViewMcpToolHandler);
// Auto-skips at session-open via `canRegister()` when no `LayoutEngine` is bound.
// Auto-skips via `isSupportedByDiagramType()` when no `LayoutEngine` is bound.
binding.add(LayoutMcpToolHandler);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import { Logger, NullLogger } from '@eclipse-glsp/server';
import { CallToolResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types.js';
import { Container, ContainerModule, inject, injectable, optional } from 'inversify';
import { describe, expect, it } from 'vitest';
import { GLSPMcpServer } from './glsp-mcp-server';
import { DefaultMcpDiagramHandlerDispatcher, DiagramTypeCatalog } from './mcp-diagram-handler-dispatcher';
import { McpDiagramToolHandlerConstructor } from './mcp-tool-handler';

/**
* Tests the SDK-callback dispatch error path covered by `runWithToolErrorEnvelope`. The
Expand Down Expand Up @@ -112,6 +115,93 @@ class FakeStaticResourceHandlerCtor {
}
}

/** Stands in for a statically bound, optional dependency such as `LayoutEngine`. */
const OptionalDependency = Symbol('OptionalDependency');

@injectable()
class GatedToolHandler {
@inject(OptionalDependency) @optional() protected dependency?: unknown;

readonly name = 'gated-tool';
readonly description = 'Only supported when the optional dependency is bound';
readonly inputSchema = { shape: {}, strict: () => ({}) };

isSupportedByDiagramType(): boolean {
return this.dependency !== undefined;
}

toRegistrationConfig(): unknown {
return { description: this.description, inputSchema: {} };
}
}

/** Never bound, so resolving {@link UnresolvableToolHandler} through the container throws. */
const UnboundDependency = Symbol('UnboundDependency');

/** Declares the hook, so it is probed, but its required dependency has no binding. */
@injectable()
class UnresolvableToolHandler {
@inject(UnboundDependency) protected dependency: unknown;

readonly name = 'unresolvable-tool';
readonly description = 'Declares the support hook but cannot be constructed by the container';
readonly inputSchema = { shape: {}, strict: () => ({}) };

isSupportedByDiagramType(): boolean {
return false;
}

toRegistrationConfig(): unknown {
return { description: this.description, inputSchema: {} };
}
}

function harvestWith(bindDependency: boolean, constructors: unknown[] = [GatedToolHandler]): DefaultMcpDiagramHandlerDispatcher {
const diagramModule = new ContainerModule(bind => {
bind(McpDiagramToolHandlerConstructor).toConstantValue(constructors as McpDiagramToolHandlerConstructor[]);
if (bindDependency) {
bind(OptionalDependency).toConstantValue({});
}
});
const dispatcher = new DefaultMcpDiagramHandlerDispatcher();
(dispatcher as unknown as { serverContainer: Container }).serverContainer = new Container();
(dispatcher as unknown as { diagramModules: Map<string, ContainerModule[]> }).diagramModules = new Map([['test', [diagramModule]]]);
(dispatcher as unknown as { logger: Logger }).logger = new NullLogger();
dispatcher.harvest();
return dispatcher;
}

describe('DefaultMcpDiagramHandlerDispatcher · diagram-type support gate', () => {
it('registers the tool when the diagram type binds its dependency', () => {
const captured = new CapturingMcpServer();
harvestWith(true).registerAll(captured as unknown as GLSPMcpServer, false);

expect(captured.tools.has('gated-tool')).toBe(true);
});

it('keeps the tool out of the catalog when no diagram type supports it', () => {
const captured = new CapturingMcpServer();
harvestWith(false).registerAll(captured as unknown as GLSPMcpServer, false);

expect(captured.tools.has('gated-tool')).toBe(false);
});

it('fails open and registers a handler that declares the hook but cannot be resolved', () => {
const captured = new CapturingMcpServer();
harvestWith(false, [UnresolvableToolHandler]).registerAll(captured as unknown as GLSPMcpServer, false);

// `isSupportedByDiagramType` returns false, so it survives only because probing threw.
expect(captured.tools.has('unresolvable-tool')).toBe(true);
});

it('registers a duck-typed handler without the hook, without resolving it', () => {
const captured = new CapturingMcpServer();
harvestWith(false, [FakeToolHandlerCtor]).registerAll(captured as unknown as GLSPMcpServer, false);

expect(captured.tools.has('fake-tool')).toBe(true);
});
});

describe('DefaultMcpDiagramHandlerDispatcher · SDK-callback dispatch error envelope', () => {
it('tool callback returns isError envelope when sessionId is missing', async () => {
const dispatcher = makeDispatcher({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ import { McpRequestContext, NoopMcpRequestContext } from './mcp-request-context'
import { AbstractMcpDiagramResourceHandler, McpDiagramResourceHandlerConstructor, toParams } from './mcp-resource-handler';
import { BaseMcpDiagramToolHandler, McpDiagramToolHandlerConstructor } from './mcp-tool-handler';

/** The diagram-type support hook shared by the diagram-scope tool and resource handler bases. */
interface DiagramTypeSupportAware {
isSupportedByDiagramType(): boolean;
}

/**
* 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
Expand Down Expand Up @@ -97,11 +102,13 @@ export class DefaultMcpDiagramHandlerDispatcher implements McpDiagramHandlerDisp

/**
* Build the per-diagram-type catalog by inspecting each diagram type's module set. We don't
* have a real GLSP session yet — and we don't want one, because we only need the bound
* have a real GLSP session yet — and we don't want one, because we mostly need the bound
* constructor *lists*, not instances. So we spin up a throwaway child container per diagram
* type, load its modules plus a placeholder session module, and read out the multi-binding
* constants. No handler is instantiated; the temporary container is unbound immediately
* after.
* constants. The temporary container is unbound immediately after.
*
* Only handlers that override `isSupportedByDiagramType` are instantiated, so a handler with
* a `@postConstruct` sees this probe just because it opted into the support gate.
*
* The placeholder session module is bound with the synthetic {@link TEMPORARY_CLIENT_ID} so
* any session-scoped `@inject(ClientId)` in module-load wiring resolves cleanly. Diagram
Expand All @@ -123,15 +130,59 @@ export class DefaultMcpDiagramHandlerDispatcher implements McpDiagramHandlerDisp
for (const [diagramType, modules] of this.diagramModules) {
const tempContainer = this.serverContainer.createChild();
tempContainer.load(...modules, placeholderSessionModule);
const tools = getConstructorList<McpDiagramToolHandlerConstructor>(tempContainer, McpDiagramToolHandlerConstructor);
const resources = getConstructorList<McpDiagramResourceHandlerConstructor>(tempContainer, McpDiagramResourceHandlerConstructor);
const tools = this.filterSupported(
tempContainer,
getConstructorList<McpDiagramToolHandlerConstructor>(tempContainer, McpDiagramToolHandlerConstructor),
diagramType,
BaseMcpDiagramToolHandler.prototype.isSupportedByDiagramType
);
const resources = this.filterSupported(
tempContainer,
getConstructorList<McpDiagramResourceHandlerConstructor>(tempContainer, McpDiagramResourceHandlerConstructor),
diagramType,
AbstractMcpDiagramResourceHandler.prototype.isSupportedByDiagramType
);
const prompts = getConstructorList<McpDiagramPromptHandlerConstructor>(tempContainer, McpDiagramPromptHandlerConstructor);
tempContainer.unbindAll();
catalogs.push({ diagramType, toolConstructors: tools, resourceConstructors: resources, promptConstructors: prompts });
}
this.diagramCatalogs = catalogs;
}

/**
* Drop constructors whose handler reports the diagram type can't support it, so they never
* reach the MCP catalog. A handler that overrides the hook is resolved against the harvest
* container, where `@optional()` dependencies reflect the diagram type's real bindings; one
* that inherits `defaultHook` or doesn't declare the hook is kept without being instantiated.
*
* Fails open: a constructor that cannot be resolved here stays in the catalog.
*/
protected filterSupported<C extends interfaces.Newable<DiagramTypeSupportAware>>(
container: Container,
constructors: C[],
diagramType: string,
defaultHook: DiagramTypeSupportAware['isSupportedByDiagramType']
): C[] {
return constructors.filter(constructor => {
// A duck-typed handler may not carry the hook at all; skip it rather than resolving it
// only to call a method that isn't there.
const hook = constructor.prototype.isSupportedByDiagramType;
if (typeof hook !== 'function' || hook === defaultHook) {
return true;
}
try {
if (container.resolve(constructor).isSupportedByDiagramType()) {
return true;
}
this.logger.debug(`Diagram type '${diagramType}' does not support MCP handler '${constructor.name}'; not registering.`);
return false;
} catch (err: unknown) {
this.logger.debug(`Could not probe MCP handler '${constructor.name}' for diagram type '${diagramType}'; registering.`, err);
return true;
}
});
}

/** 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);
Expand Down
3 changes: 3 additions & 0 deletions packages/server-mcp/src/common/server/mcp-input-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export const elementId = z.string();
/** One or more element ids. Empty arrays are rejected. */
export const elementIds = z.array(z.string()).min(1);

/** Zero or more element ids, for tools where an empty array is itself meaningful (e.g. "select nothing"). */
export const elementIdsAllowingEmpty = z.array(z.string());

/** Cartesian position used by node-creation / -modification tools. */
export const position = z
.object({
Expand Down
14 changes: 13 additions & 1 deletion packages/server-mcp/src/common/server/mcp-resource-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,19 @@ export abstract class AbstractMcpDiagramResourceHandler<
return requestActionOrFail(this.actionDispatcher, request, timeoutMs, label);
}

/** Override to opt out of registration when a runtime dependency is missing. Default: `true`. */
/**
* Whether the diagram type can support this resource at all — see
* {@link BaseMcpDiagramToolHandler.isSupportedByDiagramType}. Returning `false` keeps the
* resource out of the MCP catalog.
*/
isSupportedByDiagramType(): boolean {
return true;
}

/**
* Override to opt out when a per-session dependency is missing. Default: `true`. Gates the
* per-GLSP-session registry only — see {@link BaseMcpDiagramToolHandler.canRegister}.
*/
canRegister(): boolean {
return true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ const matrix: Array<{
name: 'modify-nodes',
Constructor: ModifyNodesMcpToolHandler,
schema: ModifyNodesOutputSchema,
sample: { modifiedNodes: [{ id: 'n1', elementTypeId: 'node:foo' }], dispatchedCommands: 1, warnings: [] }
sample: { modifiedNodes: [{ id: 'n1', elementTypeId: 'node:foo' }], dispatchedCommands: 1, errors: [], warnings: [] }
},
{
name: 'modify-edges',
Expand Down
Loading