@ultimatelemon-eu/mcp-http 0.0.0-stage → 2.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,78 @@
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
+ an optional API key the caller brings, and a JSON-RPC 500 instead of a stack
6
+ trace when a handler throws. Everything else is your tools.
7
+
8
+ ```bash
9
+ npm install @ultimatelemon-eu/mcp-http @modelcontextprotocol/server @modelcontextprotocol/node
10
+ ```
11
+
12
+ ```ts
13
+ import { McpServer } from '@modelcontextprotocol/server';
14
+ import { startMcpHttpServer } from '@ultimatelemon-eu/mcp-http';
15
+
16
+ const http = await startMcpHttpServer({
17
+ createMcpServer: ({ apiKey }) => {
18
+ const server = new McpServer({ name: 'my-mcp', version: '1.0.0' });
19
+ server.registerTool('ping', { description: 'Replies pong' }, () => ({
20
+ content: [{ type: 'text', text: `pong for ${apiKey}` }]
21
+ }));
22
+ return server;
23
+ },
24
+ port: Number(process.env.PORT ?? 3000),
25
+ apiKey: { hint: 'Create one at https://example.com/keys.' }
26
+ });
27
+
28
+ process.once('SIGTERM', () => {
29
+ void http.close().then(
30
+ () => process.exit(0),
31
+ () => process.exit(0)
32
+ );
33
+ });
34
+ ```
35
+
36
+ ## Options
37
+
38
+ | Option | Default | Does |
39
+ | ----------------- | ----------- | ------------------------------------------------------------ |
40
+ | `createMcpServer` | required | Returns a new `McpServer` per request, given `{ apiKey }` |
41
+ | `port` | required | `0` picks a free port; read it back from `.port` |
42
+ | `host` | `'0.0.0.0'` | `'127.0.0.1'` keeps it local |
43
+ | `basePath` | `''` | Also serve `<basePath>/mcp` and `<basePath>/healthz` |
44
+ | `health` | always ok | Return `false` for a 503 on `/healthz` |
45
+ | `apiKey` | off | Require the caller's key; `hint` is added to the 401 message |
46
+ | `logger` | silent | Anything with `error(obj, msg)`, such as pino |
47
+
48
+ It returns `{ url, port, close() }`.
49
+
50
+ ## API key
51
+
52
+ With `apiKey` set, every request to `/mcp` must carry the caller's own key for
53
+ the upstream API, in `X-Api-Key` or as `Authorization: Bearer <key>`
54
+ (`X-Api-Key` wins when both are present). Without one the server answers 401
55
+ before building an MCP server. The key reaches `createMcpServer` as
56
+ `apiKey`; it is never logged or stored.
57
+
58
+ This suits public servers where each user brings their own credentials: no
59
+ OAuth, no sessions, no database. In claude.ai, add the connector with
60
+ "No sign-in" and the key as a request header.
61
+
62
+ ## Why stateless
63
+
64
+ A new server per request means no session table, so it does not matter which
65
+ replica a request lands on and a restart loses nothing. The price: no
66
+ server-initiated messages outside a request and no `GET` stream, which is why
67
+ `GET` and `DELETE` on `/mcp` answer 405. Tools that read and return data do not
68
+ need either.
69
+
70
+ Keep shared state, such as a cache or a loaded list, outside `createMcpServer`
71
+ and close over it, so a request only builds the thin server.
72
+
73
+ ## Upgrading from 1.x
74
+
75
+ - The MCP SDK moved to v2: replace `@modelcontextprotocol/sdk` with
76
+ `@modelcontextprotocol/server` and `@modelcontextprotocol/node`, and import
77
+ `McpServer` from `@modelcontextprotocol/server`.
78
+ - `createMcpServer` receives a context object and may return a promise.
@@ -0,0 +1 @@
1
+ export { startMcpHttpServer, type ApiKeyOptions, type Logger, type McpHttpOptions, type McpHttpServer, type RequestContext } from './server.js';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { startMcpHttpServer } from './server.js';
@@ -0,0 +1,35 @@
1
+ import { type McpServer } from '@modelcontextprotocol/server';
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 RequestContext {
7
+ /** The caller's own key for the upstream API. Set when `apiKey` is configured. */
8
+ apiKey?: string;
9
+ }
10
+ export interface ApiKeyOptions {
11
+ /** Added to the 401 message, e.g. where to get a key. */
12
+ hint?: string;
13
+ }
14
+ export interface McpHttpOptions {
15
+ /** Builds a fresh server per request; the transport is stateless. */
16
+ createMcpServer: (context: RequestContext) => McpServer | Promise<McpServer>;
17
+ port: number;
18
+ /** Interface to listen on. Default `0.0.0.0`. */
19
+ host?: string;
20
+ /** Prefix a proxy leaves on the path, e.g. `/ns`. Both `/mcp` and `<basePath>/mcp` are served. */
21
+ basePath?: string;
22
+ /** Readiness for `/healthz`; return false for a 503. Default: always ready. */
23
+ health?: () => boolean | Promise<boolean>;
24
+ /** Require every MCP request to carry the caller's key in `X-Api-Key` or `Authorization: Bearer`. */
25
+ apiKey?: ApiKeyOptions;
26
+ logger?: Logger;
27
+ }
28
+ export interface McpHttpServer {
29
+ /** Full URL of the MCP endpoint, e.g. `http://localhost:3000/mcp`. */
30
+ url: string;
31
+ /** The port actually bound, useful with `port: 0`. */
32
+ port: number;
33
+ close(): Promise<void>;
34
+ }
35
+ export declare function startMcpHttpServer(options: McpHttpOptions): Promise<McpHttpServer>;
package/dist/server.js ADDED
@@ -0,0 +1,156 @@
1
+ import { createServer } from 'node:http';
2
+ import { toNodeHandler } from '@modelcontextprotocol/node';
3
+ import { createMcpHandler } from '@modelcontextprotocol/server';
4
+ const silent = { error: () => { } };
5
+ // PKG-10: Node's `Server.close()` waits for every still-open connection to go
6
+ // idle on its own; a keep-alive client that goes idle (or stays busy) *after*
7
+ // close() has already fired its one-off `closeIdleConnections()` call is
8
+ // never swept again, so the returned promise would never settle. We sweep
9
+ // idle connections repeatedly and, as a last resort, force every remaining
10
+ // connection closed after a fixed grace period - allowing in-flight requests
11
+ // a chance to finish first. 5000ms mirrors `DEFAULT_CONNECTION_TIMEOUT_MS` in
12
+ // packages/drizzle/src/client.ts and stays well under the grace period an
13
+ // orchestrator (Dokploy/k8s) gives a process before SIGKILL (typically
14
+ // 10-30s). packages/drizzle/src/client.ts's closeDb() has its own, longer
15
+ // CLOSE_TIMEOUT_MS backstop for the same class of problem (PKG-18); the two
16
+ // are chosen independently, since a stuck DB client and a stuck HTTP
17
+ // keep-alive socket have different acceptable wait times. This is
18
+ // intentionally internal and not exposed on `McpHttpOptions`.
19
+ const FORCE_CLOSE_TIMEOUT_MS = 5000;
20
+ const IDLE_SWEEP_INTERVAL_MS = 250;
21
+ function normalizeBasePath(basePath = '') {
22
+ const trimmed = basePath.replace(/\/+$/, '');
23
+ if (!trimmed)
24
+ return '';
25
+ return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
26
+ }
27
+ function sendJson(res, status, body) {
28
+ res.writeHead(status, { 'content-type': 'application/json' });
29
+ res.end(JSON.stringify(body));
30
+ }
31
+ function jsonRpcError(res, status, message) {
32
+ sendJson(res, status, {
33
+ jsonrpc: '2.0',
34
+ error: { code: -32000, message },
35
+ id: null
36
+ });
37
+ }
38
+ function apiKeyFrom(req) {
39
+ const header = req.headers['x-api-key'];
40
+ const direct = (Array.isArray(header) ? header[0] : header)?.trim();
41
+ if (direct)
42
+ return direct;
43
+ const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '');
44
+ return bearer?.[1]?.trim() || undefined;
45
+ }
46
+ // PKG-10: closes `http`, letting in-flight requests finish normally. A
47
+ // one-off `closeIdleConnections()` call only catches connections that are
48
+ // already idle at the exact moment close() runs; any connection that is
49
+ // still mid-request at that moment, and only goes idle afterwards, would
50
+ // otherwise never be swept, so we repeat the sweep on an interval instead. If
51
+ // connections are still open once FORCE_CLOSE_TIMEOUT_MS has passed - e.g. a
52
+ // keep-alive client that never goes idle - `closeAllConnections()`
53
+ // force-closes whatever remains so the returned promise always settles and
54
+ // the port is always released. Both timers are unref'd and are always
55
+ // cleared once `http`'s own close callback fires, on every path (success,
56
+ // forced, or error).
57
+ function closeHttpServer(http) {
58
+ return new Promise((resolve, reject) => {
59
+ const sweep = setInterval(() => {
60
+ http.closeIdleConnections();
61
+ }, IDLE_SWEEP_INTERVAL_MS).unref();
62
+ const forceExit = setTimeout(() => {
63
+ http.closeAllConnections();
64
+ }, FORCE_CLOSE_TIMEOUT_MS).unref();
65
+ http.close((error) => {
66
+ clearInterval(sweep);
67
+ clearTimeout(forceExit);
68
+ if (error)
69
+ reject(error);
70
+ else
71
+ resolve();
72
+ });
73
+ http.closeIdleConnections();
74
+ });
75
+ }
76
+ export async function startMcpHttpServer(options) {
77
+ const logger = options.logger ?? silent;
78
+ const base = normalizeBasePath(options.basePath);
79
+ const mcpPaths = new Set([`${base}/mcp`, '/mcp']);
80
+ const healthPaths = new Set([`${base}/healthz`, '/healthz']);
81
+ // The key travels as pass-through authInfo, the SDK's channel from the
82
+ // HTTP request to the per-request factory.
83
+ const mcpHandler = createMcpHandler(({ authInfo }) => options.createMcpServer(authInfo ? { apiKey: authInfo.token } : {}), {
84
+ onerror: (error) => {
85
+ logger.error({ err: String(error) }, 'mcp request failed');
86
+ }
87
+ });
88
+ const nodeHandler = toNodeHandler(mcpHandler, {
89
+ onerror: (error) => {
90
+ logger.error({ err: String(error) }, 'mcp request failed');
91
+ }
92
+ });
93
+ async function handleMcp(req, res) {
94
+ // Stateless serving has no session to attach a GET stream or a DELETE to.
95
+ if (req.method !== 'POST') {
96
+ res.setHeader('allow', 'POST');
97
+ jsonRpcError(res, 405, 'Method not allowed');
98
+ return;
99
+ }
100
+ if (!options.apiKey) {
101
+ await nodeHandler(req, res);
102
+ return;
103
+ }
104
+ const apiKey = apiKeyFrom(req);
105
+ if (!apiKey) {
106
+ const hint = options.apiKey.hint ? ` ${options.apiKey.hint}` : '';
107
+ res.setHeader('www-authenticate', 'Bearer');
108
+ jsonRpcError(res, 401, `Missing API key: send it in the X-Api-Key header or as Authorization: Bearer <key>.${hint}`);
109
+ return;
110
+ }
111
+ await nodeHandler(Object.assign(req, {
112
+ auth: { token: apiKey, clientId: 'api-key', scopes: [] }
113
+ }), res);
114
+ }
115
+ async function handle(req, res) {
116
+ const path = new URL(req.url ?? '/', 'http://localhost').pathname;
117
+ if (healthPaths.has(path) && req.method === 'GET') {
118
+ const ok = options.health ? await options.health() : true;
119
+ sendJson(res, ok ? 200 : 503, { status: ok ? 'ok' : 'starting' });
120
+ return;
121
+ }
122
+ if (mcpPaths.has(path)) {
123
+ await handleMcp(req, res);
124
+ return;
125
+ }
126
+ sendJson(res, 404, { error: 'Not found' });
127
+ }
128
+ const http = createServer((req, res) => {
129
+ handle(req, res).catch((error) => {
130
+ logger.error({ err: String(error), url: req.url }, 'request failed');
131
+ if (!res.headersSent)
132
+ jsonRpcError(res, 500, 'Internal server error');
133
+ else
134
+ res.end();
135
+ });
136
+ });
137
+ const host = options.host ?? '0.0.0.0';
138
+ await new Promise((resolve, reject) => {
139
+ http.once('error', reject);
140
+ http.listen(options.port, host, () => {
141
+ http.off('error', reject);
142
+ resolve();
143
+ });
144
+ });
145
+ const address = http.address();
146
+ const port = typeof address === 'object' && address ? address.port : options.port;
147
+ const shownHost = host === '0.0.0.0' ? 'localhost' : host;
148
+ return {
149
+ url: `http://${shownHost}:${String(port)}${base}/mcp`,
150
+ port,
151
+ close: async () => {
152
+ await closeHttpServer(http);
153
+ await mcpHandler.close();
154
+ }
155
+ };
156
+ }
package/package.json CHANGED
@@ -1,6 +1,45 @@
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": "2.0.0",
4
+ "description": "Run an MCP server over stateless Streamable HTTP: /mcp, /healthz, a base path for proxies, the caller's own API key per request, 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
+ "devDependencies": {
30
+ "@modelcontextprotocol/client": "^2.3.1",
31
+ "@modelcontextprotocol/node": "^2.1.1",
32
+ "@modelcontextprotocol/server": "^2.3.1",
33
+ "@types/node": "^24.19.1",
34
+ "typescript": "^5.9.3",
35
+ "vitest": "^5.0.1",
36
+ "zod": "^4.6.7"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "peerDependencies": {
42
+ "@modelcontextprotocol/node": "^2.1.0",
43
+ "@modelcontextprotocol/server": "^2.3.0"
44
+ }
45
+ }