@ultimatelemon-eu/mcp-http 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,59 @@
1
- # Temporary Holding Version
1
+ # @ultimatelemon-eu/mcp-http
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Runs an [MCP](https://modelcontextprotocol.io) server over Streamable HTTP in
4
+ stateless mode: `POST /mcp`, `GET /healthz`, an optional base path for a proxy,
5
+ and a JSON-RPC 500 instead of a stack trace when a handler throws. Everything
6
+ else is your tools.
7
+
8
+ ```bash
9
+ npm install @ultimatelemon-eu/mcp-http @modelcontextprotocol/sdk
10
+ ```
11
+
12
+ ```ts
13
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
14
+ import { startMcpHttpServer } from '@ultimatelemon-eu/mcp-http';
15
+
16
+ function createMcpServer() {
17
+ const server = new McpServer({ name: 'my-mcp', version: '1.0.0' });
18
+ server.registerTool('ping', { description: 'Replies pong' }, () => ({
19
+ content: [{ type: 'text', text: 'pong' }]
20
+ }));
21
+ return server;
22
+ }
23
+
24
+ const http = await startMcpHttpServer({
25
+ createMcpServer,
26
+ port: Number(process.env.PORT ?? 3000)
27
+ });
28
+
29
+ process.once('SIGTERM', () => void http.close().then(() => process.exit(0)));
30
+ ```
31
+
32
+ ## Options
33
+
34
+ | Option | Default | Does |
35
+ | ----------------- | ----------- | ---------------------------------------------------- |
36
+ | `createMcpServer` | required | Returns a new `McpServer`; called once per request |
37
+ | `port` | required | `0` picks a free port; read it back from `.port` |
38
+ | `host` | `'0.0.0.0'` | `'127.0.0.1'` keeps it local |
39
+ | `basePath` | `''` | Also serve `<basePath>/mcp` and `<basePath>/healthz` |
40
+ | `health` | always ok | Return `false` for a 503 on `/healthz` |
41
+ | `logger` | silent | Anything with `error(obj, msg)`, such as pino |
42
+
43
+ It returns `{ url, port, close() }`.
44
+
45
+ ## Why stateless
46
+
47
+ A new server and transport per request means no session table, so it does not
48
+ matter which replica a request lands on and a restart loses nothing. The
49
+ price: no server-initiated messages and no `GET` stream, which is why `GET` and
50
+ `DELETE` on `/mcp` answer 405. Tools that read and return data do not need
51
+ either.
52
+
53
+ Keep shared state, such as an API client, a cache or a loaded list, outside
54
+ `createMcpServer` and close over it, so a request only builds the thin server.
55
+
56
+ ## Auth
57
+
58
+ There is none. Put the server behind an authenticating proxy (Airlock, for the
59
+ UltimateLemon MCPs) or keep it on localhost.
@@ -0,0 +1 @@
1
+ export { startMcpHttpServer, type Logger, type McpHttpOptions, type McpHttpServer } from './server.js';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { startMcpHttpServer } from './server.js';
@@ -0,0 +1,25 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ /** The slice of a pino-style logger this package calls. */
3
+ export interface Logger {
4
+ error(obj: object, msg?: string): void;
5
+ }
6
+ export interface McpHttpOptions {
7
+ /** Builds a fresh server per request; the transport is stateless. */
8
+ createMcpServer: () => McpServer;
9
+ port: number;
10
+ /** Interface to listen on. Default `0.0.0.0`. */
11
+ host?: string;
12
+ /** Prefix a proxy leaves on the path, e.g. `/ns`. Both `/mcp` and `<basePath>/mcp` are served. */
13
+ basePath?: string;
14
+ /** Readiness for `/healthz`; return false for a 503. Default: always ready. */
15
+ health?: () => boolean | Promise<boolean>;
16
+ logger?: Logger;
17
+ }
18
+ export interface McpHttpServer {
19
+ /** Full URL of the MCP endpoint, e.g. `http://localhost:3000/mcp`. */
20
+ url: string;
21
+ /** The port actually bound, useful with `port: 0`. */
22
+ port: number;
23
+ close(): Promise<void>;
24
+ }
25
+ export declare function startMcpHttpServer(options: McpHttpOptions): Promise<McpHttpServer>;
package/dist/server.js ADDED
@@ -0,0 +1,86 @@
1
+ import { createServer } from 'node:http';
2
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
3
+ const silent = { error: () => { } };
4
+ function normalizeBasePath(basePath = '') {
5
+ const trimmed = basePath.replace(/\/+$/, '');
6
+ if (!trimmed)
7
+ return '';
8
+ return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
9
+ }
10
+ function sendJson(res, status, body) {
11
+ res.writeHead(status, { 'content-type': 'application/json' });
12
+ res.end(JSON.stringify(body));
13
+ }
14
+ function jsonRpcError(res, status, message) {
15
+ sendJson(res, status, {
16
+ jsonrpc: '2.0',
17
+ error: { code: -32000, message },
18
+ id: null
19
+ });
20
+ }
21
+ export async function startMcpHttpServer(options) {
22
+ const logger = options.logger ?? silent;
23
+ const base = normalizeBasePath(options.basePath);
24
+ const mcpPaths = new Set([`${base}/mcp`, '/mcp']);
25
+ const healthPaths = new Set([`${base}/healthz`, '/healthz']);
26
+ async function handleMcp(req, res) {
27
+ // Stateless mode has no session to attach a GET stream or a DELETE to.
28
+ if (req.method !== 'POST') {
29
+ res.setHeader('allow', 'POST');
30
+ jsonRpcError(res, 405, 'Method not allowed');
31
+ return;
32
+ }
33
+ const server = options.createMcpServer();
34
+ const transport = new StreamableHTTPServerTransport({
35
+ sessionIdGenerator: undefined,
36
+ enableJsonResponse: true
37
+ });
38
+ res.on('close', () => {
39
+ void transport.close();
40
+ void server.close();
41
+ });
42
+ await server.connect(transport);
43
+ await transport.handleRequest(req, res);
44
+ }
45
+ async function handle(req, res) {
46
+ const path = new URL(req.url ?? '/', 'http://localhost').pathname;
47
+ if (healthPaths.has(path) && req.method === 'GET') {
48
+ const ok = options.health ? await options.health() : true;
49
+ sendJson(res, ok ? 200 : 503, { status: ok ? 'ok' : 'starting' });
50
+ return;
51
+ }
52
+ if (mcpPaths.has(path)) {
53
+ await handleMcp(req, res);
54
+ return;
55
+ }
56
+ sendJson(res, 404, { error: 'Not found' });
57
+ }
58
+ const http = createServer((req, res) => {
59
+ handle(req, res).catch((error) => {
60
+ logger.error({ err: String(error), url: req.url }, 'request failed');
61
+ if (!res.headersSent)
62
+ jsonRpcError(res, 500, 'Internal server error');
63
+ else
64
+ res.end();
65
+ });
66
+ });
67
+ const host = options.host ?? '0.0.0.0';
68
+ await new Promise((resolve, reject) => {
69
+ http.once('error', reject);
70
+ http.listen(options.port, host, () => {
71
+ http.off('error', reject);
72
+ resolve();
73
+ });
74
+ });
75
+ const address = http.address();
76
+ const port = typeof address === 'object' && address ? address.port : options.port;
77
+ const shownHost = host === '0.0.0.0' ? 'localhost' : host;
78
+ return {
79
+ url: `http://${shownHost}:${String(port)}${base}/mcp`,
80
+ port,
81
+ close: () => new Promise((resolve, reject) => {
82
+ http.close((error) => (error ? reject(error) : resolve()));
83
+ http.closeIdleConnections();
84
+ })
85
+ };
86
+ }
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@ultimatelemon-eu/mcp-http",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Run an MCP server over stateless Streamable HTTP: /mcp, /healthz, a base path for proxies, and errors that never leak a stack trace.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/ultimatelemon/packages.git",
9
+ "directory": "packages/mcp-http"
10
+ },
11
+ "type": "module",
12
+ "main": "dist/index.js",
13
+ "types": "dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "default": "./dist/index.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "sideEffects": false,
24
+ "scripts": {
25
+ "build": "tsc --project tsconfig.json",
26
+ "test": "vitest run",
27
+ "prepublishOnly": "npm run build"
28
+ },
29
+ "peerDependencies": {
30
+ "@modelcontextprotocol/sdk": "^1.20.0"
31
+ },
32
+ "devDependencies": {
33
+ "@modelcontextprotocol/sdk": "^1.32.1",
34
+ "@types/node": "^24.19.1",
35
+ "typescript": "^5.9.3",
36
+ "vitest": "^5.0.1"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ }
41
+ }