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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions packages/mcp-server/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

```
┌─────────────────────────────────────────────────────────────────────┐
Expand All @@ -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 │
Expand All @@ -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:
Expand Down Expand Up @@ -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.
34 changes: 34 additions & 0 deletions packages/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion packages/mcp-server/scripts/sync-server-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
34 changes: 34 additions & 0 deletions packages/mcp-server/server.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
}
]
}
123 changes: 123 additions & 0 deletions packages/mcp-server/src/http.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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<void>): Promise<void> {
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<void>((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<string, string>): Promise<number> {
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);
});
});
100 changes: 100 additions & 0 deletions packages/mcp-server/src/http.ts
Original file line number Diff line number Diff line change
@@ -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<Server> {
const httpServer = createHttpServer((req: IncomingMessage, res: ServerResponse) => {
void handleRequest(req, res);
});

await new Promise<void>((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<void> {
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;
}
Loading
Loading