diff --git a/packages/mcp-server/CONTRIBUTING.md b/packages/mcp-server/CONTRIBUTING.md index 80a206bb1f2..024164ceebf 100644 --- a/packages/mcp-server/CONTRIBUTING.md +++ b/packages/mcp-server/CONTRIBUTING.md @@ -47,11 +47,13 @@ The MCP Inspector provides a web-based UI for testing your MCP server: npm run inspector ``` +To test the HTTP transport, start the server first (`node dist/index.js --http`), then run `npx @modelcontextprotocol/inspector`, choose **Streamable HTTP**, and connect to `http://localhost:7427/mcp`. + ## Architecture ### Overview -The MCP server is a **stdio-based** Node.js process that communicates with AI clients via the [Model Context Protocol](https://modelcontextprotocol.io/). All data is pre-processed at build time and bundled with the server — no network access is required at runtime. +The MCP server is a Node.js process that communicates with AI clients via the [Model Context Protocol](https://modelcontextprotocol.io/). It runs over **stdio** by default, or over **Streamable HTTP** with the `--http` flag (see [Transports](#transports)). All data is pre-processed at build time and bundled with the server — no network access is required at runtime. ``` ┌─────────────────────────────────────────────────────────────────────┐ @@ -70,7 +72,7 @@ The MCP server is a **stdio-based** Node.js process that communicates with AI cl └─────────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────────┐ -│ Runtime (stdio) │ +│ Runtime (stdio default / HTTP via --http) │ │ │ │ AI Client ◄──► MCP Server (index.ts) │ │ ├─ create_app reads project-templates.json │ @@ -82,6 +84,20 @@ The MCP server is a **stdio-based** Node.js process that communicates with AI cl └─────────────────────────────────────────────────────────────────────┘ ``` +### Transports + +The transport is selected in `index.ts` from CLI flags / env: + +- **stdio** (default) — no flags. The client spawns the process and communicates over stdin/stdout. Used by all [Setup](README.md#setup) configurations. +- **Streamable HTTP** — enabled with `--http` (or `MCP_TRANSPORT=http`), implemented in `src/http.ts`. + +```bash +node dist/index.js --http # http://127.0.0.1:7427/mcp (default port) +node dist/index.js --http --port 7500 # custom port (or PORT=7500) +``` + +HTTP mode is **stateless** (a fresh server + transport per request, `sessionIdGenerator: undefined`) and binds to `127.0.0.1` only, with DNS-rebinding protection scoped to the bound host/port. The endpoint is served at `POST /mcp`; `GET` and other methods return `405`, unknown paths `404`. The default port `7427` is defined by `DEFAULT_HTTP_PORT` in `index.ts` — keep it in sync with `server.json`. + ### Build Pipeline `npm run update` runs these steps: @@ -120,4 +136,10 @@ Then add the server to any project using the absolute path to the built entry po claude mcp add --scope project ui5-wcr -- node /path/to/ui5-webcomponents-react/packages/mcp-server/dist/index.js ``` +Or over HTTP — start the server first (`node dist/index.js --http`), then register the URL: + +```bash +claude mcp add --scope project --transport http ui5-wcr http://localhost:7427/mcp +``` + After code changes, `npm run compile` is enough. diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index a46114bd5be..75aa04c3a89 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -49,6 +49,40 @@ _This requires `@ui5/webcomponents-react` to be installed in the project's `node npx @ui5/webcomponents-react-mcp@$(node -p "require('@ui5/webcomponents-react/package.json').version") ``` +## Transport + +The server supports two transports. Both run locally on the developer's machine. + +### stdio (default) + +Used by the [Setup](#setup) configurations above. The client spawns the server as a subprocess and communicates over stdin/stdout. No flags are required. + +### HTTP (Streamable HTTP) + +Some MCP clients connect over HTTP instead. Start the server with the `--http` flag to enable the [Streamable HTTP](https://modelcontextprotocol.io/specification/basic/transports#streamable-http) transport: + +```bash +npx @ui5/webcomponents-react-mcp@latest --http +``` + +The server listens on `http://localhost:7427/mcp`. Change the port with `--port` (or the `PORT` environment variable): + +```bash +npx @ui5/webcomponents-react-mcp@latest --http --port 7500 +``` + +It runs **statelessly** and binds to `localhost` only — reachable over a local HTTP port, but otherwise the same local-only, version-pinned server as the stdio mode. Point your HTTP-based client at the endpoint: + +```json +{ + "servers": { + "ui5-wcr": { + "url": "http://localhost:7427/mcp" + } + } +} +``` + ## Features ### Tools diff --git a/packages/mcp-server/scripts/sync-server-version.ts b/packages/mcp-server/scripts/sync-server-version.ts index d41f365d056..e694fe3b2ca 100644 --- a/packages/mcp-server/scripts/sync-server-version.ts +++ b/packages/mcp-server/scripts/sync-server-version.ts @@ -8,7 +8,9 @@ const serverJson = JSON.parse(readFileSync('./server.json', 'utf-8')); const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8')); serverJson.version = packageJson.version; -serverJson.packages[0].version = packageJson.version; +serverJson.packages.forEach((pkg: { version: string }) => { + pkg.version = packageJson.version; +}); writeFileSync('./server.json', JSON.stringify(serverJson)); diff --git a/packages/mcp-server/server.json b/packages/mcp-server/server.json index c269eb07bcb..73be5416a52 100644 --- a/packages/mcp-server/server.json +++ b/packages/mcp-server/server.json @@ -17,6 +17,40 @@ "type": "stdio" }, "environmentVariables": [] + }, + { + "registryType": "npm", + "identifier": "@ui5/webcomponents-react-mcp", + "version": "2.25.1", + "runtimeHint": "npx", + "runtimeArguments": [ + { + "type": "named", + "name": "--http", + "description": "Serve over the Streamable HTTP transport instead of stdio." + }, + { + "type": "named", + "name": "--port", + "valueHint": "port", + "value": "{port}", + "default": "7427", + "format": "number", + "description": "Port the local HTTP server listens on.", + "variables": { + "port": { + "description": "Port the local HTTP server listens on.", + "default": "7427", + "format": "number" + } + } + } + ], + "transport": { + "type": "streamable-http", + "url": "http://localhost:{port}/mcp" + }, + "environmentVariables": [] } ] } diff --git a/packages/mcp-server/src/http.test.ts b/packages/mcp-server/src/http.test.ts new file mode 100644 index 00000000000..0afc2cfff11 --- /dev/null +++ b/packages/mcp-server/src/http.test.ts @@ -0,0 +1,123 @@ +import { request as httpRequest } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import test from 'ava'; +import { startHttpServer } from './http.js'; + +// Minimal server factory so the test exercises the HTTP transport, not the real tool set +// (importing ./index.js would self-start a stdio server on import). +function createTestServer(): McpServer { + const server = new McpServer({ name: 'test-server', version: '0.0.0' }); + server.registerTool('ping', { description: 'Test tool' }, () => ({ + content: [{ type: 'text', text: 'pong' }], + })); + return server; +} + +const JSON_RPC_HEADERS = { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', +}; + +// Responses come back as SSE (`event: message\ndata: {...}`); extract and parse the data payload. +function parseSse(body: string): T { + const line = body.split('\n').find((l) => l.startsWith('data:')); + if (!line) { + throw new Error(`No SSE data line in response: ${body}`); + } + return JSON.parse(line.slice('data:'.length).trim()) as T; +} + +async function withServer(run: (baseUrl: string) => Promise): Promise { + const server = await startHttpServer(createTestServer, 0); + const { port } = server.address() as AddressInfo; + try { + await run(`http://127.0.0.1:${port}`); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +} + +test('POST /mcp initialize returns server info', async (t) => { + await withServer(async (baseUrl) => { + const res = await fetch(`${baseUrl}/mcp`, { + method: 'POST', + headers: JSON_RPC_HEADERS, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test', version: '0.0.0' } }, + }), + }); + t.is(res.status, 200); + const message = parseSse<{ result: { serverInfo: { name: string } } }>(await res.text()); + t.is(message.result.serverInfo.name, 'test-server'); + }); +}); + +test('POST /mcp tools/list works statelessly (no session)', async (t) => { + await withServer(async (baseUrl) => { + const res = await fetch(`${baseUrl}/mcp`, { + method: 'POST', + headers: JSON_RPC_HEADERS, + body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }), + }); + t.is(res.status, 200); + const message = parseSse<{ result: { tools: { name: string }[] } }>(await res.text()); + const toolNames = message.result.tools.map((tool) => tool.name); + t.deepEqual(toolNames, ['ping']); + }); +}); + +test('GET /mcp is rejected with 405', async (t) => { + await withServer(async (baseUrl) => { + const res = await fetch(`${baseUrl}/mcp`, { method: 'GET', headers: JSON_RPC_HEADERS }); + t.is(res.status, 405); + t.is(res.headers.get('allow'), 'POST'); + }); +}); + +test('unknown path returns 404', async (t) => { + await withServer(async (baseUrl) => { + const res = await fetch(`${baseUrl}/nope`, { method: 'POST', headers: JSON_RPC_HEADERS }); + t.is(res.status, 404); + }); +}); + +// undici's fetch strips the forbidden `Host`/`Origin` headers, so use a raw http request to spoof them. +function rawPost(port: number, headers: Record): Promise { + return new Promise((resolve, reject) => { + const req = httpRequest( + { + host: '127.0.0.1', + port, + path: '/mcp', + method: 'POST', + headers: { ...JSON_RPC_HEADERS, ...headers }, + }, + (res) => { + res.resume(); + resolve(res.statusCode ?? 0); + }, + ); + req.on('error', reject); + req.end(JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} })); + }); +} + +test('DNS rebinding protection rejects a foreign Host header', async (t) => { + await withServer(async (baseUrl) => { + const { port } = new URL(baseUrl); + const status = await rawPost(Number(port), { Host: 'evil.example.com' }); + t.not(status, 200); + }); +}); + +test('rejects a foreign Origin header', async (t) => { + await withServer(async (baseUrl) => { + const { port } = new URL(baseUrl); + const status = await rawPost(Number(port), { Origin: 'https://evil.example.com' }); + t.is(status, 403); + }); +}); diff --git a/packages/mcp-server/src/http.ts b/packages/mcp-server/src/http.ts new file mode 100644 index 00000000000..0d59be4ce55 --- /dev/null +++ b/packages/mcp-server/src/http.ts @@ -0,0 +1,100 @@ +/** + * @fileoverview Streamable HTTP transport for the MCP server. + * Runs in stateless mode: a fresh server + transport is created per request, so there is no + * cross-request session state to manage. Bound to localhost for use as a local dev-time server. + */ + +import { createServer as createHttpServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { logger } from './logger.js'; + +const HOST = '127.0.0.1'; +const MCP_PATH = '/mcp'; + +function sendJsonRpcError(res: ServerResponse, status: number, message: string) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32000, message }, + id: null, + }), + ); +} + +/** + * Starts a stateless Streamable HTTP server that serves the MCP endpoint on `POST /mcp`. + * + * @param serverFactory - Builds a fresh {@link McpServer}; called once per request. + * @param port - Port to listen on. Pass `0` to let the OS choose a free port. + * @returns The listening {@link Server}. + */ +export async function startHttpServer(serverFactory: () => McpServer, port: number): Promise { + const httpServer = createHttpServer((req: IncomingMessage, res: ServerResponse) => { + void handleRequest(req, res); + }); + + await new Promise((resolve) => { + httpServer.listen(port, HOST, resolve); + }); + + // Resolve the actual bound port (differs from `port` when 0 was requested). + const address = httpServer.address(); + const boundPort = typeof address === 'object' && address !== null ? address.port : port; + + // Host header is matched exactly, so allow both localhost aliases with the bound port. + const allowedHosts = [`localhost:${boundPort}`, `${HOST}:${boundPort}`]; + // Origin is only validated when present (non-browser clients omit it); browsers from any other + // origin are rejected. Spec-mandated hardening on top of the Host check. + const allowedOrigins = [`http://${HOST}:${boundPort}`, `http://localhost:${boundPort}`]; + + logger.info(`UI5 Web Components for React MCP Server running on http://${HOST}:${boundPort}${MCP_PATH}`); + + async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? `${HOST}:${boundPort}`}`); + + if (url.pathname !== MCP_PATH) { + sendJsonRpcError(res, 404, 'Not found'); + return; + } + + // Stateless mode has no session to GET a stream from or DELETE. + if (req.method !== 'POST') { + res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'POST' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Method not allowed. Use POST for the stateless Streamable HTTP transport.' }, + id: null, + }), + ); + return; + } + + const server = serverFactory(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableDnsRebindingProtection: true, + allowedHosts, + allowedOrigins, + }); + + res.on('close', () => { + void transport.close(); + void server.close(); + }); + + try { + await server.connect(transport); + await transport.handleRequest(req, res); + } catch (error) { + logger.error('Error handling MCP request:', error); + if (!res.headersSent) { + sendJsonRpcError(res, 500, 'Internal server error'); + } + } + } + + return httpServer; +} diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 879801a3346..76196931a94 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -10,6 +10,7 @@ import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { startHttpServer } from './http.js'; import { logger } from './logger.js'; import * as tools from './tools/index.js'; @@ -19,55 +20,90 @@ const __dirname = dirname(__filename); // Read version from package.json const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')); -const server = new McpServer({ - name: 'ui5-webcomponents-react', - version: pkg.version, -}); +const llmsTxtPath = join(__dirname, '..', 'resources', 'llms.txt'); + +/** + * Builds a fully configured MCP server instance with all tools and resources registered. + * A fresh instance is created per stdio process and per HTTP request (stateless transport). + */ +export function createServer(): McpServer { + const server = new McpServer({ + name: 'ui5-webcomponents-react', + version: pkg.version, + }); -// Register all tools -logger.info('Registering tools...'); -const toolList = Object.values(tools); -toolList.forEach((tool) => { - logger.debug(`Registering tool: ${tool.name}`); - server.registerTool( - tool.name, + // Register all tools + logger.debug('Registering tools...'); + const toolList = Object.values(tools); + toolList.forEach((tool) => { + logger.debug(`Registering tool: ${tool.name}`); + server.registerTool( + tool.name, + { + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema, + outputSchema: 'outputSchema' in tool ? tool.outputSchema : undefined, + annotations: tool.annotations, + }, + tool.handler, + ); + }); + logger.debug(`Registered ${toolList.length} tools`); + + // Register llms.txt resource + server.registerResource( + 'llms-txt', + 'file:///llms.txt', { - title: tool.title, - description: tool.description, - inputSchema: tool.inputSchema, - outputSchema: 'outputSchema' in tool ? tool.outputSchema : undefined, - annotations: tool.annotations, + description: 'LLM-friendly documentation index for UI5 Web Components for React', + mimeType: 'text/plain', }, - tool.handler, + () => ({ + contents: [ + { + uri: 'file:///llms.txt', + mimeType: 'text/plain', + text: readFileSync(llmsTxtPath, 'utf-8'), + }, + ], + }), ); -}); + logger.debug('Registered llms.txt resource'); -logger.info(`Registered ${toolList.length} tools`); + return server; +} -// Register llms.txt resource -const llmsTxtPath = join(__dirname, '..', 'resources', 'llms.txt'); -server.registerResource( - 'llms-txt', - 'file:///llms.txt', - { - description: 'LLM-friendly documentation index for UI5 Web Components for React', - mimeType: 'text/plain', - }, - () => ({ - contents: [ - { - uri: 'file:///llms.txt', - mimeType: 'text/plain', - text: readFileSync(llmsTxtPath, 'utf-8'), - }, - ], - }), -); -logger.info('Registered llms.txt resource'); +const DEFAULT_HTTP_PORT = 7427; + +function parseArgs(argv: string[]): { http: boolean; port: number } { + const http = argv.includes('--http') || process.env.MCP_TRANSPORT === 'http'; + + let port = Number(process.env.PORT ?? DEFAULT_HTTP_PORT); + const portFlagIndex = argv.findIndex((arg) => arg === '--port' || arg.startsWith('--port=')); + if (portFlagIndex !== -1) { + const flag = argv[portFlagIndex]; + const raw = flag.includes('=') ? flag.slice(flag.indexOf('=') + 1) : argv[portFlagIndex + 1]; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) { + throw new Error(`Invalid --port value: ${raw}`); + } + port = parsed; + } + + return { http, port }; +} async function main() { + const { http, port } = parseArgs(process.argv.slice(2)); + + if (http) { + await startHttpServer(createServer, port); + return; + } + const transport = new StdioServerTransport(); - await server.connect(transport); + await createServer().connect(transport); logger.info('UI5 Web Components for React MCP Server running on stdio'); }