@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 +58 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/server.d.ts +25 -0
- package/dist/server.js +86 -0
- package/package.json +39 -4
package/README.md
CHANGED
|
@@ -1,3 +1,59 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ultimatelemon-eu/mcp-http
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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';
|
package/dist/server.d.ts
ADDED
|
@@ -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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|