@tigerdata/mcp-boilerplate 1.3.5 → 1.5.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 +43 -0
- package/dist/eslintPlugin.js +1 -2
- package/dist/httpServer.d.ts +39 -1
- package/dist/httpServer.js +63 -8
- package/dist/httpServer.spec.d.ts +1 -0
- package/dist/httpServer.spec.js +135 -0
- package/dist/logger.js +22 -4
- package/dist/skills/types.d.ts +9 -0
- package/dist/skills/types.js +2 -0
- package/dist/skills/utils.js +10 -5
- package/dist/testSetup.d.ts +1 -0
- package/dist/testSetup.js +14 -0
- package/dist/tracing.d.ts +4 -2
- package/dist/tracing.js +14 -5
- package/package.json +27 -24
package/README.md
CHANGED
|
@@ -10,10 +10,53 @@ npm install @tigerdata/mcp-boilerplate
|
|
|
10
10
|
|
|
11
11
|
See [tiger-skills-mcp-server](https://github.com/tigerdata/tiger-skills-mcp-server) for an example MCP server using this boilerplate.
|
|
12
12
|
|
|
13
|
+
### DNS Rebinding Protection
|
|
14
|
+
|
|
15
|
+
MCP servers reachable over HTTP can be targeted by [DNS rebinding attacks](https://en.wikipedia.org/wiki/DNS_rebinding), where a malicious website bypasses the browser same-origin policy by pointing a domain at a localhost (or otherwise private) address to reach a local server. The MCP SDK guards against this by validating the `Host` header against an allow-list.
|
|
16
|
+
|
|
17
|
+
This check is **most useful for localhost / development servers** without HTTPS or authentication. Hosted servers served on public hostnames generally don't need it (and would reject legitimate traffic unless every allowed hostname is listed), so `httpServerFactory` leaves it **disabled by default** — it is opt-in via the `dnsRebindingProtection` option (scoped to the MCP and API mount paths):
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { httpServerFactory } from '@tigerdata/mcp-boilerplate';
|
|
21
|
+
|
|
22
|
+
await httpServerFactory({
|
|
23
|
+
name: 'my-server',
|
|
24
|
+
context,
|
|
25
|
+
// Omitted (default): disabled, UNLESS MCP_ALLOWED_HOSTS is set — in which
|
|
26
|
+
// case protection is enabled using that env var's allow-list.
|
|
27
|
+
// true: enabled. Uses the MCP_ALLOWED_HOSTS allow-list when set, otherwise
|
|
28
|
+
// localhost-only (localhost, 127.0.0.1, [::1]).
|
|
29
|
+
// string[]: enabled with this exact allow-list (MCP_ALLOWED_HOSTS ignored).
|
|
30
|
+
// Hostnames only, without ports; use [::1] for IPv6.
|
|
31
|
+
// false: disabled. Always wins, even if MCP_ALLOWED_HOSTS is set.
|
|
32
|
+
dnsRebindingProtection: true,
|
|
33
|
+
});
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The `MCP_ALLOWED_HOSTS` environment variable can both enable and configure protection without touching code: set it to a comma-separated list of hostnames (e.g. `localhost,127.0.0.1,[::1]`) and protection turns on using that list. It is consulted whenever `dnsRebindingProtection` is omitted or `true`, but is ignored when an explicit `string[]` is passed (that list wins) or when `false` is passed (protection stays off).
|
|
37
|
+
|
|
38
|
+
### Binding to a specific network interface
|
|
39
|
+
|
|
40
|
+
Separately from Host header validation, you can restrict which network interface the server's socket accepts connections on via the `host` option (forwarded to `app.listen()`). Binding to loopback is an OS-level defense that makes the port unreachable from other machines entirely — useful for localhost/development servers:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
await httpServerFactory({
|
|
44
|
+
name: 'my-server',
|
|
45
|
+
context,
|
|
46
|
+
host: '127.0.0.1', // only accept connections on loopback
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Defaults to the `HOST` environment variable, or all available interfaces when unset. This is independent of `dnsRebindingProtection` and is ignored when an external `app` is provided (the caller owns the server lifecycle).
|
|
51
|
+
|
|
13
52
|
### Skills
|
|
14
53
|
|
|
15
54
|
Add skills support to your MCP server by leveraging the skills submodule in `@tigerdata/mcp-boilerplate/skills`. See the [Skills README](./src/skills/README.md) for details.
|
|
16
55
|
|
|
56
|
+
### Logging
|
|
57
|
+
|
|
58
|
+
The exported `log` helper writes to the console (via `stderr`, to avoid interfering with the stdio MCP transport) and emits OpenTelemetry log records. The `CONSOLE_LOG_LEVEL` environment variable sets the minimum severity written to the console: `debug` (the default, writes everything), `info`, `warn`, `error`, or `none` to disable console output entirely. OpenTelemetry log records are always emitted regardless of this setting.
|
|
59
|
+
|
|
17
60
|
## Eslint Plugin
|
|
18
61
|
|
|
19
62
|
This project includes a custom ESLint plugin to guard against the problematic use of optional parameters for tool inputs. Doing so leads to tools that are incompatible with certain models, such as GPT-5.
|
package/dist/eslintPlugin.js
CHANGED
|
@@ -17,7 +17,6 @@ const noOptionalInputSchema = {
|
|
|
17
17
|
type: 'problem',
|
|
18
18
|
docs: {
|
|
19
19
|
description: 'Disallow .optional(), .default(), and .nullish() on zod schemas in ApiFactory inputSchema',
|
|
20
|
-
category: 'Best Practices',
|
|
21
20
|
recommended: true,
|
|
22
21
|
},
|
|
23
22
|
messages: {
|
|
@@ -91,7 +90,7 @@ const noOptionalInputSchema = {
|
|
|
91
90
|
* Check if a node is inside a schema that's used as an ApiFactory Input type parameter
|
|
92
91
|
*/
|
|
93
92
|
function isInsideApiFactoryInputSchema(node, context, apiFactoryInputSchemas) {
|
|
94
|
-
const sourceCode = context.sourceCode
|
|
93
|
+
const sourceCode = context.sourceCode;
|
|
95
94
|
const ancestors = sourceCode?.getAncestors?.(node) ?? [];
|
|
96
95
|
// Check ancestors for variables that are ApiFactory input schemas
|
|
97
96
|
for (const ancestor of ancestors) {
|
package/dist/httpServer.d.ts
CHANGED
|
@@ -28,6 +28,44 @@ interface HttpServerOptions<Context extends Record<string, unknown>> {
|
|
|
28
28
|
* Path to mount the API router at. Defaults to `"/api"`.
|
|
29
29
|
*/
|
|
30
30
|
apiPath?: string;
|
|
31
|
+
/**
|
|
32
|
+
* DNS rebinding protection via Host header validation. DNS rebinding
|
|
33
|
+
* attacks can bypass the browser same-origin policy by pointing a domain at
|
|
34
|
+
* a localhost address, letting a malicious website reach a local server.
|
|
35
|
+
* Validating the Host header against an allow-list prevents this. This is
|
|
36
|
+
* especially important for servers without authorization or HTTPS.
|
|
37
|
+
*
|
|
38
|
+
* Protection is **disabled by default** (opt-in). This library is primarily
|
|
39
|
+
* used by hosted MCP servers served on arbitrary public hostnames, where a
|
|
40
|
+
* Host allow-list would reject legitimate traffic and the DNS rebinding
|
|
41
|
+
* threat (a victim's browser reaching an unauthenticated `localhost`
|
|
42
|
+
* server) does not apply. Localhost/development servers should opt in.
|
|
43
|
+
*
|
|
44
|
+
* The `MCP_ALLOWED_HOSTS` environment variable (a comma-separated list of
|
|
45
|
+
* hostnames) is consulted when this option is omitted or `true`, and is
|
|
46
|
+
* ignored when an explicit `string[]` or `false` is passed:
|
|
47
|
+
*
|
|
48
|
+
* - omitted (default): disabled, unless `MCP_ALLOWED_HOSTS` is set — in
|
|
49
|
+
* which case protection is enabled using that allow-list.
|
|
50
|
+
* - `true`: enabled. Uses the `MCP_ALLOWED_HOSTS` allow-list when set,
|
|
51
|
+
* otherwise defaults to localhost (`localhost`, `127.0.0.1`, `[::1]`).
|
|
52
|
+
* - `string[]`: enabled, validating against exactly these hostnames
|
|
53
|
+
* (`MCP_ALLOWED_HOSTS` is ignored). Hostnames only, without ports; for
|
|
54
|
+
* IPv6, use bracket notation (e.g. `"[::1]"`).
|
|
55
|
+
* - `false`: disabled. Always wins, even if `MCP_ALLOWED_HOSTS` is set.
|
|
56
|
+
*/
|
|
57
|
+
dnsRebindingProtection?: boolean | readonly string[];
|
|
58
|
+
/**
|
|
59
|
+
* Network interface (hostname or IP address) to bind the HTTP server to.
|
|
60
|
+
* Forwarded to `app.listen()`. For example, `"127.0.0.1"` restricts the
|
|
61
|
+
* server to loopback so it is unreachable from other machines — a strong,
|
|
62
|
+
* OS-level defense for localhost/development servers.
|
|
63
|
+
*
|
|
64
|
+
* Defaults to the `HOST` environment variable, or undefined (bind all
|
|
65
|
+
* available interfaces) when unset. Ignored when an external `app` is
|
|
66
|
+
* provided, since the caller owns the server lifecycle.
|
|
67
|
+
*/
|
|
68
|
+
host?: string;
|
|
31
69
|
}
|
|
32
70
|
interface HttpServerResult {
|
|
33
71
|
app: Router;
|
|
@@ -37,5 +75,5 @@ interface HttpServerResult {
|
|
|
37
75
|
mcpRouter: express.Router;
|
|
38
76
|
registerCleanupFn: (fn: () => Promise<void>) => void;
|
|
39
77
|
}
|
|
40
|
-
export declare const httpServerFactory: <Context extends Record<string, unknown>>({ name, version, context, apiFactories, promptFactories, resourceFactories, additionalSetup, cleanupFn, stateful, instructions, app: externalApp, mcpPath, apiPath, }: HttpServerOptions<Context>) => Promise<HttpServerResult>;
|
|
78
|
+
export declare const httpServerFactory: <Context extends Record<string, unknown>>({ name, version, context, apiFactories, promptFactories, resourceFactories, additionalSetup, cleanupFn, stateful, instructions, app: externalApp, mcpPath, apiPath, dnsRebindingProtection, host, }: HttpServerOptions<Context>) => Promise<HttpServerResult>;
|
|
41
79
|
export {};
|
package/dist/httpServer.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { hostHeaderValidation } from '@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js';
|
|
2
3
|
import bodyParser from 'body-parser';
|
|
3
4
|
import express from 'express';
|
|
4
5
|
import { apiRouterFactory } from './http/api.js';
|
|
@@ -7,7 +8,37 @@ import { log } from './logger.js';
|
|
|
7
8
|
import { mcpServerFactory } from './mcpServer.js';
|
|
8
9
|
import { registerExitHandlers } from './registerExitHandlers.js';
|
|
9
10
|
import { StatusError } from './StatusError.js';
|
|
10
|
-
|
|
11
|
+
const LOCALHOST_HOSTNAMES = ['localhost', '127.0.0.1', '[::1]'];
|
|
12
|
+
const hostnamesFromEnv = () => {
|
|
13
|
+
const fromEnv = process.env.MCP_ALLOWED_HOSTS?.split(',')
|
|
14
|
+
.map((hostname) => hostname.trim())
|
|
15
|
+
.filter(Boolean);
|
|
16
|
+
return fromEnv?.length ? fromEnv : null;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Resolves the Host header allow-list for DNS rebinding protection.
|
|
20
|
+
* Returns `null` when protection is disabled. Protection is disabled by
|
|
21
|
+
* default (opt-in); it is enabled by passing `true`, passing an explicit
|
|
22
|
+
* allow-list, or setting the `MCP_ALLOWED_HOSTS` environment variable.
|
|
23
|
+
*/
|
|
24
|
+
const resolveAllowedHostnames = (dnsRebindingProtection) => {
|
|
25
|
+
// Explicit custom allow-list.
|
|
26
|
+
if (Array.isArray(dnsRebindingProtection)) {
|
|
27
|
+
return [...dnsRebindingProtection];
|
|
28
|
+
}
|
|
29
|
+
// Explicit opt-out wins over the env var.
|
|
30
|
+
if (dnsRebindingProtection === false) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
// Explicitly enabled: use the env-configured allow-list when present,
|
|
34
|
+
// otherwise fall back to localhost-only.
|
|
35
|
+
if (dnsRebindingProtection === true) {
|
|
36
|
+
return hostnamesFromEnv() ?? [...LOCALHOST_HOSTNAMES];
|
|
37
|
+
}
|
|
38
|
+
// Disabled by default (omitted), unless the env var opts in.
|
|
39
|
+
return hostnamesFromEnv();
|
|
40
|
+
};
|
|
41
|
+
export const httpServerFactory = async ({ name, version, context, apiFactories = [], promptFactories, resourceFactories, additionalSetup, cleanupFn, stateful = true, instructions, app: externalApp, mcpPath = '/mcp', apiPath = '/api', dnsRebindingProtection, host = process.env.HOST, }) => {
|
|
11
42
|
const cleanupFns = cleanupFn
|
|
12
43
|
? [cleanupFn]
|
|
13
44
|
: [];
|
|
@@ -22,7 +53,20 @@ export const httpServerFactory = async ({ name, version, context, apiFactories =
|
|
|
22
53
|
ownApp.enable('trust proxy');
|
|
23
54
|
app = ownApp;
|
|
24
55
|
}
|
|
25
|
-
const PORT = process.env.PORT
|
|
56
|
+
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3001;
|
|
57
|
+
if (!Number.isInteger(PORT) || PORT < 0 || PORT > 65535) {
|
|
58
|
+
throw new Error(`Invalid PORT environment variable: ${process.env.PORT}`);
|
|
59
|
+
}
|
|
60
|
+
// DNS rebinding protection: validate the Host header against an allow-list.
|
|
61
|
+
// Scoped to the MCP and API mount paths so callers providing an external
|
|
62
|
+
// app keep full control over their other routes.
|
|
63
|
+
const allowedHostnames = resolveAllowedHostnames(dnsRebindingProtection);
|
|
64
|
+
const hostValidation = allowedHostnames
|
|
65
|
+
? hostHeaderValidation(allowedHostnames)
|
|
66
|
+
: null;
|
|
67
|
+
if (allowedHostnames) {
|
|
68
|
+
log.info('DNS rebinding protection enabled', { allowedHostnames });
|
|
69
|
+
}
|
|
26
70
|
const inspector = process.env.NODE_ENV !== 'production' ||
|
|
27
71
|
['1', 'true'].includes(process.env.ENABLE_INSPECTOR ?? '0');
|
|
28
72
|
const [mcpRouter, mcpCleanup] = mcpRouterFactory(context, (context, featureFlags) => mcpServerFactory({
|
|
@@ -37,9 +81,15 @@ export const httpServerFactory = async ({ name, version, context, apiFactories =
|
|
|
37
81
|
instructions,
|
|
38
82
|
}), { name, stateful, inspector });
|
|
39
83
|
cleanupFns.push(mcpCleanup);
|
|
84
|
+
if (hostValidation) {
|
|
85
|
+
app.use(mcpPath, hostValidation);
|
|
86
|
+
}
|
|
40
87
|
app.use(mcpPath, mcpRouter);
|
|
41
88
|
const [apiRouter, apiCleanup] = await apiRouterFactory(context, apiFactories);
|
|
42
89
|
cleanupFns.push(apiCleanup);
|
|
90
|
+
if (hostValidation) {
|
|
91
|
+
app.use(apiPath, hostValidation);
|
|
92
|
+
}
|
|
43
93
|
app.use(apiPath, apiRouter);
|
|
44
94
|
// Error handler
|
|
45
95
|
app.use((err, _req, res, _next) => {
|
|
@@ -83,21 +133,26 @@ export const httpServerFactory = async ({ name, version, context, apiFactories =
|
|
|
83
133
|
// Start the server (ownApp is guaranteed to exist here — we returned early for external apps)
|
|
84
134
|
if (!ownApp)
|
|
85
135
|
throw new Error('Expected own Express app');
|
|
86
|
-
const
|
|
136
|
+
const onListen = async (error) => {
|
|
87
137
|
if (error) {
|
|
88
138
|
log.error('Error starting HTTP server:', error);
|
|
89
139
|
exitHandler(1);
|
|
90
140
|
}
|
|
91
141
|
else {
|
|
92
|
-
log.info(`HTTP Server listening on port ${PORT}`);
|
|
142
|
+
log.info(`HTTP Server listening on ${host ? `${host}:${PORT}` : `port ${PORT}`}`);
|
|
93
143
|
if (inspector) {
|
|
94
144
|
log.info(`🌐 MCP inspector running at http://localhost:${PORT}/inspector`);
|
|
95
145
|
}
|
|
96
146
|
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
147
|
+
};
|
|
148
|
+
// `app.listen` overloads differ by whether a host is provided; calling with
|
|
149
|
+
// an explicit `undefined` host binds all interfaces (Node's default).
|
|
150
|
+
const server = host
|
|
151
|
+
? ownApp.listen(PORT, host, onListen)
|
|
152
|
+
: ownApp.listen(PORT, onListen);
|
|
153
|
+
cleanupFns.push(() => new Promise((resolve, reject) => {
|
|
154
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
155
|
+
}));
|
|
101
156
|
return {
|
|
102
157
|
app,
|
|
103
158
|
server,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import { httpServerFactory } from './httpServer.js';
|
|
4
|
+
const startServer = async (dnsRebindingProtection) => {
|
|
5
|
+
const app = express();
|
|
6
|
+
await httpServerFactory({
|
|
7
|
+
name: 'test-server',
|
|
8
|
+
context: {},
|
|
9
|
+
app,
|
|
10
|
+
dnsRebindingProtection,
|
|
11
|
+
});
|
|
12
|
+
const server = app.listen(0);
|
|
13
|
+
await new Promise((resolve) => server.once('listening', resolve));
|
|
14
|
+
const address = server.address();
|
|
15
|
+
if (!address || typeof address === 'string') {
|
|
16
|
+
throw new Error('Expected a TCP server address');
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
port: address.port,
|
|
20
|
+
cleanup: async () => {
|
|
21
|
+
await new Promise((resolve, reject) => {
|
|
22
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
const postMcp = (port, host) => {
|
|
28
|
+
const headers = {
|
|
29
|
+
'content-type': 'application/json',
|
|
30
|
+
accept: 'application/json, text/event-stream',
|
|
31
|
+
};
|
|
32
|
+
if (host !== undefined) {
|
|
33
|
+
headers.host = host;
|
|
34
|
+
}
|
|
35
|
+
// Use 127.0.0.1 so the request reaches the server, while overriding the
|
|
36
|
+
// Host header to exercise validation.
|
|
37
|
+
return fetch(`http://127.0.0.1:${port}/mcp`, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers,
|
|
40
|
+
body: JSON.stringify({
|
|
41
|
+
jsonrpc: '2.0',
|
|
42
|
+
id: 1,
|
|
43
|
+
method: 'initialize',
|
|
44
|
+
params: {
|
|
45
|
+
protocolVersion: '2025-06-18',
|
|
46
|
+
capabilities: {},
|
|
47
|
+
clientInfo: { name: 'test', version: '1.0.0' },
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
describe('httpServerFactory DNS rebinding protection', () => {
|
|
53
|
+
const started = [];
|
|
54
|
+
afterEach(async () => {
|
|
55
|
+
while (started.length > 0) {
|
|
56
|
+
const s = started.pop();
|
|
57
|
+
await s?.cleanup();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
const track = (s) => {
|
|
61
|
+
started.push(s);
|
|
62
|
+
return s;
|
|
63
|
+
};
|
|
64
|
+
it('rejects disallowed Host headers when protection is enabled', async () => {
|
|
65
|
+
const { port } = track(await startServer(true));
|
|
66
|
+
const res = await postMcp(port, 'evil.example.com');
|
|
67
|
+
expect(res.status).toBe(403);
|
|
68
|
+
const body = (await res.json());
|
|
69
|
+
expect(body.error?.message).toContain('Invalid Host');
|
|
70
|
+
});
|
|
71
|
+
it('is disabled by default when the option is omitted', async () => {
|
|
72
|
+
const { port } = track(await startServer(undefined));
|
|
73
|
+
const res = await postMcp(port, 'evil.example.com');
|
|
74
|
+
expect(res.status).not.toBe(403);
|
|
75
|
+
});
|
|
76
|
+
it('allows localhost Host headers when protection is enabled', async () => {
|
|
77
|
+
const { port } = track(await startServer(true));
|
|
78
|
+
const res = await postMcp(port, `127.0.0.1:${port}`);
|
|
79
|
+
// Not a 403 — the request passes Host validation and reaches the MCP
|
|
80
|
+
// handler (which then negotiates the session normally).
|
|
81
|
+
expect(res.status).not.toBe(403);
|
|
82
|
+
});
|
|
83
|
+
it('allows any Host header when protection is disabled', async () => {
|
|
84
|
+
const { port } = track(await startServer(false));
|
|
85
|
+
const res = await postMcp(port, 'evil.example.com');
|
|
86
|
+
expect(res.status).not.toBe(403);
|
|
87
|
+
});
|
|
88
|
+
it('validates against a custom allow-list', async () => {
|
|
89
|
+
const { port } = track(await startServer(['mcp.internal']));
|
|
90
|
+
const allowed = await postMcp(port, 'mcp.internal');
|
|
91
|
+
expect(allowed.status).not.toBe(403);
|
|
92
|
+
const denied = await postMcp(port, 'localhost');
|
|
93
|
+
expect(denied.status).toBe(403);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
describe('httpServerFactory interface binding', () => {
|
|
97
|
+
it('binds to the requested host', async () => {
|
|
98
|
+
const prevPort = process.env.PORT;
|
|
99
|
+
process.env.PORT = '0';
|
|
100
|
+
try {
|
|
101
|
+
const { server } = await httpServerFactory({
|
|
102
|
+
name: 'test-server',
|
|
103
|
+
context: {},
|
|
104
|
+
host: '127.0.0.1',
|
|
105
|
+
});
|
|
106
|
+
if (!server) {
|
|
107
|
+
throw new Error('Expected own server');
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
if (!server.listening) {
|
|
111
|
+
await new Promise((resolve, reject) => {
|
|
112
|
+
server.once('listening', resolve);
|
|
113
|
+
server.once('error', reject);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
const address = server.address();
|
|
117
|
+
if (!address || typeof address === 'string') {
|
|
118
|
+
throw new Error('Expected a TCP server address');
|
|
119
|
+
}
|
|
120
|
+
expect(address.address).toBe('127.0.0.1');
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
if (prevPort === undefined) {
|
|
128
|
+
delete process.env.PORT;
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
process.env.PORT = prevPort;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|
package/dist/logger.js
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
import { logs, SeverityNumber, } from '@opentelemetry/api-logs';
|
|
2
2
|
const name = process.env.OTEL_SERVICE_NAME || 'mcp-app';
|
|
3
3
|
const logger = logs.getLogger(name);
|
|
4
|
+
const consoleLogLevelSeverity = {
|
|
5
|
+
debug: 0,
|
|
6
|
+
info: 1,
|
|
7
|
+
warn: 2,
|
|
8
|
+
error: 3,
|
|
9
|
+
none: 4,
|
|
10
|
+
};
|
|
11
|
+
const configuredConsoleLogLevel = (process.env.CONSOLE_LOG_LEVEL || 'debug').toLowerCase();
|
|
12
|
+
if (!(configuredConsoleLogLevel in consoleLogLevelSeverity)) {
|
|
13
|
+
throw new Error(`Invalid CONSOLE_LOG_LEVEL '${process.env.CONSOLE_LOG_LEVEL}'. ` +
|
|
14
|
+
`Expected one of: ${Object.keys(consoleLogLevelSeverity).join(', ')}.`);
|
|
15
|
+
}
|
|
16
|
+
const minConsoleLogSeverity = consoleLogLevelSeverity[configuredConsoleLogLevel];
|
|
17
|
+
const writeToConsole = (level, ...args) => {
|
|
18
|
+
if (consoleLogLevelSeverity[level] >= minConsoleLogSeverity) {
|
|
19
|
+
console.error(...args);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
4
22
|
// Helper functions to replace console.log
|
|
5
23
|
// We use console.error for all levels so that messages are written to stderr
|
|
6
24
|
// and not stdout, which would interfere with the stdio MCP transport.
|
|
7
25
|
export const log = {
|
|
8
26
|
debug: (...args) => {
|
|
9
|
-
|
|
27
|
+
writeToConsole('debug', ...args);
|
|
10
28
|
const [body, attributes] = args;
|
|
11
29
|
logger.emit({
|
|
12
30
|
severityText: 'DEBUG',
|
|
@@ -20,7 +38,7 @@ export const log = {
|
|
|
20
38
|
});
|
|
21
39
|
},
|
|
22
40
|
info: (...args) => {
|
|
23
|
-
|
|
41
|
+
writeToConsole('info', ...args);
|
|
24
42
|
const [body, attributes] = args;
|
|
25
43
|
logger.emit({
|
|
26
44
|
severityText: 'INFO',
|
|
@@ -34,7 +52,7 @@ export const log = {
|
|
|
34
52
|
});
|
|
35
53
|
},
|
|
36
54
|
warn: (...args) => {
|
|
37
|
-
|
|
55
|
+
writeToConsole('warn', ...args);
|
|
38
56
|
const [body, attributes] = args;
|
|
39
57
|
logger.emit({
|
|
40
58
|
severityText: 'WARN',
|
|
@@ -48,7 +66,7 @@ export const log = {
|
|
|
48
66
|
});
|
|
49
67
|
},
|
|
50
68
|
error: (...args) => {
|
|
51
|
-
|
|
69
|
+
writeToConsole('error', ...args);
|
|
52
70
|
const [body, error, attributes] = args;
|
|
53
71
|
logger.emit({
|
|
54
72
|
severityText: 'ERROR',
|
package/dist/skills/types.d.ts
CHANGED
|
@@ -36,6 +36,7 @@ export declare const zGitHubSkillCfg: z.ZodObject<{
|
|
|
36
36
|
type: z.ZodLiteral<"github">;
|
|
37
37
|
repo: z.ZodString;
|
|
38
38
|
path: z.ZodOptional<z.ZodString>;
|
|
39
|
+
ref: z.ZodOptional<z.ZodString>;
|
|
39
40
|
}, z.core.$strip>;
|
|
40
41
|
export type GitHubSkillCfg = z.infer<typeof zGitHubSkillCfg>;
|
|
41
42
|
export declare const zGitHubCollectionSkillCfg: z.ZodObject<{
|
|
@@ -45,6 +46,7 @@ export declare const zGitHubCollectionSkillCfg: z.ZodObject<{
|
|
|
45
46
|
type: z.ZodLiteral<"github_collection">;
|
|
46
47
|
repo: z.ZodString;
|
|
47
48
|
path: z.ZodOptional<z.ZodString>;
|
|
49
|
+
ref: z.ZodOptional<z.ZodString>;
|
|
48
50
|
}, z.core.$strip>;
|
|
49
51
|
export type GitHubCollectionSkillCfg = z.infer<typeof zGitHubCollectionSkillCfg>;
|
|
50
52
|
export declare const zSkillCfg: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
@@ -60,6 +62,7 @@ export declare const zSkillCfg: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
60
62
|
type: z.ZodLiteral<"github">;
|
|
61
63
|
repo: z.ZodString;
|
|
62
64
|
path: z.ZodOptional<z.ZodString>;
|
|
65
|
+
ref: z.ZodOptional<z.ZodString>;
|
|
63
66
|
}, z.core.$strip>, z.ZodObject<{
|
|
64
67
|
enabled_skills: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
65
68
|
disabled_skills: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
@@ -67,6 +70,7 @@ export declare const zSkillCfg: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
67
70
|
type: z.ZodLiteral<"github_collection">;
|
|
68
71
|
repo: z.ZodString;
|
|
69
72
|
path: z.ZodOptional<z.ZodString>;
|
|
73
|
+
ref: z.ZodOptional<z.ZodString>;
|
|
70
74
|
}, z.core.$strip>], "type">;
|
|
71
75
|
export type SkillCfg = z.infer<typeof zSkillCfg>;
|
|
72
76
|
export declare const zSkillCfgMap: z.ZodRecord<z.ZodString, z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
@@ -82,6 +86,7 @@ export declare const zSkillCfgMap: z.ZodRecord<z.ZodString, z.ZodDiscriminatedUn
|
|
|
82
86
|
type: z.ZodLiteral<"github">;
|
|
83
87
|
repo: z.ZodString;
|
|
84
88
|
path: z.ZodOptional<z.ZodString>;
|
|
89
|
+
ref: z.ZodOptional<z.ZodString>;
|
|
85
90
|
}, z.core.$strip>, z.ZodObject<{
|
|
86
91
|
enabled_skills: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
87
92
|
disabled_skills: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
@@ -89,6 +94,7 @@ export declare const zSkillCfgMap: z.ZodRecord<z.ZodString, z.ZodDiscriminatedUn
|
|
|
89
94
|
type: z.ZodLiteral<"github_collection">;
|
|
90
95
|
repo: z.ZodString;
|
|
91
96
|
path: z.ZodOptional<z.ZodString>;
|
|
97
|
+
ref: z.ZodOptional<z.ZodString>;
|
|
92
98
|
}, z.core.$strip>], "type">>;
|
|
93
99
|
export type SkillCfgMap = z.infer<typeof zSkillCfgMap>;
|
|
94
100
|
export declare const zSkillMatter: z.ZodObject<{
|
|
@@ -109,6 +115,7 @@ export declare const zGitHubSkill: z.ZodObject<{
|
|
|
109
115
|
type: z.ZodLiteral<"github">;
|
|
110
116
|
repo: z.ZodString;
|
|
111
117
|
path: z.ZodOptional<z.ZodString>;
|
|
118
|
+
ref: z.ZodOptional<z.ZodString>;
|
|
112
119
|
}, z.core.$strip>;
|
|
113
120
|
export type GitHubSkill = z.infer<typeof zGitHubSkill>;
|
|
114
121
|
export declare const zSkill: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
@@ -122,6 +129,7 @@ export declare const zSkill: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
122
129
|
type: z.ZodLiteral<"github">;
|
|
123
130
|
repo: z.ZodString;
|
|
124
131
|
path: z.ZodOptional<z.ZodString>;
|
|
132
|
+
ref: z.ZodOptional<z.ZodString>;
|
|
125
133
|
}, z.core.$strip>], "type">;
|
|
126
134
|
export type Skill = z.infer<typeof zSkill>;
|
|
127
135
|
export declare const zSkillMap: z.ZodRecord<z.ZodString, z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
@@ -135,6 +143,7 @@ export declare const zSkillMap: z.ZodRecord<z.ZodString, z.ZodDiscriminatedUnion
|
|
|
135
143
|
type: z.ZodLiteral<"github">;
|
|
136
144
|
repo: z.ZodString;
|
|
137
145
|
path: z.ZodOptional<z.ZodString>;
|
|
146
|
+
ref: z.ZodOptional<z.ZodString>;
|
|
138
147
|
}, z.core.$strip>], "type">>;
|
|
139
148
|
export type SkillMap = z.infer<typeof zSkillMap>;
|
|
140
149
|
export interface SkillsFlags {
|
package/dist/skills/types.js
CHANGED
|
@@ -22,11 +22,13 @@ export const zGitHubSkillCfg = z.object({
|
|
|
22
22
|
type: z.literal('github'),
|
|
23
23
|
repo: z.string(),
|
|
24
24
|
path: z.string().optional(),
|
|
25
|
+
ref: z.string().optional(),
|
|
25
26
|
});
|
|
26
27
|
export const zGitHubCollectionSkillCfg = zCollectionFlagsCfg.extend({
|
|
27
28
|
type: z.literal('github_collection'),
|
|
28
29
|
repo: z.string(),
|
|
29
30
|
path: z.string().optional(),
|
|
31
|
+
ref: z.string().optional(),
|
|
30
32
|
});
|
|
31
33
|
export const zSkillCfg = z.discriminatedUnion('type', [
|
|
32
34
|
zLocalSkillCfg,
|
package/dist/skills/utils.js
CHANGED
|
@@ -107,7 +107,7 @@ const doLoadSkills = async (octokit) => {
|
|
|
107
107
|
log.error(`Failed to load skill at path: ${skillPath}`, err);
|
|
108
108
|
}
|
|
109
109
|
};
|
|
110
|
-
const loadGitHubPath = async (owner, repo, path, flags) => {
|
|
110
|
+
const loadGitHubPath = async (owner, repo, path, ref, flags) => {
|
|
111
111
|
if (shouldIgnorePath(path, flags))
|
|
112
112
|
return;
|
|
113
113
|
const skillPath = `${path}/SKILL.md`;
|
|
@@ -120,6 +120,7 @@ const doLoadSkills = async (octokit) => {
|
|
|
120
120
|
owner,
|
|
121
121
|
repo,
|
|
122
122
|
path: skillPath,
|
|
123
|
+
...(ref ? { ref } : {}),
|
|
123
124
|
});
|
|
124
125
|
if (Array.isArray(skillFileResponse.data) ||
|
|
125
126
|
skillFileResponse.data.type !== 'file') {
|
|
@@ -127,6 +128,7 @@ const doLoadSkills = async (octokit) => {
|
|
|
127
128
|
owner,
|
|
128
129
|
repo,
|
|
129
130
|
path: skillPath,
|
|
131
|
+
ref,
|
|
130
132
|
});
|
|
131
133
|
return;
|
|
132
134
|
}
|
|
@@ -140,13 +142,14 @@ const doLoadSkills = async (octokit) => {
|
|
|
140
142
|
type: 'github',
|
|
141
143
|
repo: `${owner}/${repo}`,
|
|
142
144
|
path,
|
|
145
|
+
...(ref ? { ref } : {}),
|
|
143
146
|
name,
|
|
144
147
|
description,
|
|
145
148
|
});
|
|
146
149
|
skillContentCache.set(`${name}/SKILL.md`, content);
|
|
147
150
|
}
|
|
148
151
|
catch (err) {
|
|
149
|
-
log.error(`Failed to load skill at GitHub path: ${owner}/${repo}/${skillPath}\n${err.message}`);
|
|
152
|
+
log.error(`Failed to load skill at GitHub path: ${owner}/${repo}/${skillPath}${ref ? `@${ref}` : ''}\n${err.message}`);
|
|
150
153
|
}
|
|
151
154
|
};
|
|
152
155
|
const promises = [];
|
|
@@ -181,7 +184,7 @@ const doLoadSkills = async (octokit) => {
|
|
|
181
184
|
log.error(`Invalid GitHub repo format in skill config: ${cfg.repo}`, null, { name, repo: cfg.repo });
|
|
182
185
|
break;
|
|
183
186
|
}
|
|
184
|
-
promises.push(loadGitHubPath(owner, repo, cfg.path || '.'));
|
|
187
|
+
promises.push(loadGitHubPath(owner, repo, cfg.path || '.', cfg.ref));
|
|
185
188
|
break;
|
|
186
189
|
}
|
|
187
190
|
case 'github_collection': {
|
|
@@ -201,9 +204,10 @@ const doLoadSkills = async (octokit) => {
|
|
|
201
204
|
owner,
|
|
202
205
|
repo,
|
|
203
206
|
path: rootPath,
|
|
207
|
+
...(cfg.ref ? { ref: cfg.ref } : {}),
|
|
204
208
|
});
|
|
205
209
|
if (!Array.isArray(dirResponse.data)) {
|
|
206
|
-
log.error(`Expected github_collection repo path to be a directory`, null, { name, owner, repo, path: cfg.path || '.' });
|
|
210
|
+
log.error(`Expected github_collection repo path to be a directory`, null, { name, owner, repo, path: cfg.path || '.', ref: cfg.ref });
|
|
207
211
|
break;
|
|
208
212
|
}
|
|
209
213
|
const flags = parseCollectionFlags(cfg);
|
|
@@ -217,7 +221,7 @@ const doLoadSkills = async (octokit) => {
|
|
|
217
221
|
});
|
|
218
222
|
continue;
|
|
219
223
|
}
|
|
220
|
-
promises.push(loadGitHubPath(owner, repo, entry.path, flags));
|
|
224
|
+
promises.push(loadGitHubPath(owner, repo, entry.path, cfg.ref, flags));
|
|
221
225
|
}
|
|
222
226
|
break;
|
|
223
227
|
}
|
|
@@ -385,6 +389,7 @@ const getSkillContent = async ({ skill, path: targetPath, octokit, }) => {
|
|
|
385
389
|
owner,
|
|
386
390
|
repo,
|
|
387
391
|
path,
|
|
392
|
+
...(skill.ref ? { ref: skill.ref } : {}),
|
|
388
393
|
});
|
|
389
394
|
if (Array.isArray(response.data)) {
|
|
390
395
|
// Directory listing
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Test preload (configured via bunfig.toml) that silences console output so
|
|
2
|
+
// successful `bun test` / `bun check` runs stay quiet. Our logger and some
|
|
3
|
+
// dependencies write directly to the console, which would otherwise flood the
|
|
4
|
+
// output with logs. Set VERBOSE_TESTS=1 to keep the original console behavior
|
|
5
|
+
// when debugging.
|
|
6
|
+
if (!process.env.VERBOSE_TESTS) {
|
|
7
|
+
const noop = () => { };
|
|
8
|
+
console.log = noop;
|
|
9
|
+
console.info = noop;
|
|
10
|
+
console.debug = noop;
|
|
11
|
+
console.warn = noop;
|
|
12
|
+
console.error = noop;
|
|
13
|
+
}
|
|
14
|
+
export {};
|
package/dist/tracing.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { type Span, type Tracer } from '@opentelemetry/api';
|
|
2
|
-
import type {
|
|
2
|
+
import type { generateText, ModelMessage } from 'ai';
|
|
3
|
+
type AnyGenerateTextResult = Awaited<ReturnType<typeof generateText>>;
|
|
3
4
|
export declare const withSpan: <T>(tracer: Tracer, name: string, fn: (span: Span) => Promise<T>) => Promise<T>;
|
|
4
|
-
export declare const addAiResultToSpan: (span: Span, aiResult:
|
|
5
|
+
export declare const addAiResultToSpan: (span: Span, aiResult: AnyGenerateTextResult, inputMessages: ModelMessage[]) => void;
|
|
6
|
+
export {};
|
package/dist/tracing.js
CHANGED
|
@@ -20,7 +20,12 @@ export const withSpan = async (tracer, name, fn) => {
|
|
|
20
20
|
});
|
|
21
21
|
};
|
|
22
22
|
const getToolContent = (content) => {
|
|
23
|
-
const {
|
|
23
|
+
const { output } = content;
|
|
24
|
+
// The `execution-denied` output variant carries no `value` field.
|
|
25
|
+
if (!('value' in output)) {
|
|
26
|
+
return output;
|
|
27
|
+
}
|
|
28
|
+
const { type, value } = output;
|
|
24
29
|
if (type === 'json' && value) {
|
|
25
30
|
if (typeof value === 'object' && 'structuredContent' in value) {
|
|
26
31
|
return value.structuredContent;
|
|
@@ -33,7 +38,7 @@ const getToolContent = (content) => {
|
|
|
33
38
|
else if (value) {
|
|
34
39
|
return value;
|
|
35
40
|
}
|
|
36
|
-
return
|
|
41
|
+
return output;
|
|
37
42
|
};
|
|
38
43
|
const annotateModelMessage = (m, i) => {
|
|
39
44
|
const msg = {
|
|
@@ -43,9 +48,13 @@ const annotateModelMessage = (m, i) => {
|
|
|
43
48
|
};
|
|
44
49
|
if (m.role === 'tool' && Array.isArray(m.content)) {
|
|
45
50
|
const [c] = m.content;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
// A tool message's content may also include tool-approval responses,
|
|
52
|
+
// which have no tool result to annotate.
|
|
53
|
+
if (c?.type === 'tool-result') {
|
|
54
|
+
msg.id = c.toolCallId;
|
|
55
|
+
msg.name = c.toolName;
|
|
56
|
+
msg.content = getToolContent(c);
|
|
57
|
+
}
|
|
49
58
|
}
|
|
50
59
|
return msg;
|
|
51
60
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tigerdata/mcp-boilerplate",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "MCP boilerplate code for Node.js",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "TigerData",
|
|
@@ -38,24 +38,26 @@
|
|
|
38
38
|
"prepublishOnly": "tsc",
|
|
39
39
|
"watch": "tsc --watch",
|
|
40
40
|
"lint": "./bun x @biomejs/biome check",
|
|
41
|
-
"test": "./bun test ./src"
|
|
41
|
+
"test": "./bun test ./src",
|
|
42
|
+
"check": "tsc --noEmit && ./bun --silent lint --write && ./bun --silent run test --only-failures",
|
|
43
|
+
"release": "bump-release"
|
|
42
44
|
},
|
|
43
45
|
"dependencies": {
|
|
44
|
-
"@mcp-use/inspector": "^0.
|
|
45
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
46
|
-
"@opentelemetry/api": "^1.9.
|
|
47
|
-
"@opentelemetry/auto-instrumentations-node": "^0.
|
|
48
|
-
"@opentelemetry/exporter-trace-otlp-grpc": "^0.
|
|
49
|
-
"@opentelemetry/instrumentation-http": "^0.
|
|
50
|
-
"@opentelemetry/sdk-metrics": "^2.
|
|
51
|
-
"@opentelemetry/sdk-node": "^0.
|
|
52
|
-
"@opentelemetry/sdk-trace-node": "^2.
|
|
53
|
-
"@opentelemetry/semantic-conventions": "^1.
|
|
54
|
-
"@toon-format/toon": "^2.
|
|
46
|
+
"@mcp-use/inspector": "^10.0.1",
|
|
47
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
48
|
+
"@opentelemetry/api": "^1.9.1",
|
|
49
|
+
"@opentelemetry/auto-instrumentations-node": "^0.77.0",
|
|
50
|
+
"@opentelemetry/exporter-trace-otlp-grpc": "^0.219.0",
|
|
51
|
+
"@opentelemetry/instrumentation-http": "^0.219.0",
|
|
52
|
+
"@opentelemetry/sdk-metrics": "^2.8.0",
|
|
53
|
+
"@opentelemetry/sdk-node": "^0.219.0",
|
|
54
|
+
"@opentelemetry/sdk-trace-node": "^2.8.0",
|
|
55
|
+
"@opentelemetry/semantic-conventions": "^1.41.1",
|
|
56
|
+
"@toon-format/toon": "^2.3.0",
|
|
55
57
|
"express": "^5.2.1",
|
|
56
58
|
"gray-matter": "^4.0.3",
|
|
57
59
|
"raw-body": "^3.0.2",
|
|
58
|
-
"yaml": "^2.
|
|
60
|
+
"yaml": "^2.9.0"
|
|
59
61
|
},
|
|
60
62
|
"peerDependencies": {
|
|
61
63
|
"migrate": "^2.1.0",
|
|
@@ -71,18 +73,19 @@
|
|
|
71
73
|
}
|
|
72
74
|
},
|
|
73
75
|
"devDependencies": {
|
|
74
|
-
"@biomejs/biome": "^2.
|
|
75
|
-
"migrate": "^2.1.0",
|
|
76
|
-
"pg": "^8.16.3",
|
|
77
|
-
"zod": "^4.3.6",
|
|
76
|
+
"@biomejs/biome": "^2.5.1",
|
|
78
77
|
"@octokit/rest": "^22.0.1",
|
|
79
|
-
"@
|
|
78
|
+
"@tigerdata/bump-release": "^0.1.2",
|
|
79
|
+
"@types/bun": "^1.3.14",
|
|
80
80
|
"@types/express": "^5.0.6",
|
|
81
|
-
"@types/node": "^
|
|
82
|
-
"@typescript-eslint/typescript-estree": "^8.
|
|
83
|
-
"ai": "^
|
|
84
|
-
"eslint": "^
|
|
85
|
-
"
|
|
81
|
+
"@types/node": "^24.13.2",
|
|
82
|
+
"@typescript-eslint/typescript-estree": "^8.62.0",
|
|
83
|
+
"ai": "^7.0.2",
|
|
84
|
+
"eslint": "^10.5.0",
|
|
85
|
+
"migrate": "^2.1.0",
|
|
86
|
+
"pg": "^8.22.0",
|
|
87
|
+
"typescript": "^6.0.3",
|
|
88
|
+
"zod": "^4.3.6"
|
|
86
89
|
},
|
|
87
90
|
"publishConfig": {
|
|
88
91
|
"access": "public"
|