@ultimatelemon-eu/mcp-http 1.0.0 → 2.1.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
@@ -2,58 +2,77 @@
2
2
 
3
3
  Runs an [MCP](https://modelcontextprotocol.io) server over Streamable HTTP in
4
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.
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
7
 
8
8
  ```bash
9
- npm install @ultimatelemon-eu/mcp-http @modelcontextprotocol/sdk
9
+ npm install @ultimatelemon-eu/mcp-http @modelcontextprotocol/server @modelcontextprotocol/node
10
10
  ```
11
11
 
12
12
  ```ts
13
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
+ import { McpServer } from '@modelcontextprotocol/server';
14
14
  import { startMcpHttpServer } from '@ultimatelemon-eu/mcp-http';
15
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
16
  const http = await startMcpHttpServer({
25
- createMcpServer,
26
- port: Number(process.env.PORT ?? 3000)
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.' }
27
26
  });
28
27
 
29
- process.once('SIGTERM', () => void http.close().then(() => process.exit(0)));
28
+ process.once('SIGTERM', () => {
29
+ void http.close().then(
30
+ () => process.exit(0),
31
+ () => process.exit(0)
32
+ );
33
+ });
30
34
  ```
31
35
 
32
36
  ## Options
33
37
 
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 |
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 MCP on `<basePath>` and `<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 |
42
47
 
43
48
  It returns `{ url, port, close() }`.
44
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
+
45
62
  ## Why stateless
46
63
 
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.
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.
52
69
 
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.
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.
55
72
 
56
- ## Auth
73
+ ## Upgrading from 1.x
57
74
 
58
- There is none. Put the server behind an authenticating proxy (Airlock, for the
59
- UltimateLemon MCPs) or keep it on localhost.
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.
package/dist/index.d.ts CHANGED
@@ -1 +1 @@
1
- export { startMcpHttpServer, type Logger, type McpHttpOptions, type McpHttpServer } from './server.js';
1
+ export { startMcpHttpServer, type ApiKeyOptions, type Logger, type McpHttpOptions, type McpHttpServer, type RequestContext } from './server.js';
package/dist/server.d.ts CHANGED
@@ -1,18 +1,28 @@
1
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1
+ import { type McpServer } from '@modelcontextprotocol/server';
2
2
  /** The slice of a pino-style logger this package calls. */
3
3
  export interface Logger {
4
4
  error(obj: object, msg?: string): void;
5
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
+ }
6
14
  export interface McpHttpOptions {
7
15
  /** Builds a fresh server per request; the transport is stateless. */
8
- createMcpServer: () => McpServer;
16
+ createMcpServer: (context: RequestContext) => McpServer | Promise<McpServer>;
9
17
  port: number;
10
18
  /** Interface to listen on. Default `0.0.0.0`. */
11
19
  host?: string;
12
- /** Prefix a proxy leaves on the path, e.g. `/ns`. Both `/mcp` and `<basePath>/mcp` are served. */
20
+ /** Prefix a proxy leaves on the path, e.g. `/ns`. MCP is served on `/mcp`, `<basePath>/mcp` and `<basePath>` itself. */
13
21
  basePath?: string;
14
22
  /** Readiness for `/healthz`; return false for a 503. Default: always ready. */
15
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;
16
26
  logger?: Logger;
17
27
  }
18
28
  export interface McpHttpServer {
package/dist/server.js CHANGED
@@ -1,6 +1,23 @@
1
1
  import { createServer } from 'node:http';
2
- import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
2
+ import { toNodeHandler } from '@modelcontextprotocol/node';
3
+ import { createMcpHandler } from '@modelcontextprotocol/server';
3
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;
4
21
  function normalizeBasePath(basePath = '') {
5
22
  const trimmed = basePath.replace(/\/+$/, '');
6
23
  if (!trimmed)
@@ -18,29 +35,84 @@ function jsonRpcError(res, status, message) {
18
35
  id: null
19
36
  });
20
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
+ }
21
76
  export async function startMcpHttpServer(options) {
22
77
  const logger = options.logger ?? silent;
23
78
  const base = normalizeBasePath(options.basePath);
24
79
  const mcpPaths = new Set([`${base}/mcp`, '/mcp']);
80
+ if (base)
81
+ mcpPaths.add(base);
25
82
  const healthPaths = new Set([`${base}/healthz`, '/healthz']);
83
+ // The key travels as pass-through authInfo, the SDK's channel from the
84
+ // HTTP request to the per-request factory.
85
+ const mcpHandler = createMcpHandler(({ authInfo }) => options.createMcpServer(authInfo ? { apiKey: authInfo.token } : {}), {
86
+ onerror: (error) => {
87
+ logger.error({ err: String(error) }, 'mcp request failed');
88
+ }
89
+ });
90
+ const nodeHandler = toNodeHandler(mcpHandler, {
91
+ onerror: (error) => {
92
+ logger.error({ err: String(error) }, 'mcp request failed');
93
+ }
94
+ });
26
95
  async function handleMcp(req, res) {
27
- // Stateless mode has no session to attach a GET stream or a DELETE to.
96
+ // Stateless serving has no session to attach a GET stream or a DELETE to.
28
97
  if (req.method !== 'POST') {
29
98
  res.setHeader('allow', 'POST');
30
99
  jsonRpcError(res, 405, 'Method not allowed');
31
100
  return;
32
101
  }
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);
102
+ if (!options.apiKey) {
103
+ await nodeHandler(req, res);
104
+ return;
105
+ }
106
+ const apiKey = apiKeyFrom(req);
107
+ if (!apiKey) {
108
+ const hint = options.apiKey.hint ? ` ${options.apiKey.hint}` : '';
109
+ res.setHeader('www-authenticate', 'Bearer');
110
+ jsonRpcError(res, 401, `Missing API key: send it in the X-Api-Key header or as Authorization: Bearer <key>.${hint}`);
111
+ return;
112
+ }
113
+ await nodeHandler(Object.assign(req, {
114
+ auth: { token: apiKey, clientId: 'api-key', scopes: [] }
115
+ }), res);
44
116
  }
45
117
  async function handle(req, res) {
46
118
  const path = new URL(req.url ?? '/', 'http://localhost').pathname;
@@ -78,9 +150,9 @@ export async function startMcpHttpServer(options) {
78
150
  return {
79
151
  url: `http://${shownHost}:${String(port)}${base}/mcp`,
80
152
  port,
81
- close: () => new Promise((resolve, reject) => {
82
- http.close((error) => (error ? reject(error) : resolve()));
83
- http.closeIdleConnections();
84
- })
153
+ close: async () => {
154
+ await closeHttpServer(http);
155
+ await mcpHandler.close();
156
+ }
85
157
  };
86
158
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ultimatelemon-eu/mcp-http",
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.",
3
+ "version": "2.1.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
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -26,16 +26,20 @@
26
26
  "test": "vitest run",
27
27
  "prepublishOnly": "npm run build"
28
28
  },
29
- "peerDependencies": {
30
- "@modelcontextprotocol/sdk": "^1.20.0"
31
- },
32
29
  "devDependencies": {
33
- "@modelcontextprotocol/sdk": "^1.32.1",
30
+ "@modelcontextprotocol/client": "^2.3.1",
31
+ "@modelcontextprotocol/node": "^2.1.1",
32
+ "@modelcontextprotocol/server": "^2.3.1",
34
33
  "@types/node": "^24.19.1",
35
34
  "typescript": "^5.9.3",
36
- "vitest": "^5.0.1"
35
+ "vitest": "^5.0.1",
36
+ "zod": "^4.6.7"
37
37
  },
38
38
  "publishConfig": {
39
39
  "access": "public"
40
+ },
41
+ "peerDependencies": {
42
+ "@modelcontextprotocol/node": "^2.1.0",
43
+ "@modelcontextprotocol/server": "^2.3.0"
40
44
  }
41
45
  }