diff --git a/.gitignore b/.gitignore index 86adcc8..68b9336 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ wf-glsp-server-node.js wf-glsp-server-node.js.map wf-glsp-server-webworker.js wf-glsp-server-webworker.js.map +examples/workflow-server-bundled-web/mcp-service-worker.js +examples/workflow-server-bundled-web/index.bundle.js +examples/workflow-server-bundled-web/index.bundle.js.map .claude/settings.local.json CLAUDE.local.md diff --git a/.prettierignore b/.prettierignore index b50995a..05ba8db 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,6 +8,10 @@ dist/ # Generated files *.min.js *.min.css +examples/*/wf-glsp-server-webworker.js +examples/*/wf-glsp-server-webworker.js.map +examples/*/index.bundle.js +examples/*/index.bundle.js.map # Lock files package-lock.json diff --git a/examples/workflow-server-bundled-web/README.md b/examples/workflow-server-bundled-web/README.md index be89c78..3971090 100644 --- a/examples/workflow-server-bundled-web/README.md +++ b/examples/workflow-server-bundled-web/README.md @@ -14,6 +14,12 @@ See [our project website](https://www.eclipse.org/glsp/documentation/#workflowov +## MCP demo + +A self-contained dev demo that drives this bundle through `@eclipse-glsp/server-mcp` lives in +its own private workspace at +[`examples/workflow-server-mcp-demo`](../workflow-server-mcp-demo). See its README for how to run. + ## More information For more information, please visit the [Eclipse GLSP Umbrella repository](https://github.com/eclipse-glsp/glsp) and the [Eclipse GLSP Website](https://www.eclipse.org/glsp/). diff --git a/examples/workflow-server-mcp-demo/.gitignore b/examples/workflow-server-mcp-demo/.gitignore new file mode 100644 index 0000000..904f199 --- /dev/null +++ b/examples/workflow-server-mcp-demo/.gitignore @@ -0,0 +1,2 @@ +# Populated by `yarn start` (verbatim assets + synced worker bundle + webpack output) +dist/ diff --git a/examples/workflow-server-mcp-demo/README.md b/examples/workflow-server-mcp-demo/README.md new file mode 100644 index 0000000..df20bd8 --- /dev/null +++ b/examples/workflow-server-mcp-demo/README.md @@ -0,0 +1,66 @@ +# Workflow Server MCP Demo (browser) + +A browser demo for the `@eclipse-glsp/server-mcp` portable Fetch handler. The page opens a +workflow GLSP session inside an in-page Web Worker and drives the MCP server through a +Service Worker that intercepts `fetch('/mcp', …)` and proxies the request to the Worker via +`MessageChannel`. + +This package is **private** — it ships nothing; it's a local demo and manual test bench for +the portable handler. + +## Why only a browser demo? + +The browser variant needs a custom harness because **external MCP clients can't reach an +in-page launcher**: a browser tab doesn't accept inbound network traffic. The Service Worker +in this demo is what makes the in-page launcher reachable to a same-origin MCP client. + +For the **Node variant**, no demo is needed in this repo — the launcher binds a real HTTP +listener and announces its URL via stdout (`[GLSP-MCP-Server]:Ready. {…}`). Point any MCP +client at that URL: + +- The official **[MCP Inspector](https://github.com/modelcontextprotocol/inspector)** is the + best manual debug tool — runs as a local web UI and lets you exercise tools, prompts and + resources interactively. +- **Claude Code**, **Cursor**, or any other MCP-aware client also work. + +The Node path is additionally covered by the automated end-to-end spec at +`packages/server-mcp/src/node/server/mcp-http-transport-e2e.spec.ts`, which runs an MCP SDK +`Client` over real HTTP against the launcher. + +## Running + +From the repository root: + +```bash +yarn start:mcp-demo +``` + +This builds the workflow server, copies the worker bundle from +`@eclipse-glsp-examples/workflow-server-bundled-web`, builds the page-side bundle, and serves +the directory on `http://localhost:8000/`. + +Open `http://localhost:8000/` in any modern browser. Step through the buttons +top-to-bottom; the workflow auto-renders once MCP is initialized, and **Create task** +mutates the live session. + +## What it exercises + +1. The page acts as a minimal GLSP JSON-RPC client over `postMessage` to the in-page Web + Worker (using `vscode-jsonrpc/browser`, bundled). +2. GLSP `initialize` carries `mcpServer: {}` — the Worker's per-connection child container + activates `BrowserMcpServerModule`'s launcher as a `GLSPServerInitializer`. +3. `initializeClientSession` opens a real workflow GLSP session with + `diagramType: workflow-diagram`. +4. A Service Worker (`mcp-service-worker.js`) intercepts `fetch('/mcp', …)` and proxies the + request through a `MessageChannel` to the Worker. +5. MCP tools (`initialize`, `tools/list`, `session-info`, `query-elements`, `diagram-model`, + `create-nodes`) round-trip end-to-end against the live session. + +The on-page SVG is a minimal schematic renderer — **not** the GLSP client; its only purpose +is to make the MCP handlers' output visible so the demo can be eyeballed end-to-end. + +## Hard reset + +If a stale Service Worker is misbehaving: DevTools → Application → Service Workers → +Unregister, then close the tab and re-open. The page's Worker bundle URL is cache-busted per +load, but the SW itself updates only on full lifecycle restart. diff --git a/examples/workflow-server-mcp-demo/package.json b/examples/workflow-server-mcp-demo/package.json new file mode 100644 index 0000000..27fb180 --- /dev/null +++ b/examples/workflow-server-mcp-demo/package.json @@ -0,0 +1,30 @@ +{ + "name": "@eclipse-glsp-examples/workflow-server-mcp-demo", + "version": "2.7.0-next", + "private": true, + "description": "Browser demo that drives the @eclipse-glsp/server-mcp portable Fetch handler against the workflow web server bundle.", + "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" + }, + "scripts": { + "build": "webpack", + "clean": "rimraf dist", + "prestart": "node ./scripts/prepare-dist.mjs && yarn build", + "start": "npx -y serve -l 8000 ./dist" + }, + "dependencies": { + "@eclipse-glsp-examples/workflow-server-bundled-web": "2.7.0-next" + }, + "devDependencies": { + "vscode-jsonrpc": "8.2.0", + "webpack": "^5.75.0", + "webpack-cli": "^5.0.1" + } +} diff --git a/examples/workflow-server-mcp-demo/public/index.html b/examples/workflow-server-mcp-demo/public/index.html new file mode 100644 index 0000000..8aec83f --- /dev/null +++ b/examples/workflow-server-mcp-demo/public/index.html @@ -0,0 +1,776 @@ + + + + + + GLSP MCP Smoke + + + + + + +
+
+
+

GLSP · MCP demo

+

+ A workflow diagram running entirely in the page, driven through MCP. Step through the actions to see the model load, + query it, and create a new node. +

+
+
Connecting…
+
+ +
+
+ Boot + Worker, GLSP, Service Worker +
+
Connecting the in-page Worker, GLSP, and the Service Worker…
+
+ +
+
+ Live status + updates from the server +
+
+
No messages yet
+ +
+
+ +
+
+ Sessions + identifiers +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ Diagram + + + schematic SVG of diagram-model + +
+
+ +
+ Click Initialize MCP below to load the workflow diagram. +
+
+

+ Schematic only — not the GLSP client and not Sprotty. Shapes are painted from the + diagram-model MCP tool's payload to make the handlers' output visible. +

+
+ +
+
+ Last result + +
+
+ No tool calls yet. +
+
+ +
+
+ Actions + step through the demo +
+
+ + + + + + + + + + + + +
+
+ +
+
+ Activity log + +
+
+
+
+ + + + diff --git a/examples/workflow-server-mcp-demo/public/mcp-service-worker.js b/examples/workflow-server-mcp-demo/public/mcp-service-worker.js new file mode 100644 index 0000000..c82b6d8 --- /dev/null +++ b/examples/workflow-server-mcp-demo/public/mcp-service-worker.js @@ -0,0 +1,111 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +// Proxies `fetch('/mcp', ...)` calls from the page to the embedded Web Worker via a MessageChannel +// the page hands over at boot. Lets the page use plain `fetch` (or the MCP SDK's standard +// `StreamableHTTPClientTransport`) without the worker needing to host an HTTP listener. + +const PENDING_TIMEOUT_MS = 60_000; + +self.addEventListener('install', () => self.skipWaiting()); +self.addEventListener('activate', event => event.waitUntil(self.clients.claim())); + +let workerPort; +let resolveWorkerPort; +let workerPortPromise = new Promise(resolve => { + resolveWorkerPort = resolve; +}); +const pending = new Map(); +let nextId = 1; + +function failPending(id, status, statusText, message) { + const entry = pending.get(id); + if (!entry) return; + pending.delete(id); + clearTimeout(entry.timeout); + entry.resolve({ type: 'mcp-response', id, status, statusText, headers: { 'content-type': 'text/plain' }, body: message }); +} + +function failAllPending(reason) { + for (const id of [...pending.keys()]) { + failPending(id, 503, 'Service Unavailable', reason); + } +} + +self.addEventListener('message', event => { + const data = event.data; + if (!data || data.type !== 'mcp-init-port') { + return; + } + const port = event.ports[0]; + if (!port) { + return; + } + // Port replacement (page reload, worker swap) — orphan any in-flight resolvers cleanly. + if (workerPort && workerPort !== port) { + failAllPending('MCP worker port replaced before response arrived'); + workerPortPromise = new Promise(resolve => { + resolveWorkerPort = resolve; + }); + } + workerPort = port; + workerPort.onmessage = portEvent => { + const reply = portEvent.data; + if (!reply || reply.type !== 'mcp-response') { + return; + } + const entry = pending.get(reply.id); + if (entry) { + pending.delete(reply.id); + clearTimeout(entry.timeout); + entry.resolve(reply); + } + }; + workerPort.start(); + resolveWorkerPort(workerPort); +}); + +self.addEventListener('fetch', event => { + if (new URL(event.request.url).pathname !== '/mcp') { + return; + } + event.respondWith(handleMcp(event.request)); +}); + +async function handleMcp(request) { + if (!workerPort) { + await workerPortPromise; + } + const id = `${Date.now()}-${nextId++}`; + const headers = {}; + request.headers.forEach((value, key) => { + headers[key] = value; + }); + const body = request.method === 'GET' || request.method === 'HEAD' ? null : await request.text(); + const reply = await new Promise(resolve => { + const timeout = setTimeout( + () => failPending(id, 504, 'Gateway Timeout', `MCP worker bridge timed out after ${PENDING_TIMEOUT_MS}ms`), + PENDING_TIMEOUT_MS + ); + pending.set(id, { resolve, timeout }); + workerPort.postMessage({ type: 'mcp-request', id, url: request.url, method: request.method, headers, body }); + }); + return new Response(reply.body, { + status: reply.status, + statusText: reply.statusText, + headers: reply.headers + }); +} diff --git a/examples/workflow-server-mcp-demo/scripts/prepare-dist.mjs b/examples/workflow-server-mcp-demo/scripts/prepare-dist.mjs new file mode 100644 index 0000000..e50c526 --- /dev/null +++ b/examples/workflow-server-mcp-demo/scripts/prepare-dist.mjs @@ -0,0 +1,55 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +// Populates `dist/` with everything `serve` needs that webpack doesn't emit: +// - verbatim files from `public/` (index.html, mcp-service-worker.js) +// - the worker bundle from `@eclipse-glsp-examples/workflow-server-bundled-web` +// Webpack writes `index.bundle.js` into the same dir as a separate step. + +import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; + +const require = createRequire(import.meta.url); +const here = new URL('..', import.meta.url).pathname; +const dist = join(here, 'dist'); + +mkdirSync(dist, { recursive: true }); + +// 1. Verbatim assets from ./public/ +const publicDir = join(here, 'public'); +if (existsSync(publicDir)) { + for (const entry of readdirSync(publicDir)) { + const source = join(publicDir, entry); + if (statSync(source).isFile()) { + copyFileSync(source, join(dist, entry)); + } + } +} + +// 2. Worker bundle from the bundled-web dependency +const workerPackage = dirname(require.resolve('@eclipse-glsp-examples/workflow-server-bundled-web/package.json')); +const workerFiles = ['wf-glsp-server-webworker.js', 'wf-glsp-server-webworker.js.map']; +for (const file of workerFiles) { + const source = join(workerPackage, file); + if (!existsSync(source)) { + console.error(`[prepare-dist] Missing ${source} — build workflow-server first.`); + process.exit(1); + } + copyFileSync(source, join(dist, file)); +} + +console.log('[prepare-dist] dist/ populated (public assets + worker bundle)'); diff --git a/examples/workflow-server-mcp-demo/src/index.js b/examples/workflow-server-mcp-demo/src/index.js new file mode 100644 index 0000000..ed07cbd --- /dev/null +++ b/examples/workflow-server-mcp-demo/src/index.js @@ -0,0 +1,905 @@ +/******************************************************************************** + * 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 { BrowserMessageReader, BrowserMessageWriter, createMessageConnection } from 'vscode-jsonrpc/browser'; + +const GLSP_PROTOCOL_VERSION = '1.0.0'; +const MCP_PROTOCOL_VERSION = '2025-03-26'; +const MCP_URL = '/mcp'; +const DIAGRAM_TYPE = 'workflow-diagram'; +const CLIENT_SESSION_ID = 'smoke-' + Math.random().toString(36).slice(2, 9); + +// Server-pushed action kinds we want forwarded to us via JSON-RPC `process` notifications. +// The framework's `ClientActionForwarder` consults this list; anything not in it triggers a +// "No handler registered" error on the server. Extend as new server-side actions surface. +const CLIENT_ACTION_KINDS = [ + 'status', + 'startProgress', + 'endProgress', + 'requestBounds', + 'setDirtyState', + 'setMarkers', + 'setEditMode', + 'updateModel', + 'setModel', + 'selectAll', + 'selectAction', + 'elementSelected', + 'sourceModelChanged' +]; + +const SVG_NS = 'http://www.w3.org/2000/svg'; +const DIRTY_RENDER_DEBOUNCE_MS = 80; + +const dom = { + log: document.getElementById('log'), + bootStatus: document.getElementById('boot-status'), + systemIndicator: document.getElementById('system-indicator'), + glspSession: document.getElementById('glsp-session'), + mcpSession: document.getElementById('mcp-session'), + btnMcpInit: document.getElementById('btn-mcp-init'), + btnMcpTools: document.getElementById('btn-mcp-tools'), + btnMcpSessionInfo: document.getElementById('btn-mcp-session-info'), + btnMcpElementTypes: document.getElementById('btn-mcp-element-types'), + btnMcpQuery: document.getElementById('btn-mcp-query'), + btnMcpValidate: document.getElementById('btn-mcp-validate'), + btnMcpCreate: document.getElementById('btn-mcp-create'), + btnMcpMove: document.getElementById('btn-mcp-move'), + btnMcpDelete: document.getElementById('btn-mcp-delete'), + btnMcpUndo: document.getElementById('btn-mcp-undo'), + btnMcpRedo: document.getElementById('btn-mcp-redo'), + btnMcpTerminate: document.getElementById('btn-mcp-terminate'), + btnClear: document.getElementById('btn-clear'), + lastResultTitle: document.getElementById('last-result-title'), + lastResult: document.getElementById('last-result'), + diagramDirty: document.getElementById('diagram-dirty'), + diagram: document.getElementById('diagram-canvas'), + infoBar: document.getElementById('info-bar'), + statusLine: document.getElementById('status-line'), + progressRow: document.getElementById('progress-row'), + progressTitle: document.getElementById('progress-title'), + progressBar: document.getElementById('progress-bar') +}; + +function setSystemState(state, label) { + if (!dom.systemIndicator) return; + dom.systemIndicator.dataset.state = state; + dom.systemIndicator.textContent = label; +} + +// ---------- log + helpers ---------- + +function logEntry(kind, summary, payload) { + const entry = document.createElement('details'); + if (kind === 'error') entry.classList.add('error'); + entry.open = true; + const sum = document.createElement('summary'); + sum.textContent = `[${new Date().toLocaleTimeString()}] ${summary}`; + entry.appendChild(sum); + const pre = document.createElement('pre'); + pre.textContent = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2); + entry.appendChild(pre); + dom.log.prepend(entry); +} + +function headersToObject(headers) { + const result = {}; + headers.forEach((value, key) => { + result[key] = value; + }); + return result; +} + +// ---------- GLSP client ---------- + +/** + * Thin wrapper around `vscode-jsonrpc/browser` that knows the handful of GLSP requests + * this smoke needs: `initialize`, `initializeClientSession`, plus `process` notifications + * for action message round-trips (`requestModel` out, server-pushed actions in). + */ +class GlspClient { + /** @param {Worker} worker */ + constructor(worker) { + const reader = new BrowserMessageReader(worker); + const writer = new BrowserMessageWriter(worker); + this.connection = createMessageConnection(reader, writer); + this.connection.onError(err => logEntry('error', 'GLSP JSON-RPC error', String(err))); + this._actionHandler = null; + this.connection.onNotification('process', message => { + if (this._actionHandler && message && message.action) { + this._actionHandler(message.action); + } + }); + this.connection.listen(); + } + + /** @param {(action: any) => void} handler */ + onAction(handler) { + this._actionHandler = handler; + } + + async initialize(applicationId, mcpServerOptions) { + logEntry('request', '→ GLSP initialize', { mcpServer: mcpServerOptions }); + const result = await this.connection.sendRequest('initialize', { + applicationId, + protocolVersion: GLSP_PROTOCOL_VERSION, + mcpServer: mcpServerOptions + }); + logEntry('response', '← GLSP initialize', result); + return result; + } + + async initializeClientSession(clientSessionId, diagramType, clientActionKinds) { + const params = { diagramType, clientSessionId, clientActionKinds, args: {} }; + logEntry('request', '→ GLSP initializeClientSession', params); + await this.connection.sendRequest('initializeClientSession', params); + logEntry('response', '← GLSP initializeClientSession', { ok: true }); + } + + /** Send an action to the server via `process` notification. */ + dispatchAction(clientId, action) { + this.connection.sendNotification('process', { clientId, action }); + } +} + +// ---------- bounds reply (RequestBoundsAction → ComputedBoundsAction) ---------- + +// The workflow MCP serializer drops task elements whose label child has no Dimension. A +// real Sprotty-based client computes those via DOM measurement and replies; here we +// estimate per element type so new nodes survive the serializer's filter. +function estimateBounds(element) { + const type = typeof element.type === 'string' ? element.type : ''; + if (type === 'label:heading' || type.startsWith('label')) { + const textLen = typeof element.text === 'string' ? element.text.length : 5; + return { width: Math.max(20, textLen * 7), height: 16 }; + } + if (type.startsWith('task')) return { width: 80, height: 30 }; + if (type === 'icon') return { width: 25, height: 20 }; + if (type.startsWith('activityNode')) return { width: 32, height: 32 }; + return { width: 60, height: 30 }; +} + +function collectMissingBounds(root) { + const bounds = []; + const stack = [root]; + while (stack.length) { + const element = stack.pop(); + if (!element) continue; + const size = element.size; + const hasSize = size && Number.isFinite(size.width) && Number.isFinite(size.height) && size.width > 0 && size.height > 0; + if (typeof element.id === 'string' && !hasSize) { + bounds.push({ elementId: element.id, newSize: estimateBounds(element) }); + } + if (Array.isArray(element.children)) { + for (const child of element.children) stack.push(child); + } + } + return bounds; +} + +function buildComputedBoundsAction(requestAction) { + // `ComputedBoundsActionHandler` discards the reply unless `revision` matches the + // current `modelState.root.revision`. Mirror the request's newRoot revision. + return { + kind: 'computedBounds', + bounds: collectMissingBounds(requestAction.newRoot), + revision: requestAction.newRoot?.revision, + responseId: requestAction.requestId + }; +} + +// ---------- MCP client ---------- + +let rpcId = 1; +let mcpSessionId = ''; + +function mcpHeaders() { + return mcpSessionId ? { 'mcp-session-id': mcpSessionId, 'mcp-protocol-version': MCP_PROTOCOL_VERSION } : {}; +} + +// Parse a Streamable HTTP MCP response body. The transport returns either plain JSON or +// SSE-framed (`event:` / `data:` lines); we want the JSON payload either way. +async function readResponseBody(response) { + const text = await response.text(); + if (!text) return ''; + const trimmed = text.trim(); + if (trimmed.startsWith('event:') || trimmed.startsWith('data:')) { + for (const line of text.split('\n')) { + const stripped = line.trim(); + if (stripped.startsWith('data:')) { + try { + return JSON.parse(stripped.slice(5).trim()); + } catch { + /* fall through to raw text */ + } + } + } + } + try { + return JSON.parse(text); + } catch { + return text; + } +} + +async function mcpFetch(extraHeaders, jsonBody, summary, options = {}) { + const headers = { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + ...extraHeaders + }; + const method = options.method ?? 'POST'; + const fetchInit = { method, headers }; + if (jsonBody !== undefined) { + fetchInit.body = JSON.stringify(jsonBody); + logEntry('request', `→ ${method} ${MCP_URL}`, { headers, body: jsonBody }); + } else { + logEntry('request', `→ ${method} ${MCP_URL}`, { headers }); + } + try { + const response = await fetch(MCP_URL, fetchInit); + const responseHeaders = headersToObject(response.headers); + const body = await readResponseBody(response); + logEntry(response.ok ? 'response' : 'error', `← ${response.status} ${response.statusText} ${summary}`, { + headers: responseHeaders, + body + }); + // `internal` calls are infrastructure round-trips (e.g. the auto `diagram-model` fetch after + // each mutation) — they shouldn't clobber the user-visible last-result panel. + if (!options.internal) { + renderLastResult(summary, body); + } + return { response, headers: responseHeaders, body }; + } catch (err) { + logEntry('error', `← ${summary} failed`, String(err)); + throw err; + } +} + +function mcpToolCall(name, args, summary, options) { + return mcpFetch( + mcpHeaders(), + { jsonrpc: '2.0', id: rpcId++, method: 'tools/call', params: { name, arguments: args } }, + summary, + options + ); +} + +// ---------- last-result panel ---------- + +// Pretty-print the most recent MCP response. Recognises the common shapes the demo's tools +// emit (arrays of {name, description, …}, plain primitive objects, text-only `content`) and +// falls back to indented JSON when it can't summarise meaningfully. +function renderLastResult(summary, body) { + if (!dom.lastResult || !dom.lastResultTitle) return; + dom.lastResultTitle.textContent = summary; + dom.lastResult.replaceChildren(); + const payload = pickRenderPayload(body); + dom.lastResult.appendChild(renderPayload(payload)); +} + +// Strip the JSON-RPC envelope down to the bit a human cares about. +function pickRenderPayload(body) { + if (!body || typeof body !== 'object') return body; + if (body.error) return { error: body.error }; + const result = body.result; + if (!result || typeof result !== 'object') return result ?? body; + // `tools/list` / `resources/list` / `prompts/list` returns { tools | resources | prompts: [...] }. + if (Array.isArray(result.tools)) return result.tools; + if (Array.isArray(result.resources)) return result.resources; + if (Array.isArray(result.prompts)) return result.prompts; + // `tools/call` returns structuredContent (preferred) or content[]. + if (result.structuredContent && typeof result.structuredContent === 'object') return result.structuredContent; + if (Array.isArray(result.content)) return result.content; + return result; +} + +function renderPayload(payload) { + if (Array.isArray(payload) && payload.length > 0 && payload.every(item => item && typeof item === 'object' && !Array.isArray(item))) { + return renderObjectArrayTable(payload); + } + if (payload && typeof payload === 'object' && !Array.isArray(payload)) { + // Plain `content` entries from tools/call — `[{ type: 'text', text: '…' }]` flattened to text. + if (typeof payload.type === 'string' && typeof payload.text === 'string') { + const pre = document.createElement('pre'); + pre.textContent = payload.text; + return pre; + } + if (Object.values(payload).every(v => typeof v !== 'object' || v === null)) { + return renderKeyValueTable(payload); + } + } + const pre = document.createElement('pre'); + pre.textContent = JSON.stringify(payload, null, 2); + return pre; +} + +function renderObjectArrayTable(items) { + // Pick a small set of "interesting" columns. `name`/`title` and `description` cover the + // tools/list, resources/list, and query-elements shapes; fall back to whatever keys exist. + const preferred = ['name', 'title', 'description', 'id', 'type', 'elementTypeId', 'label']; + const present = preferred.filter(key => items.some(item => key in item)); + const keys = present.length > 0 ? present : Object.keys(items[0]).slice(0, 4); + const table = document.createElement('table'); + const thead = document.createElement('thead'); + const headRow = document.createElement('tr'); + for (const key of keys) { + const th = document.createElement('th'); + th.textContent = key; + headRow.appendChild(th); + } + thead.appendChild(headRow); + table.appendChild(thead); + const tbody = document.createElement('tbody'); + for (const item of items) { + const row = document.createElement('tr'); + for (const key of keys) { + const td = document.createElement('td'); + td.className = key === keys[0] ? 'key' : 'value'; + const value = item[key]; + td.textContent = value === undefined ? '' : typeof value === 'string' ? value : JSON.stringify(value); + row.appendChild(td); + } + tbody.appendChild(row); + } + table.appendChild(tbody); + return table; +} + +function renderKeyValueTable(payload) { + const table = document.createElement('table'); + const tbody = document.createElement('tbody'); + for (const [key, value] of Object.entries(payload)) { + const row = document.createElement('tr'); + const keyCell = document.createElement('td'); + keyCell.className = 'key'; + keyCell.textContent = key; + const valueCell = document.createElement('td'); + valueCell.className = 'value'; + valueCell.textContent = value === null ? 'null' : String(value); + row.append(keyCell, valueCell); + tbody.appendChild(row); + } + table.appendChild(tbody); + return table; +} + +// ---------- info bar (server-pushed actions) ---------- + +function showInfoBar() { + dom.infoBar.classList.add('visible'); + document.getElementById('server-messages-section')?.classList.remove('section-hidden'); +} + +function renderStatus(action) { + const severity = (action.severity ?? 'info').toLowerCase(); + const message = (action.message ?? '').trim(); + // `{severity: 'NONE', message: ''}` is a "clear status" beat between meaningful + // updates; render that as a quiet idle line rather than verbatim text. + if (severity === 'none' && !message) { + dom.statusLine.textContent = 'All quiet.'; + } else if (!message) { + dom.statusLine.textContent = severity[0].toUpperCase() + severity.slice(1); + } else { + dom.statusLine.textContent = message; + } + showInfoBar(); +} + +function startProgress(action) { + dom.progressTitle.textContent = action.title ?? 'In progress…'; + dom.progressBar.removeAttribute('value'); + dom.progressRow.style.display = 'flex'; + showInfoBar(); +} + +function endProgress() { + dom.progressRow.style.display = 'none'; +} + +// ---------- diagram rendering ---------- + +function nodeCssClass(type) { + if (type.startsWith('task:automated')) return 'diagram-node task-automated'; + if (type.startsWith('task:manual')) return 'diagram-node task-manual'; + if (type.startsWith('activityNode')) return 'diagram-node activity-node'; + return 'diagram-node'; +} + +function elementLabel(element) { + if (typeof element.label === 'string' && element.label.trim()) return element.label; + if (typeof element.name === 'string' && element.name.trim()) return element.name; + if (typeof element.type === 'string' && element.type.includes(':')) { + return element.type.slice(element.type.lastIndexOf(':') + 1); + } + return element.id; +} + +// Painted geometry for a node, derived from its bounding rect. Diamonds shrink to +// 75% of the rect so they don't read as overscaled. Synchronization bars (fork/join) +// align to the long axis of the bounding rect — vertical when the rect is tall +// (workflow's canonical 10×50), horizontal when wide. +const DIAMOND_SCALE = 0.75; +const BAR_THICKNESS = 4; +function nodeShape(node) { + const type = node.type ?? ''; + const x = node.position.x; + const y = node.position.y; + const w = node.size.width; + const h = node.size.height; + const cx = x + w / 2; + const cy = y + h / 2; + if (type === 'activityNode:decision' || type === 'activityNode:merge') { + return { kind: 'diamond', cx, cy, halfW: (w * DIAMOND_SCALE) / 2, halfH: (h * DIAMOND_SCALE) / 2 }; + } + if (type === 'activityNode:fork' || type === 'activityNode:join') { + if (h >= w) { + // Vertical bar — thin column along the long (vertical) axis. + return { kind: 'rect', x: cx - BAR_THICKNESS / 2, y, width: BAR_THICKNESS, height: h }; + } + return { kind: 'rect', x, y: cy - BAR_THICKNESS / 2, width: w, height: BAR_THICKNESS }; + } + return { kind: 'rect', x, y, width: w, height: h }; +} + +// Clip the line from the shape's center toward (fromX,fromY) to the shape's boundary, +// so edges meet the painted shape instead of disappearing into it or stopping short. +function clipLineToShape(shape, fromX, fromY) { + if (shape.kind === 'diamond') { + const dx = fromX - shape.cx; + const dy = fromY - shape.cy; + if (dx === 0 && dy === 0) return { x: shape.cx, y: shape.cy }; + // Diamond |X|/halfW + |Y|/halfH = 1 → r = 1 / (|dx|/halfW + |dy|/halfH). + const r = 1 / (Math.abs(dx) / shape.halfW + Math.abs(dy) / shape.halfH); + return { x: shape.cx + dx * r, y: shape.cy + dy * r }; + } + const cx = shape.x + shape.width / 2; + const cy = shape.y + shape.height / 2; + const dx = fromX - cx; + const dy = fromY - cy; + if (dx === 0 && dy === 0) return { x: cx, y: cy }; + const scale = Math.min(shape.width / 2 / Math.abs(dx || Infinity), shape.height / 2 / Math.abs(dy || Infinity)); + return { x: cx + dx * scale, y: cy + dy * scale }; +} + +function svg(tag, attrs = {}) { + const node = document.createElementNS(SVG_NS, tag); + for (const [key, value] of Object.entries(attrs)) { + node.setAttribute(key, String(value)); + } + return node; +} + +function partitionElements(elements) { + const nodes = []; + const edges = []; + const nodeIndex = new Map(); + for (const element of elements) { + const type = element.type ?? ''; + if (typeof element.sourceId === 'string' && typeof element.targetId === 'string') { + edges.push(element); + } else if (element.position && element.size && (type.startsWith('task') || type.startsWith('activityNode'))) { + nodes.push(element); + nodeIndex.set(element.id, element); + } + } + return { nodes, edges, nodeIndex }; +} + +function computeViewBox(nodes) { + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (const node of nodes) { + const { x, y } = node.position; + const { width, height } = node.size; + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x + width > maxX) maxX = x + width; + if (y + height > maxY) maxY = y + height; + } + const padding = 20; + const width = Math.max(maxX - minX + padding * 2, 200); + const height = Math.max(maxY - minY + padding * 2, 100); + return { x: minX - padding, y: minY - padding, width, height }; +} + +function renderDiagram(elements) { + dom.diagram.replaceChildren(); + const { nodes, edges, nodeIndex } = partitionElements(elements); + if (nodes.length === 0) { + dom.diagram.classList.remove('visible'); + logEntry('error', 'No renderable nodes in diagram-model output', { elements }); + return; + } + + const viewBox = computeViewBox(nodes); + dom.diagram.setAttribute('viewBox', `${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`); + const displayWidth = Math.min(viewBox.width, 960); + dom.diagram.setAttribute('width', String(displayWidth)); + dom.diagram.setAttribute('height', String(viewBox.height * (displayWidth / viewBox.width))); + + const defs = svg('defs'); + const marker = svg('marker', { id: 'arrow', markerWidth: 10, markerHeight: 10, refX: 8, refY: 3, orient: 'auto' }); + marker.appendChild(svg('path', { d: 'M0,0 L0,6 L8,3 z', class: 'diagram-edge-arrow' })); + defs.appendChild(marker); + dom.diagram.appendChild(defs); + + // Precompute painted geometry once per node so edge clipping and shape painting + // agree on where the visible boundary actually is. + const shapes = new Map(); + for (const node of nodes) { + shapes.set(node.id, nodeShape(node)); + } + + // Edges first so nodes paint on top. Both endpoints clip to the painted shape so + // the arrow head lands on the shape's edge, not behind it or short of it. + for (const edge of edges) { + const sourceShape = shapes.get(edge.sourceId); + const targetShape = shapes.get(edge.targetId); + if (!sourceShape || !targetShape) continue; + const sourceCx = sourceShape.kind === 'diamond' ? sourceShape.cx : sourceShape.x + sourceShape.width / 2; + const sourceCy = sourceShape.kind === 'diamond' ? sourceShape.cy : sourceShape.y + sourceShape.height / 2; + const targetCx = targetShape.kind === 'diamond' ? targetShape.cx : targetShape.x + targetShape.width / 2; + const targetCy = targetShape.kind === 'diamond' ? targetShape.cy : targetShape.y + targetShape.height / 2; + const start = clipLineToShape(sourceShape, targetCx, targetCy); + const end = clipLineToShape(targetShape, sourceCx, sourceCy); + dom.diagram.appendChild( + svg('line', { + x1: start.x, + y1: start.y, + x2: end.x, + y2: end.y, + class: 'diagram-edge', + 'marker-end': 'url(#arrow)' + }) + ); + } + + for (const node of nodes) { + const type = node.type ?? ''; + const cssClass = nodeCssClass(type); + const shape = shapes.get(node.id); + if (shape.kind === 'diamond') { + const points = `${shape.cx},${shape.cy - shape.halfH} ${shape.cx + shape.halfW},${shape.cy} ${shape.cx},${shape.cy + shape.halfH} ${shape.cx - shape.halfW},${shape.cy}`; + dom.diagram.appendChild(svg('polygon', { points, class: cssClass })); + } else if (type.startsWith('activityNode')) { + // Bar — flat filled rect, no inset label. + dom.diagram.appendChild( + svg('rect', { x: shape.x, y: shape.y, width: shape.width, height: shape.height, rx: 1.5, class: cssClass }) + ); + } else { + dom.diagram.appendChild( + svg('rect', { x: shape.x, y: shape.y, width: shape.width, height: shape.height, rx: 4, class: cssClass }) + ); + const text = svg('text', { + x: shape.x + shape.width / 2, + y: shape.y + shape.height / 2, + class: 'diagram-label' + }); + text.textContent = elementLabel(node); + dom.diagram.appendChild(text); + } + } + + dom.diagram.classList.add('visible'); +} + +async function fetchAndRenderDiagram() { + const result = await mcpToolCall('diagram-model', { sessionId: CLIENT_SESSION_ID }, '(diagram-model)', { internal: true }); + const elements = result.body?.result?.structuredContent?.elements; + if (Array.isArray(elements)) { + renderDiagram(elements); + } else { + logEntry('error', 'diagram-model response missing structuredContent.elements', result.body); + } +} + +// Re-render on every `updateModel` push (debounced). `updateModel` is the canonical +// GLSP signal carrying the new model root — Sprotty-based clients swap the root on +// this action; we re-fetch `diagram-model` for the same effect. +let updateRenderTimer = 0; +function scheduleModelRender() { + if (!mcpSessionId || updateRenderTimer) return; + updateRenderTimer = setTimeout(() => { + updateRenderTimer = 0; + fetchAndRenderDiagram().catch(err => logEntry('error', 'auto re-render on updateModel failed', String(err))); + }, DIRTY_RENDER_DEBOUNCE_MS); +} + +// ---------- boot ---------- + +let glsp; + +async function bootGlsp(worker) { + glsp = new GlspClient(worker); + glsp.onAction(action => { + switch (action.kind) { + case 'status': + renderStatus(action); + break; + case 'updateModel': + scheduleModelRender(); + break; + case 'setDirtyState': + if (dom.diagramDirty) { + dom.diagramDirty.hidden = !action.isDirty; + } + break; + case 'startProgress': + startProgress(action); + break; + case 'endProgress': + endProgress(); + break; + case 'requestBounds': + glsp.dispatchAction(CLIENT_SESSION_ID, buildComputedBoundsAction(action)); + break; + default: + logEntry('response', `← server-pushed action ${action.kind}`, action); + } + }); + + await glsp.initialize('workflow-server-bundled-web-smoke', { name: 'workflow-glsp', route: MCP_URL }); + + dom.glspSession.value = CLIENT_SESSION_ID; + await glsp.initializeClientSession(CLIENT_SESSION_ID, DIAGRAM_TYPE, CLIENT_ACTION_KINDS); + + // Trigger model load. `WorkflowMockModelStorage` ignores the URI and always serves + // its bundled example1.json, so any non-empty `sourceUri` works. + logEntry('request', '→ GLSP process(requestModel)', { sourceUri: 'example1.json' }); + glsp.dispatchAction(CLIENT_SESSION_ID, { + kind: 'requestModel', + options: { sourceUri: 'example1.json', diagramType: DIAGRAM_TYPE } + }); +} + +async function bootSw(worker) { + if (!('serviceWorker' in navigator)) { + throw new Error('Service Workers not supported in this browser'); + } + await navigator.serviceWorker.register('./mcp-service-worker.js'); + await navigator.serviceWorker.ready; + if (!navigator.serviceWorker.controller) { + // First registration: SW activated but the page isn't controlled yet. With + // `skipWaiting`/`clients.claim` this usually fires within a tick. + await new Promise(resolve => { + navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true }); + }); + } + // One MessageChannel; the page holds neither end. Port 1 goes to the Service Worker + // (incoming MCP fetches), port 2 to the Web Worker (MCP request handler). + const channel = new MessageChannel(); + navigator.serviceWorker.controller.postMessage({ type: 'mcp-init-port' }, [channel.port1]); + worker.postMessage({ type: 'mcp-init-port' }, [channel.port2]); +} + +async function boot() { + // Cache-bust the worker bundle URL per page load. Browsers HTTP-cache `new Worker(url)` + // fetches; without this, rebuilding the bundle takes effect only after a Service Worker + // unregister or cache flush. + const worker = new Worker(`./wf-glsp-server-webworker.js?v=${Date.now()}`); + worker.addEventListener('error', event => logEntry('error', 'Worker error', event.message ?? String(event))); + + try { + await Promise.all([bootGlsp(worker), bootSw(worker)]); + dom.bootStatus.textContent = `Connected. Workflow session ${CLIENT_SESSION_ID} is open.`; + dom.bootStatus.classList.add('ready'); + setSystemState('ready', 'Ready'); + dom.btnMcpInit.disabled = false; + } catch (err) { + dom.bootStatus.textContent = `Boot failed — ${err.message ?? err}`; + dom.bootStatus.classList.add('failed'); + setSystemState('failed', 'Failed'); + logEntry('error', 'Boot failed', String(err)); + } +} + +// ---------- button wiring ---------- + +dom.btnMcpInit.addEventListener('click', async () => { + dom.btnMcpInit.disabled = true; + const result = await mcpFetch( + {}, + { + jsonrpc: '2.0', + id: rpcId++, + method: 'initialize', + params: { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'glsp-mcp-smoke', version: '0.0.1' } + } + }, + '(initialize)' + ); + mcpSessionId = result.headers['mcp-session-id'] ?? ''; + if (!mcpSessionId) { + logEntry('error', 'No mcp-session-id in response headers', result.headers); + dom.btnMcpInit.disabled = false; + return; + } + dom.mcpSession.value = mcpSessionId; + // Demote the primary treatment once init has succeeded — every action is now equal. + dom.btnMcpInit.classList.remove('primary'); + dom.btnMcpTools.disabled = false; + dom.btnMcpSessionInfo.disabled = false; + dom.btnMcpElementTypes.disabled = false; + dom.btnMcpQuery.disabled = false; + dom.btnMcpValidate.disabled = false; + dom.btnMcpCreate.disabled = false; + dom.btnMcpTerminate.disabled = false; + // Move/Delete activate after the first create; Undo/Redo follow the dispatched-commands stack. + await mcpFetch(mcpHeaders(), { jsonrpc: '2.0', method: 'notifications/initialized' }, '(initialized)'); + await fetchAndRenderDiagram(); + dom.btnMcpInit.disabled = false; +}); + +dom.btnMcpTools.addEventListener('click', () => { + mcpFetch(mcpHeaders(), { jsonrpc: '2.0', id: rpcId++, method: 'tools/list' }, '(tools/list)'); +}); + +dom.btnMcpSessionInfo.addEventListener('click', () => { + mcpToolCall('session-info', {}, '(session-info)'); +}); + +dom.btnMcpElementTypes.addEventListener('click', () => { + mcpToolCall('element-types', { sessionId: CLIENT_SESSION_ID }, '(element-types)'); +}); + +dom.btnMcpQuery.addEventListener('click', () => { + mcpToolCall('query-elements', { sessionId: CLIENT_SESSION_ID }, '(query-elements)'); +}); + +dom.btnMcpValidate.addEventListener('click', () => { + mcpToolCall('validate-diagram', { sessionId: CLIENT_SESSION_ID }, '(validate-diagram)'); +}); + +dom.btnMcpUndo.addEventListener('click', async () => { + const count = undoStack[undoStack.length - 1]; + if (!count) return; + dom.btnMcpUndo.disabled = true; + await mcpToolCall('undo', { sessionId: CLIENT_SESSION_ID, commandsToUndo: count }, `(undo · ${count})`); + undoStack.pop(); + redoStack.push(count); + updateUndoRedoButtons(); +}); +dom.btnMcpRedo.addEventListener('click', async () => { + const count = redoStack[redoStack.length - 1]; + if (!count) return; + dom.btnMcpRedo.disabled = true; + await mcpToolCall('redo', { sessionId: CLIENT_SESSION_ID, commandsToRedo: count }, `(redo · ${count})`); + redoStack.pop(); + undoStack.push(count); + updateUndoRedoButtons(); +}); + +// Each click drops a new manual task with an incrementing position so repeated clicks +// don't overlap. The `updateModel` push from the server auto-triggers a re-render. +let createOffset = 0; +const recentTaskIds = []; +// Each mutating tool reports `dispatchedCommands` — the number of underlying GLSP commands the +// call produced (e.g. create-with-label = 2 commands). Track per-call counts so Undo / Redo can +// roll back / replay a full user action instead of just its last sub-command. +const undoStack = []; +const redoStack = []; +function updateUndoRedoButtons() { + dom.btnMcpUndo.disabled = undoStack.length === 0; + dom.btnMcpRedo.disabled = redoStack.length === 0; +} +function trackDispatched(toolResult) { + const count = toolResult?.body?.result?.structuredContent?.dispatchedCommands; + if (typeof count === 'number' && count > 0) { + undoStack.push(count); + redoStack.length = 0; + updateUndoRedoButtons(); + } +} +function rememberCreatedTaskIds(toolResult) { + const created = toolResult?.body?.result?.structuredContent?.createdNodes; + if (!Array.isArray(created)) return; + for (const node of created) { + // ElementIdentitySchema fields: `id` is the (aliased) element id. + if (node && typeof node.id === 'string') { + recentTaskIds.push(node.id); + } + } + if (recentTaskIds.length > 0) { + dom.btnMcpMove.disabled = false; + dom.btnMcpDelete.disabled = false; + } +} +dom.btnMcpCreate.addEventListener('click', async () => { + dom.btnMcpCreate.disabled = true; + createOffset += 1; + const result = await mcpToolCall( + 'create-nodes', + { + sessionId: CLIENT_SESSION_ID, + nodes: [ + { + elementTypeId: 'task:manual', + position: { x: 40, y: 30 + createOffset * 50 }, + text: `Task ${createOffset}` + } + ] + }, + '(create-nodes)' + ); + rememberCreatedTaskIds(result); + trackDispatched(result); + dom.btnMcpCreate.disabled = false; +}); + +// Move/delete target the most recently created task. Both rely on `updateModel` +// for the diagram re-render. +dom.btnMcpMove.addEventListener('click', async () => { + const elementId = recentTaskIds[recentTaskIds.length - 1]; + if (!elementId) return; + dom.btnMcpMove.disabled = true; + const result = await mcpToolCall( + 'modify-nodes', + { + sessionId: CLIENT_SESSION_ID, + nodes: [{ elementId, position: { x: 220, y: 30 + createOffset * 50 } }] + }, + '(modify-nodes · move)' + ); + trackDispatched(result); + dom.btnMcpMove.disabled = false; +}); +dom.btnMcpDelete.addEventListener('click', async () => { + const elementId = recentTaskIds.pop(); + if (!elementId) return; + dom.btnMcpDelete.disabled = true; + const result = await mcpToolCall('delete-elements', { sessionId: CLIENT_SESSION_ID, elementIds: [elementId] }, '(delete-elements)'); + trackDispatched(result); + if (recentTaskIds.length === 0) { + dom.btnMcpMove.disabled = true; + dom.btnMcpDelete.disabled = true; + } else { + dom.btnMcpDelete.disabled = false; + } +}); + +// Terminate the current MCP session via the spec's `DELETE /mcp` op. After this the server +// drops the per-session state; subsequent tool calls fail with 404 until the user re-initialises. +dom.btnMcpTerminate.addEventListener('click', async () => { + if (!mcpSessionId) return; + dom.btnMcpTerminate.disabled = true; + await mcpFetch(mcpHeaders(), undefined, '(terminate)', { method: 'DELETE' }); + mcpSessionId = ''; + dom.mcpSession.value = ''; + // Tool buttons can no longer succeed against this session; the user must re-Initialize. + dom.btnMcpTools.disabled = true; + dom.btnMcpSessionInfo.disabled = true; + dom.btnMcpElementTypes.disabled = true; + dom.btnMcpQuery.disabled = true; + dom.btnMcpValidate.disabled = true; + dom.btnMcpCreate.disabled = true; + dom.btnMcpMove.disabled = true; + dom.btnMcpDelete.disabled = true; + dom.btnMcpUndo.disabled = true; + dom.btnMcpRedo.disabled = true; + dom.btnMcpInit.disabled = false; + dom.btnMcpInit.classList.add('primary'); +}); + +dom.btnClear.addEventListener('click', () => { + dom.log.innerHTML = ''; +}); + +boot(); diff --git a/examples/workflow-server-mcp-demo/webpack.config.js b/examples/workflow-server-mcp-demo/webpack.config.js new file mode 100644 index 0000000..126e0a7 --- /dev/null +++ b/examples/workflow-server-mcp-demo/webpack.config.js @@ -0,0 +1,33 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +const path = require('path'); + +// Bundle `vscode-jsonrpc/browser` plus the page-side script into a single file emitted into +// `dist/`, alongside the verbatim assets copied from `public/` and the worker bundle synced +// from `@eclipse-glsp-examples/workflow-server-bundled-web`. `serve` then serves `dist/`. +module.exports = { + entry: path.resolve(__dirname, 'src', 'index.js'), + output: { + filename: 'index.bundle.js', + path: path.resolve(__dirname, 'dist') + }, + mode: 'development', + devtool: 'source-map', + target: 'web', + resolve: { + extensions: ['.js'] + } +}; diff --git a/examples/workflow-server/package.json b/examples/workflow-server/package.json index 9307c94..3614ee6 100644 --- a/examples/workflow-server/package.json +++ b/examples/workflow-server/package.json @@ -46,7 +46,7 @@ "browser.js" ], "scripts": { - "build": "tsc -b && yarn bundle", + "build": "tsc -b && yarn bundle && yarn bundle:browser", "bundle": "webpack", "bundle:browser": "webpack --env target=webworker ", "clean": "rimraf lib *.tsbuildinfo", diff --git a/examples/workflow-server/src/browser/app.ts b/examples/workflow-server/src/browser/app.ts index a25c261..7140def 100644 --- a/examples/workflow-server/src/browser/app.ts +++ b/examples/workflow-server/src/browser/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 @@ -17,12 +17,17 @@ import 'reflect-metadata'; import { configureELKLayoutModule } from '@eclipse-glsp/layout-elk'; import { createAppModule, LogLevel, WorkerServerLauncher } from '@eclipse-glsp/server/browser'; +import { McpWorkerBridge } from '@eclipse-glsp/server-mcp/browser'; import { Container } from 'inversify'; import { WorkflowLayoutConfigurator } from '../common/layout/workflow-layout-configurator'; +import { WorkflowMcpDiagramModule } from '../common/mcp/workflow-mcp-diagram-module'; import { WorkflowDiagramModule, WorkflowServerModule } from '../common/workflow-diagram-module'; import { WorkflowMockModelStorage } from './mock-model-storage'; -export async function launch(argv?: string[]): Promise { +export async function launch(_argv?: string[]): Promise { + // Bridge must be created before any await so postMessages that arrive on the next event-loop tick aren't dropped. + const bridge = new McpWorkerBridge(); + const appContainer = new Container(); appContainer.load(createAppModule({ logLevel: LogLevel.info })); @@ -35,11 +40,13 @@ export async function launch(argv?: string[]): Promise { const serverModule = new WorkflowServerModule().configureDiagramModule( new WorkflowDiagramModule(() => WorkflowMockModelStorage), - elkLayoutModule + elkLayoutModule, + new WorkflowMcpDiagramModule() ); - launcher.configure(serverModule); + launcher.configure(serverModule, bridge.createServerModule()); + // Resolves only on connection close — must be the last await. await launcher.start({}); } diff --git a/examples/workflow-server/src/common/index.ts b/examples/workflow-server/src/common/index.ts index d1627b2..452c517 100644 --- a/examples/workflow-server/src/common/index.ts +++ b/examples/workflow-server/src/common/index.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2023-2024 STMicroelectronics and others. + * Copyright (c) 2023-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 @@ -33,9 +33,9 @@ export * from './labeledit/workflow-label-edit-validator'; export * from './layout/workflow-layout-configurator'; export * from './marker/workflow-model-validator'; export * from './mcp/workflow-element-types-provider'; +export * from './mcp/workflow-mcp-diagram-module'; export * from './mcp/workflow-mcp-label-provider'; export * from './mcp/workflow-mcp-model-serializer'; -export * from './mcp/workflow-mcp-module'; export * from './model/workflow-navigation-target-resolver'; export * from './provider/abstract-next-or-previous-navigation-target-provider'; export * from './provider/next-node-navigation-target-provider'; diff --git a/examples/workflow-server/src/common/mcp/workflow-mcp-module.ts b/examples/workflow-server/src/common/mcp/workflow-mcp-diagram-module.ts similarity index 72% rename from examples/workflow-server/src/common/mcp/workflow-mcp-module.ts rename to examples/workflow-server/src/common/mcp/workflow-mcp-diagram-module.ts index 436b9f5..70252b9 100644 --- a/examples/workflow-server/src/common/mcp/workflow-mcp-module.ts +++ b/examples/workflow-server/src/common/mcp/workflow-mcp-diagram-module.ts @@ -15,29 +15,14 @@ ********************************************************************************/ import { BindingTarget } from '@eclipse-glsp/server'; -import { - DefaultMcpDiagramModule, - DefaultMcpServerModule, - ElementTypesProvider, - McpLabelProvider, - McpModelSerializer -} from '@eclipse-glsp/server-mcp'; +import { DefaultMcpDiagramModule, 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 { diff --git a/examples/workflow-server/src/node/app.ts b/examples/workflow-server/src/node/app.ts index a3b5ac3..2033998 100644 --- a/examples/workflow-server/src/node/app.ts +++ b/examples/workflow-server/src/node/app.ts @@ -20,8 +20,9 @@ import { GModelStorage, Logger, SocketServerLauncher, WebSocketServerLauncher, c import { Container } from 'inversify'; import { WorkflowLayoutConfigurator } from '../common/layout/workflow-layout-configurator'; -import { WorkflowMcpDiagramModule, WorkflowMcpServerModule } from '../common/mcp/workflow-mcp-module'; +import { WorkflowMcpDiagramModule } from '../common/mcp/workflow-mcp-diagram-module'; import { WorkflowDiagramModule, WorkflowServerModule } from '../common/workflow-diagram-module'; +import { WorkflowMcpServerModule } from './mcp/workflow-mcp-module'; import { createWorkflowCliParser } from './workflow-cli-parser'; async function launch(argv?: string[]): Promise { diff --git a/examples/workflow-server/src/node/index.ts b/examples/workflow-server/src/node/index.ts index fca7741..8df6a5b 100644 --- a/examples/workflow-server/src/node/index.ts +++ b/examples/workflow-server/src/node/index.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022-2024 EclipseSource and others. + * Copyright (c) 2022-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 @@ -13,5 +13,6 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +export * from './mcp/workflow-mcp-module'; export * from './reexport'; export * from './workflow-cli-parser'; diff --git a/examples/workflow-server/src/node/mcp/workflow-mcp-module.ts b/examples/workflow-server/src/node/mcp/workflow-mcp-module.ts new file mode 100644 index 0000000..7566952 --- /dev/null +++ b/examples/workflow-server/src/node/mcp/workflow-mcp-module.ts @@ -0,0 +1,23 @@ +/******************************************************************************** + * 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 { NodeMcpServerModule } from '@eclipse-glsp/server-mcp/node'; + +/** + * Workflow-scope MCP module — adopter hook for future workflow-specific server-scope bindings. + * Currently a no-op subclass; diagram-scope customizations live on `WorkflowMcpDiagramModule`. + */ +export class WorkflowMcpServerModule extends NodeMcpServerModule {} diff --git a/examples/workflow-server/webpack.config.js b/examples/workflow-server/webpack.config.js index 6e4ca85..ac7dad0 100644 --- a/examples/workflow-server/webpack.config.js +++ b/examples/workflow-server/webpack.config.js @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022 EclipseSource and others. + * Copyright (c) 2022-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 @@ -13,7 +13,6 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -const webpack = require('webpack'); const path = require('path'); const buildRoot = path.resolve(__dirname, 'lib'); diff --git a/package.json b/package.json index 926fc46..6b885e3 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "publish:latest": "lerna publish from-package --no-git-reset -y", "publish:next": "lerna publish preminor --exact --canary --preid next --dist-tag next --no-git-reset --no-git-tag-version --no-push --ignore-scripts --yes", "start": "yarn --cwd examples/workflow-server-bundled start", + "start:mcp-demo": "yarn --cwd examples/workflow-server build && yarn --cwd examples/workflow-server-mcp-demo start", "start:websocket": "yarn --cwd examples/workflow-server-bundled start:websocket", "test": "lerna run test --no-bail", "test:ci": "lerna run test:ci --no-bail", diff --git a/packages/server-mcp/ARCHITECTURE.md b/packages/server-mcp/ARCHITECTURE.md index 737d4e3..32d5d57 100644 --- a/packages/server-mcp/ARCHITECTURE.md +++ b/packages/server-mcp/ARCHITECTURE.md @@ -208,10 +208,21 @@ The tag is exported as `MCP_SERVER_READY_MSG` so IDE integrations can parse the ### 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. +The MCP server uses the **Streamable HTTP transport** (`WebStandardStreamableHTTPServerTransport` 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. +#### Portable handler — Node, browser, and web-runtime targets + +The launcher exposes a Fetch-API `handleRequest(req: Request): Promise` that any runtime with a `fetch`-shaped listener can drive — Node (via `@hono/node-server`), Bun, Deno, Cloudflare Workers, and in-page Web Workers. Two concrete launcher subclasses ship: + +- `McpServerLauncher` (Node) binds a Hono listener and announces the loopback URL. +- `WebMcpServerLauncher` (browser / web-runtime) returns no transport endpoint; the adopter wires `launcher.getRequestHandler()` into their own listener. + +For the browser/Web-Worker case where the GLSP client and the MCP server share a tab, `McpWorkerBridge` (in `@eclipse-glsp/server-mcp/browser`) plumbs Service-Worker→Web-Worker `MessageChannel` traffic into the launcher. The matching page-side proxy — a Service Worker that intercepts `fetch('/mcp', …)` and forwards each `Request` over a `MessageChannel` — is host-side scaffolding that adopters own. The browser demo at `examples/workflow-server-mcp-demo/` is the canonical end-to-end reference, including a working `mcp-service-worker.js`. + +Auth and shared session state for non-loopback deploys (Cloudflare DurableObjects-style multi-isolate setups) are explicitly out of scope — adopters wrap `getRequestHandler()` with their own middleware. + --- ## Deployment Model @@ -245,7 +256,9 @@ Both the GLSP server itself and this MCP server default to **random port allocat 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`. +- **IDE-internal MCP clients** (VS Code Copilot chat, Theia AI, etc.) consume the resolved URL programmatically — the GLSP IDE integration reads it from `InitializeResult.mcpServer` (or, on the spawn side, the `MCP_SERVER_READY_MSG` stdout marker) and registers it with the host IDE's native MCP infrastructure: + - **Theia**: `@eclipse-glsp/theia-mcp-integration` ships a `FrontendApplicationContribution` that auto-registers every GLSP server's MCP URL with `@theia/ai-mcp` on startup. No adopter wiring beyond installing the package. + - **VS Code**: `@eclipse-glsp/vscode-integration` exposes a `GlspMcpServerProvider` (a `vscode.McpServerDefinitionProvider` implementation). Adopters declare an `mcpServerDefinitionProviders` contribution in their extension's `package.json`, register the provider via `vscode.lm.registerMcpServerDefinitionProvider`, and feed it the GLSP `InitializeResult` via `addServer(...)`. See `example/workflow/extension/src/workflow-extension.ts` in the integration repo for the canonical wiring. - **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. --- diff --git a/packages/server-mcp/README.md b/packages/server-mcp/README.md index 6cdcc05..0532e9c 100644 --- a/packages/server-mcp/README.md +++ b/packages/server-mcp/README.md @@ -27,7 +27,8 @@ 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'; +import { DefaultMcpDiagramModule } from '@eclipse-glsp/server-mcp'; +import { NodeMcpServerModule } from '@eclipse-glsp/server-mcp/node'; const appContainer = new Container(); appContainer.load(createAppModule(options)); @@ -38,16 +39,29 @@ const serverModule = new MyServerModule().configureDiagramModule(new MyDiagramMo const launcher = appContainer.resolve(WebSocketServerLauncher); // Launcher-level bindings — must not be part of `configureDiagramModule`. -launcher.configure(serverModule, new DefaultMcpServerModule()); +launcher.configure(serverModule, new NodeMcpServerModule()); ``` 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. +- `NodeMcpServerModule` 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. +### Browser / Web Worker target + +For browser, Bun, Deno, Cloudflare Workers, or any Fetch-shaped runtime, use the `browser` subpath. `McpWorkerBridge` wires `postMessage` traffic into the launcher for the common Service-Worker → Web-Worker setup: + +```typescript +import { McpWorkerBridge } from '@eclipse-glsp/server-mcp/browser'; + +const bridge = new McpWorkerBridge(); +launcher.configure(serverModule, bridge.createServerModule()); +``` + +The page-side proxy — a Service Worker that intercepts `fetch('/mcp', …)` and forwards each `Request` to the bridge over a `MessageChannel` — is host-side scaffolding that adopters own. See `examples/workflow-server-mcp-demo/mcp-service-worker.js` for a working reference implementation that the workflow browser demo uses end-to-end. + ## 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. diff --git a/packages/server-mcp/browser.d.ts b/packages/server-mcp/browser.d.ts new file mode 100644 index 0000000..46e1032 --- /dev/null +++ b/packages/server-mcp/browser.d.ts @@ -0,0 +1,16 @@ +/******************************************************************************** + * 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 './lib/browser/index'; diff --git a/packages/server-mcp/browser.js b/packages/server-mcp/browser.js new file mode 100644 index 0000000..35eb1c6 --- /dev/null +++ b/packages/server-mcp/browser.js @@ -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 + ********************************************************************************/ +'use strict'; + +module.exports = require('./lib/browser/index'); diff --git a/packages/server-mcp/common.d.ts b/packages/server-mcp/common.d.ts new file mode 100644 index 0000000..2deed3f --- /dev/null +++ b/packages/server-mcp/common.d.ts @@ -0,0 +1,16 @@ +/******************************************************************************** + * 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 './lib/common/index'; diff --git a/packages/server-mcp/common.js b/packages/server-mcp/common.js new file mode 100644 index 0000000..407053c --- /dev/null +++ b/packages/server-mcp/common.js @@ -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 + ********************************************************************************/ +'use strict'; + +module.exports = require('./lib/common/index'); diff --git a/packages/server-mcp/node.d.ts b/packages/server-mcp/node.d.ts new file mode 100644 index 0000000..0551c66 --- /dev/null +++ b/packages/server-mcp/node.d.ts @@ -0,0 +1,16 @@ +/******************************************************************************** + * 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 './lib/node/index'; diff --git a/packages/server-mcp/node.js b/packages/server-mcp/node.js new file mode 100644 index 0000000..d24a5c4 --- /dev/null +++ b/packages/server-mcp/node.js @@ -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 + ********************************************************************************/ +'use strict'; + +module.exports = require('./lib/node/index'); diff --git a/packages/server-mcp/package.json b/packages/server-mcp/package.json index 1efd5ca..a72964a 100644 --- a/packages/server-mcp/package.json +++ b/packages/server-mcp/package.json @@ -1,7 +1,7 @@ { "name": "@eclipse-glsp/server-mcp", "version": "2.7.0-next", - "description": "Extension of the GLSP Node Server for the Model Context Protocol", + "description": "Model Context Protocol (MCP) server for the GLSP TypeScript server — runs on Node, browser, and Fetch-API runtimes", "keywords": [ "eclipse", "graphics", @@ -29,16 +29,25 @@ "url": "https://projects.eclipse.org/projects/ecd.glsp" } ], - "main": "lib/index", - "types": "lib/index", + "main": "lib/node/index", + "browser": { + "lib/node/index": "./lib/browser/index" + }, + "types": "lib/common/index", "files": [ "lib", - "src" + "src", + "node.js", + "node.d.ts", + "browser.js", + "browser.d.ts", + "common.js", + "common.d.ts" ], "scripts": { "build": "tsc -b", "clean": "rimraf lib *.tsbuildinfo coverage .nyc_output", - "generate:index": "glsp generateIndex src -f -s", + "generate:index": "glsp generateIndex src/browser src/common src/node -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", @@ -47,11 +56,8 @@ }, "dependencies": { "@eclipse-glsp/server": "2.7.0-next", - "@modelcontextprotocol/sdk": "^1.29.0", - "express": "^5.2.1" - }, - "devDependencies": { - "@types/express": "^5.0.6" + "@hono/node-server": "^1.19.9", + "@modelcontextprotocol/sdk": "^1.29.0" }, "peerDependencies": { "inversify": "^6.1.3" diff --git a/packages/server-mcp/src/browser/index.ts b/packages/server-mcp/src/browser/index.ts new file mode 100644 index 0000000..d19d7a1 --- /dev/null +++ b/packages/server-mcp/src/browser/index.ts @@ -0,0 +1,21 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +export * from './modules/browser-mcp-server-module'; +export * from './reexport'; +export * from './server/browser-mcp-request-context'; +export * from './server/mcp-worker-bridge'; +export * from './server/web-mcp-server-launcher'; diff --git a/packages/server-mcp/src/browser/modules/browser-mcp-server-module.ts b/packages/server-mcp/src/browser/modules/browser-mcp-server-module.ts new file mode 100644 index 0000000..5244a3b --- /dev/null +++ b/packages/server-mcp/src/browser/modules/browser-mcp-server-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 { AbstractMcpServerModule, DEFAULT_MCP_OPTIONS } from '../../common/modules/abstract-mcp-server-module'; +import { AbstractMcpServerLauncher } from '../../common/server/abstract-mcp-server-launcher'; +import { McpServerDefaults } from '../../common/server/mcp-options'; +import { McpRequestContext } from '../../common/server/mcp-request-context'; +import { BrowserMcpRequestContext } from '../server/browser-mcp-request-context'; +import { WebMcpServerLauncher } from '../server/web-mcp-server-launcher'; + +/** + * Default {@link AbstractMcpServerModule} entry point for browser/web hosts (Bun, Deno, Cloudflare + * Worker, Service Worker, in-page Worker). Ships GLSP-default option values (see + * {@link BrowserMcpServerModule.DEFAULT_OPTIONS}) on top of the abstract module's hook defaults, + * and binds the web {@link WebMcpServerLauncher} + {@link BrowserMcpRequestContext}. Adopter-provided + * overrides via the constructor merge on top. + * + * Node-only deploy-side fields (`host`, `allowedHosts`, `allowedOrigins`, `acknowledgedNoAuth`) + * are intentionally omitted from defaults — adopters set these for their deployment, or run their + * own auth middleware around {@link AbstractMcpServerLauncher.getRequestHandler}. + * + * @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 BrowserMcpServerModule extends AbstractMcpServerModule { + static readonly DEFAULT_OPTIONS: McpServerDefaults = { ...DEFAULT_MCP_OPTIONS }; + + constructor(overrides: McpServerDefaults = {}) { + super({ ...BrowserMcpServerModule.DEFAULT_OPTIONS, ...overrides }); + } + + protected override bindMcpServerLauncher(): BindingTarget { + return WebMcpServerLauncher; + } + + protected override bindMcpRequestContext(): BindingTarget { + return BrowserMcpRequestContext; + } +} diff --git a/packages/server-mcp/src/browser/reexport.ts b/packages/server-mcp/src/browser/reexport.ts new file mode 100644 index 0000000..be944aa --- /dev/null +++ b/packages/server-mcp/src/browser/reexport.ts @@ -0,0 +1,16 @@ +/******************************************************************************** + * 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 '../common'; diff --git a/packages/server-mcp/src/browser/server/browser-mcp-request-context.ts b/packages/server-mcp/src/browser/server/browser-mcp-request-context.ts new file mode 100644 index 0000000..cc1e4f6 --- /dev/null +++ b/packages/server-mcp/src/browser/server/browser-mcp-request-context.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 { injectable } from 'inversify'; +import { McpRequestContext, McpRequestExtra } from '../../common/server/mcp-request-context'; + +/** + * Browser-compatible {@link McpRequestContext} that holds one request context at a time. + * Hosts must serialise dispatches so they don't overwrite each other (e.g. via {@link McpWorkerBridge}). + */ +@injectable() +export class BrowserMcpRequestContext implements McpRequestContext { + protected current: McpRequestExtra | undefined; + protected concurrencyWarned = false; + + run(extra: McpRequestExtra, callback: () => R): R { + // Warn once if an adopter forgot to serialise dispatches — silent context corruption would be hard to debug. + if (this.current !== undefined && !this.concurrencyWarned) { + this.concurrencyWarned = true; + console.warn( + 'BrowserMcpRequestContext: concurrent run() detected — request contexts will overwrite each other. ' + + 'Serialise MCP dispatches (e.g. via McpWorkerBridge or an adopter-side queue around launcher.handleRequest).' + ); + } + const prior = this.current; + this.current = extra; + let result: R; + try { + result = callback(); + } catch (error) { + this.current = prior; + throw error; + } + if (result instanceof Promise) { + return result.finally(() => { + this.current = prior; + }) as unknown as R; + } + this.current = prior; + return result; + } + + getStore(): McpRequestExtra | undefined { + return this.current; + } +} diff --git a/packages/server-mcp/src/browser/server/mcp-worker-bridge.ts b/packages/server-mcp/src/browser/server/mcp-worker-bridge.ts new file mode 100644 index 0000000..d1298e6 --- /dev/null +++ b/packages/server-mcp/src/browser/server/mcp-worker-bridge.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 } from '@eclipse-glsp/server'; +import { AbstractMcpServerLauncher } from '../../common/server/abstract-mcp-server-launcher'; +import { McpServerDefaults } from '../../common/server/mcp-options'; +import { BrowserMcpServerModule } from '../modules/browser-mcp-server-module'; + +export const MCP_REQUEST_MESSAGE_TYPE = 'mcp-request'; +export const MCP_RESPONSE_MESSAGE_TYPE = 'mcp-response'; +export const MCP_INIT_PORT_MESSAGE_TYPE = 'mcp-init-port'; + +export interface McpRequestMessage { + type: typeof MCP_REQUEST_MESSAGE_TYPE; + id: string; + url: string; + method: string; + headers: Record; + body?: string; +} + +export interface McpResponseMessage { + type: typeof MCP_RESPONSE_MESSAGE_TYPE; + id: string; + status: number; + statusText: string; + headers: Record; + /** `ReadableStream` on Chromium (streams SSE); buffered `ArrayBuffer` on browsers without transferable streams. */ + body: ReadableStream | ArrayBuffer | null; +} + +/** Carries the SW↔Worker `MessagePort` transfer that wires MCP traffic over a dedicated channel. */ +export interface McpInitPortMessage { + type: typeof MCP_INIT_PORT_MESSAGE_TYPE; +} + +export function isMcpRequestMessage(value: unknown): value is McpRequestMessage { + return Object(value) === value && (value as { type?: unknown }).type === MCP_REQUEST_MESSAGE_TYPE; +} + +export function isMcpInitPortMessage(value: unknown): value is McpInitPortMessage { + return Object(value) === value && (value as { type?: unknown }).type === MCP_INIT_PORT_MESSAGE_TYPE; +} + +/** Probes whether the current realm can transfer `ReadableStream` via `postMessage` (Chromium yes; Firefox / Safari no). */ +export function canTransferReadableStream(): boolean { + try { + const probe = new ReadableStream(); + structuredClone(probe, { transfer: [probe] }); + return true; + } catch { + return false; + } +} + +/** Minimal Worker scope contract — defined locally so the package compiles without the `webworker` lib. */ +export interface McpBridgeScope { + addEventListener(type: 'message', listener: (event: MessageEvent) => void): void; + removeEventListener(type: 'message', listener: (event: MessageEvent) => void): void; + postMessage(message: unknown, transfer?: Transferable[]): void; +} + +/** + * Bridges `postMessage` traffic to an {@link AbstractMcpServerLauncher} for browser / Web Worker + * adopters that proxy MCP requests via Service-Worker → Web-Worker plumbing. + * See `ARCHITECTURE.md` (Portable handler) for the message protocol and queueing semantics. + */ +export class McpWorkerBridge implements Disposable { + protected resolveLauncher!: (launcher: AbstractMcpServerLauncher) => void; + protected readonly launcherReady: Promise; + protected readonly listener = (event: MessageEvent): void => this.onMessage(event); + /** Serialise dispatches — `BrowserMcpRequestContext` is a single-slot store, not concurrency-safe. */ + protected dispatchChain: Promise = Promise.resolve(); + protected readonly streamTransferable: boolean = canTransferReadableStream(); + protected bufferingWarned = false; + + constructor(protected readonly scope: McpBridgeScope = self as unknown as McpBridgeScope) { + this.launcherReady = new Promise(resolve => { + this.resolveLauncher = resolve; + }); + this.scope.addEventListener('message', this.listener); + } + + dispose(): void { + this.scope.removeEventListener('message', this.listener); + } + + /** Returns a {@link BrowserMcpServerModule} that registers its launcher with this bridge on DI activation. */ + createServerModule(overrides?: McpServerDefaults): BrowserMcpServerModule { + const bridge = this; + class BridgedBrowserMcpServerModule extends BrowserMcpServerModule { + protected override onMcpServerLauncherActivated(launcher: AbstractMcpServerLauncher): void { + bridge.bindLauncher(launcher); + } + } + return new BridgedBrowserMcpServerModule(overrides); + } + + /** First call binds; subsequent calls are no-ops. */ + bindLauncher(launcher: AbstractMcpServerLauncher): void { + this.resolveLauncher(launcher); + } + + protected onMessage(event: MessageEvent): void { + const data: unknown = event.data; + if (isMcpRequestMessage(data)) { + this.enqueueDispatch(data, event.ports[0]); + return; + } + if (isMcpInitPortMessage(data)) { + const port = event.ports[0]; + if (!port) { + return; + } + port.onmessage = portEvent => { + const portData: unknown = portEvent.data; + if (isMcpRequestMessage(portData)) { + this.enqueueDispatch(portData, port); + } + }; + port.start(); + } + } + + protected enqueueDispatch(request: McpRequestMessage, port: MessagePort | undefined): void { + this.dispatchChain = this.dispatchChain + .then(() => this.dispatch(request, port)) + .catch(err => console.error('MCP worker bridge failed:', err)); + } + + protected async dispatch(request: McpRequestMessage, port: MessagePort | undefined): Promise { + try { + const launcher = await this.launcherReady; + const init: RequestInit = { method: request.method, headers: request.headers }; + if (typeof request.body === 'string') { + init.body = request.body; + } + const response = await launcher.handleRequest(new Request(request.url, init)); + const headers: Record = {}; + response.headers.forEach((value, key) => { + headers[key] = value; + }); + this.reply(port, { + type: MCP_RESPONSE_MESSAGE_TYPE, + id: request.id, + status: response.status, + statusText: response.statusText, + headers, + body: await this.encodeBody(response) + }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + this.reply(port, { + type: MCP_RESPONSE_MESSAGE_TYPE, + id: request.id, + status: 500, + statusText: 'Internal Server Error', + headers: { 'content-type': 'text/plain' }, + body: await this.encodeBody(new Response(`MCP worker bridge error: ${message}`)) + }); + } + } + + /** Stream on Chromium; buffer to `ArrayBuffer` (still transferable) on browsers without transferable streams. */ + protected async encodeBody(response: Response): Promise | ArrayBuffer | null> { + if (this.streamTransferable) { + return response.body; + } + if (!this.bufferingWarned) { + this.bufferingWarned = true; + console.warn( + 'McpWorkerBridge: ReadableStream is not transferable in this browser — buffering MCP response bodies. ' + + 'Chunked / SSE streaming will deliver as a single chunk; non-streaming MCP calls are unaffected.' + ); + } + return response.arrayBuffer(); + } + + protected reply(port: MessagePort | undefined, message: McpResponseMessage): void { + const transfer: Transferable[] = message.body ? [message.body] : []; + if (port) { + port.postMessage(message, transfer); + } else { + this.scope.postMessage(message, transfer); + } + } +} diff --git a/packages/server-mcp/src/browser/server/web-mcp-server-launcher.ts b/packages/server-mcp/src/browser/server/web-mcp-server-launcher.ts new file mode 100644 index 0000000..66f93c2 --- /dev/null +++ b/packages/server-mcp/src/browser/server/web-mcp-server-launcher.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 { injectable } from 'inversify'; +import { AbstractMcpServerLauncher, FullMcpServerConfiguration, TransportEndpoint } from '../../common/server/abstract-mcp-server-launcher'; + +/** + * Web target for {@link AbstractMcpServerLauncher}. Web targets (Cloudflare Worker, Deno Deploy, + * Bun, Service Worker, in-page Worker) have no socket to bind; the adopter plugs the inherited + * {@link AbstractMcpServerLauncher.getRequestHandler} into their own listener. `bindTransport` + * is therefore a no-op. + * + * **Adopter responsibilities.** Authentication is the adopter's job. The MCP server has no + * built-in auth — wrap `getRequestHandler()` with whatever middleware your deployment requires + * (bearer token, mTLS at proxy, Cloudflare Access, OAuth, etc.). Session state lives in memory + * inside the launcher; multi-isolate / multi-region deployments that need shared session state + * must subclass and override the session store. + * + * @see `examples/workflow-server-bundled-web/smoke.html` for a Web Worker-based example. + */ +@injectable() +export class WebMcpServerLauncher extends AbstractMcpServerLauncher { + protected async bindTransport(_config: FullMcpServerConfiguration): Promise { + return {}; + } +} diff --git a/packages/server-mcp/src/index.ts b/packages/server-mcp/src/common/index.ts similarity index 94% rename from packages/server-mcp/src/index.ts rename to packages/server-mcp/src/common/index.ts index 9b4b935..2bd1ff4 100644 --- a/packages/server-mcp/src/index.ts +++ b/packages/server-mcp/src/common/index.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2025-2026 EclipseSource and others. + * Copyright (c) 2026 EclipseSource and others. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0 which is available at @@ -13,14 +13,16 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ + +export * from './modules/abstract-mcp-server-module'; export * from './modules/mcp-diagram-module'; -export * from './modules/mcp-server-module'; export * from './prompts/handlers/describe-diagram-mcp-prompt-handler'; export * from './prompts/handlers/suggest-improvements-mcp-prompt-handler'; export * from './resources/handlers/diagram-png-mcp-resource-handler'; export * from './resources/handlers/diagram-svg-mcp-resource-handler'; export * from './resources/services/element-types-provider'; export * from './resources/services/mcp-model-serializer'; +export * from './server/abstract-mcp-server-launcher'; export * from './server/glsp-mcp-server'; export * from './server/lru-event-store'; export * from './server/mcp-diagram-handler-dispatcher'; @@ -28,7 +30,6 @@ export * from './server/mcp-diagram-prompt-handler-registry'; export * from './server/mcp-diagram-resource-handler-registry'; export * from './server/mcp-diagram-tool-handler-registry'; export * from './server/mcp-handler-shared'; -export * from './server/mcp-http-transport'; export * from './server/mcp-id-alias-service'; export * from './server/mcp-input-schemas'; export * from './server/mcp-label-provider'; @@ -40,7 +41,6 @@ export * from './server/mcp-progress-reporter'; export * from './server/mcp-prompt-handler'; export * from './server/mcp-request-context'; export * from './server/mcp-resource-handler'; -export * from './server/mcp-server-launcher'; export * from './server/mcp-session'; export * from './server/mcp-tool-handler'; export * from './tools/handlers/count-elements-mcp-tool-handler'; diff --git a/packages/server-mcp/src/modules/mcp-server-module.ts b/packages/server-mcp/src/common/modules/abstract-mcp-server-module.ts similarity index 76% rename from packages/server-mcp/src/modules/mcp-server-module.ts rename to packages/server-mcp/src/common/modules/abstract-mcp-server-module.ts index e63a591..74def41 100644 --- a/packages/server-mcp/src/modules/mcp-server-module.ts +++ b/packages/server-mcp/src/common/modules/abstract-mcp-server-module.ts @@ -29,24 +29,24 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { interfaces } from 'inversify'; import { DescribeDiagramMcpPromptHandler } from '../prompts/handlers/describe-diagram-mcp-prompt-handler'; import { SuggestImprovementsMcpPromptHandler } from '../prompts/handlers/suggest-improvements-mcp-prompt-handler'; -import { ElementTypesMcpToolHandler } from '../tools/handlers/element-types-mcp-tool-handler'; -import { SessionInfoMcpToolHandler } from '../tools/handlers/session-info-mcp-tool-handler'; +import { AbstractMcpServerLauncher } from '../server/abstract-mcp-server-launcher'; import { DefaultGLSPMcpServer, GLSPMcpServerFactory } from '../server/glsp-mcp-server'; +import { LruEventStore } from '../server/lru-event-store'; import { DefaultMcpDiagramHandlerDispatcher, McpDiagramHandlerDispatcher } from '../server/mcp-diagram-handler-dispatcher'; import { DefaultMcpLogLevelRegistry, McpLogLevelRegistry } from '../server/mcp-log-level-registry'; -import { LruEventStore } from '../server/lru-event-store'; -import { McpHttpTransport } from '../server/mcp-http-transport'; import { McpLogger } from '../server/mcp-logger'; import { McpServerDefaults, McpServerOptions } from '../server/mcp-options'; import { McpProgressReporter } from '../server/mcp-progress-reporter'; import { McpPromptHandler } from '../server/mcp-prompt-handler'; +import { McpRequestContext } from '../server/mcp-request-context'; import { McpResourceHandler } from '../server/mcp-resource-handler'; -import { McpServerLauncher } from '../server/mcp-server-launcher'; import { McpToolHandler } from '../server/mcp-tool-handler'; +import { ElementTypesMcpToolHandler } from '../tools/handlers/element-types-mcp-tool-handler'; +import { SessionInfoMcpToolHandler } from '../tools/handlers/session-info-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 + * the concrete server module's 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 @@ -55,7 +55,7 @@ import { McpToolHandler } from '../server/mcp-tool-handler'; * 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 = ` +export 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. @@ -73,6 +73,17 @@ GLSP MCP server. You have to adhere to the following principles: \`action: "center-on-elements"\` — to point the user at the elements you reference. `; +/** + * Target-neutral default option values shared by every {@link AbstractMcpServerModule} subclass. + * Node and Browser concrete modules spread this and add their own target-specific fields + * (e.g. `host`/`allowedHosts` on Node). + */ +export const DEFAULT_MCP_OPTIONS: McpServerDefaults = { + dataMode: 'tools', + agentPersona: DEFAULT_AGENT_PERSONA, + eventStoreLimit: LruEventStore.DEFAULT_LIMIT +}; + /** * Multi-binding helper for MCP handler classes. Singleton-scoped sibling of core's * `MultiBinding` — same `binding.add(...)` / `binding.rebind(old, new)` adopter shape, but @@ -91,9 +102,11 @@ export class McpHandlerMultiBinding extends AbstractMultiBinding extends AbstractMultiBinding extends AbstractMultiBinding { + this.onMcpServerLauncherActivated(launcher); + return launcher; + }); + // The launcher is aliased 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(); + bind(GLSPServerInitializer).toService(AbstractMcpServerLauncher); + bind(GLSPServerListener).toService(AbstractMcpServerLauncher); 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, McpRequestContext, this.bindMcpRequestContext()).inSingletonScope(); applyBindingTarget(context, GLSPMcpServerFactory, this.bindGLSPMcpServerFactory()); this.configureMultiBinding(new McpHandlerMultiBinding(McpToolHandler), binding => this.configureToolHandlers(binding as McpHandlerMultiBinding) @@ -160,18 +173,23 @@ export abstract class AbstractMcpServerModule extends GLSPModule { } /** - * {@link McpServerLauncher} binding. Bound as a singleton AND aliased to - * `GLSPServerInitializer` + `GLSPServerListener` (the launcher implements both). - * Override to swap in a custom launcher impl. + * {@link AbstractMcpServerLauncher} binding. Bound as a singleton AND aliased to + * `GLSPServerInitializer` + `GLSPServerListener` (the launcher implements both). Concrete + * modules return the target-specific launcher class (Node, Web, ...). */ - protected bindMcpServerLauncher(): BindingTarget { - return McpServerLauncher; - } + protected abstract bindMcpServerLauncher(): BindingTarget; - /** {@link McpHttpTransport} binding. Override to swap to a different transport implementation. */ - protected bindMcpHttpTransport(): BindingTarget { - return McpHttpTransport; - } + /** + * Hook fired once when the {@link AbstractMcpServerLauncher} singleton is first resolved by + * the DI container — lets adopters obtain the instance without subclassing it. No-op by default. + */ + protected onMcpServerLauncherActivated(_launcher: AbstractMcpServerLauncher): void {} + + /** + * {@link McpRequestContext} binding. Concrete modules return the target-specific async-context + * implementation (e.g. Node's `AsyncLocalStorage`-backed variant or a browser-friendly one). + */ + protected abstract bindMcpRequestContext(): BindingTarget; /** * {@link McpDiagramHandlerDispatcher} binding. Owns diagram-scope handler discovery, @@ -196,16 +214,12 @@ export abstract class AbstractMcpServerModule extends GLSPModule { 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}. - */ + /** {@link McpLogger} binding — routes through the active MCP request via {@link McpRequestContext}. */ protected bindMcpLogger(): BindingTarget { return McpLogger; } - /** {@link McpProgressReporter} binding. Same scope/lifecycle story as {@link bindMcpLogger}. */ + /** {@link McpProgressReporter} binding. */ protected bindMcpProgressReporter(): BindingTarget { return McpProgressReporter; } @@ -259,31 +273,3 @@ export abstract class AbstractMcpServerModule extends GLSPModule { 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/modules/mcp-diagram-module.ts b/packages/server-mcp/src/common/modules/mcp-diagram-module.ts similarity index 100% rename from packages/server-mcp/src/modules/mcp-diagram-module.ts rename to packages/server-mcp/src/common/modules/mcp-diagram-module.ts diff --git a/packages/server-mcp/src/prompts/handlers/describe-diagram-mcp-prompt-handler.ts b/packages/server-mcp/src/common/prompts/handlers/describe-diagram-mcp-prompt-handler.ts similarity index 100% rename from packages/server-mcp/src/prompts/handlers/describe-diagram-mcp-prompt-handler.ts rename to packages/server-mcp/src/common/prompts/handlers/describe-diagram-mcp-prompt-handler.ts diff --git a/packages/server-mcp/src/prompts/handlers/suggest-improvements-mcp-prompt-handler.ts b/packages/server-mcp/src/common/prompts/handlers/suggest-improvements-mcp-prompt-handler.ts similarity index 100% rename from packages/server-mcp/src/prompts/handlers/suggest-improvements-mcp-prompt-handler.ts rename to packages/server-mcp/src/common/prompts/handlers/suggest-improvements-mcp-prompt-handler.ts diff --git a/packages/server-mcp/src/resources/handlers/diagram-png-mcp-resource-handler.ts b/packages/server-mcp/src/common/resources/handlers/diagram-png-mcp-resource-handler.ts similarity index 100% rename from packages/server-mcp/src/resources/handlers/diagram-png-mcp-resource-handler.ts rename to packages/server-mcp/src/common/resources/handlers/diagram-png-mcp-resource-handler.ts diff --git a/packages/server-mcp/src/resources/handlers/diagram-svg-mcp-resource-handler.ts b/packages/server-mcp/src/common/resources/handlers/diagram-svg-mcp-resource-handler.ts similarity index 100% rename from packages/server-mcp/src/resources/handlers/diagram-svg-mcp-resource-handler.ts rename to packages/server-mcp/src/common/resources/handlers/diagram-svg-mcp-resource-handler.ts diff --git a/packages/server-mcp/src/resources/services/element-types-provider.ts b/packages/server-mcp/src/common/resources/services/element-types-provider.ts similarity index 100% rename from packages/server-mcp/src/resources/services/element-types-provider.ts rename to packages/server-mcp/src/common/resources/services/element-types-provider.ts diff --git a/packages/server-mcp/src/resources/services/mcp-model-serializer.ts b/packages/server-mcp/src/common/resources/services/mcp-model-serializer.ts similarity index 100% rename from packages/server-mcp/src/resources/services/mcp-model-serializer.ts rename to packages/server-mcp/src/common/resources/services/mcp-model-serializer.ts diff --git a/packages/server-mcp/src/common/server/abstract-mcp-server-launcher.spec.ts b/packages/server-mcp/src/common/server/abstract-mcp-server-launcher.spec.ts new file mode 100644 index 0000000..1beffd5 --- /dev/null +++ b/packages/server-mcp/src/common/server/abstract-mcp-server-launcher.spec.ts @@ -0,0 +1,458 @@ +/******************************************************************************** + * 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, + InitializeParameters, + InitializeResult, + Logger, + NullLogger, + McpInitializeResult +} from '@eclipse-glsp/server'; +import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; +import { expect } from 'chai'; +import { Container, ContainerModule } from 'inversify'; +import * as z from 'zod/v4'; +import { AbstractMcpServerLauncher, FullMcpServerConfiguration, TransportEndpoint } from './abstract-mcp-server-launcher'; +import { DefaultGLSPMcpServer, GLSPMcpServerFactory } from './glsp-mcp-server'; +import { McpDiagramHandlerDispatcher } from './mcp-diagram-handler-dispatcher'; +import { DefaultMcpLogLevelRegistry, McpLogLevelRegistry } from './mcp-log-level-registry'; +import { McpServerDefaults, McpServerOptions } from './mcp-options'; + +class StubDispatcher implements McpDiagramHandlerDispatcher { + harvest(): void { + /* no-op */ + } + reset(): void { + /* no-op */ + } + hasDiagramTools(): boolean { + return false; + } + hasDiagramResources(): boolean { + return false; + } + hasDiagramPrompts(): boolean { + return false; + } + registerAll(): void { + /* no-op */ + } +} + +class StubClientSessionManager { + addListener(): boolean { + return true; + } + removeListener(): boolean { + return true; + } + getSessions(): unknown[] { + return []; + } + getSession(): unknown { + return undefined; + } + getSessionsByType(): unknown[] { + return []; + } + disposeClientSession(): void { + /* no-op */ + } + addClientSession(): boolean { + return true; + } +} + +interface RegisteredAuthCapture { + authInfo?: AuthInfo; + invoked: boolean; +} + +/** + * Test subclass: `bindTransport` is a no-op so the test drives `handleRequest` directly against + * synthetic `Request` objects, asserting the routed `Response`. + */ +class TestNodeMcpServerLauncher extends AbstractMcpServerLauncher { + readonly authCapture: RegisteredAuthCapture = { invoked: false }; + + protected async bindTransport(_config: FullMcpServerConfiguration): Promise { + return {}; + } + + /** + * Register an `echo` tool on each new GLSP MCP server so we can drive a tool/list + tools/call + * round-trip and capture the `extra.authInfo` argument forwarded by the SDK. + */ + protected override createGlspMcpServer(config: FullMcpServerConfiguration): DefaultGLSPMcpServer { + const server = super.createGlspMcpServer(config) as DefaultGLSPMcpServer; + const capture = this.authCapture; + server.registerTool( + 'echo', + { description: 'Echoes back the supplied message.', inputSchema: { message: z.string() } }, + async ({ message }, extra) => { + capture.invoked = true; + capture.authInfo = extra?.authInfo; + return { content: [{ type: 'text', text: message }] }; + } + ); + return server; + } +} + +function buildLauncher(): TestNodeMcpServerLauncher { + const container = new Container(); + container.load( + new ContainerModule(bind => { + bind(Logger).toConstantValue(new NullLogger()); + bind(McpServerOptions).toSelf().inSingletonScope(); + bind(McpServerDefaults).toConstantValue({}); + bind(McpDiagramHandlerDispatcher).toConstantValue(new StubDispatcher()); + bind(McpLogLevelRegistry).to(DefaultMcpLogLevelRegistry).inSingletonScope(); + bind(ClientSessionManager).toConstantValue(new StubClientSessionManager()); + const factory: GLSPMcpServerFactory = (mcpServer, options) => new DefaultGLSPMcpServer(mcpServer, options, new NullLogger()); + bind(GLSPMcpServerFactory).toConstantValue(factory); + bind(TestNodeMcpServerLauncher).toSelf().inSingletonScope(); + }) + ); + return container.get(TestNodeMcpServerLauncher); +} + +const INIT_BODY = { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'spec-client', version: '0.0.1' } + } +}; + +async function initLauncher( + launcher: TestNodeMcpServerLauncher, + overrides: Partial<{ route: string; port: number; host: string; name: string; options: Record }> = {} +): Promise { + const params: InitializeParameters = { + applicationId: 'spec-app', + clientSessionId: 'spec-session', + protocolVersion: '1.0.0', + args: {}, + mcpServer: { + port: overrides.port ?? 0, + host: overrides.host ?? '127.0.0.1', + route: overrides.route ?? '/mcp', + name: overrides.name ?? 'spec', + options: overrides.options ?? {} + } + } as unknown as InitializeParameters; + const baseResult: InitializeResult = { protocolVersion: '1.0.0', serverActions: {} } as unknown as InitializeResult; + return launcher.initializeServer({} as never, params, baseResult); +} + +/** + * Read an SSE stream into a list of `data:` payloads. The Web-standard transport streams JSON-RPC + * responses over `text/event-stream`; we only need to find the first complete event for assertions. + */ +async function readSseFirstData(response: Response, timeoutMs = 2000): Promise { + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('Response has no body'); + } + const decoder = new TextDecoder(); + let buffer = ''; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + for (const block of buffer.split('\n\n')) { + const dataLine = block.split('\n').find(line => line.startsWith('data: ')); + if (dataLine) { + reader.cancel().catch(() => undefined); + return JSON.parse(dataLine.slice('data: '.length)); + } + } + } + reader.cancel().catch(() => undefined); + throw new Error('Timed out waiting for SSE data event'); +} + +function postInit(): Request { + return new Request('http://test/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(INIT_BODY) + }); +} + +function postJson(sessionId: string | undefined, body: unknown): Request { + const headers: Record = { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream' + }; + if (sessionId !== undefined) headers['mcp-session-id'] = sessionId; + return new Request('http://test/mcp', { method: 'POST', headers, body: JSON.stringify(body) }); +} + +describe('AbstractMcpServerLauncher.handleRequest', () => { + let launcher: TestNodeMcpServerLauncher; + + beforeEach(async () => { + launcher = buildLauncher(); + await initLauncher(launcher); + }); + + afterEach(() => { + launcher.dispose(); + }); + + it('POST initialize creates a session and returns 200 with mcp-session-id header', async () => { + const response = await launcher.handleRequest(postInit()); + expect(response.status).to.equal(200); + expect(response.headers.get('mcp-session-id'), 'must echo a session id after initialize').to.be.a('string'); + }); + + it('POST to a known session dispatches to its transport (tools/call round-trip)', async () => { + const initResponse = await launcher.handleRequest(postInit()); + const sessionId = initResponse.headers.get('mcp-session-id')!; + // Read+discard the init SSE response so the transport is ready for the next POST. + await readSseFirstData(initResponse).catch(() => undefined); + + // Per MCP spec, send `notifications/initialized` after the initialize handshake. + await launcher.handleRequest(postJson(sessionId, { jsonrpc: '2.0', method: 'notifications/initialized', params: {} })); + + const callResponse = await launcher.handleRequest( + postJson(sessionId, { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'echo', arguments: { message: 'hello' } } + }) + ); + expect(callResponse.status).to.equal(200); + const payload = (await readSseFirstData(callResponse)) as { result?: { content?: Array<{ text?: string }> } }; + expect(payload.result?.content?.[0]?.text).to.equal('hello'); + }); + + it('POST without session id, non-initialize body → 400 with JSON-RPC error envelope', async () => { + const response = await launcher.handleRequest(postJson(undefined, { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} })); + expect(response.status).to.equal(400); + const payload = (await response.json()) as { jsonrpc: string; error: { code: number; message: string }; id: unknown }; + expect(payload.jsonrpc).to.equal('2.0'); + // eslint-disable-next-line no-null/no-null + expect(payload.id).to.equal(null); + expect(payload.error.code).to.equal(-32000); + expect(payload.error.message).to.match(/No valid session ID/); + }); + + it('POST with unknown session id → 404 with JSON-RPC error envelope', async () => { + const response = await launcher.handleRequest( + postJson('not-a-real-session', { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }) + ); + expect(response.status).to.equal(404); + const payload = (await response.json()) as { error: { code: number } }; + expect(payload.error.code).to.equal(-32001); + }); + + it('GET with accept: text/event-stream on a known session → 200, text/event-stream', async () => { + const initResponse = await launcher.handleRequest(postInit()); + const sessionId = initResponse.headers.get('mcp-session-id')!; + await readSseFirstData(initResponse).catch(() => undefined); + + const getResponse = await launcher.handleRequest( + new Request('http://test/mcp', { + method: 'GET', + headers: { accept: 'text/event-stream', 'mcp-session-id': sessionId } + }) + ); + expect(getResponse.status).to.equal(200); + expect(getResponse.headers.get('content-type')).to.match(/text\/event-stream/); + getResponse.body?.cancel().catch(() => undefined); + }); + + it('GET on unknown session → 404', async () => { + const response = await launcher.handleRequest( + new Request('http://test/mcp', { + method: 'GET', + headers: { accept: 'text/event-stream', 'mcp-session-id': 'nope' } + }) + ); + expect(response.status).to.equal(404); + }); + + it('DELETE on known session fires onSessionClosed; subsequent POSTs return 404', async () => { + const closedIds: string[] = []; + launcher.onSessionClosed(sessionId => closedIds.push(sessionId)); + + const initResponse = await launcher.handleRequest(postInit()); + const sessionId = initResponse.headers.get('mcp-session-id')!; + await readSseFirstData(initResponse).catch(() => undefined); + + const deleteResponse = await launcher.handleRequest( + new Request('http://test/mcp', { method: 'DELETE', headers: { 'mcp-session-id': sessionId } }) + ); + expect(deleteResponse.status).to.be.lessThan(300); + expect(closedIds).to.include(sessionId); + + const followUp = await launcher.handleRequest(postJson(sessionId, { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })); + expect(followUp.status).to.equal(404); + }); + + it('Non-init POST with unsupported MCP-Protocol-Version → 400 with JSON-RPC error envelope', async () => { + const response = await launcher.handleRequest( + new Request('http://test/mcp', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'mcp-session-id': 'anything', + 'mcp-protocol-version': '1999-01-01' + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }) + }) + ); + expect(response.status).to.equal(400); + const payload = (await response.json()) as { error: { code: number; message: string } }; + expect(payload.error.code).to.equal(-32000); + expect(payload.error.message).to.match(/Unsupported MCP-Protocol-Version/); + }); + + it('POST with no MCP-Protocol-Version header passes the version gate (falls through to session-id check)', async () => { + // Spec: server defaults to `2025-03-26` when the header is absent. We assert by + // sending no session id and observing the 400 "No valid session ID" — proving the + // version gate didn't short-circuit. + const response = await launcher.handleRequest( + new Request('http://test/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }) + }) + ); + expect(response.status).to.equal(400); + const payload = (await response.json()) as { error: { message: string } }; + expect(payload.error.message).to.match(/No valid session ID/); + }); + + it('authInfo from handleRequest reaches the registered tool handler extra', async () => { + const initResponse = await launcher.handleRequest(postInit()); + const sessionId = initResponse.headers.get('mcp-session-id')!; + await readSseFirstData(initResponse).catch(() => undefined); + + await launcher.handleRequest(postJson(sessionId, { jsonrpc: '2.0', method: 'notifications/initialized', params: {} })); + + const authInfo: AuthInfo = { token: 'bearer-xyz', clientId: 'spec-client', scopes: ['mcp:tools'] }; + const callResponse = await launcher.handleRequest( + postJson(sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'echo', arguments: { message: 'auth-check' } } + }), + { authInfo } + ); + await readSseFirstData(callResponse).catch(() => undefined); + + expect(launcher.authCapture.invoked).to.equal(true); + expect(launcher.authCapture.authInfo?.token).to.equal('bearer-xyz'); + expect(launcher.authCapture.authInfo?.clientId).to.equal('spec-client'); + }); + + it('onSessionInitialized fires on initialize', async () => { + const sessionIds: string[] = []; + launcher.onSessionInitialized(session => sessionIds.push(session.sessionId)); + + const response = await launcher.handleRequest(postInit()); + const sessionId = response.headers.get('mcp-session-id')!; + expect(sessionIds).to.include(sessionId); + }); + + it('Route mismatch (POST /other when launcher route is /mcp) → 404', async () => { + const response = await launcher.handleRequest( + new Request('http://test/other', { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } }) + ); + expect(response.status).to.equal(404); + }); + + it('Unsupported method (PUT /mcp) → 405 with Allow header', async () => { + const response = await launcher.handleRequest(new Request('http://test/mcp', { method: 'PUT' })); + expect(response.status).to.equal(405); + expect(response.headers.get('Allow')).to.equal('POST, GET, DELETE'); + }); +}); + +describe('AbstractMcpServerLauncher · initializeServer lifecycle', () => { + it('Idempotent initializeServer: second call reuses existing config without re-binding', async () => { + const launcher = buildLauncher(); + let bindCount = 0; + const original = (launcher as unknown as { bindTransport: () => Promise }).bindTransport.bind(launcher); + (launcher as unknown as { bindTransport: () => Promise }).bindTransport = async () => { + bindCount += 1; + return original(); + }; + + await initLauncher(launcher); + await initLauncher(launcher); + expect(bindCount).to.equal(1); + launcher.dispose(); + }); + + it('dispose() closes sessions and resets state; a subsequent initializeServer boots cleanly', async () => { + const launcher = buildLauncher(); + await initLauncher(launcher); + const firstResponse = await launcher.handleRequest(postInit()); + expect(firstResponse.headers.get('mcp-session-id')).to.be.a('string'); + await readSseFirstData(firstResponse).catch(() => undefined); + + launcher.dispose(); + + // Re-init from the same instance — proves dispose() cleared the singleton state. + await initLauncher(launcher, { route: '/mcp', name: 'spec-2' }); + const secondResponse = await launcher.handleRequest(postInit()); + expect(secondResponse.status).to.equal(200); + expect(secondResponse.headers.get('mcp-session-id')).to.be.a('string'); + launcher.dispose(); + }); + + it('honors the custom `route` from initialize params', async () => { + const launcher = buildLauncher(); + await initLauncher(launcher, { route: '/custom' }); + + const wrongRoute = await launcher.handleRequest(postInit()); + expect(wrongRoute.status).to.equal(404); + + const correctRoute = await launcher.handleRequest( + new Request('http://test/custom', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(INIT_BODY) + }) + ); + expect(correctRoute.status).to.equal(200); + launcher.dispose(); + }); + + it('attaches mcpServer.url to the returned InitializeResult when bindTransport reports a URL', async () => { + const launcher = buildLauncher(); + // Override the test subclass's `bindTransport` to report a synthetic URL. + (launcher as unknown as { bindTransport: () => Promise }).bindTransport = async () => ({ + url: 'http://announced.example/mcp' + }); + const result = await initLauncher(launcher); + expect(McpInitializeResult.is(result)).to.equal(true); + expect(McpInitializeResult.getServer(result)?.url).to.equal('http://announced.example/mcp'); + launcher.dispose(); + }); +}); diff --git a/packages/server-mcp/src/common/server/abstract-mcp-server-launcher.ts b/packages/server-mcp/src/common/server/abstract-mcp-server-launcher.ts new file mode 100644 index 0000000..4395c34 --- /dev/null +++ b/packages/server-mcp/src/common/server/abstract-mcp-server-launcher.ts @@ -0,0 +1,524 @@ +/******************************************************************************** + * 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 { + ClientSessionListener, + ClientSessionManager, + Disposable, + DisposableCollection, + Emitter, + GLSPServer, + GLSPServerInitializer, + GLSPServerListener, + InitializeParameters, + InitializeResult, + Logger, + McpInitializeParameters, + McpInitializeResult, + McpServerConfiguration, + McpServerInitOptions +} from '@eclipse-glsp/server'; +import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; +import { + ServerCapabilities, + SUPPORTED_PROTOCOL_VERSIONS, + SetLevelRequestSchema, + isInitializeRequest +} 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 { LruEventStore } from './lru-event-store'; +import { McpDiagramHandlerDispatcher } from './mcp-diagram-handler-dispatcher'; +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, McpSessionId, WithSessionId } from './mcp-session'; +import { McpToolHandler } from './mcp-tool-handler'; + +/** + * 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`. + */ +export type FullMcpServerConfiguration = Omit, 'options'> & { + host: string; + options: McpServerOptionsType; +}; + +/** + * Where this launcher's transport can be reached. Node binders populate `url` (loopback HTTP + * announcement). The web binder returns `{}` because the adopter wires the handler into its + * own listener and may not have a routable URL. + */ +export interface TransportEndpoint { + url?: string; + headers?: Record; +} + +/** + * Per-call options accepted by {@link AbstractMcpServerLauncher.handleRequest}. Adopters wrap + * their own auth middleware around the handler and forward parsed bearer/JWT/etc. info here. + */ +export interface McpRequestHandlerOptions { + /** Authenticated principal info — passed through to the SDK so tool handlers can read it via `extra.authInfo`. */ + authInfo?: AuthInfo; +} + +/** + * 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; +} + +/** + * 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). Centralized 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; + +/** + * Portable, target-neutral MCP server launcher. Owns the per-MCP-session + * {@link WebStandardStreamableHTTPServerTransport} map, the per-session {@link GLSPMcpServer} + * registry, handler registration, and the Fetch-shaped `(Request) => Promise` entry + * point. Concrete subclasses bind a listener (or no listener) in {@link bindTransport}. + * + * **Session state is single-process and in-memory.** Multi-isolate/Durable-Objects adopters + * need to override the session storage hook in a follow-up. + * + * **Authentication** is the adopter's responsibility on non-Node targets — wrap the handler + * with whatever middleware the deployment requires (bearer, mTLS at proxy, Cloudflare Access, + * etc.). Node binders run the {@link assertLoopbackOrAcknowledged} guard since they bind the + * socket themselves. + */ +@injectable() +export abstract class AbstractMcpServerLauncher implements GLSPServerInitializer, GLSPServerListener, Disposable { + @inject(Logger) protected logger: Logger; + + @inject(McpServerOptions) protected mcpOptions: McpServerOptions; + + @inject(McpServerDefaults) protected mcpDefaults: McpServerDefaults; + + @inject(GLSPMcpServerFactory) protected glspMcpServerFactory: GLSPMcpServerFactory; + + @inject(McpDiagramHandlerDispatcher) protected dispatcher: McpDiagramHandlerDispatcher; + + @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 transport — populated on session-init, cleared on session-close. */ + protected readonly sessions = new Map(); + + /** Per-MCP-session GLSPMcpServer registry — populated on session-init, cleared on session-close. */ + protected readonly sessionServers = new Map(); + + protected onSessionInitializedEmitter = new Emitter(); + readonly onSessionInitialized = this.onSessionInitializedEmitter.event; + protected onSessionClosedEmitter = new Emitter(); + readonly onSessionClosed = this.onSessionClosedEmitter.event; + + 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 server. Only the first call binds the listener. + if (this.serverConfig) { + if (this.serverUrl) { + return McpInitializeResult.attachServer(result, { name: this.serverConfig.name, url: this.serverUrl }); + } + return result; + } + + const { port = 0, route = '/mcp', name = 'glsp', options = {} } = mcpServerParam; + const mergedOptions = { ...this.mcpDefaults, ...pickInitOptions(options) }; + this.mcpOptions.values = mergedOptions; + const host = mergedOptions.host ?? '127.0.0.1'; + const mcpServerConfig: FullMcpServerConfiguration = { port, host, route, name, options: mergedOptions }; + + this.dispatcher.harvest(); + this.installResourceListChangedNotifier(); + + const endpoint = await this.bindTransport(mcpServerConfig); + this.serverConfig = mcpServerConfig; + this.serverUrl = endpoint.url; + this.logger.info( + `MCP server '${mcpServerConfig.name}' is ready to accept new client requests on: ${this.serverUrl ?? '(no network endpoint)'}` + ); + + if (endpoint.url) { + return McpInitializeResult.attachServer(result, { + name: mcpServerConfig.name, + url: endpoint.url, + headers: endpoint.headers + }); + } + return result; + } + + serverShutDown(_server: GLSPServer): void { + this.dispose(); + } + + dispose(): void { + // Close transports first so in-flight SSE responses are signalled cleanly. The HTTP + // listener (registered on `toDispose`) is torn down only after the transport closes + // settle — closing it earlier would cut the SSE socket mid-flush. + const closing = Array.from(this.sessions.values()).map(transport => + transport.close().catch(err => this.logger.warn(`Error closing MCP session ${transport.sessionId}: ${err}`)) + ); + this.sessions.clear(); + this.sessionServers.forEach(glspMcpServer => glspMcpServer.dispose()); + this.sessionServers.clear(); + Promise.allSettled(closing).then(() => { + this.toDispose.dispose(); + this.toDispose.clear(); + }); + this.serverUrl = undefined; + this.serverConfig = undefined; + this.dispatcher.reset(); + } + + /** + * Fetch-shaped request entry point. Dispatches POST/GET/DELETE against `config.route`, + * routing to the right per-session SDK transport. Validates the `MCP-Protocol-Version` + * header and the spec-mandated session-id rules before handing off to the SDK. + */ + async handleRequest(req: Request, options?: McpRequestHandlerOptions): Promise { + const url = new URL(req.url); + const route = this.serverConfig?.route ?? '/mcp'; + if (url.pathname !== route) { + return new Response('Not Found', { status: 404 }); + } + if (req.method !== 'POST' && req.method !== 'GET' && req.method !== 'DELETE') { + return new Response('Method Not Allowed', { status: 405, headers: { Allow: 'POST, GET, DELETE' } }); + } + const hostError = this.validateHostHeader(req); + if (hostError) { + return hostError; + } + + let parsedBody: unknown | undefined; + if (req.method === 'POST') { + // `req.clone()` is mandatory: the body stream can only be read once; the SDK reads the original. + parsedBody = await req + .clone() + .json() + .catch(() => undefined); + } + + const isInit = req.method === 'POST' && isInitializeRequest(parsedBody); + const versionError = this.validateProtocolVersionHeader(req, isInit); + if (versionError) { + return versionError; + } + + const transport = this.resolveTransport(req, isInit); + if (transport instanceof Response) { + return transport; + } + try { + return await transport.handleRequest(req, { parsedBody, authInfo: options?.authInfo }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + this.logger.error('Error handling MCP request:', err); + return this.jsonRpcErrorResponse(500, -32603, `Internal server error: ${message}`); + } + } + + /** Bound `(req) => Promise` for adopters plugging the handler into any Fetch-shaped listener. */ + getRequestHandler(): (req: Request, options?: McpRequestHandlerOptions) => Promise { + return (req, options) => this.handleRequest(req, options); + } + + /** Concrete subclasses bind their transport (or no-op for runtimes where the adopter owns the listener). */ + protected abstract bindTransport(config: FullMcpServerConfiguration): Promise; + + /** + * Resolve the right transport for an incoming request. Returns a {@link Response} for the + * spec-mandated 400/404 rejections so the caller can early-return. + */ + protected resolveTransport(req: Request, isInit: boolean): WebStandardStreamableHTTPServerTransport | Response { + const sessionId = req.headers.get('mcp-session-id') ?? undefined; + if (isInit && !sessionId) { + return this.createTransport(); + } + if (!sessionId) { + // MCP Streamable HTTP § Session Management #2: non-initialize request without session id → 400. + return this.jsonRpcErrorResponse(400, -32000, 'Bad Request: No valid session ID provided'); + } + const existing = this.sessions.get(sessionId); + if (!existing) { + // § Session Management #3: unknown or terminated session id → 404. + return this.jsonRpcErrorResponse(404, -32001, 'Session not found'); + } + return existing; + } + + /** + * 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 → pass through (server defaults to `2025-03-26`); + * present-but-unsupported → respond `400` with a JSON-RPC error envelope. + */ + protected validateProtocolVersionHeader(req: Request, isInit: boolean): Response | undefined { + if (isInit) { + return undefined; + } + const version = req.headers.get('mcp-protocol-version') ?? undefined; + if (version === undefined) { + return undefined; + } + if (!SUPPORTED_PROTOCOL_VERSIONS.includes(version)) { + return this.jsonRpcErrorResponse( + 400, + -32000, + `Unsupported MCP-Protocol-Version: '${version}'. Supported versions: ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')}.` + ); + } + return undefined; + } + + /** Port-agnostic Host validation — `WebStandardStreamableHTTPServerTransport` does exact-string match incl. port. */ + protected validateHostHeader(req: Request): Response | undefined { + const allowedHosts = this.mcpOptions.values.allowedHosts; + if (!allowedHosts || allowedHosts.length === 0) { + return undefined; + } + const hostHeader = req.headers.get('host'); + if (!hostHeader) { + return this.jsonRpcErrorResponse(403, -32000, 'Missing Host header'); + } + let hostname: string; + try { + hostname = new URL(`http://${hostHeader}`).hostname; + } catch { + return this.jsonRpcErrorResponse(403, -32000, `Invalid Host header: ${hostHeader}`); + } + if (!allowedHosts.includes(hostname)) { + return this.jsonRpcErrorResponse(403, -32000, `Invalid Host: ${hostname}`); + } + return undefined; + } + + protected createTransport(): WebStandardStreamableHTTPServerTransport { + const allowedOrigins = this.mcpOptions.values.allowedOrigins; + const transport = new WebStandardStreamableHTTPServerTransport({ + // `globalThis.crypto` is available on Node 18+, Bun, Deno, Cloudflare Workers, browsers. + // Avoid `node:crypto` so this file compiles cleanly into a browser bundle. + sessionIdGenerator: () => globalThis.crypto.randomUUID(), + eventStore: new LruEventStore(this.mcpOptions.values.eventStoreLimit, this.logger), + // Host validation lives on `validateHostHeader`. + allowedOrigins, + enableDnsRebindingProtection: (allowedOrigins?.length ?? 0) > 0, + onsessioninitialized: sessionId => { + this.logger.info(`MCP session initialized with ID: ${sessionId}`); + this.sessions.set(sessionId, transport); + if (this.serverConfig) { + this.attachServerToSession(transport, this.serverConfig); + } + this.onSessionInitializedEmitter.fire(transport as WithSessionId); + }, + onsessionclosed: sessionId => this.closeSession(sessionId) + }); + transport.onclose = () => this.closeSession(transport.sessionId); + transport.onerror = err => this.logger.error(`MCP transport error (session ${transport.sessionId ?? ''}):`, err); + return transport; + } + + protected attachServerToSession(transport: WebStandardStreamableHTTPServerTransport, config: FullMcpServerConfiguration): void { + const sessionId = transport.sessionId; + if (!sessionId) { + return; + } + const glspMcpServer = this.createGlspMcpServer(config); + this.sessionServers.set(sessionId, glspMcpServer); + this.registerLogLevelHandler(glspMcpServer, sessionId); + glspMcpServer.connect(transport as McpSession); + } + + protected closeSession(sessionId: string | undefined): void { + if (!sessionId) { + return; + } + const existed = this.sessions.delete(sessionId); + const glspMcpServer = this.sessionServers.get(sessionId); + if (glspMcpServer) { + this.sessionServers.delete(sessionId); + this.logLevelRegistry.clear(sessionId); + glspMcpServer.dispose(); + } + if (existed) { + this.logger.info(`MCP session closed: ${sessionId}`); + this.onSessionClosedEmitter.fire(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 capabilities map from what is actually bound. Only declare a key when at + * least one handler contributes — declaring a capability the SDK never registers a handler + * for produces `-32601 Method not found` on `/list`. 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 } } : {}), + ...(hasResources ? { resources: { listChanged: hasDiagramResources } } : {}), + ...(hasPrompts ? { prompts: { listChanged: false } } : {}) + }; + } + + 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 references a tool that isn't registered on this MCP session + * — catches adopters who unbind a tool a shipped prompt mentions 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.' + ); + } + } + } + + protected jsonRpcErrorResponse(status: number, code: number, message: string): Response { + return new Response(JSON.stringify({ jsonrpc: '2.0', error: { code, message }, id: JSON_RPC_NULL_ID }), { + status, + headers: { 'Content-Type': 'application/json' } + }); + } +} diff --git a/packages/server-mcp/src/server/glsp-mcp-server.spec.ts b/packages/server-mcp/src/common/server/glsp-mcp-server.spec.ts similarity index 100% rename from packages/server-mcp/src/server/glsp-mcp-server.spec.ts rename to packages/server-mcp/src/common/server/glsp-mcp-server.spec.ts diff --git a/packages/server-mcp/src/server/glsp-mcp-server.ts b/packages/server-mcp/src/common/server/glsp-mcp-server.ts similarity index 95% rename from packages/server-mcp/src/server/glsp-mcp-server.ts rename to packages/server-mcp/src/common/server/glsp-mcp-server.ts index 2f67e96..acac27e 100644 --- a/packages/server-mcp/src/server/glsp-mcp-server.ts +++ b/packages/server-mcp/src/common/server/glsp-mcp-server.ts @@ -90,7 +90,7 @@ export class DefaultGLSPMcpServer implements GLSPMcpServer { protected readonly tools = new Map(); protected readonly resources = new Map(); protected readonly prompts = new Map(); - protected keepAliveTimer?: NodeJS.Timeout; + protected keepAliveTimer?: ReturnType; readonly registerTool: McpServer['registerTool']; readonly registerResource: McpServer['registerResource']; @@ -152,7 +152,12 @@ export class DefaultGLSPMcpServer implements GLSPMcpServer { }), KEEP_ALIVE_INTERVAL_MS ); - this.keepAliveTimer.unref(); + // `unref` lives on Node's Timeout but not on the browser's numeric handle; guard so the + // call is a no-op when the runtime doesn't ship it. + const maybeUnref = (this.keepAliveTimer as unknown as { unref?: () => void }).unref; + if (typeof maybeUnref === 'function') { + maybeUnref.call(this.keepAliveTimer); + } } isConnected(): boolean { diff --git a/packages/server-mcp/src/server/lru-event-store.spec.ts b/packages/server-mcp/src/common/server/lru-event-store.spec.ts similarity index 100% rename from packages/server-mcp/src/server/lru-event-store.spec.ts rename to packages/server-mcp/src/common/server/lru-event-store.spec.ts diff --git a/packages/server-mcp/src/server/lru-event-store.ts b/packages/server-mcp/src/common/server/lru-event-store.ts similarity index 97% rename from packages/server-mcp/src/server/lru-event-store.ts rename to packages/server-mcp/src/common/server/lru-event-store.ts index d999970..fb530db 100644 --- a/packages/server-mcp/src/server/lru-event-store.ts +++ b/packages/server-mcp/src/common/server/lru-event-store.ts @@ -15,7 +15,7 @@ ********************************************************************************/ import { Logger } from '@eclipse-glsp/server'; -import { EventId, EventStore, StreamId } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import type { EventId, EventStore, StreamId } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; /** diff --git a/packages/server-mcp/src/server/mcp-diagram-handler-dispatcher.spec.ts b/packages/server-mcp/src/common/server/mcp-diagram-handler-dispatcher.spec.ts similarity index 100% rename from packages/server-mcp/src/server/mcp-diagram-handler-dispatcher.spec.ts rename to packages/server-mcp/src/common/server/mcp-diagram-handler-dispatcher.spec.ts diff --git a/packages/server-mcp/src/server/mcp-diagram-handler-dispatcher.ts b/packages/server-mcp/src/common/server/mcp-diagram-handler-dispatcher.ts similarity index 95% rename from packages/server-mcp/src/server/mcp-diagram-handler-dispatcher.ts rename to packages/server-mcp/src/common/server/mcp-diagram-handler-dispatcher.ts index 3bf61df..a3d4bca 100644 --- a/packages/server-mcp/src/server/mcp-diagram-handler-dispatcher.ts +++ b/packages/server-mcp/src/common/server/mcp-diagram-handler-dispatcher.ts @@ -25,7 +25,7 @@ import { 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, interfaces } from 'inversify'; +import { Container, ContainerModule, inject, injectable, interfaces, optional } from 'inversify'; import { GLSPMcpServer } from './glsp-mcp-server'; import { McpDiagramPromptHandlerRegistry } from './mcp-diagram-prompt-handler-registry'; import { McpDiagramResourceHandlerRegistry } from './mcp-diagram-resource-handler-registry'; @@ -33,7 +33,7 @@ import { McpDiagramToolHandlerRegistry } from './mcp-diagram-tool-handler-regist 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 { McpRequestContext, NoopMcpRequestContext } from './mcp-request-context'; import { AbstractMcpDiagramResourceHandler, McpDiagramResourceHandlerConstructor, toParams } from './mcp-resource-handler'; import { BaseMcpDiagramToolHandler, McpDiagramToolHandlerConstructor } from './mcp-tool-handler'; @@ -53,7 +53,7 @@ export interface DiagramTypeCatalog { /** * Owns diagram-scope handler discovery, SDK registration, and per-MCP-call dispatch routing. - * Extracted from {@link McpServerLauncher} so adopters can `rebind(McpDiagramHandlerDispatcher)` + * Extracted from {@link AbstractMcpServerLauncher} so adopters can `rebind(McpDiagramHandlerDispatcher)` * to a custom implementation without subclassing the entire launcher lifecycle. * * Responsibilities: @@ -84,6 +84,10 @@ export const McpDiagramHandlerDispatcher = Symbol('McpDiagramHandlerDispatcher') @injectable() export class DefaultMcpDiagramHandlerDispatcher implements McpDiagramHandlerDispatcher { + @inject(McpRequestContext) + @optional() + protected requestContext: McpRequestContext = new NoopMcpRequestContext(); + @inject(InjectionContainer) protected serverContainer: Container; @inject(DiagramModules) protected diagramModules: Map; @inject(ClientSessionManager) protected clientSessionManager: ClientSessionManager; @@ -181,7 +185,7 @@ export class DefaultMcpDiagramHandlerDispatcher implements McpDiagramHandlerDisp } seen.set(probe.name, fingerprint); glspMcpServer.registerTool(probe.name, probe.toRegistrationConfig(), (params, extra) => - mcpRequestContext.run(extra, () => this.dispatchDiagramTool(probe.name, params)) + this.requestContext.run(extra, () => this.dispatchDiagramTool(probe.name, params)) ); } } @@ -220,12 +224,12 @@ export class DefaultMcpDiagramHandlerDispatcher implements McpDiagramHandlerDisp if (typeof probe.uri === 'string') { const uri = probe.uri; glspMcpServer.registerResource(name, uri, config, (_uri, extra) => - mcpRequestContext.run(extra, () => this.dispatchStaticDiagramRead(name, uri)) + this.requestContext.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)) + this.requestContext.run(extra, () => this.dispatchTemplatedDiagramRead(name, uri, params)) ); } } @@ -247,7 +251,7 @@ export class DefaultMcpDiagramHandlerDispatcher implements McpDiagramHandlerDisp inputSchema: inputSchema.strict(), outputSchema: probe.toolAlternativeOutputSchema }, - (params, extra) => mcpRequestContext.run(extra, () => this.dispatchDiagramResourceAsTool(name, params)) + (params, extra) => this.requestContext.run(extra, () => this.dispatchDiagramResourceAsTool(name, params)) ); } @@ -262,7 +266,7 @@ export class DefaultMcpDiagramHandlerDispatcher implements McpDiagramHandlerDisp 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)) + this.requestContext.run(extra, () => this.dispatchDiagramPrompt(probe.name, args)) ); } } @@ -276,7 +280,7 @@ export class DefaultMcpDiagramHandlerDispatcher implements McpDiagramHandlerDisp */ protected buildAggregatingResourceTemplate(uriTemplate: string, name: string): ResourceTemplate { return new ResourceTemplate(uriTemplate, { - list: extra => mcpRequestContext.run(extra, () => this.aggregateList(name)), + list: extra => this.requestContext.run(extra, () => this.aggregateList(name)), complete: this.buildAggregatedCompleters(name) }); } diff --git a/packages/server-mcp/src/server/mcp-diagram-prompt-handler-registry.ts b/packages/server-mcp/src/common/server/mcp-diagram-prompt-handler-registry.ts similarity index 100% rename from packages/server-mcp/src/server/mcp-diagram-prompt-handler-registry.ts rename to packages/server-mcp/src/common/server/mcp-diagram-prompt-handler-registry.ts diff --git a/packages/server-mcp/src/server/mcp-diagram-resource-handler-registry.ts b/packages/server-mcp/src/common/server/mcp-diagram-resource-handler-registry.ts similarity index 89% rename from packages/server-mcp/src/server/mcp-diagram-resource-handler-registry.ts rename to packages/server-mcp/src/common/server/mcp-diagram-resource-handler-registry.ts index 7aebb0a..6bc0278 100644 --- a/packages/server-mcp/src/server/mcp-diagram-resource-handler-registry.ts +++ b/packages/server-mcp/src/common/server/mcp-diagram-resource-handler-registry.ts @@ -63,7 +63,10 @@ export class McpDiagramResourceHandlerRegistryInitializer implements ClientSessi } if (!handler.canRegister()) { if (!isProbe) { - this.logger.warn(`Skipping MCP diagram resource handler '${handler.name}': canRegister() returned false.`); + // Debug-level: returning false is the designed-for outcome when the connecting + // client doesn't speak the action the handler relies on (e.g. RequestExportAction + // for diagram-png / diagram-svg). Not actionable for operators. + this.logger.debug(`Skipping MCP diagram resource handler '${handler.name}': canRegister() returned false.`); } continue; } diff --git a/packages/server-mcp/src/server/mcp-diagram-tool-handler-registry.ts b/packages/server-mcp/src/common/server/mcp-diagram-tool-handler-registry.ts similarity index 93% rename from packages/server-mcp/src/server/mcp-diagram-tool-handler-registry.ts rename to packages/server-mcp/src/common/server/mcp-diagram-tool-handler-registry.ts index ae42746..7c55ed1 100644 --- a/packages/server-mcp/src/server/mcp-diagram-tool-handler-registry.ts +++ b/packages/server-mcp/src/common/server/mcp-diagram-tool-handler-registry.ts @@ -87,7 +87,9 @@ export class McpDiagramToolHandlerRegistryInitializer implements ClientSessionIn } if (!handler.canRegister()) { if (!isProbe) { - this.logger.warn(`Skipping MCP diagram tool handler '${handler.name}': canRegister() returned false.`); + // Debug-level: returning false is the designed-for outcome when the connecting + // client doesn't speak the action the handler relies on. Not actionable for operators. + this.logger.debug(`Skipping MCP diagram tool handler '${handler.name}': canRegister() returned false.`); } continue; } diff --git a/packages/server-mcp/src/server/mcp-handler-shared.spec.ts b/packages/server-mcp/src/common/server/mcp-handler-shared.spec.ts similarity index 100% rename from packages/server-mcp/src/server/mcp-handler-shared.spec.ts rename to packages/server-mcp/src/common/server/mcp-handler-shared.spec.ts diff --git a/packages/server-mcp/src/server/mcp-handler-shared.ts b/packages/server-mcp/src/common/server/mcp-handler-shared.ts similarity index 100% rename from packages/server-mcp/src/server/mcp-handler-shared.ts rename to packages/server-mcp/src/common/server/mcp-handler-shared.ts diff --git a/packages/server-mcp/src/server/mcp-id-alias-service.spec.ts b/packages/server-mcp/src/common/server/mcp-id-alias-service.spec.ts similarity index 100% rename from packages/server-mcp/src/server/mcp-id-alias-service.spec.ts rename to packages/server-mcp/src/common/server/mcp-id-alias-service.spec.ts diff --git a/packages/server-mcp/src/server/mcp-id-alias-service.ts b/packages/server-mcp/src/common/server/mcp-id-alias-service.ts similarity index 100% rename from packages/server-mcp/src/server/mcp-id-alias-service.ts rename to packages/server-mcp/src/common/server/mcp-id-alias-service.ts diff --git a/packages/server-mcp/src/server/mcp-input-schemas.ts b/packages/server-mcp/src/common/server/mcp-input-schemas.ts similarity index 86% rename from packages/server-mcp/src/server/mcp-input-schemas.ts rename to packages/server-mcp/src/common/server/mcp-input-schemas.ts index 7081915..9e2a328 100644 --- a/packages/server-mcp/src/server/mcp-input-schemas.ts +++ b/packages/server-mcp/src/common/server/mcp-input-schemas.ts @@ -73,6 +73,19 @@ export const ElementIdentitySchema = z.object({ /** Inferred shape of {@link ElementIdentitySchema} — see its docstring for usage. */ export type ElementIdentity = z.infer; +/** + * Shared output field for mutating tools — the number of underlying GLSP operations a single + * call dispatched. Adopters chain `undo` / `redo` against this count to roll back or replay a + * full user action (a single MCP call may produce several commands, e.g. create-with-label). + * Always non-negative; `0` when nothing was dispatched (e.g. all inputs hit a pre-dispatch + * validation error). + */ +export const dispatchedCommands = z + .number() + .int() + .nonnegative() + .describe('Number of underlying GLSP operations dispatched. Useful when chaining `undo` / `redo`.'); + /** * Inferred shape of {@link McpDiagramScopedInputSchema} — `{ sessionId: string }`. Any * adopter input schema that extends `McpDiagramScopedInputSchema` infers a type structurally diff --git a/packages/server-mcp/src/server/mcp-label-provider.ts b/packages/server-mcp/src/common/server/mcp-label-provider.ts similarity index 100% rename from packages/server-mcp/src/server/mcp-label-provider.ts rename to packages/server-mcp/src/common/server/mcp-label-provider.ts diff --git a/packages/server-mcp/src/server/mcp-log-level-registry.spec.ts b/packages/server-mcp/src/common/server/mcp-log-level-registry.spec.ts similarity index 100% rename from packages/server-mcp/src/server/mcp-log-level-registry.spec.ts rename to packages/server-mcp/src/common/server/mcp-log-level-registry.spec.ts diff --git a/packages/server-mcp/src/server/mcp-log-level-registry.ts b/packages/server-mcp/src/common/server/mcp-log-level-registry.ts similarity index 99% rename from packages/server-mcp/src/server/mcp-log-level-registry.ts rename to packages/server-mcp/src/common/server/mcp-log-level-registry.ts index 32f21a2..c2970fa 100644 --- a/packages/server-mcp/src/server/mcp-log-level-registry.ts +++ b/packages/server-mcp/src/common/server/mcp-log-level-registry.ts @@ -21,7 +21,7 @@ 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 + * server's `logging/setLevel` request handler (registered in {@link AbstractMcpServerLauncher} 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 diff --git a/packages/server-mcp/src/server/mcp-logger.spec.ts b/packages/server-mcp/src/common/server/mcp-logger.spec.ts similarity index 86% rename from packages/server-mcp/src/server/mcp-logger.spec.ts rename to packages/server-mcp/src/common/server/mcp-logger.spec.ts index f1d566f..1d7f997 100644 --- a/packages/server-mcp/src/server/mcp-logger.spec.ts +++ b/packages/server-mcp/src/common/server/mcp-logger.spec.ts @@ -20,7 +20,8 @@ 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'; +import { NodeMcpRequestContext } from '../../node/server/node-mcp-request-context'; +import { McpRequestContext, McpRequestExtra } from './mcp-request-context'; interface RecordedLog { level: string; @@ -43,18 +44,25 @@ class RecordingLogger extends NullLogger { } } -function buildLogger(): { logger: McpLogger; glspLogger: RecordingLogger; levelRegistry: DefaultMcpLogLevelRegistry } { +function buildLogger(): { + logger: McpLogger; + glspLogger: RecordingLogger; + levelRegistry: DefaultMcpLogLevelRegistry; + requestContext: McpRequestContext; +} { const container = new Container(); const glspLogger = new RecordingLogger(); const levelRegistry = new DefaultMcpLogLevelRegistry(); + const requestContext = new NodeMcpRequestContext(); container.load( new ContainerModule(bind => { bind(Logger).toConstantValue(glspLogger); bind(McpLogLevelRegistry).toConstantValue(levelRegistry); + bind(McpRequestContext).toConstantValue(requestContext); bind(McpLogger).toSelf().inSingletonScope(); }) ); - return { logger: container.get(McpLogger), glspLogger, levelRegistry }; + return { logger: container.get(McpLogger), glspLogger, levelRegistry, requestContext }; } /** @@ -92,12 +100,12 @@ describe('McpLogger', () => { }); }); - describe('inside an mcpRequestContext.run frame', () => { + describe('inside an requestContext.run frame', () => { it('emits notifications/message to the bound MCP client AND the GLSP Logger', async () => { - const { logger, glspLogger } = buildLogger(); + const { logger, glspLogger, requestContext } = buildLogger(); const { extra, sent } = buildExtra(); - await mcpRequestContext.run(extra, async () => { + await requestContext.run(extra, async () => { logger.info('one'); logger.warn('two'); logger.error('three'); @@ -122,14 +130,14 @@ describe('McpLogger', () => { }); it('swallows transport failures so a broken MCP send never breaks the producing tool', async () => { - const { logger, glspLogger } = buildLogger(); + const { logger, glspLogger, requestContext } = buildLogger(); const failingExtra = { sendNotification: async () => { throw new Error('transport closed'); } } as unknown as McpRequestExtra; - await mcpRequestContext.run(failingExtra, async () => { + await requestContext.run(failingExtra, async () => { expect(() => logger.error('still works')).to.not.throw(); await new Promise(resolve => setImmediate(resolve)); }); @@ -141,17 +149,17 @@ describe('McpLogger', () => { describe('concurrent request contexts', () => { it('keeps each request frame isolated via AsyncLocalStorage', async () => { - const { logger } = buildLogger(); + const { logger, requestContext } = buildLogger(); const { extra: extraA, sent: sentA } = buildExtra(); const { extra: extraB, sent: sentB } = buildExtra(); await Promise.all([ - mcpRequestContext.run(extraA, async () => { + requestContext.run(extraA, async () => { logger.info('A'); await new Promise(resolve => setImmediate(resolve)); logger.info('A-after-yield'); }), - mcpRequestContext.run(extraB, async () => { + requestContext.run(extraB, async () => { logger.info('B'); await new Promise(resolve => setImmediate(resolve)); logger.info('B-after-yield'); @@ -165,12 +173,12 @@ describe('McpLogger', () => { 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 { logger, glspLogger, levelRegistry, requestContext } = 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 () => { + await requestContext.run(extra, async () => { logger.info('chatty'); logger.debug('verbose'); logger.warn('important'); @@ -185,19 +193,19 @@ describe('McpLogger', () => { }); it('isolates thresholds across sessions (different setLevel per session id)', async () => { - const { logger, levelRegistry } = buildLogger(); + const { logger, levelRegistry, requestContext } = 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 () => { + requestContext.run(extraA, async () => { logger.info('A-info'); logger.error('A-error'); await new Promise(resolve => setImmediate(resolve)); }), - mcpRequestContext.run(extraB, async () => { + requestContext.run(extraB, async () => { logger.info('B-info'); logger.error('B-error'); await new Promise(resolve => setImmediate(resolve)); @@ -209,11 +217,11 @@ describe('McpLogger', () => { }); it('default threshold (no setLevel sent) lets every level through (preserves prior behavior)', async () => { - const { logger } = buildLogger(); + const { logger, requestContext } = buildLogger(); const { extra, sent } = buildExtra('session-default'); // No setLevel call → default threshold = 'debug' → everything emitted. - await mcpRequestContext.run(extra, async () => { + await requestContext.run(extra, async () => { logger.debug('d'); logger.info('i'); logger.warn('w'); diff --git a/packages/server-mcp/src/server/mcp-logger.ts b/packages/server-mcp/src/common/server/mcp-logger.ts similarity index 89% rename from packages/server-mcp/src/server/mcp-logger.ts rename to packages/server-mcp/src/common/server/mcp-logger.ts index 62bc0b0..9cbbb18 100644 --- a/packages/server-mcp/src/server/mcp-logger.ts +++ b/packages/server-mcp/src/common/server/mcp-logger.ts @@ -16,16 +16,16 @@ import { Logger } from '@eclipse-glsp/server'; import type { LoggingLevel } from '@modelcontextprotocol/sdk/types.js'; -import { inject, injectable } from 'inversify'; +import { inject, injectable, optional } from 'inversify'; import { McpLogLevelRegistry, passesLogThreshold } from './mcp-log-level-registry'; -import { mcpRequestContext } from './mcp-request-context'; +import { McpRequestContext, NoopMcpRequestContext } 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}). + * 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 @@ -38,13 +38,17 @@ import { mcpRequestContext } from './mcp-request-context'; * - `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 + * active `McpRequestContext` frame, and the per-MCP-session `logging/setLevel` threshold is * stored in {@link McpLogLevelRegistry}. * * @experimental */ @injectable() export class McpLogger { + @inject(McpRequestContext) + @optional() + protected requestContext: McpRequestContext = new NoopMcpRequestContext(); + @inject(Logger) protected glspLogger: Logger; @inject(McpLogLevelRegistry) protected levelRegistry: McpLogLevelRegistry; @@ -78,7 +82,7 @@ export class McpLogger { * 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(); + const extra = this.requestContext.getStore(); if (!extra) { return; } diff --git a/packages/server-mcp/src/server/mcp-mime-types.ts b/packages/server-mcp/src/common/server/mcp-mime-types.ts similarity index 100% rename from packages/server-mcp/src/server/mcp-mime-types.ts rename to packages/server-mcp/src/common/server/mcp-mime-types.ts diff --git a/packages/server-mcp/src/server/mcp-options.ts b/packages/server-mcp/src/common/server/mcp-options.ts similarity index 96% rename from packages/server-mcp/src/server/mcp-options.ts rename to packages/server-mcp/src/common/server/mcp-options.ts index 7663b93..b1eb96b 100644 --- a/packages/server-mcp/src/server/mcp-options.ts +++ b/packages/server-mcp/src/common/server/mcp-options.ts @@ -37,7 +37,7 @@ export class McpServerOptions { /** * 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. + * `AbstractMcpServerLauncher.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/common/server/mcp-progress-reporter.spec.ts similarity index 76% rename from packages/server-mcp/src/server/mcp-progress-reporter.spec.ts rename to packages/server-mcp/src/common/server/mcp-progress-reporter.spec.ts index 755ac7d..cfa55ce 100644 --- a/packages/server-mcp/src/server/mcp-progress-reporter.spec.ts +++ b/packages/server-mcp/src/common/server/mcp-progress-reporter.spec.ts @@ -17,7 +17,9 @@ import { ServerNotification } from '@modelcontextprotocol/sdk/types.js'; import { expect } from 'chai'; import { McpProgressReporter } from './mcp-progress-reporter'; -import { McpRequestExtra, mcpRequestContext } from './mcp-request-context'; +import { Container } from 'inversify'; +import { NodeMcpRequestContext } from '../../node/server/node-mcp-request-context'; +import { McpRequestContext, McpRequestExtra } from './mcp-request-context'; function buildExtra(progressToken?: string | number): { extra: McpRequestExtra; sent: ServerNotification[] } { const sent: ServerNotification[] = []; @@ -30,12 +32,20 @@ function buildExtra(progressToken?: string | number): { extra: McpRequestExtra; return { extra, sent }; } +function buildReporter(): { reporter: McpProgressReporter; requestContext: McpRequestContext } { + const container = new Container(); + const requestContext = new NodeMcpRequestContext(); + container.bind(McpRequestContext).toConstantValue(requestContext); + container.bind(McpProgressReporter).toSelf().inSingletonScope(); + return { reporter: container.get(McpProgressReporter), requestContext }; +} + describe('McpProgressReporter', () => { it('no-ops inside a request that has no progressToken (client did not opt in)', async () => { - const reporter = new McpProgressReporter(); + const { reporter, requestContext } = buildReporter(); const { extra, sent } = buildExtra(undefined); - await mcpRequestContext.run(extra, async () => { + await requestContext.run(extra, async () => { await reporter.emit({ progress: 0, message: 'starting' }); }); @@ -43,10 +53,10 @@ describe('McpProgressReporter', () => { }); it('emits notifications/progress when the request carries a progressToken', async () => { - const reporter = new McpProgressReporter(); + const { reporter, requestContext } = buildReporter(); const { extra, sent } = buildExtra('tok-42'); - await mcpRequestContext.run(extra, async () => { + await requestContext.run(extra, async () => { await reporter.emit({ progress: 0, message: 'starting' }); await reporter.emit({ progress: 1, total: 3, message: 'step 1/3' }); }); @@ -63,10 +73,10 @@ describe('McpProgressReporter', () => { }); it('accepts numeric progress tokens', async () => { - const reporter = new McpProgressReporter(); + const { reporter, requestContext } = buildReporter(); const { extra, sent } = buildExtra(7); - await mcpRequestContext.run(extra, async () => { + await requestContext.run(extra, async () => { await reporter.emit({ progress: 0 }); }); @@ -75,7 +85,7 @@ describe('McpProgressReporter', () => { }); it('swallows transport failures so a broken send never breaks the producing tool', async () => { - const reporter = new McpProgressReporter(); + const { reporter, requestContext } = buildReporter(); const failingExtra = { sendNotification: async () => { throw new Error('transport closed'); @@ -83,7 +93,7 @@ describe('McpProgressReporter', () => { _meta: { progressToken: 'tok-1' } } as unknown as McpRequestExtra; - await mcpRequestContext.run(failingExtra, async () => { + await requestContext.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. diff --git a/packages/server-mcp/src/server/mcp-progress-reporter.ts b/packages/server-mcp/src/common/server/mcp-progress-reporter.ts similarity index 86% rename from packages/server-mcp/src/server/mcp-progress-reporter.ts rename to packages/server-mcp/src/common/server/mcp-progress-reporter.ts index ec2953f..de47252 100644 --- a/packages/server-mcp/src/server/mcp-progress-reporter.ts +++ b/packages/server-mcp/src/common/server/mcp-progress-reporter.ts @@ -14,8 +14,8 @@ * 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'; +import { inject, injectable, optional } from 'inversify'; +import { McpRequestContext, NoopMcpRequestContext } from './mcp-request-context'; /** * Per-call shape of a `notifications/progress` emission. Mirrors the SDK's @@ -33,7 +33,7 @@ export interface McpProgressBeat { /** * 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 + * `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: @@ -43,14 +43,18 @@ export interface McpProgressBeat { * - 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. + * active `McpRequestContext` frame. * * @experimental */ @injectable() export class McpProgressReporter { + @inject(McpRequestContext) + @optional() + protected requestContext: McpRequestContext = new NoopMcpRequestContext(); + async emit(beat: McpProgressBeat): Promise { - const extra = mcpRequestContext.getStore(); + const extra = this.requestContext.getStore(); const progressToken = extra?._meta?.progressToken; if (extra === undefined || progressToken === undefined) { return; diff --git a/packages/server-mcp/src/server/mcp-prompt-handler.ts b/packages/server-mcp/src/common/server/mcp-prompt-handler.ts similarity index 93% rename from packages/server-mcp/src/server/mcp-prompt-handler.ts rename to packages/server-mcp/src/common/server/mcp-prompt-handler.ts index b5911f2..9606fe2 100644 --- a/packages/server-mcp/src/server/mcp-prompt-handler.ts +++ b/packages/server-mcp/src/common/server/mcp-prompt-handler.ts @@ -15,13 +15,13 @@ ********************************************************************************/ import { ClientId, Logger, MaybePromise, ModelState } from '@eclipse-glsp/server'; -import { inject, injectable, interfaces } from 'inversify'; +import { inject, injectable, interfaces, optional } 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'; +import { McpRequestContext, NoopMcpRequestContext } from './mcp-request-context'; /** * Multi-binding key for **server-scope** prompt handlers — singletons that don't target a @@ -45,6 +45,10 @@ export const McpPromptHandler = Symbol('McpPromptHandler'); /** Shared infrastructure for both server- and diagram-scope prompt handlers. */ @injectable() abstract class BaseMcpPromptHandler { + @inject(McpRequestContext) + @optional() + protected requestContext: McpRequestContext = new NoopMcpRequestContext(); + @inject(Logger) protected logger: Logger; /** @@ -113,7 +117,7 @@ export abstract class AbstractMcpPromptHandler> exte registerPrompt(server: GLSPMcpServer): void { server.registerPrompt(this.name, this.toRegistrationConfig(), async (args, extra) => - mcpRequestContext.run(extra, () => this.execute(() => this.createResult(args as T))) + this.requestContext.run(extra, () => this.execute(() => this.createResult(args as T))) ); } } @@ -141,7 +145,7 @@ export abstract class AbstractMcpDiagramPromptHandler< protected abstract createResult(args: T): MaybePromise; /** - * Public dispatch entry point invoked by {@link McpServerLauncher}'s SDK callback. + * Public dispatch entry point invoked by {@link AbstractMcpServerLauncher}'s SDK callback. */ handle(args: T): Promise { return this.execute(() => this.createResult(args)); diff --git a/packages/server-mcp/src/server/mcp-request-context.ts b/packages/server-mcp/src/common/server/mcp-request-context.ts similarity index 50% rename from packages/server-mcp/src/server/mcp-request-context.ts rename to packages/server-mcp/src/common/server/mcp-request-context.ts index 401800a..cb994b2 100644 --- a/packages/server-mcp/src/server/mcp-request-context.ts +++ b/packages/server-mcp/src/common/server/mcp-request-context.ts @@ -16,24 +16,28 @@ 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; +export const McpRequestContext = Symbol('McpRequestContext'); + /** - * 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()`. + * Carries the SDK's per-request `extra` (notification sender, progress token, request id, + * session id) for the duration of a tool/resource/prompt callback so consumers like the MCP + * logger and progress reporter can reach the client without threading `extra` through their + * own signatures. */ -export const mcpRequestContext = new AsyncLocalStorage(); +export interface McpRequestContext { + run(extra: McpRequestExtra, callback: () => R): R; + getStore(): McpRequestExtra | undefined; +} + +/** No-op fallback used when no implementation is bound (e.g. in unit tests). */ +export class NoopMcpRequestContext implements McpRequestContext { + run(_extra: McpRequestExtra, callback: () => R): R { + return callback(); + } + getStore(): McpRequestExtra | undefined { + return undefined; + } +} diff --git a/packages/server-mcp/src/server/mcp-resource-handler.ts b/packages/server-mcp/src/common/server/mcp-resource-handler.ts similarity index 95% rename from packages/server-mcp/src/server/mcp-resource-handler.ts rename to packages/server-mcp/src/common/server/mcp-resource-handler.ts index 37ba93a..52e0839 100644 --- a/packages/server-mcp/src/server/mcp-resource-handler.ts +++ b/packages/server-mcp/src/common/server/mcp-resource-handler.ts @@ -18,7 +18,7 @@ import { ActionDispatcher, ClientId, Logger, MaybePromise, ModelState, RequestAc 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 { inject, injectable, interfaces, optional } from 'inversify'; import { ZodObject, ZodRawShape } from 'zod/v4'; import { GLSPMcpServer } from './glsp-mcp-server'; import { @@ -33,7 +33,7 @@ import { 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'; +import { McpRequestContext, NoopMcpRequestContext } from './mcp-request-context'; /** * Multi-binding key for **server-scope** resource handlers — singletons that don't target a @@ -55,6 +55,10 @@ export type McpResourceUri = string | { template: string }; /** Shared infrastructure for both server- and diagram-scope resource handlers. */ @injectable() abstract class BaseMcpResourceHandler { + @inject(McpRequestContext) + @optional() + protected requestContext: McpRequestContext = new NoopMcpRequestContext(); + @inject(Logger) protected logger: Logger; /** @@ -184,7 +188,7 @@ abstract class BaseMcpResourceHandler { /** 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, + list: this.list ? async extra => this.requestContext.run(extra, () => this.list!()) : undefined, complete: this.complete?.() }); } @@ -217,11 +221,11 @@ export abstract class AbstractMcpResourceHandler> ex 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)))) + this.requestContext.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.requestContext.run(extra, async () => this.toResourceResult(uri.toString(), await this.execute(() => this.createResult(toParams(params) as T))) ) ); @@ -250,7 +254,7 @@ export abstract class AbstractMcpResourceHandler> ex outputSchema: this.toolAlternativeOutputSchema }, async (params, extra) => - mcpRequestContext.run(extra, async () => this.toToolResult(await this.execute(() => this.createResult(params as T)))) + this.requestContext.run(extra, async () => this.toToolResult(await this.execute(() => this.createResult(params as T)))) ); } } @@ -330,7 +334,7 @@ export abstract class AbstractMcpDiagramResourceHandler< } /** - * Public dispatch entry point invoked by {@link McpServerLauncher}'s SDK callback for + * Public dispatch entry point invoked by {@link AbstractMcpServerLauncher}'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. */ diff --git a/packages/server-mcp/src/server/mcp-session.ts b/packages/server-mcp/src/common/server/mcp-session.ts similarity index 100% rename from packages/server-mcp/src/server/mcp-session.ts rename to packages/server-mcp/src/common/server/mcp-session.ts diff --git a/packages/server-mcp/src/server/mcp-tool-handler.spec.ts b/packages/server-mcp/src/common/server/mcp-tool-handler.spec.ts similarity index 97% rename from packages/server-mcp/src/server/mcp-tool-handler.spec.ts rename to packages/server-mcp/src/common/server/mcp-tool-handler.spec.ts index 3a1cbc4..0656fa8 100644 --- a/packages/server-mcp/src/server/mcp-tool-handler.spec.ts +++ b/packages/server-mcp/src/common/server/mcp-tool-handler.spec.ts @@ -106,13 +106,13 @@ const matrix: Array<{ name: 'create-nodes', Constructor: CreateNodesMcpToolHandler, schema: CreateNodesOutputSchema, - sample: { createdNodes: [{ id: 'n1', elementTypeId: 'node:foo' }], errors: [], warnings: [] } + sample: { createdNodes: [{ id: 'n1', elementTypeId: 'node:foo' }], dispatchedCommands: 1, errors: [], warnings: [] } }, { name: 'create-edges', Constructor: CreateEdgesMcpToolHandler, schema: CreateEdgesOutputSchema, - sample: { createdEdges: [{ id: 'e1', elementTypeId: 'edge' }], errors: [] } + sample: { createdEdges: [{ id: 'e1', elementTypeId: 'edge' }], dispatchedCommands: 1, errors: [] } }, { name: 'modify-nodes', @@ -130,11 +130,11 @@ const matrix: Array<{ name: 'delete-elements', Constructor: DeleteElementsMcpToolHandler, schema: DeleteElementsOutputSchema, - sample: { deletedElements: [{ id: 'n1', elementTypeId: 'node:foo' }], deletedCount: 3 } + sample: { deletedElements: [{ id: 'n1', elementTypeId: 'node:foo' }], deletedCount: 3, dispatchedCommands: 1 } }, { 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: 'layout', Constructor: LayoutMcpToolHandler, schema: LayoutOutputSchema, sample: { applied: true, dispatchedCommands: 1 } }, { name: 'validate-diagram', Constructor: ValidateDiagramMcpToolHandler, diff --git a/packages/server-mcp/src/server/mcp-tool-handler.ts b/packages/server-mcp/src/common/server/mcp-tool-handler.ts similarity index 97% rename from packages/server-mcp/src/server/mcp-tool-handler.ts rename to packages/server-mcp/src/common/server/mcp-tool-handler.ts index 49f2f6c..851e208 100644 --- a/packages/server-mcp/src/server/mcp-tool-handler.ts +++ b/packages/server-mcp/src/common/server/mcp-tool-handler.ts @@ -25,7 +25,7 @@ import { ResponseAction } from '@eclipse-glsp/server'; import { ToolAnnotations } from '@modelcontextprotocol/sdk/types'; -import { inject, injectable, interfaces } from 'inversify'; +import { inject, injectable, interfaces, optional } from 'inversify'; import { ZodObject, ZodRawShape } from 'zod/v4'; import { GLSPMcpServer } from './glsp-mcp-server'; import { @@ -44,7 +44,7 @@ import { 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'; +import { McpRequestContext, NoopMcpRequestContext } from './mcp-request-context'; /** * Multi-binding key for **server-scope** tool handlers — singletons that don't target a @@ -75,6 +75,10 @@ export const McpToolHandler = Symbol('McpToolHandler'); */ @injectable() export abstract class BaseMcpToolHandler { + @inject(McpRequestContext) + @optional() + protected requestContext: McpRequestContext = new NoopMcpRequestContext(); + @inject(Logger) protected logger: Logger; /** @@ -222,7 +226,7 @@ export abstract class AbstractMcpToolHandler> extend registerTool(server: GLSPMcpServer): void { server.registerTool(this.name, this.toRegistrationConfig(), async (params, extra) => - mcpRequestContext.run(extra, () => this.execute(() => this.createResult(params as T))) + this.requestContext.run(extra, () => this.execute(() => this.createResult(params as T))) ); } } @@ -253,7 +257,7 @@ export abstract class BaseMcpDiagramToolHandler; /** - * Public dispatch entry point invoked by {@link McpServerLauncher}'s registered SDK + * Public dispatch entry point invoked by {@link AbstractMcpServerLauncher}'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. diff --git a/packages/server-mcp/src/tools/handlers/count-elements-mcp-tool-handler.spec.ts b/packages/server-mcp/src/common/tools/handlers/count-elements-mcp-tool-handler.spec.ts similarity index 100% rename from packages/server-mcp/src/tools/handlers/count-elements-mcp-tool-handler.spec.ts rename to packages/server-mcp/src/common/tools/handlers/count-elements-mcp-tool-handler.spec.ts diff --git a/packages/server-mcp/src/tools/handlers/count-elements-mcp-tool-handler.ts b/packages/server-mcp/src/common/tools/handlers/count-elements-mcp-tool-handler.ts similarity index 100% rename from packages/server-mcp/src/tools/handlers/count-elements-mcp-tool-handler.ts rename to packages/server-mcp/src/common/tools/handlers/count-elements-mcp-tool-handler.ts diff --git a/packages/server-mcp/src/tools/handlers/create-edges-mcp-tool-handler.spec.ts b/packages/server-mcp/src/common/tools/handlers/create-edges-mcp-tool-handler.spec.ts similarity index 100% rename from packages/server-mcp/src/tools/handlers/create-edges-mcp-tool-handler.spec.ts rename to packages/server-mcp/src/common/tools/handlers/create-edges-mcp-tool-handler.spec.ts diff --git a/packages/server-mcp/src/tools/handlers/create-edges-mcp-tool-handler.ts b/packages/server-mcp/src/common/tools/handlers/create-edges-mcp-tool-handler.ts similarity index 97% rename from packages/server-mcp/src/tools/handlers/create-edges-mcp-tool-handler.ts rename to packages/server-mcp/src/common/tools/handlers/create-edges-mcp-tool-handler.ts index 6c3f024..7e37cfe 100644 --- a/packages/server-mcp/src/tools/handlers/create-edges-mcp-tool-handler.ts +++ b/packages/server-mcp/src/common/tools/handlers/create-edges-mcp-tool-handler.ts @@ -18,7 +18,13 @@ import { ChangeRoutingPointsOperation, CreateEdgeOperation, DiagramConfiguration import { inject, injectable, optional } from 'inversify'; import * as z from 'zod/v4'; import { McpToolResult } from '../../server/mcp-handler-shared'; -import { ElementIdentity, ElementIdentitySchema, McpDiagramScopedInputSchema, position } from '../../server/mcp-input-schemas'; +import { + ElementIdentity, + ElementIdentitySchema, + McpDiagramScopedInputSchema, + dispatchedCommands, + position +} from '../../server/mcp-input-schemas'; import { OperationMcpDiagramToolHandler } from '../../server/mcp-tool-handler'; import { formatNoticeList } from '../../util/mcp-util'; @@ -53,6 +59,7 @@ export const CreateEdgesValidationResultSchema = z.object({ export const CreateEdgesOutputSchema = z.object({ createdEdges: z.array(ElementIdentitySchema).describe('Identity of each edge successfully created. Empty in `dryRun` mode.'), + dispatchedCommands, errors: z.array(z.string()).describe('Per-input failure messages; absent or empty when every input succeeded.'), validationResults: z .array(CreateEdgesValidationResultSchema) @@ -159,7 +166,7 @@ export class CreateEdgesMcpToolHandler extends OperationMcpDiagramToolHandler; export const LayoutOutputSchema = z.object({ - applied: z.boolean().describe('Always true on success — surfaced for parity with other operations.') + applied: z.boolean().describe('Always true on success — surfaced for parity with other operations.'), + dispatchedCommands }); /** Not registered by default: requires an adopter-supplied `LayoutEngine` to bind, which only some GLSP servers ship. */ @@ -53,6 +54,6 @@ export class LayoutMcpToolHandler extends OperationMcpDiagramToolHandler { await this.actionDispatcher.dispatch(LayoutOperation.create()); - return this.success('Automatic layout applied', { applied: true }); + return this.success('Automatic layout applied', { applied: true, dispatchedCommands: 1 }); } } diff --git a/packages/server-mcp/src/tools/handlers/modify-edges-mcp-tool-handler.ts b/packages/server-mcp/src/common/tools/handlers/modify-edges-mcp-tool-handler.ts similarity index 96% rename from packages/server-mcp/src/tools/handlers/modify-edges-mcp-tool-handler.ts rename to packages/server-mcp/src/common/tools/handlers/modify-edges-mcp-tool-handler.ts index d4562b7..8bf38ea 100644 --- a/packages/server-mcp/src/tools/handlers/modify-edges-mcp-tool-handler.ts +++ b/packages/server-mcp/src/common/tools/handlers/modify-edges-mcp-tool-handler.ts @@ -18,7 +18,13 @@ import { ChangeRoutingPointsOperation, GEdge, ReconnectEdgeOperation } from '@ec import { injectable } from 'inversify'; import * as z from 'zod/v4'; import { McpToolError, McpToolResult } from '../../server/mcp-handler-shared'; -import { ElementIdentitySchema, McpDiagramScopedInputSchema, elementId as elementIdSchema, position } from '../../server/mcp-input-schemas'; +import { + ElementIdentitySchema, + McpDiagramScopedInputSchema, + dispatchedCommands, + elementId as elementIdSchema, + position +} from '../../server/mcp-input-schemas'; import { OperationMcpDiagramToolHandler } from '../../server/mcp-tool-handler'; import { formatNoticeList } from '../../util/mcp-util'; @@ -46,7 +52,7 @@ 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.'), + dispatchedCommands, errors: z.array(z.string()).describe('Per-input failure messages; absent or empty when every input succeeded.') }); diff --git a/packages/server-mcp/src/tools/handlers/modify-nodes-mcp-tool-handler.ts b/packages/server-mcp/src/common/tools/handlers/modify-nodes-mcp-tool-handler.ts similarity index 96% rename from packages/server-mcp/src/tools/handlers/modify-nodes-mcp-tool-handler.ts rename to packages/server-mcp/src/common/tools/handlers/modify-nodes-mcp-tool-handler.ts index e2fe387..85f4864 100644 --- a/packages/server-mcp/src/tools/handlers/modify-nodes-mcp-tool-handler.ts +++ b/packages/server-mcp/src/common/tools/handlers/modify-nodes-mcp-tool-handler.ts @@ -18,7 +18,13 @@ import { ApplyLabelEditOperation, ChangeBoundsOperation, GEdge, GShapeElement } import { injectable } from 'inversify'; import * as z from 'zod/v4'; import { McpToolError, McpToolResult } from '../../server/mcp-handler-shared'; -import { ElementIdentitySchema, McpDiagramScopedInputSchema, elementId, position } from '../../server/mcp-input-schemas'; +import { + ElementIdentitySchema, + McpDiagramScopedInputSchema, + dispatchedCommands, + elementId, + position +} from '../../server/mcp-input-schemas'; import { OperationMcpDiagramToolHandler } from '../../server/mcp-tool-handler'; import { formatNoticeList } from '../../util/mcp-util'; @@ -48,7 +54,7 @@ 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).'), + dispatchedCommands, warnings: z .array(z.string()) .describe( diff --git a/packages/server-mcp/src/tools/handlers/query-elements-mcp-tool-handler.spec.ts b/packages/server-mcp/src/common/tools/handlers/query-elements-mcp-tool-handler.spec.ts similarity index 100% rename from packages/server-mcp/src/tools/handlers/query-elements-mcp-tool-handler.spec.ts rename to packages/server-mcp/src/common/tools/handlers/query-elements-mcp-tool-handler.spec.ts diff --git a/packages/server-mcp/src/tools/handlers/query-elements-mcp-tool-handler.ts b/packages/server-mcp/src/common/tools/handlers/query-elements-mcp-tool-handler.ts similarity index 100% rename from packages/server-mcp/src/tools/handlers/query-elements-mcp-tool-handler.ts rename to packages/server-mcp/src/common/tools/handlers/query-elements-mcp-tool-handler.ts diff --git a/packages/server-mcp/src/tools/handlers/redo-mcp-tool-handler.ts b/packages/server-mcp/src/common/tools/handlers/redo-mcp-tool-handler.ts similarity index 100% rename from packages/server-mcp/src/tools/handlers/redo-mcp-tool-handler.ts rename to packages/server-mcp/src/common/tools/handlers/redo-mcp-tool-handler.ts diff --git a/packages/server-mcp/src/tools/handlers/save-model-mcp-tool-handler.ts b/packages/server-mcp/src/common/tools/handlers/save-model-mcp-tool-handler.ts similarity index 100% rename from packages/server-mcp/src/tools/handlers/save-model-mcp-tool-handler.ts rename to packages/server-mcp/src/common/tools/handlers/save-model-mcp-tool-handler.ts diff --git a/packages/server-mcp/src/tools/handlers/session-info-mcp-tool-handler.spec.ts b/packages/server-mcp/src/common/tools/handlers/session-info-mcp-tool-handler.spec.ts similarity index 100% rename from packages/server-mcp/src/tools/handlers/session-info-mcp-tool-handler.spec.ts rename to packages/server-mcp/src/common/tools/handlers/session-info-mcp-tool-handler.spec.ts diff --git a/packages/server-mcp/src/tools/handlers/session-info-mcp-tool-handler.ts b/packages/server-mcp/src/common/tools/handlers/session-info-mcp-tool-handler.ts similarity index 100% rename from packages/server-mcp/src/tools/handlers/session-info-mcp-tool-handler.ts rename to packages/server-mcp/src/common/tools/handlers/session-info-mcp-tool-handler.ts diff --git a/packages/server-mcp/src/tools/handlers/set-selection-mcp-tool-handler.spec.ts b/packages/server-mcp/src/common/tools/handlers/set-selection-mcp-tool-handler.spec.ts similarity index 100% rename from packages/server-mcp/src/tools/handlers/set-selection-mcp-tool-handler.spec.ts rename to packages/server-mcp/src/common/tools/handlers/set-selection-mcp-tool-handler.spec.ts diff --git a/packages/server-mcp/src/tools/handlers/set-selection-mcp-tool-handler.ts b/packages/server-mcp/src/common/tools/handlers/set-selection-mcp-tool-handler.ts similarity index 100% rename from packages/server-mcp/src/tools/handlers/set-selection-mcp-tool-handler.ts rename to packages/server-mcp/src/common/tools/handlers/set-selection-mcp-tool-handler.ts diff --git a/packages/server-mcp/src/tools/handlers/set-view-mcp-tool-handler.ts b/packages/server-mcp/src/common/tools/handlers/set-view-mcp-tool-handler.ts similarity index 100% rename from packages/server-mcp/src/tools/handlers/set-view-mcp-tool-handler.ts rename to packages/server-mcp/src/common/tools/handlers/set-view-mcp-tool-handler.ts diff --git a/packages/server-mcp/src/tools/handlers/undo-mcp-tool-handler.ts b/packages/server-mcp/src/common/tools/handlers/undo-mcp-tool-handler.ts similarity index 100% rename from packages/server-mcp/src/tools/handlers/undo-mcp-tool-handler.ts rename to packages/server-mcp/src/common/tools/handlers/undo-mcp-tool-handler.ts diff --git a/packages/server-mcp/src/tools/handlers/validate-diagram-mcp-tool-handler.ts b/packages/server-mcp/src/common/tools/handlers/validate-diagram-mcp-tool-handler.ts similarity index 100% rename from packages/server-mcp/src/tools/handlers/validate-diagram-mcp-tool-handler.ts rename to packages/server-mcp/src/common/tools/handlers/validate-diagram-mcp-tool-handler.ts diff --git a/packages/server-mcp/src/tools/tool-annotations.spec.ts b/packages/server-mcp/src/common/tools/tool-annotations.spec.ts similarity index 100% rename from packages/server-mcp/src/tools/tool-annotations.spec.ts rename to packages/server-mcp/src/common/tools/tool-annotations.spec.ts diff --git a/packages/server-mcp/src/util/markdown-util.ts b/packages/server-mcp/src/common/util/markdown-util.ts similarity index 100% rename from packages/server-mcp/src/util/markdown-util.ts rename to packages/server-mcp/src/common/util/markdown-util.ts diff --git a/packages/server-mcp/src/util/mcp-util.ts b/packages/server-mcp/src/common/util/mcp-util.ts similarity index 100% rename from packages/server-mcp/src/util/mcp-util.ts rename to packages/server-mcp/src/common/util/mcp-util.ts diff --git a/packages/server-mcp/src/node/index.ts b/packages/server-mcp/src/node/index.ts new file mode 100644 index 0000000..6103438 --- /dev/null +++ b/packages/server-mcp/src/node/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 './modules/node-mcp-server-module'; +export * from './reexport'; +export * from './server/node-mcp-request-context'; +export * from './server/node-mcp-server-launcher'; diff --git a/packages/server-mcp/src/node/modules/node-mcp-server-module.ts b/packages/server-mcp/src/node/modules/node-mcp-server-module.ts new file mode 100644 index 0000000..401714c --- /dev/null +++ b/packages/server-mcp/src/node/modules/node-mcp-server-module.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 { BindingTarget } from '@eclipse-glsp/server'; +import { AbstractMcpServerModule, DEFAULT_MCP_OPTIONS } from '../../common/modules/abstract-mcp-server-module'; +import { AbstractMcpServerLauncher } from '../../common/server/abstract-mcp-server-launcher'; +import { McpServerDefaults } from '../../common/server/mcp-options'; +import { McpRequestContext } from '../../common/server/mcp-request-context'; +import { NodeMcpServerLauncher } from '../server/node-mcp-server-launcher'; +import { NodeMcpRequestContext } from '../server/node-mcp-request-context'; + +/** + * Default {@link AbstractMcpServerModule} entry point for Node hosts. Ships GLSP-default option + * values (see {@link DEFAULT_OPTIONS}) on top of the abstract module's hook defaults, and binds + * the Node-flavored {@link NodeMcpServerLauncher} + {@link NodeMcpRequestContext}. 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 NodeMcpServerModule extends AbstractMcpServerModule { + static readonly DEFAULT_OPTIONS: McpServerDefaults = { + ...DEFAULT_MCP_OPTIONS, + 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. + }; + + constructor(overrides: McpServerDefaults = {}) { + super({ ...NodeMcpServerModule.DEFAULT_OPTIONS, ...overrides }); + } + + protected override bindMcpServerLauncher(): BindingTarget { + return NodeMcpServerLauncher; + } + + protected override bindMcpRequestContext(): BindingTarget { + return NodeMcpRequestContext; + } +} diff --git a/packages/server-mcp/src/node/reexport.ts b/packages/server-mcp/src/node/reexport.ts new file mode 100644 index 0000000..be944aa --- /dev/null +++ b/packages/server-mcp/src/node/reexport.ts @@ -0,0 +1,16 @@ +/******************************************************************************** + * 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 '../common'; diff --git a/packages/server-mcp/src/node/server/mcp-http-transport-e2e.spec.ts b/packages/server-mcp/src/node/server/mcp-http-transport-e2e.spec.ts new file mode 100644 index 0000000..18cfe6b --- /dev/null +++ b/packages/server-mcp/src/node/server/mcp-http-transport-e2e.spec.ts @@ -0,0 +1,297 @@ +/******************************************************************************** + * 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, InitializeParameters, InitializeResult, Logger, NullLogger } from '@eclipse-glsp/server'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { expect } from 'chai'; +import { Container, ContainerModule } from 'inversify'; +import * as z from 'zod/v4'; +import { FullMcpServerConfiguration } from '../../common/server/abstract-mcp-server-launcher'; +import { DefaultGLSPMcpServer, GLSPMcpServerFactory } from '../../common/server/glsp-mcp-server'; +import { McpDiagramHandlerDispatcher } from '../../common/server/mcp-diagram-handler-dispatcher'; +import { DefaultMcpLogLevelRegistry, McpLogLevelRegistry } from '../../common/server/mcp-log-level-registry'; +import { McpServerDefaults, McpServerOptions } from '../../common/server/mcp-options'; +import { NodeMcpServerLauncher } from './node-mcp-server-launcher'; +import { rawHttpRequest } from './raw-http.spec'; + +class StubDispatcher implements McpDiagramHandlerDispatcher { + harvest(): void { + /* no-op */ + } + reset(): void { + /* no-op */ + } + hasDiagramTools(): boolean { + return false; + } + hasDiagramResources(): boolean { + return false; + } + hasDiagramPrompts(): boolean { + return false; + } + registerAll(): void { + /* no-op */ + } +} + +class StubClientSessionManager { + addListener(): boolean { + return true; + } + removeListener(): boolean { + return true; + } + getSessions(): unknown[] { + return []; + } + getSession(): unknown { + return undefined; + } + getSessionsByType(): unknown[] { + return []; + } +} + +/** + * Test subclass that registers a single `echo` tool on every fresh GLSP MCP server. We use this + * to drive a real MCP SDK client through a full handshake without depending on the workflow + * server's diagram handler stack. + */ +class EchoLauncher extends NodeMcpServerLauncher { + protected override createGlspMcpServer(config: FullMcpServerConfiguration): DefaultGLSPMcpServer { + const server = super.createGlspMcpServer(config) as DefaultGLSPMcpServer; + server.registerTool( + 'echo', + { description: 'Returns the supplied message verbatim.', inputSchema: { message: z.string() } }, + async ({ message }) => ({ content: [{ type: 'text', text: message }] }) + ); + return server; + } +} + +function buildLauncher(optionValues: McpServerOptions['values'] = {}): EchoLauncher { + 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(McpServerDefaults).toConstantValue(optionValues); + bind(McpDiagramHandlerDispatcher).toConstantValue(new StubDispatcher()); + bind(McpLogLevelRegistry).to(DefaultMcpLogLevelRegistry).inSingletonScope(); + bind(ClientSessionManager).toConstantValue(new StubClientSessionManager()); + const factory: GLSPMcpServerFactory = (mcpServer, options) => new DefaultGLSPMcpServer(mcpServer, options, new NullLogger()); + bind(GLSPMcpServerFactory).toConstantValue(factory); + bind(EchoLauncher).toSelf().inSingletonScope(); + }) + ); + return container.get(EchoLauncher); +} + +async function startLauncher( + launcher: EchoLauncher, + options: { port?: number; host?: string; route?: string; allowedHosts?: string[]; allowedOrigins?: string[] } = {} +): Promise { + const params: InitializeParameters = { + applicationId: 'spec-app', + clientSessionId: 'spec-session', + protocolVersion: '1.0.0', + args: {}, + mcpServer: { + port: options.port ?? 0, + route: options.route ?? '/mcp', + name: 'spec', + options: {} + } + } as unknown as InitializeParameters; + // Inject the deploy-only fields directly on the merged options holder. + launcher['mcpDefaults'] = { + host: options.host ?? '127.0.0.1', + allowedHosts: options.allowedHosts, + allowedOrigins: options.allowedOrigins + }; + const baseResult: InitializeResult = { protocolVersion: '1.0.0', serverActions: {} } as unknown as InitializeResult; + const result = await launcher.initializeServer({} as never, params, baseResult); + const url = (result as unknown as { mcpServer?: { url?: string } }).mcpServer?.url; + if (!url) throw new Error('launcher did not announce a URL'); + return url; +} + +/** + * End-to-end smoke test: boot the launcher (which spins up the Hono-on-Node listener), + * connect a real SDK MCP client over Streamable HTTP, and exercise a tool round-trip (`echo`). + */ +describe('NodeMcpServerLauncher (e2e — real MCP SDK client over HTTP)', () => { + let launcher: EchoLauncher | undefined; + let client: Client | undefined; + + 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; + launcher?.dispose(); + launcher = undefined; + }); + + it('round-trips tools/list and tools/call against a registered echo tool', async () => { + launcher = buildLauncher(); + const url = await startLauncher(launcher); + expect(url, 'launcher must announce a URL after initializeServer').to.be.a('string'); + + client = new Client({ name: 'wn-058-test-client', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(new URL(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 () => { + launcher = buildLauncher(); + const url = await startLauncher(launcher); + + client = new Client({ name: 'delete-smoke-test', version: '1.0.0' }); + const clientTransport = new StreamableHTTPClientTransport(new URL(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 = Number(new URL(url).port); + + const deleteRes = await rawHttpRequest(port, 'DELETE', { 'mcp-session-id': sessionId! }); + expect(deleteRes.status, 'DELETE should succeed for an active session').to.be.lessThan(300); + + 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); + }); +}); + +describe('NodeMcpServerLauncher (raw HTTP wire validation)', () => { + let launcher: EchoLauncher | undefined; + + afterEach(() => { + launcher?.dispose(); + launcher = undefined; + }); + + async function startWith(values: McpServerOptions['values']): Promise { + launcher = buildLauncher(values); + const url = await startLauncher(launcher, { allowedHosts: values.allowedHosts, allowedOrigins: values.allowedOrigins }); + return Number(new URL(url).port); + } + + it('binds an HTTP listener on a resolvable loopback port', async () => { + launcher = buildLauncher(); + const url = await startLauncher(launcher); + expect(url).to.match(/^http:\/\/(127\.0\.0\.1|localhost):\d+\/mcp$/); + }); + + it('rejects a non-initialize POST without an Mcp-Session-Id header with 400 (§ #2)', async () => { + const port = await startWith({}); + 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.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 startWith({}); + 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.error.code).to.equal(-32001); + }); + + it('rejects a non-initialize POST whose MCP-Protocol-Version is unsupported with HTTP 400', async () => { + const port = await startWith({}); + 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.error.message).to.match(/Unsupported MCP-Protocol-Version/); + }); + + it('rejects requests with a Host header outside the allowlist', async () => { + const port = await startWith({ allowedHosts: ['127.0.0.1', 'localhost'] }); + // POST init body so we go past session-id validation into the SDK transport's host check. + const res = await rawHttpRequest( + port, + 'POST', + { Host: 'evil.example' }, + { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'x', version: '1' } } + } + ); + expect(res.status).to.equal(403); + }); + + it('rejects start when the requested port is already in use with an actionable error', async () => { + const firstLauncher = buildLauncher(); + const url = await startLauncher(firstLauncher); + const taken = Number(new URL(url).port); + const secondLauncher = buildLauncher(); + let error: Error | undefined; + try { + await startLauncher(secondLauncher, { port: taken }); + } catch (err: unknown) { + error = err as Error; + } finally { + firstLauncher.dispose(); + secondLauncher.dispose(); + } + expect(error, 'expected initializeServer to reject').to.not.equal(undefined); + expect(error!.message).to.match(/127\.0\.0\.1:\d+/); + expect(error!.message).to.include('mcpServer.port'); + expect(error!.message).to.match(/already in use/); + }); +}); diff --git a/packages/server-mcp/src/node/server/node-mcp-request-context.ts b/packages/server-mcp/src/node/server/node-mcp-request-context.ts new file mode 100644 index 0000000..82bef66 --- /dev/null +++ b/packages/server-mcp/src/node/server/node-mcp-request-context.ts @@ -0,0 +1,33 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { AsyncLocalStorage } from 'async_hooks'; +import { injectable } from 'inversify'; +import { McpRequestContext, McpRequestExtra } from '../../common/server/mcp-request-context'; + +/** {@link McpRequestContext} backed by native `AsyncLocalStorage`; frame survives across `await`. */ +@injectable() +export class NodeMcpRequestContext implements McpRequestContext { + protected storage = new AsyncLocalStorage(); + + run(extra: McpRequestExtra, callback: () => R): R { + return this.storage.run(extra, callback); + } + + getStore(): McpRequestExtra | undefined { + return this.storage.getStore(); + } +} diff --git a/packages/server-mcp/src/server/mcp-server-launcher.spec.ts b/packages/server-mcp/src/node/server/node-mcp-server-launcher.spec.ts similarity index 86% rename from packages/server-mcp/src/server/mcp-server-launcher.spec.ts rename to packages/server-mcp/src/node/server/node-mcp-server-launcher.spec.ts index 6c21d2e..473645a 100644 --- a/packages/server-mcp/src/server/mcp-server-launcher.spec.ts +++ b/packages/server-mcp/src/node/server/node-mcp-server-launcher.spec.ts @@ -16,10 +16,11 @@ 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'; +import { version as packageVersion } from '../../../package.json'; +import { SERVER_VERSION, pickInitOptions } from '../../common/server/abstract-mcp-server-launcher'; +import { NodeMcpServerLauncher, assertLoopbackOrAcknowledged, isLoopbackHost } from './node-mcp-server-launcher'; -describe('McpServerLauncher · SERVER_VERSION', () => { +describe('NodeMcpServerLauncher · 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 @@ -28,7 +29,7 @@ describe('McpServerLauncher · SERVER_VERSION', () => { }); }); -describe('McpServerLauncher · buildCapabilities', () => { +describe('NodeMcpServerLauncher · 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. @@ -54,7 +55,7 @@ describe('McpServerLauncher · buildCapabilities', () => { hasDiagramResources: () => args.hasDiagramResources ?? false } }; - const proto = McpServerLauncher.prototype as unknown as { + const proto = NodeMcpServerLauncher.prototype as unknown as { buildCapabilities(this: typeof stub, resourcesAsResources: boolean): Record; }; return proto.buildCapabilities.call(stub, resourcesAsResources); @@ -97,7 +98,7 @@ describe('McpServerLauncher · buildCapabilities', () => { }); }); -describe('McpServerLauncher · isLoopbackHost', () => { +describe('NodeMcpServerLauncher · 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); @@ -114,7 +115,7 @@ describe('McpServerLauncher · isLoopbackHost', () => { }); }); -describe('McpServerLauncher · assertLoopbackOrAcknowledged (auth footgun)', () => { +describe('NodeMcpServerLauncher · 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(); @@ -139,7 +140,17 @@ describe('McpServerLauncher · assertLoopbackOrAcknowledged (auth footgun)', () }); }); -describe('McpServerLauncher · pickInitOptions (deploy/init split — defense-in-depth)', () => { +// Compile-time exhaustiveness check — fails the build if `McpServerInitOptions` grows or +// shrinks while the `pickInitOptions` destructure doesn't follow. +type _Equal = (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 ? true : false; +function assertPickInitKeysExhaustive( + _: _Equal +): void { + /* type-only */ +} +assertPickInitKeysExhaustive(true); + +describe('NodeMcpServerLauncher · 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 }); diff --git a/packages/server-mcp/src/node/server/node-mcp-server-launcher.ts b/packages/server-mcp/src/node/server/node-mcp-server-launcher.ts new file mode 100644 index 0000000..69df23f --- /dev/null +++ b/packages/server-mcp/src/node/server/node-mcp-server-launcher.ts @@ -0,0 +1,130 @@ +/******************************************************************************** + * 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 { Disposable } from '@eclipse-glsp/server'; +import { serve } from '@hono/node-server'; +import { injectable } from 'inversify'; +import * as http from 'http'; +import { AddressInfo } from 'net'; +import { AbstractMcpServerLauncher, FullMcpServerConfiguration, TransportEndpoint } from '../../common/server/abstract-mcp-server-launcher'; + +// Re-export the subclass-extension types that adopters need when subclassing the launcher. +export { FullMcpServerConfiguration, TransportEndpoint } from '../../common/server/abstract-mcp-server-launcher'; + +/** + * 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})`. + */ +export const MCP_SERVER_READY_MSG = '[GLSP-MCP-Server]:Ready. '; + +/** + * 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); +} + +/** + * Refuse to bind on a non-loopback host unless the operator has acknowledged that traffic is + * authenticated externally (reverse proxy, mTLS, ACL). The MCP server has no built-in auth. + * Exported for regression tests only; not part of the public 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. Runs in-process via the {@link GLSPServerInitializer} lifecycle. The portable + * per-session multiplexer + handler dispatch lives on {@link AbstractMcpServerLauncher}; this + * subclass only binds the Node HTTP listener via `@hono/node-server`, runs the loopback-auth + * guard, and announces the URL via {@link MCP_SERVER_READY_MSG}. + */ +@injectable() +export class NodeMcpServerLauncher extends AbstractMcpServerLauncher { + protected async bindTransport(config: FullMcpServerConfiguration): Promise { + // Auth-footgun guard: refuse non-loopback bind unless the operator opted in via + // `acknowledgedNoAuth`. Runs BEFORE the listener binds so a careless `host: '0.0.0.0'` + // doesn't get a chance to expose an unauthenticated endpoint. + assertLoopbackOrAcknowledged(config.host, config.options.acknowledgedNoAuth); + + const handler = this.getRequestHandler(); + const server = await this.startListener(handler, config); + // Disable the per-request timeout so long-lived SSE GET streams aren't killed during + // chat idle periods. Node's default 5-minute `requestTimeout` (>=18.1) treats SSE as + // a single in-progress request and tears the socket whenever no events flow for ≥5 min. + server.requestTimeout = 0; + + const addressInfo = this.resolveAddress(server); + const url = this.toServerUrl(addressInfo, config.route); + this.toDispose.push(Disposable.create(() => server.close())); + + // stdout ready-marker for parent processes to discover the URL. Uses `console.log` + // (not the GLSP logger) so adopter logger config can never hide it. + console.log(MCP_SERVER_READY_MSG + JSON.stringify({ name: config.name, url, route: config.route })); + return { url }; + } + + /** + * Start the Hono-on-Node listener, returning the underlying Node `http.Server` once it is + * listening. Translates `EADDRINUSE` into an actionable error naming the offending port + * and the override path adopters reach for first (`mcpServer.port` in `initialize`). + */ + protected startListener(handler: (req: Request) => Promise, config: FullMcpServerConfiguration): Promise { + return new Promise((resolve, reject) => { + const server = serve({ fetch: handler, port: config.port, hostname: config.host }) as http.Server; + server.once('error', err => { + const nodeErr = err as NodeJS.ErrnoException; + if (nodeErr.code === 'EADDRINUSE') { + const portLabel = config.port === 0 ? 'requested address' : `${config.host}:${config.port}`; + 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; + } + reject(err); + }); + server.once('listening', () => resolve(server)); + }); + } + + protected resolveAddress(server: http.Server): AddressInfo { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error(`MCP server unexpectedly bound to ${String(address)} — expected an AddressInfo.`); + } + return address; + } + + 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}`; + } +} diff --git a/packages/server-mcp/src/server/raw-http.spec.ts b/packages/server-mcp/src/node/server/raw-http.spec.ts similarity index 100% rename from packages/server-mcp/src/server/raw-http.spec.ts rename to packages/server-mcp/src/node/server/raw-http.spec.ts 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 deleted file mode 100644 index 9ec2659..0000000 --- a/packages/server-mcp/src/server/mcp-http-transport-e2e.spec.ts +++ /dev/null @@ -1,151 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2026 EclipseSource and others. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * This Source Code may also be made available under the following Secondary - * Licenses when the conditions for such availability set forth in the Eclipse - * Public License v. 2.0 are satisfied: GNU General Public License, version 2 - * with the GNU Classpath Exception which is available at - * https://www.gnu.org/software/classpath/license.html. - * - * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 - ********************************************************************************/ - -import { 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.spec'; - -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 deleted file mode 100644 index 4311003..0000000 --- a/packages/server-mcp/src/server/mcp-http-transport.spec.ts +++ /dev/null @@ -1,385 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2026 EclipseSource and others. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * This Source Code may also be made available under the following Secondary - * Licenses when the conditions for such availability set forth in the Eclipse - * Public License v. 2.0 are satisfied: GNU General Public License, version 2 - * with the GNU Classpath Exception which is available at - * https://www.gnu.org/software/classpath/license.html. - * - * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 - ********************************************************************************/ - -import { 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.spec'; - -/** - * 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 deleted file mode 100644 index b7980dd..0000000 --- a/packages/server-mcp/src/server/mcp-http-transport.ts +++ /dev/null @@ -1,368 +0,0 @@ -/******************************************************************************** - * 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. - // `Transport.close()` is async (returns Promise) but `Disposable.dispose()` is sync, - // so we attach a catch handler to keep stray rejections out of the unhandled-rejection log. - Array.from(this.sessions.values()).forEach(client => - client.close().catch(err => this.logger.warn(`Error closing MCP session ${client.sessionId}: ${err}`)) - ); - 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-server-launcher.ts b/packages/server-mcp/src/server/mcp-server-launcher.ts deleted file mode 100644 index ff42390..0000000 --- a/packages/server-mcp/src/server/mcp-server-launcher.ts +++ /dev/null @@ -1,369 +0,0 @@ -/******************************************************************************** - * 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 { 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); -} - -/** - * Refuse to bind on a non-loopback host unless the operator has acknowledged that traffic is - * authenticated externally (reverse proxy, mTLS, ACL). The MCP server has no built-in auth. - * Exported for regression tests only; not part of the public 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. Runs in-process via the {@link GLSPServerInitializer} lifecycle — not a - * separate process runner. Diagram-scope handler discovery and dispatch are delegated to - * {@link McpDiagramHandlerDispatcher}. - */ -@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: McpDiagramHandlerDispatcher; - - @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 }); - } - - // Port defaults to 0 (random); the resolved URL is announced via the stdout marker - // below. `host` is intentionally NOT in the init-time schema — it comes from the - // server module's adopter defaults (no DNS-rebinding foot-gun via the LLM path). - const { port = 0, route = '/mcp', name = 'glsp', options = {} } = mcpServerParam; - // Init-time options win per field, but only fields in the init allowlist — - // `pickInitOptions` strips any wire-smuggled deploy-only keys before merge. - 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 for parent processes to discover the URL. Uses `console.log` - // (not the GLSP logger) so adopter logger config can never hide it. - 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 capabilities map from what is actually bound. Only declare a key when at - * least one handler contributes — declaring a capability the SDK never registers a handler - * for produces `-32601 Method not found` on `/list`. 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/yarn.lock b/yarn.lock index 87626e6..586a990 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1476,26 +1476,11 @@ 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" @@ -1522,30 +1507,6 @@ 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" @@ -1590,31 +1551,6 @@ 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"