@ultimatelemon-eu/mcp-http 1.0.0 → 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 +52 -33
- package/dist/index.d.ts +1 -1
- package/dist/server.d.ts +12 -2
- package/dist/server.js +87 -17
- package/package.json +11 -7
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
|
|
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/
|
|
9
|
+
npm install @ultimatelemon-eu/mcp-http @modelcontextprotocol/server @modelcontextprotocol/node
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
```ts
|
|
13
|
-
import { McpServer } from '@modelcontextprotocol/
|
|
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
|
-
|
|
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', () =>
|
|
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
|
|
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
|
-
| `
|
|
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 |
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
`DELETE` on `/mcp` answer 405. Tools that read and return data do not
|
|
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
|
|
54
|
-
|
|
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
|
-
##
|
|
73
|
+
## Upgrading from 1.x
|
|
57
74
|
|
|
58
|
-
|
|
59
|
-
|
|
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,11 +1,19 @@
|
|
|
1
|
-
import type
|
|
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;
|
|
@@ -13,6 +21,8 @@ export interface McpHttpOptions {
|
|
|
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 {
|
|
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,82 @@ 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']);
|
|
25
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
|
+
});
|
|
26
93
|
async function handleMcp(req, res) {
|
|
27
|
-
// Stateless
|
|
94
|
+
// Stateless serving has no session to attach a GET stream or a DELETE to.
|
|
28
95
|
if (req.method !== 'POST') {
|
|
29
96
|
res.setHeader('allow', 'POST');
|
|
30
97
|
jsonRpcError(res, 405, 'Method not allowed');
|
|
31
98
|
return;
|
|
32
99
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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);
|
|
44
114
|
}
|
|
45
115
|
async function handle(req, res) {
|
|
46
116
|
const path = new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
@@ -78,9 +148,9 @@ export async function startMcpHttpServer(options) {
|
|
|
78
148
|
return {
|
|
79
149
|
url: `http://${shownHost}:${String(port)}${base}/mcp`,
|
|
80
150
|
port,
|
|
81
|
-
close:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
151
|
+
close: async () => {
|
|
152
|
+
await closeHttpServer(http);
|
|
153
|
+
await mcpHandler.close();
|
|
154
|
+
}
|
|
85
155
|
};
|
|
86
156
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimatelemon-eu/mcp-http",
|
|
3
|
-
"version": "
|
|
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.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
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/
|
|
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
|
}
|