@ttoss/http-server-mcp 0.5.7 → 0.6.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 +103 -0
- package/dist/esm/index.js +109 -11
- package/dist/index.d.cts +109 -3
- package/dist/index.d.ts +109 -3
- package/dist/index.js +110 -10
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -57,6 +57,84 @@ app.listen(3000, () => {
|
|
|
57
57
|
});
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
+
## `apiCall` — Generic HTTP Helper
|
|
61
|
+
|
|
62
|
+
`apiCall` is a generic HTTP helper for use inside MCP tool handlers. It works with any URL — your own REST API, third-party APIs, public APIs, or services using `x-api-key` or any other header scheme.
|
|
63
|
+
|
|
64
|
+
Use `getApiHeaders` in `createMcpRouter` to configure which headers from the incoming MCP request are automatically forwarded to every `apiCall`. Tool handlers stay clean and auth-agnostic.
|
|
65
|
+
|
|
66
|
+
### Bearer token forwarding
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
import {
|
|
70
|
+
apiCall,
|
|
71
|
+
createMcpRouter,
|
|
72
|
+
Server as McpServer,
|
|
73
|
+
} from '@ttoss/http-server-mcp';
|
|
74
|
+
|
|
75
|
+
const mcpServer = new McpServer({ name: 'my-server', version: '1.0.0' });
|
|
76
|
+
|
|
77
|
+
mcpServer.registerTool(
|
|
78
|
+
'list-portfolios',
|
|
79
|
+
{ description: 'List all portfolios', inputSchema: {} },
|
|
80
|
+
async () => {
|
|
81
|
+
// Bearer token is forwarded automatically — no manual wiring
|
|
82
|
+
const data = await apiCall('GET', '/portfolios');
|
|
83
|
+
return { content: [{ type: 'text', text: JSON.stringify(data) }] };
|
|
84
|
+
}
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const mcpRouter = createMcpRouter(mcpServer, {
|
|
88
|
+
apiBaseUrl: `http://localhost:${process.env.PORT}/api/v1`,
|
|
89
|
+
// Extract the caller's Bearer token and inject it into every apiCall
|
|
90
|
+
getApiHeaders: (ctx) => ({ Authorization: ctx.headers.authorization ?? '' }),
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### x-api-key forwarding
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
const mcpRouter = createMcpRouter(mcpServer, {
|
|
98
|
+
apiBaseUrl: 'https://internal-service/api',
|
|
99
|
+
getApiHeaders: (ctx) => ({
|
|
100
|
+
'x-api-key': ctx.headers['x-api-key'] as string,
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Third-party or public APIs (full URL, no context required)
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
mcpServer.registerTool(
|
|
109
|
+
'get-rates',
|
|
110
|
+
{ description: 'Currency rates', inputSchema: {} },
|
|
111
|
+
async () => {
|
|
112
|
+
// Full URL — works entirely outside any context
|
|
113
|
+
const rates = await apiCall('GET', 'https://api.exchangerate.host/latest');
|
|
114
|
+
return { content: [{ type: 'text', text: JSON.stringify(rates) }] };
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### POST with a body
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
const result = await apiCall('POST', '/portfolios', {
|
|
123
|
+
body: { name: 'Growth Fund' },
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Per-call header override
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
// Context-injected headers are merged; per-call headers take precedence
|
|
131
|
+
const data = await apiCall('GET', 'https://partner.api.com/data', {
|
|
132
|
+
headers: { Authorization: 'Bearer fixed-service-token' },
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`apiCall` throws with a clear message when called with a relative path and no `apiBaseUrl` is configured in the context.
|
|
137
|
+
|
|
60
138
|
## API Reference
|
|
61
139
|
|
|
62
140
|
### `createMcpRouter(server, options?)`
|
|
@@ -69,9 +147,24 @@ Creates a Koa router configured to handle MCP protocol requests.
|
|
|
69
147
|
- `options` (`McpRouterOptions`) - Optional configuration
|
|
70
148
|
- `path` (`string`) - HTTP path for MCP endpoint (default: `'/mcp'`)
|
|
71
149
|
- `sessionIdGenerator` (`() => string`) - Session ID generator for stateful servers (default: `undefined` for stateless)
|
|
150
|
+
- `apiBaseUrl` (`string`) - Base URL prepended to relative paths in `apiCall`
|
|
151
|
+
- `getApiHeaders` (`(ctx: Context) => Record<string, string>`) - Return headers to inject into every `apiCall` for this request (auth tokens, API keys, trace headers, etc.)
|
|
72
152
|
|
|
73
153
|
**Returns:** `Router` - Koa router instance
|
|
74
154
|
|
|
155
|
+
### `apiCall(method, url, options?)`
|
|
156
|
+
|
|
157
|
+
Generic HTTP helper for use inside MCP tool handlers.
|
|
158
|
+
|
|
159
|
+
**Parameters:**
|
|
160
|
+
|
|
161
|
+
- `method` (`string`) - HTTP method (`'GET'`, `'POST'`, `'PUT'`, `'DELETE'`, …)
|
|
162
|
+
- `url` (`string`) - Full URL **or** a path starting with `/` (prepended with `apiBaseUrl`)
|
|
163
|
+
- `options.body` (`unknown`, optional) - Request body, serialised as JSON
|
|
164
|
+
- `options.headers` (`Record<string, string>`, optional) - Per-call header overrides; merged on top of context-injected headers
|
|
165
|
+
|
|
166
|
+
**Returns:** `Promise<unknown>` - Parsed JSON response body
|
|
167
|
+
|
|
75
168
|
## Examples
|
|
76
169
|
|
|
77
170
|
### Basic Tool
|
|
@@ -202,6 +295,16 @@ This package implements the [Model Context Protocol](https://spec.modelcontextpr
|
|
|
202
295
|
- `POST /mcp` - Send JSON-RPC requests/notifications
|
|
203
296
|
- `DELETE /mcp` - Terminate session (optional)
|
|
204
297
|
|
|
298
|
+
**Client requirements (per MCP spec):**
|
|
299
|
+
|
|
300
|
+
- `Content-Type: application/json`
|
|
301
|
+
- `Accept: application/json, text/event-stream`
|
|
302
|
+
|
|
303
|
+
**Stateless vs stateful mode:**
|
|
304
|
+
|
|
305
|
+
- **Stateless** (default, `sessionIdGenerator: undefined`) — a fresh transport is created per HTTP request. No session tracking. Suitable for serverless environments and simple integrations.
|
|
306
|
+
- **Stateful** (`sessionIdGenerator` provided) — a single shared transport handles all requests and tracks sessions by ID.
|
|
307
|
+
|
|
205
308
|
## Related Packages
|
|
206
309
|
|
|
207
310
|
- [@ttoss/http-server](https://ttoss.dev/docs/modules/packages/http-server) - HTTP server foundation
|
package/dist/esm/index.js
CHANGED
|
@@ -6,25 +6,124 @@ var __name = (target, value) => __defProp(target, "name", {
|
|
|
6
6
|
});
|
|
7
7
|
|
|
8
8
|
// src/index.ts
|
|
9
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
9
10
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
10
11
|
import { Router } from "@ttoss/http-server";
|
|
11
12
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
12
13
|
import { z } from "zod";
|
|
14
|
+
var requestContextStore = new AsyncLocalStorage();
|
|
15
|
+
var apiCall = /* @__PURE__ */__name(async (method, url, options) => {
|
|
16
|
+
const context = requestContextStore.getStore();
|
|
17
|
+
let resolvedUrl = url;
|
|
18
|
+
if (url.startsWith("/")) {
|
|
19
|
+
if (!context?.apiBaseUrl) {
|
|
20
|
+
throw new Error(`apiCall received a relative path ("${url}") but no apiBaseUrl is configured. Either pass a full URL or set apiBaseUrl in createMcpRouter options.`);
|
|
21
|
+
}
|
|
22
|
+
resolvedUrl = `${context.apiBaseUrl.replace(/\/$/, "")}${url}`;
|
|
23
|
+
}
|
|
24
|
+
const hasBody = options?.body !== void 0;
|
|
25
|
+
const headers = {
|
|
26
|
+
...(context !== void 0 ? context.apiHeaders : {}),
|
|
27
|
+
...(options?.headers ?? {})
|
|
28
|
+
};
|
|
29
|
+
const hasExplicitContentType = Object.keys(headers).some(headerName => {
|
|
30
|
+
return headerName.toLowerCase() === "content-type";
|
|
31
|
+
});
|
|
32
|
+
if (hasBody && !hasExplicitContentType) {
|
|
33
|
+
headers["Content-Type"] = "application/json";
|
|
34
|
+
}
|
|
35
|
+
const response = await fetch(resolvedUrl, {
|
|
36
|
+
method,
|
|
37
|
+
headers,
|
|
38
|
+
body: hasBody ? JSON.stringify(options.body) : void 0
|
|
39
|
+
});
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
const err = await response.json().catch(() => {
|
|
42
|
+
return {
|
|
43
|
+
error: response.statusText
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
throw new Error(err.error || `HTTP ${response.status}`);
|
|
47
|
+
}
|
|
48
|
+
if (response.status === 204 || response.status === 205) {
|
|
49
|
+
return void 0;
|
|
50
|
+
}
|
|
51
|
+
const contentType = response.headers.get("content-type");
|
|
52
|
+
if (contentType?.includes("application/json")) {
|
|
53
|
+
return response.json();
|
|
54
|
+
}
|
|
55
|
+
return response.text();
|
|
56
|
+
}, "apiCall");
|
|
13
57
|
var createMcpRouter = /* @__PURE__ */__name((server, options = {}) => {
|
|
14
58
|
const {
|
|
15
59
|
path = "/mcp",
|
|
16
|
-
sessionIdGenerator
|
|
17
|
-
} = options;
|
|
18
|
-
const transport = new StreamableHTTPServerTransport({
|
|
19
60
|
sessionIdGenerator,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
61
|
+
apiBaseUrl,
|
|
62
|
+
getApiHeaders
|
|
63
|
+
} = options;
|
|
64
|
+
const isStateful = sessionIdGenerator !== void 0;
|
|
65
|
+
const needsContext = apiBaseUrl !== void 0 || getApiHeaders !== void 0;
|
|
66
|
+
let sharedTransport;
|
|
67
|
+
if (isStateful) {
|
|
68
|
+
sharedTransport = new StreamableHTTPServerTransport({
|
|
69
|
+
sessionIdGenerator,
|
|
70
|
+
enableJsonResponse: true
|
|
71
|
+
});
|
|
72
|
+
server.connect(sharedTransport);
|
|
73
|
+
}
|
|
74
|
+
let statelessQueue = Promise.resolve();
|
|
75
|
+
const enqueueStateless = /* @__PURE__ */__name(work => {
|
|
76
|
+
const result = statelessQueue.then(() => {
|
|
77
|
+
return work();
|
|
78
|
+
});
|
|
79
|
+
statelessQueue = result.catch(() => {});
|
|
80
|
+
return result;
|
|
81
|
+
}, "enqueueStateless");
|
|
23
82
|
const router = new Router();
|
|
83
|
+
const handleWithContext = /* @__PURE__ */__name(async (ctx, body) => {
|
|
84
|
+
const apiHeaders = getApiHeaders ? getApiHeaders(ctx) : {};
|
|
85
|
+
const runRequest = /* @__PURE__ */__name(async transport => {
|
|
86
|
+
await transport.handleRequest(ctx.req, ctx.res, body);
|
|
87
|
+
ctx.respond = false;
|
|
88
|
+
}, "runRequest");
|
|
89
|
+
if (isStateful && sharedTransport) {
|
|
90
|
+
if (needsContext) {
|
|
91
|
+
await requestContextStore.run({
|
|
92
|
+
apiBaseUrl,
|
|
93
|
+
apiHeaders
|
|
94
|
+
}, () => {
|
|
95
|
+
return runRequest(sharedTransport);
|
|
96
|
+
});
|
|
97
|
+
} else {
|
|
98
|
+
await runRequest(sharedTransport);
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
await enqueueStateless(async () => {
|
|
102
|
+
const transport = new StreamableHTTPServerTransport({
|
|
103
|
+
sessionIdGenerator: void 0,
|
|
104
|
+
enableJsonResponse: true
|
|
105
|
+
});
|
|
106
|
+
try {
|
|
107
|
+
await server.connect(transport);
|
|
108
|
+
if (needsContext) {
|
|
109
|
+
await requestContextStore.run({
|
|
110
|
+
apiBaseUrl,
|
|
111
|
+
apiHeaders
|
|
112
|
+
}, () => {
|
|
113
|
+
return runRequest(transport);
|
|
114
|
+
});
|
|
115
|
+
} else {
|
|
116
|
+
await runRequest(transport);
|
|
117
|
+
}
|
|
118
|
+
} finally {
|
|
119
|
+
await transport.close();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}, "handleWithContext");
|
|
24
124
|
router.post(path, async ctx => {
|
|
25
125
|
try {
|
|
26
|
-
await
|
|
27
|
-
ctx.respond = false;
|
|
126
|
+
await handleWithContext(ctx, ctx.request.body);
|
|
28
127
|
} catch (error) {
|
|
29
128
|
if (!ctx.res.headersSent) {
|
|
30
129
|
ctx.status = 500;
|
|
@@ -36,8 +135,7 @@ var createMcpRouter = /* @__PURE__ */__name((server, options = {}) => {
|
|
|
36
135
|
});
|
|
37
136
|
router.delete(path, async ctx => {
|
|
38
137
|
try {
|
|
39
|
-
await
|
|
40
|
-
ctx.respond = false;
|
|
138
|
+
await handleWithContext(ctx);
|
|
41
139
|
} catch (error) {
|
|
42
140
|
if (!ctx.res.headersSent) {
|
|
43
141
|
ctx.status = 500;
|
|
@@ -49,4 +147,4 @@ var createMcpRouter = /* @__PURE__ */__name((server, options = {}) => {
|
|
|
49
147
|
});
|
|
50
148
|
return router;
|
|
51
149
|
}, "createMcpRouter");
|
|
52
|
-
export { McpServer as Server, createMcpRouter, z };
|
|
150
|
+
export { McpServer as Server, apiCall, createMcpRouter, z };
|
package/dist/index.d.cts
CHANGED
|
@@ -1,9 +1,80 @@
|
|
|
1
1
|
import * as _koa_router from '@koa/router';
|
|
2
2
|
import * as koa from 'koa';
|
|
3
|
+
import { Context } from 'koa';
|
|
3
4
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
5
|
export { McpServer, McpServer as Server } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
6
|
export { z } from 'zod';
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Options for a single `apiCall` request.
|
|
10
|
+
*/
|
|
11
|
+
interface ApiCallOptions {
|
|
12
|
+
/**
|
|
13
|
+
* JSON-serialisable request body. Automatically serialised and sent with
|
|
14
|
+
* `Content-Type: application/json`.
|
|
15
|
+
*/
|
|
16
|
+
body?: unknown;
|
|
17
|
+
/**
|
|
18
|
+
* Additional or override headers for this specific request.
|
|
19
|
+
* These are merged on top of any headers injected from the MCP request
|
|
20
|
+
* context via `getApiHeaders`, allowing per-call overrides.
|
|
21
|
+
*/
|
|
22
|
+
headers?: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Generic HTTP helper for use inside MCP tool handlers.
|
|
26
|
+
*
|
|
27
|
+
* Accepts any full URL (third-party APIs, public APIs, etc.) or a path
|
|
28
|
+
* relative to the `apiBaseUrl` configured in `createMcpRouter`.
|
|
29
|
+
*
|
|
30
|
+
* Headers configured via `getApiHeaders` in `createMcpRouter` are injected
|
|
31
|
+
* automatically into every request, allowing transparent forwarding of auth
|
|
32
|
+
* tokens, API keys, or any other header — without coupling this helper to a
|
|
33
|
+
* specific authentication scheme. Per-call `options.headers` take precedence
|
|
34
|
+
* over context-injected headers.
|
|
35
|
+
*
|
|
36
|
+
* @param method - HTTP method (e.g. `'GET'`, `'POST'`, `'PUT'`, `'DELETE'`)
|
|
37
|
+
* @param url - Full URL **or** a path starting with `/` (appended to `apiBaseUrl`)
|
|
38
|
+
* @param options - Optional body and per-call header overrides
|
|
39
|
+
* @returns Parsed JSON response body
|
|
40
|
+
*
|
|
41
|
+
* @example Bearer token forwarding (configured once in `createMcpRouter`)
|
|
42
|
+
* ```typescript
|
|
43
|
+
* import { apiCall, createMcpRouter, Server as McpServer } from '@ttoss/http-server-mcp';
|
|
44
|
+
*
|
|
45
|
+
* // Tool handler – no manual auth wiring needed
|
|
46
|
+
* mcpServer.registerTool('list-portfolios', { description: '...', inputSchema: {} }, async () => {
|
|
47
|
+
* const data = await apiCall('GET', '/portfolios');
|
|
48
|
+
* return { content: [{ type: 'text', text: JSON.stringify(data) }] };
|
|
49
|
+
* });
|
|
50
|
+
*
|
|
51
|
+
* const mcpRouter = createMcpRouter(mcpServer, {
|
|
52
|
+
* apiBaseUrl: `http://localhost:${process.env.PORT}/api/v1`,
|
|
53
|
+
* // Forward the caller's Bearer token to every apiCall
|
|
54
|
+
* getApiHeaders: (ctx) => ({ Authorization: ctx.headers.authorization ?? '' }),
|
|
55
|
+
* });
|
|
56
|
+
* ```
|
|
57
|
+
*
|
|
58
|
+
* @example x-api-key forwarding
|
|
59
|
+
* ```typescript
|
|
60
|
+
* const mcpRouter = createMcpRouter(mcpServer, {
|
|
61
|
+
* apiBaseUrl: 'https://internal-service/api',
|
|
62
|
+
* getApiHeaders: (ctx) => ({
|
|
63
|
+
* 'x-api-key': ctx.headers['x-api-key'] as string,
|
|
64
|
+
* }),
|
|
65
|
+
* });
|
|
66
|
+
* ```
|
|
67
|
+
*
|
|
68
|
+
* @example Third-party or public API (full URL, no context required)
|
|
69
|
+
* ```typescript
|
|
70
|
+
* const weather = await apiCall('GET', 'https://api.weather.com/current?city=Berlin');
|
|
71
|
+
* const created = await apiCall('POST', 'https://api.example.com/items', {
|
|
72
|
+
* body: { name: 'widget' },
|
|
73
|
+
* headers: { Authorization: 'Bearer fixed-service-token' },
|
|
74
|
+
* });
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
declare const apiCall: (method: string, url: string, options?: ApiCallOptions) => Promise<unknown>;
|
|
7
78
|
/**
|
|
8
79
|
* Options for configuring the MCP router
|
|
9
80
|
*/
|
|
@@ -14,10 +85,45 @@ interface McpRouterOptions {
|
|
|
14
85
|
*/
|
|
15
86
|
path?: string;
|
|
16
87
|
/**
|
|
17
|
-
* Optional session ID generator for stateful MCP servers
|
|
18
|
-
*
|
|
88
|
+
* Optional session ID generator for stateful MCP servers.
|
|
89
|
+
* When provided, a single shared transport is created and sessions are tracked.
|
|
90
|
+
* When undefined (default), the server operates in stateless mode where each
|
|
91
|
+
* HTTP request uses its own transport instance.
|
|
19
92
|
*/
|
|
20
93
|
sessionIdGenerator?: () => string;
|
|
94
|
+
/**
|
|
95
|
+
* Base URL prepended to relative paths passed to `apiCall` (paths starting
|
|
96
|
+
* with `/`). Tool handlers can then call `apiCall('GET', '/resource')` without
|
|
97
|
+
* specifying a host.
|
|
98
|
+
*
|
|
99
|
+
* @example 'http://localhost:3000/api/v1'
|
|
100
|
+
*/
|
|
101
|
+
apiBaseUrl?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Called once per incoming MCP HTTP request. Return a plain object whose
|
|
104
|
+
* key-value pairs will be merged into the headers of every `apiCall` made
|
|
105
|
+
* within that request's tool handlers.
|
|
106
|
+
*
|
|
107
|
+
* Use this to forward any header from the MCP request — Bearer tokens, API
|
|
108
|
+
* keys, tenant IDs, trace headers, etc. — without coupling tool handlers to
|
|
109
|
+
* a specific authentication scheme.
|
|
110
|
+
*
|
|
111
|
+
* @example Forward a Bearer token
|
|
112
|
+
* ```typescript
|
|
113
|
+
* getApiHeaders: (ctx) => ({ Authorization: ctx.headers.authorization ?? '' })
|
|
114
|
+
* ```
|
|
115
|
+
*
|
|
116
|
+
* @example Forward an x-api-key header
|
|
117
|
+
* ```typescript
|
|
118
|
+
* getApiHeaders: (ctx) => ({ 'x-api-key': ctx.headers['x-api-key'] as string })
|
|
119
|
+
* ```
|
|
120
|
+
*
|
|
121
|
+
* @example Inject a static service-to-service key
|
|
122
|
+
* ```typescript
|
|
123
|
+
* getApiHeaders: () => ({ 'x-internal-key': process.env.INTERNAL_API_KEY! })
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
getApiHeaders?: (ctx: Context) => Record<string, string>;
|
|
21
127
|
}
|
|
22
128
|
/**
|
|
23
129
|
* Creates a Koa router configured to handle MCP protocol requests
|
|
@@ -58,4 +164,4 @@ interface McpRouterOptions {
|
|
|
58
164
|
*/
|
|
59
165
|
declare const createMcpRouter: (server: McpServer, options?: McpRouterOptions) => _koa_router.RouterInstance<koa.DefaultState, koa.DefaultContext>;
|
|
60
166
|
|
|
61
|
-
export { type McpRouterOptions, createMcpRouter };
|
|
167
|
+
export { type ApiCallOptions, type McpRouterOptions, apiCall, createMcpRouter };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,80 @@
|
|
|
1
1
|
import * as _koa_router from '@koa/router';
|
|
2
2
|
import * as koa from 'koa';
|
|
3
|
+
import { Context } from 'koa';
|
|
3
4
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
5
|
export { McpServer, McpServer as Server } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
6
|
export { z } from 'zod';
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Options for a single `apiCall` request.
|
|
10
|
+
*/
|
|
11
|
+
interface ApiCallOptions {
|
|
12
|
+
/**
|
|
13
|
+
* JSON-serialisable request body. Automatically serialised and sent with
|
|
14
|
+
* `Content-Type: application/json`.
|
|
15
|
+
*/
|
|
16
|
+
body?: unknown;
|
|
17
|
+
/**
|
|
18
|
+
* Additional or override headers for this specific request.
|
|
19
|
+
* These are merged on top of any headers injected from the MCP request
|
|
20
|
+
* context via `getApiHeaders`, allowing per-call overrides.
|
|
21
|
+
*/
|
|
22
|
+
headers?: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Generic HTTP helper for use inside MCP tool handlers.
|
|
26
|
+
*
|
|
27
|
+
* Accepts any full URL (third-party APIs, public APIs, etc.) or a path
|
|
28
|
+
* relative to the `apiBaseUrl` configured in `createMcpRouter`.
|
|
29
|
+
*
|
|
30
|
+
* Headers configured via `getApiHeaders` in `createMcpRouter` are injected
|
|
31
|
+
* automatically into every request, allowing transparent forwarding of auth
|
|
32
|
+
* tokens, API keys, or any other header — without coupling this helper to a
|
|
33
|
+
* specific authentication scheme. Per-call `options.headers` take precedence
|
|
34
|
+
* over context-injected headers.
|
|
35
|
+
*
|
|
36
|
+
* @param method - HTTP method (e.g. `'GET'`, `'POST'`, `'PUT'`, `'DELETE'`)
|
|
37
|
+
* @param url - Full URL **or** a path starting with `/` (appended to `apiBaseUrl`)
|
|
38
|
+
* @param options - Optional body and per-call header overrides
|
|
39
|
+
* @returns Parsed JSON response body
|
|
40
|
+
*
|
|
41
|
+
* @example Bearer token forwarding (configured once in `createMcpRouter`)
|
|
42
|
+
* ```typescript
|
|
43
|
+
* import { apiCall, createMcpRouter, Server as McpServer } from '@ttoss/http-server-mcp';
|
|
44
|
+
*
|
|
45
|
+
* // Tool handler – no manual auth wiring needed
|
|
46
|
+
* mcpServer.registerTool('list-portfolios', { description: '...', inputSchema: {} }, async () => {
|
|
47
|
+
* const data = await apiCall('GET', '/portfolios');
|
|
48
|
+
* return { content: [{ type: 'text', text: JSON.stringify(data) }] };
|
|
49
|
+
* });
|
|
50
|
+
*
|
|
51
|
+
* const mcpRouter = createMcpRouter(mcpServer, {
|
|
52
|
+
* apiBaseUrl: `http://localhost:${process.env.PORT}/api/v1`,
|
|
53
|
+
* // Forward the caller's Bearer token to every apiCall
|
|
54
|
+
* getApiHeaders: (ctx) => ({ Authorization: ctx.headers.authorization ?? '' }),
|
|
55
|
+
* });
|
|
56
|
+
* ```
|
|
57
|
+
*
|
|
58
|
+
* @example x-api-key forwarding
|
|
59
|
+
* ```typescript
|
|
60
|
+
* const mcpRouter = createMcpRouter(mcpServer, {
|
|
61
|
+
* apiBaseUrl: 'https://internal-service/api',
|
|
62
|
+
* getApiHeaders: (ctx) => ({
|
|
63
|
+
* 'x-api-key': ctx.headers['x-api-key'] as string,
|
|
64
|
+
* }),
|
|
65
|
+
* });
|
|
66
|
+
* ```
|
|
67
|
+
*
|
|
68
|
+
* @example Third-party or public API (full URL, no context required)
|
|
69
|
+
* ```typescript
|
|
70
|
+
* const weather = await apiCall('GET', 'https://api.weather.com/current?city=Berlin');
|
|
71
|
+
* const created = await apiCall('POST', 'https://api.example.com/items', {
|
|
72
|
+
* body: { name: 'widget' },
|
|
73
|
+
* headers: { Authorization: 'Bearer fixed-service-token' },
|
|
74
|
+
* });
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
declare const apiCall: (method: string, url: string, options?: ApiCallOptions) => Promise<unknown>;
|
|
7
78
|
/**
|
|
8
79
|
* Options for configuring the MCP router
|
|
9
80
|
*/
|
|
@@ -14,10 +85,45 @@ interface McpRouterOptions {
|
|
|
14
85
|
*/
|
|
15
86
|
path?: string;
|
|
16
87
|
/**
|
|
17
|
-
* Optional session ID generator for stateful MCP servers
|
|
18
|
-
*
|
|
88
|
+
* Optional session ID generator for stateful MCP servers.
|
|
89
|
+
* When provided, a single shared transport is created and sessions are tracked.
|
|
90
|
+
* When undefined (default), the server operates in stateless mode where each
|
|
91
|
+
* HTTP request uses its own transport instance.
|
|
19
92
|
*/
|
|
20
93
|
sessionIdGenerator?: () => string;
|
|
94
|
+
/**
|
|
95
|
+
* Base URL prepended to relative paths passed to `apiCall` (paths starting
|
|
96
|
+
* with `/`). Tool handlers can then call `apiCall('GET', '/resource')` without
|
|
97
|
+
* specifying a host.
|
|
98
|
+
*
|
|
99
|
+
* @example 'http://localhost:3000/api/v1'
|
|
100
|
+
*/
|
|
101
|
+
apiBaseUrl?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Called once per incoming MCP HTTP request. Return a plain object whose
|
|
104
|
+
* key-value pairs will be merged into the headers of every `apiCall` made
|
|
105
|
+
* within that request's tool handlers.
|
|
106
|
+
*
|
|
107
|
+
* Use this to forward any header from the MCP request — Bearer tokens, API
|
|
108
|
+
* keys, tenant IDs, trace headers, etc. — without coupling tool handlers to
|
|
109
|
+
* a specific authentication scheme.
|
|
110
|
+
*
|
|
111
|
+
* @example Forward a Bearer token
|
|
112
|
+
* ```typescript
|
|
113
|
+
* getApiHeaders: (ctx) => ({ Authorization: ctx.headers.authorization ?? '' })
|
|
114
|
+
* ```
|
|
115
|
+
*
|
|
116
|
+
* @example Forward an x-api-key header
|
|
117
|
+
* ```typescript
|
|
118
|
+
* getApiHeaders: (ctx) => ({ 'x-api-key': ctx.headers['x-api-key'] as string })
|
|
119
|
+
* ```
|
|
120
|
+
*
|
|
121
|
+
* @example Inject a static service-to-service key
|
|
122
|
+
* ```typescript
|
|
123
|
+
* getApiHeaders: () => ({ 'x-internal-key': process.env.INTERNAL_API_KEY! })
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
getApiHeaders?: (ctx: Context) => Record<string, string>;
|
|
21
127
|
}
|
|
22
128
|
/**
|
|
23
129
|
* Creates a Koa router configured to handle MCP protocol requests
|
|
@@ -58,4 +164,4 @@ interface McpRouterOptions {
|
|
|
58
164
|
*/
|
|
59
165
|
declare const createMcpRouter: (server: McpServer, options?: McpRouterOptions) => _koa_router.RouterInstance<koa.DefaultState, koa.DefaultContext>;
|
|
60
166
|
|
|
61
|
-
export { type McpRouterOptions, createMcpRouter };
|
|
167
|
+
export { type ApiCallOptions, type McpRouterOptions, apiCall, createMcpRouter };
|
package/dist/index.js
CHANGED
|
@@ -32,29 +32,129 @@ var __toCommonJS = mod => __copyProps(__defProp({}, "__esModule", {
|
|
|
32
32
|
var index_exports = {};
|
|
33
33
|
__export(index_exports, {
|
|
34
34
|
Server: () => import_mcp.McpServer,
|
|
35
|
+
apiCall: () => apiCall,
|
|
35
36
|
createMcpRouter: () => createMcpRouter,
|
|
36
37
|
z: () => import_zod.z
|
|
37
38
|
});
|
|
38
39
|
module.exports = __toCommonJS(index_exports);
|
|
40
|
+
var import_node_async_hooks = require("async_hooks");
|
|
39
41
|
var import_streamableHttp = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
|
40
42
|
var import_http_server = require("@ttoss/http-server");
|
|
41
43
|
var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
42
44
|
var import_zod = require("zod");
|
|
45
|
+
var requestContextStore = new import_node_async_hooks.AsyncLocalStorage();
|
|
46
|
+
var apiCall = /* @__PURE__ */__name(async (method, url, options) => {
|
|
47
|
+
const context = requestContextStore.getStore();
|
|
48
|
+
let resolvedUrl = url;
|
|
49
|
+
if (url.startsWith("/")) {
|
|
50
|
+
if (!context?.apiBaseUrl) {
|
|
51
|
+
throw new Error(`apiCall received a relative path ("${url}") but no apiBaseUrl is configured. Either pass a full URL or set apiBaseUrl in createMcpRouter options.`);
|
|
52
|
+
}
|
|
53
|
+
resolvedUrl = `${context.apiBaseUrl.replace(/\/$/, "")}${url}`;
|
|
54
|
+
}
|
|
55
|
+
const hasBody = options?.body !== void 0;
|
|
56
|
+
const headers = {
|
|
57
|
+
...(context !== void 0 ? context.apiHeaders : {}),
|
|
58
|
+
...(options?.headers ?? {})
|
|
59
|
+
};
|
|
60
|
+
const hasExplicitContentType = Object.keys(headers).some(headerName => {
|
|
61
|
+
return headerName.toLowerCase() === "content-type";
|
|
62
|
+
});
|
|
63
|
+
if (hasBody && !hasExplicitContentType) {
|
|
64
|
+
headers["Content-Type"] = "application/json";
|
|
65
|
+
}
|
|
66
|
+
const response = await fetch(resolvedUrl, {
|
|
67
|
+
method,
|
|
68
|
+
headers,
|
|
69
|
+
body: hasBody ? JSON.stringify(options.body) : void 0
|
|
70
|
+
});
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
const err = await response.json().catch(() => {
|
|
73
|
+
return {
|
|
74
|
+
error: response.statusText
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
throw new Error(err.error || `HTTP ${response.status}`);
|
|
78
|
+
}
|
|
79
|
+
if (response.status === 204 || response.status === 205) {
|
|
80
|
+
return void 0;
|
|
81
|
+
}
|
|
82
|
+
const contentType = response.headers.get("content-type");
|
|
83
|
+
if (contentType?.includes("application/json")) {
|
|
84
|
+
return response.json();
|
|
85
|
+
}
|
|
86
|
+
return response.text();
|
|
87
|
+
}, "apiCall");
|
|
43
88
|
var createMcpRouter = /* @__PURE__ */__name((server, options = {}) => {
|
|
44
89
|
const {
|
|
45
90
|
path = "/mcp",
|
|
46
|
-
sessionIdGenerator
|
|
47
|
-
} = options;
|
|
48
|
-
const transport = new import_streamableHttp.StreamableHTTPServerTransport({
|
|
49
91
|
sessionIdGenerator,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
92
|
+
apiBaseUrl,
|
|
93
|
+
getApiHeaders
|
|
94
|
+
} = options;
|
|
95
|
+
const isStateful = sessionIdGenerator !== void 0;
|
|
96
|
+
const needsContext = apiBaseUrl !== void 0 || getApiHeaders !== void 0;
|
|
97
|
+
let sharedTransport;
|
|
98
|
+
if (isStateful) {
|
|
99
|
+
sharedTransport = new import_streamableHttp.StreamableHTTPServerTransport({
|
|
100
|
+
sessionIdGenerator,
|
|
101
|
+
enableJsonResponse: true
|
|
102
|
+
});
|
|
103
|
+
server.connect(sharedTransport);
|
|
104
|
+
}
|
|
105
|
+
let statelessQueue = Promise.resolve();
|
|
106
|
+
const enqueueStateless = /* @__PURE__ */__name(work => {
|
|
107
|
+
const result = statelessQueue.then(() => {
|
|
108
|
+
return work();
|
|
109
|
+
});
|
|
110
|
+
statelessQueue = result.catch(() => {});
|
|
111
|
+
return result;
|
|
112
|
+
}, "enqueueStateless");
|
|
53
113
|
const router = new import_http_server.Router();
|
|
114
|
+
const handleWithContext = /* @__PURE__ */__name(async (ctx, body) => {
|
|
115
|
+
const apiHeaders = getApiHeaders ? getApiHeaders(ctx) : {};
|
|
116
|
+
const runRequest = /* @__PURE__ */__name(async transport => {
|
|
117
|
+
await transport.handleRequest(ctx.req, ctx.res, body);
|
|
118
|
+
ctx.respond = false;
|
|
119
|
+
}, "runRequest");
|
|
120
|
+
if (isStateful && sharedTransport) {
|
|
121
|
+
if (needsContext) {
|
|
122
|
+
await requestContextStore.run({
|
|
123
|
+
apiBaseUrl,
|
|
124
|
+
apiHeaders
|
|
125
|
+
}, () => {
|
|
126
|
+
return runRequest(sharedTransport);
|
|
127
|
+
});
|
|
128
|
+
} else {
|
|
129
|
+
await runRequest(sharedTransport);
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
await enqueueStateless(async () => {
|
|
133
|
+
const transport = new import_streamableHttp.StreamableHTTPServerTransport({
|
|
134
|
+
sessionIdGenerator: void 0,
|
|
135
|
+
enableJsonResponse: true
|
|
136
|
+
});
|
|
137
|
+
try {
|
|
138
|
+
await server.connect(transport);
|
|
139
|
+
if (needsContext) {
|
|
140
|
+
await requestContextStore.run({
|
|
141
|
+
apiBaseUrl,
|
|
142
|
+
apiHeaders
|
|
143
|
+
}, () => {
|
|
144
|
+
return runRequest(transport);
|
|
145
|
+
});
|
|
146
|
+
} else {
|
|
147
|
+
await runRequest(transport);
|
|
148
|
+
}
|
|
149
|
+
} finally {
|
|
150
|
+
await transport.close();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}, "handleWithContext");
|
|
54
155
|
router.post(path, async ctx => {
|
|
55
156
|
try {
|
|
56
|
-
await
|
|
57
|
-
ctx.respond = false;
|
|
157
|
+
await handleWithContext(ctx, ctx.request.body);
|
|
58
158
|
} catch (error) {
|
|
59
159
|
if (!ctx.res.headersSent) {
|
|
60
160
|
ctx.status = 500;
|
|
@@ -66,8 +166,7 @@ var createMcpRouter = /* @__PURE__ */__name((server, options = {}) => {
|
|
|
66
166
|
});
|
|
67
167
|
router.delete(path, async ctx => {
|
|
68
168
|
try {
|
|
69
|
-
await
|
|
70
|
-
ctx.respond = false;
|
|
169
|
+
await handleWithContext(ctx);
|
|
71
170
|
} catch (error) {
|
|
72
171
|
if (!ctx.res.headersSent) {
|
|
73
172
|
ctx.status = 500;
|
|
@@ -82,6 +181,7 @@ var createMcpRouter = /* @__PURE__ */__name((server, options = {}) => {
|
|
|
82
181
|
// Annotate the CommonJS export names for ESM import in node:
|
|
83
182
|
0 && (module.exports = {
|
|
84
183
|
Server,
|
|
184
|
+
apiCall,
|
|
85
185
|
createMcpRouter,
|
|
86
186
|
z
|
|
87
187
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ttoss/http-server-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Model Context Protocol (MCP) server integration for @ttoss/http-server",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "ttoss",
|
|
@@ -25,16 +25,16 @@
|
|
|
25
25
|
],
|
|
26
26
|
"sideEffects": false,
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@modelcontextprotocol/sdk": "^1.0
|
|
28
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
29
29
|
"zod": "^3.25.0",
|
|
30
|
-
"@ttoss/http-server": "^0.5.
|
|
30
|
+
"@ttoss/http-server": "^0.5.8"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/koa": "^3.0.1",
|
|
34
34
|
"jest": "^30.3.0",
|
|
35
35
|
"supertest": "^7.1.4",
|
|
36
36
|
"tsup": "^8.5.1",
|
|
37
|
-
"@ttoss/config": "^1.37.
|
|
37
|
+
"@ttoss/config": "^1.37.8"
|
|
38
38
|
},
|
|
39
39
|
"keywords": [
|
|
40
40
|
"ai",
|