@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 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.
@@ -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 ?? context.getSourceCode?.();
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) {
@@ -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 {};
@@ -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
- export const httpServerFactory = async ({ name, version, context, apiFactories = [], promptFactories, resourceFactories, additionalSetup, cleanupFn, stateful = true, instructions, app: externalApp, mcpPath = '/mcp', apiPath = '/api', }) => {
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 || 3001;
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 server = ownApp.listen(PORT, async (error) => {
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
- cleanupFns.push(async () => {
99
- await server.close();
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
- console.error(...args);
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
- console.error(...args);
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
- console.error(...args);
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
- console.error(...args);
69
+ writeToConsole('error', ...args);
52
70
  const [body, error, attributes] = args;
53
71
  logger.emit({
54
72
  severityText: 'ERROR',
@@ -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 {
@@ -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,
@@ -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 { GenerateTextResult, ModelMessage, ToolSet } from 'ai';
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: GenerateTextResult<ToolSet, unknown>, inputMessages: ModelMessage[]) => void;
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 { type, value } = content.output;
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 content.output;
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
- msg.id = c.toolCallId;
47
- msg.name = c.toolName;
48
- msg.content = getToolContent(c);
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.5",
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.24.5",
45
- "@modelcontextprotocol/sdk": "^1.27.1",
46
- "@opentelemetry/api": "^1.9.0",
47
- "@opentelemetry/auto-instrumentations-node": "^0.71.0",
48
- "@opentelemetry/exporter-trace-otlp-grpc": "^0.213.0",
49
- "@opentelemetry/instrumentation-http": "^0.213.0",
50
- "@opentelemetry/sdk-metrics": "^2.6.0",
51
- "@opentelemetry/sdk-node": "^0.213.0",
52
- "@opentelemetry/sdk-trace-node": "^2.6.0",
53
- "@opentelemetry/semantic-conventions": "^1.40.0",
54
- "@toon-format/toon": "^2.1.0",
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.8.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.4.8",
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
- "@types/bun": "^1.3.10",
78
+ "@tigerdata/bump-release": "^0.1.2",
79
+ "@types/bun": "^1.3.14",
80
80
  "@types/express": "^5.0.6",
81
- "@types/node": "^22.19.15",
82
- "@typescript-eslint/typescript-estree": "^8.57.1",
83
- "ai": "^5.0.155",
84
- "eslint": "^9.39.4",
85
- "typescript": "^5.9.3"
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"