@tigerdata/mcp-boilerplate 0.4.3 → 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 CHANGED
@@ -5,16 +5,39 @@ This provides some common code for creating a [Model Context Protocol](https://m
5
5
  ## Setup
6
6
 
7
7
  1. Clone the repository:
8
+
8
9
  ```bash
9
10
  git clone <repository-url>
10
11
  cd mcp-boilerplate-node
11
12
  ```
12
13
 
13
14
  2. Install dependencies:
15
+
14
16
  ```bash
15
17
  npm install
16
18
  ```
17
19
 
20
+ ## Eslint Plugin
21
+
22
+ 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.
23
+
24
+ Add to your `eslint.config.mjs`:
25
+
26
+ ```js
27
+ import boilerplatePlugin from '@tigerdata/mcp-boilerplate/eslintPlugin';
28
+ export default [
29
+ // ... your existing config
30
+ {
31
+ plugins: {
32
+ 'mcp-boilerplate': boilerplatePlugin,
33
+ },
34
+ rules: {
35
+ 'mcp-boilerplate/no-optional-tool-params': 'error',
36
+ },
37
+ },
38
+ ];
39
+ ```
40
+
18
41
  ## Development
19
42
 
20
43
  ### Build
@@ -0,0 +1,13 @@
1
+ /**
2
+ * TypeScript ESLint plugin for custom rules specific to this project
3
+ */
4
+ import type { Rule } from 'eslint';
5
+ export declare const rules: {
6
+ 'no-optional-input-schema': Rule.RuleModule;
7
+ };
8
+ declare const _default: {
9
+ rules: {
10
+ 'no-optional-input-schema': Rule.RuleModule;
11
+ };
12
+ };
13
+ export default _default;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * TypeScript ESLint plugin for custom rules specific to this project
3
+ */
4
+ /**
5
+ * Rule: no-optional-in-input-schema
6
+ *
7
+ * Detects when `.optional()`, `.default()`, or `.nullish()` are called on zod schemas
8
+ * that are used in the `inputSchema` property of ApiFactory config objects.
9
+ *
10
+ * Some LLMs (like GPT-5) require all tool input parameters to be marked as required
11
+ * in the schema, otherwise the tools become completely unusable. Using .optional(),
12
+ * .default(), or .nullish() makes parameters optional in the JSON schema, breaking
13
+ * compatibility with these LLMs.
14
+ */
15
+ const noOptionalInputSchema = {
16
+ meta: {
17
+ type: 'problem',
18
+ docs: {
19
+ description: 'Disallow .optional(), .default(), and .nullish() on zod schemas in ApiFactory inputSchema',
20
+ category: 'Best Practices',
21
+ recommended: true,
22
+ },
23
+ messages: {
24
+ noOptional: 'Avoid using .optional(), .default(), or .nullish() on zod schemas in inputSchema. Some LLMs (like GPT-5) require all tool parameters to be marked as required, and tools become unusable otherwise. Use .nullable() instead if you need to accept null values, or handle empty/missing values in your function implementation.',
25
+ },
26
+ schema: [], // no options
27
+ },
28
+ create(context) {
29
+ // Track variables that are used as the Input type parameter in ApiFactory<Context, Input, Output>
30
+ const apiFactoryInputSchemas = new Set();
31
+ const problematicCalls = [];
32
+ return {
33
+ // Detect ApiFactory type annotations and extract the Input type parameter
34
+ VariableDeclarator(node) {
35
+ const varNode = node;
36
+ // Check if this variable has a TypeScript type annotation
37
+ if (varNode.id?.typeAnnotation?.typeAnnotation) {
38
+ const typeAnn = varNode.id.typeAnnotation.typeAnnotation;
39
+ // Look for ApiFactory type reference
40
+ if (typeAnn.type === 'TSTypeReference' &&
41
+ typeAnn.typeName?.name === 'ApiFactory') {
42
+ // Get the type parameters
43
+ const typeParams = typeAnn.typeArguments?.params;
44
+ if (typeParams && typeParams.length >= 2) {
45
+ const inputTypeParam = typeParams[1];
46
+ // Check if it's a typeof reference (e.g., typeof inputSchema2)
47
+ if (inputTypeParam.type === 'TSTypeQuery' &&
48
+ inputTypeParam.exprName?.type === 'Identifier') {
49
+ apiFactoryInputSchemas.add(inputTypeParam.exprName.name);
50
+ }
51
+ }
52
+ }
53
+ }
54
+ },
55
+ // Collect all .optional(), .default(), and .nullish() calls on zod schemas
56
+ CallExpression(node) {
57
+ const callNode = node;
58
+ // Check if this is a .optional(), .default(), or .nullish() call
59
+ if (callNode.callee.type === 'MemberExpression') {
60
+ const memberNode = callNode.callee;
61
+ if (memberNode.property.type === 'Identifier' &&
62
+ ['optional', 'default', 'nullish'].includes(memberNode.property.name)) {
63
+ // Check if it's being called on a zod schema
64
+ const isZodSchema = isLikelyZodSchema(memberNode.object);
65
+ if (isZodSchema) {
66
+ problematicCalls.push(callNode);
67
+ }
68
+ }
69
+ }
70
+ },
71
+ // After processing the entire file, check all problematic calls
72
+ 'Program:exit'() {
73
+ for (const node of problematicCalls) {
74
+ if (isInsideApiFactoryInputSchema(node, context, apiFactoryInputSchemas)) {
75
+ const memberNode = node.callee;
76
+ context.report({
77
+ node: memberNode.property,
78
+ messageId: 'noOptional',
79
+ });
80
+ }
81
+ }
82
+ },
83
+ };
84
+ },
85
+ };
86
+ /**
87
+ * Check if a node is inside a schema that's used as an ApiFactory Input type parameter
88
+ */
89
+ function isInsideApiFactoryInputSchema(node, context, apiFactoryInputSchemas) {
90
+ const sourceCode = context.sourceCode ?? context.getSourceCode?.();
91
+ const ancestors = sourceCode?.getAncestors?.(node) ?? [];
92
+ // Check ancestors for variables that are ApiFactory input schemas
93
+ for (const ancestor of ancestors) {
94
+ // Check if ancestor is a VariableDeclarator whose name is in apiFactoryInputSchemas
95
+ if (ancestor.type === 'VariableDeclarator') {
96
+ const varNode = ancestor;
97
+ if (varNode.id?.type === 'Identifier' &&
98
+ apiFactoryInputSchemas.has(varNode.id.name)) {
99
+ return true;
100
+ }
101
+ }
102
+ }
103
+ // Fallback: walk up parent chain if node.parent is available
104
+ let current = node.parent;
105
+ while (current) {
106
+ // Variable that's an ApiFactory input schema
107
+ if (current.type === 'VariableDeclarator') {
108
+ const varNode = current;
109
+ if (varNode.id?.type === 'Identifier' &&
110
+ apiFactoryInputSchemas.has(varNode.id.name)) {
111
+ return true;
112
+ }
113
+ }
114
+ current = current.parent;
115
+ }
116
+ return false;
117
+ }
118
+ /**
119
+ * Heuristic to determine if a node is likely a zod schema
120
+ */
121
+ function isLikelyZodSchema(node) {
122
+ if (!node || node.type === 'PrivateIdentifier')
123
+ return false;
124
+ // Direct z identifier (the base of all zod schemas)
125
+ if (node.type === 'Identifier') {
126
+ return node.name === 'z';
127
+ }
128
+ // Direct z.* calls (e.g., z.string)
129
+ if (node.type === 'MemberExpression') {
130
+ if (node.object.type === 'Identifier' && node.object.name === 'z') {
131
+ return true;
132
+ }
133
+ // Member expressions that might be chained zod methods (e.g., z.string)
134
+ return isLikelyZodSchema(node.object);
135
+ }
136
+ // Chained method calls on zod schemas (e.g., z.string().describe())
137
+ if (node.type === 'CallExpression') {
138
+ if (node.callee.type === 'MemberExpression') {
139
+ // Recursively check the object of the member expression
140
+ return isLikelyZodSchema(node.callee.object);
141
+ }
142
+ // Also check if the callee itself is 'z'
143
+ if (node.callee.type === 'Identifier') {
144
+ return node.callee.name === 'z';
145
+ }
146
+ }
147
+ return false;
148
+ }
149
+ export const rules = {
150
+ 'no-optional-input-schema': noOptionalInputSchema,
151
+ };
152
+ export default {
153
+ rules,
154
+ };
package/dist/http/api.js CHANGED
@@ -8,7 +8,7 @@ export const apiRouterFactory = (context, apiFactories) => {
8
8
  const router = Router();
9
9
  router.use(bodyParser.json());
10
10
  for (const factory of apiFactories) {
11
- const tool = factory(context);
11
+ const tool = factory(context, {});
12
12
  if (!tool.method || !tool.route)
13
13
  continue;
14
14
  router[tool.method](tool.route, async (req, res) => {
@@ -23,9 +23,7 @@ export const apiRouterFactory = (context, apiFactories) => {
23
23
  parsedInput = Input.parse(input);
24
24
  }
25
25
  catch (error) {
26
- res
27
- .status(400)
28
- .json({
26
+ res.status(400).json({
29
27
  error: 'zod validation failure',
30
28
  issues: error.issues,
31
29
  });
@@ -1,6 +1,6 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { RouterFactoryResult } from '../types.js';
3
- export declare const mcpRouterFactory: <Context extends Record<string, unknown>>(context: Context, createServer: (context: Context) => {
2
+ import { RouterFactoryResult, McpFeatureFlags } from '../types.js';
3
+ export declare const mcpRouterFactory: <Context extends Record<string, unknown>>(context: Context, createServer: (context: Context, featureFlags: McpFeatureFlags) => {
4
4
  server: McpServer;
5
5
  }, { name, stateful, }?: {
6
6
  name?: string;
package/dist/http/mcp.js CHANGED
@@ -11,8 +11,29 @@ const tracer = trace.getTracer(name ? `${name}.router.mcp` : 'router.mcp');
11
11
  export const mcpRouterFactory = (context, createServer, { name, stateful = true, } = {}) => {
12
12
  const router = Router();
13
13
  const transports = new Map();
14
+ const sessionFeatureFlags = new Map();
15
+ const toSet = (flag) => flag
16
+ ? Array.isArray(flag)
17
+ ? new Set(flag)
18
+ : typeof flag === 'string'
19
+ ? new Set(flag.split(',').map((s) => s.trim()))
20
+ : null
21
+ : null;
22
+ const parseFeatureFlags = (req) => ({
23
+ prompts: req.query.prompts !== 'false' && req.query.prompts !== '0',
24
+ enabledPrompts: toSet(req.query.enabled_prompts),
25
+ disabledPrompts: toSet(req.query.disabled_prompts),
26
+ resources: req.query.resources !== 'false' && req.query.resources !== '0',
27
+ enabledResources: toSet(req.query.enabled_resources),
28
+ disabledResources: toSet(req.query.disabled_resources),
29
+ tools: req.query.tools !== 'false' && req.query.tools !== '0',
30
+ enabledTools: toSet(req.query.enabled_tools),
31
+ disabledTools: toSet(req.query.disabled_tools),
32
+ query: req.query,
33
+ });
14
34
  const handleStatelessRequest = async (req, res) => {
15
- const { server } = createServer(context);
35
+ const featureFlags = parseFeatureFlags(req);
36
+ const { server } = createServer(context, featureFlags);
16
37
  const transport = new StreamableHTTPServerTransport({
17
38
  sessionIdGenerator: undefined,
18
39
  });
@@ -60,20 +81,23 @@ export const mcpRouterFactory = (context, createServer, { name, stateful = true,
60
81
  });
61
82
  return;
62
83
  }
84
+ const featureFlags = parseFeatureFlags(req);
63
85
  transport = new StreamableHTTPServerTransport({
64
86
  sessionIdGenerator: () => randomUUID(),
65
87
  onsessioninitialized: (sessionId) => {
66
88
  log.info(`Session initialized with ID: ${sessionId}`);
67
89
  transports.set(sessionId, transport);
90
+ sessionFeatureFlags.set(sessionId, featureFlags);
68
91
  },
69
92
  onsessionclosed: (sessionId) => {
70
93
  if (sessionId && transports.has(sessionId)) {
71
94
  log.info(`Transport closed for session ${sessionId}, removing from transports map`);
72
95
  transports.delete(sessionId);
96
+ sessionFeatureFlags.delete(sessionId);
73
97
  }
74
98
  },
75
99
  });
76
- const { server } = createServer(context);
100
+ const { server } = createServer(context, featureFlags);
77
101
  await server.connect(transport);
78
102
  }
79
103
  await transport.handleRequest(req, res, body);
@@ -1,14 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import express from 'express';
3
- import { ApiFactory, PromptFactory } from './types.js';
3
+ import { ApiFactory, PromptFactory, ResourceFactory } from './types.js';
4
4
  import { AdditionalSetupArgs } from './mcpServer.js';
5
5
  import { Server } from 'node:http';
6
- export declare const httpServerFactory: <Context extends Record<string, unknown>>({ name, version, context, apiFactories, promptFactories, additionalSetup, cleanupFn, stateful, }: {
6
+ export declare const httpServerFactory: <Context extends Record<string, unknown>>({ name, version, context, apiFactories, promptFactories, resourceFactories, additionalSetup, cleanupFn, stateful, }: {
7
7
  name: string;
8
8
  version?: string;
9
9
  context: Context;
10
10
  apiFactories?: readonly ApiFactory<Context, any, any>[];
11
11
  promptFactories?: readonly PromptFactory<Context, any>[];
12
+ resourceFactories?: readonly ResourceFactory<Context>[];
12
13
  additionalSetup?: (args: AdditionalSetupArgs<Context>) => void;
13
14
  cleanupFn?: () => void | Promise<void>;
14
15
  stateful?: boolean;
@@ -6,7 +6,7 @@ import { registerExitHandlers } from './registerExitHandlers.js';
6
6
  import { mcpServerFactory } from './mcpServer.js';
7
7
  import { log } from './logger.js';
8
8
  import { StatusError } from './StatusError.js';
9
- export const httpServerFactory = ({ name, version, context, apiFactories = [], promptFactories = [], additionalSetup, cleanupFn, stateful = true, }) => {
9
+ export const httpServerFactory = ({ name, version, context, apiFactories = [], promptFactories, resourceFactories, additionalSetup, cleanupFn, stateful = true, }) => {
10
10
  const cleanupFns = cleanupFn
11
11
  ? [cleanupFn]
12
12
  : [];
@@ -14,13 +14,15 @@ export const httpServerFactory = ({ name, version, context, apiFactories = [], p
14
14
  log.info('Starting HTTP server...');
15
15
  const app = express();
16
16
  app.enable('trust proxy');
17
- const [mcpRouter, mcpCleanup] = mcpRouterFactory(context, () => mcpServerFactory({
17
+ const [mcpRouter, mcpCleanup] = mcpRouterFactory(context, (context, featureFlags) => mcpServerFactory({
18
18
  name,
19
19
  version,
20
20
  context,
21
21
  apiFactories,
22
22
  promptFactories,
23
+ resourceFactories,
23
24
  additionalSetup,
25
+ featureFlags,
24
26
  }), { name, stateful });
25
27
  cleanupFns.push(mcpCleanup);
26
28
  app.use('/mcp', mcpRouter);
package/dist/index.d.ts CHANGED
@@ -2,8 +2,8 @@ export { cliEntrypoint } from './cliEntrypoint.js';
2
2
  export { httpServerFactory } from './httpServer.js';
3
3
  export { log } from './logger.js';
4
4
  export { stdioServerFactory } from './stdio.js';
5
- export { type ApiFactory } from './types.js';
5
+ export type { ApiFactory, McpFeatureFlags, PromptFactory, ResourceFactory, ParsedQs, } from './types.js';
6
6
  export { StatusError } from './StatusError.js';
7
- export { type AdditionalSetupArgs } from './mcpServer.js';
7
+ export type { AdditionalSetupArgs } from './mcpServer.js';
8
8
  export { withSpan, addAiResultToSpan } from './tracing.js';
9
9
  export { registerExitHandlers } from './registerExitHandlers.js';
@@ -1,16 +1,21 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { ApiFactory, PromptFactory } from './types.js';
2
+ import { ApiFactory, PromptFactory, McpFeatureFlags, ResourceFactory } from './types.js';
3
+ import { ServerCapabilities } from '@modelcontextprotocol/sdk/types.js';
3
4
  export interface AdditionalSetupArgs<Context extends Record<string, unknown>> {
4
5
  context: Context;
5
6
  server: McpServer;
7
+ featureFlags: McpFeatureFlags;
6
8
  }
7
- export declare const mcpServerFactory: <Context extends Record<string, unknown>>({ name, version, context, apiFactories, promptFactories, additionalSetup, }: {
9
+ export declare const mcpServerFactory: <Context extends Record<string, unknown>>({ name, version, context, apiFactories, promptFactories, resourceFactories, additionalSetup, additionalCapabilities, featureFlags, }: {
8
10
  name: string;
9
11
  version?: string;
10
12
  context: Context;
11
- apiFactories: readonly ApiFactory<Context, any, any>[];
13
+ apiFactories?: readonly ApiFactory<Context, any, any>[];
12
14
  promptFactories?: readonly PromptFactory<Context, any>[];
15
+ resourceFactories?: readonly ResourceFactory<Context>[];
13
16
  additionalSetup?: (args: AdditionalSetupArgs<Context>) => void;
17
+ additionalCapabilities?: ServerCapabilities;
18
+ featureFlags?: McpFeatureFlags;
14
19
  }) => {
15
20
  server: McpServer;
16
21
  };
package/dist/mcpServer.js CHANGED
@@ -1,116 +1,227 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1
+ import { McpServer, ResourceTemplate, } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { SpanStatusCode, trace, context as otelContext, propagation, SpanKind, } from '@opentelemetry/api';
3
3
  import { log } from './logger.js';
4
4
  const name = process.env.OTEL_SERVICE_NAME;
5
5
  const tracer = trace.getTracer(name ? `${name}.mcpServer` : 'mcpServer');
6
- const enabledTools = process.env.MCP_ENABLED_TOOLS
7
- ? new Set(process.env.MCP_ENABLED_TOOLS.split(',').map((s) => s.trim()))
8
- : null;
9
- const disabledTools = process.env.MCP_DISABLED_TOOLS
10
- ? new Set(process.env.MCP_DISABLED_TOOLS.split(',').map((s) => s.trim()))
11
- : null;
12
- export const mcpServerFactory = ({ name, version = '1.0.0', context, apiFactories, promptFactories = [], additionalSetup, }) => {
6
+ const toSet = (str) => str ? new Set(str.split(',').map((s) => s.trim())) : null;
7
+ const enabledTools = toSet(process.env.MCP_ENABLED_TOOLS);
8
+ const disabledTools = toSet(process.env.MCP_DISABLED_TOOLS);
9
+ const enabledPrompts = toSet(process.env.MCP_ENABLED_PROMPTS);
10
+ const disabledPrompts = toSet(process.env.MCP_DISABLED_PROMPTS);
11
+ const enabledResources = toSet(process.env.MCP_ENABLED_RESOURCES);
12
+ const disabledResources = toSet(process.env.MCP_DISABLED_RESOURCES);
13
+ const shouldSkip = (item, enabledSets, disabledSets) => {
14
+ if (item.disabled)
15
+ return true;
16
+ for (const enabledSet of enabledSets) {
17
+ if (enabledSet && !enabledSet.has(item.name)) {
18
+ return true;
19
+ }
20
+ }
21
+ for (const disabledSet of disabledSets) {
22
+ if (disabledSet && disabledSet.has(item.name)) {
23
+ return true;
24
+ }
25
+ }
26
+ return false;
27
+ };
28
+ export const mcpServerFactory = ({ name, version = '1.0.0', context, apiFactories = [], promptFactories = [], resourceFactories = [], additionalSetup, additionalCapabilities = {}, featureFlags = {}, }) => {
29
+ const enablePrompts = featureFlags.prompts !== false;
30
+ const enableResources = featureFlags.resources !== false;
31
+ const enableTools = featureFlags.tools !== false;
13
32
  const server = new McpServer({
14
33
  name,
15
34
  version,
16
35
  }, {
17
36
  capabilities: {
18
- tools: {},
19
- ...(promptFactories.length ? { prompts: {} } : null),
37
+ ...(enableTools && apiFactories.length ? { tools: {} } : null),
38
+ ...(enablePrompts && promptFactories.length ? { prompts: {} } : null),
39
+ ...(enableResources && resourceFactories.length
40
+ ? { resources: {} }
41
+ : null),
42
+ ...additionalCapabilities,
20
43
  },
21
44
  });
22
- for (const factory of apiFactories) {
23
- const tool = factory(context);
24
- if (tool.disabled)
25
- continue;
26
- if (enabledTools && !enabledTools.has(tool.name)) {
27
- continue;
28
- }
29
- if (disabledTools && disabledTools.has(tool.name)) {
30
- continue;
31
- }
32
- server.registerTool(tool.name, {
33
- ...tool.config,
34
- annotations: {
35
- ...tool.config.annotations,
36
- // Some clients (e.g. claude code) do not yet support the title field
37
- // at the top level and instead expect it in annotations. We also
38
- // don't allow setting different titles in two places as that doesn't
39
- // make sense.
40
- title: tool.config.title,
41
- },
42
- }, async (args, extra) => {
43
- let traceContext = otelContext.active();
44
- if (extra?._meta?.traceparent) {
45
- // Some MCP clients (e.g. pydantic) pass the parent trace context
46
- traceContext = propagation.extract(traceContext, {
47
- traceparent: extra._meta.traceparent,
48
- tracestate: extra._meta.tracestate,
45
+ if (enableTools) {
46
+ for (const factory of apiFactories) {
47
+ const tool = factory(context, featureFlags);
48
+ if (shouldSkip(tool, [enabledTools, featureFlags.enabledTools], [disabledTools, featureFlags.disabledTools])) {
49
+ continue;
50
+ }
51
+ server.registerTool(tool.name, {
52
+ ...tool.config,
53
+ annotations: {
54
+ ...tool.config.annotations,
55
+ // Some clients (e.g. claude code) do not yet support the title field
56
+ // at the top level and instead expect it in annotations. We also
57
+ // don't allow setting different titles in two places as that doesn't
58
+ // make sense.
59
+ title: tool.config.title,
60
+ },
61
+ }, async (args, extra) => {
62
+ let traceContext = otelContext.active();
63
+ if (extra?._meta?.traceparent) {
64
+ // Some MCP clients (e.g. pydantic) pass the parent trace context
65
+ traceContext = propagation.extract(traceContext, {
66
+ traceparent: extra._meta.traceparent,
67
+ tracestate: extra._meta.tracestate,
68
+ });
69
+ }
70
+ return tracer.startActiveSpan(`mcp.tool.${tool.name}`, { kind: SpanKind.SERVER }, traceContext, async (span) => {
71
+ span.setAttribute('mcp.tool.args', JSON.stringify(args));
72
+ try {
73
+ const result = await tool.fn(args);
74
+ const text = JSON.stringify(result);
75
+ span.setAttribute('mcp.tool.responseBytes', text.length);
76
+ span.setStatus({ code: SpanStatusCode.OK });
77
+ return {
78
+ content: [
79
+ {
80
+ type: 'text',
81
+ text,
82
+ },
83
+ ],
84
+ structuredContent: result,
85
+ };
86
+ }
87
+ catch (error) {
88
+ log.error('Error invoking tool:', error);
89
+ span.recordException(error);
90
+ span.setStatus({
91
+ code: SpanStatusCode.ERROR,
92
+ message: error.message,
93
+ });
94
+ return {
95
+ content: [
96
+ {
97
+ type: 'text',
98
+ text: `Error: ${error.message || 'Unknown error'}`,
99
+ },
100
+ ],
101
+ isError: true,
102
+ };
103
+ }
104
+ finally {
105
+ span.end();
106
+ }
49
107
  });
108
+ });
109
+ }
110
+ }
111
+ if (enablePrompts) {
112
+ for (const factory of promptFactories) {
113
+ const prompt = factory(context, featureFlags);
114
+ if (shouldSkip(prompt, [enabledPrompts, featureFlags.enabledPrompts], [disabledPrompts, featureFlags.disabledPrompts])) {
115
+ continue;
50
116
  }
51
- return tracer.startActiveSpan(`mcp.tool.${tool.name}`, { kind: SpanKind.SERVER }, traceContext, async (span) => {
52
- span.setAttribute('mcp.tool.args', JSON.stringify(args));
117
+ server.registerPrompt(prompt.name, prompt.config, async (args) => tracer.startActiveSpan(`mcp.prompt.${prompt.name}`, async (span) => {
118
+ span.setAttribute('mcp.prompt.args', JSON.stringify(args));
53
119
  try {
54
- const result = await tool.fn(args);
55
- const text = JSON.stringify(result);
56
- span.setAttribute('mcp.tool.responseBytes', text.length);
120
+ const result = await prompt.fn(args);
57
121
  span.setStatus({ code: SpanStatusCode.OK });
58
- return {
59
- content: [
60
- {
61
- type: 'text',
62
- text,
63
- },
64
- ],
65
- structuredContent: result,
66
- };
122
+ return result;
67
123
  }
68
124
  catch (error) {
69
- log.error('Error invoking tool:', error);
125
+ log.error('Error invoking prompt:', error);
70
126
  span.recordException(error);
71
127
  span.setStatus({
72
128
  code: SpanStatusCode.ERROR,
73
129
  message: error.message,
74
130
  });
75
- return {
76
- content: [
77
- {
78
- type: 'text',
79
- text: `Error: ${error.message || 'Unknown error'}`,
80
- },
81
- ],
82
- isError: true,
83
- };
131
+ throw error;
84
132
  }
85
133
  finally {
86
134
  span.end();
87
135
  }
88
- });
89
- });
136
+ }));
137
+ }
90
138
  }
91
- for (const factory of promptFactories) {
92
- const prompt = factory(context);
93
- server.registerPrompt(prompt.name, prompt.config, async (args) => tracer.startActiveSpan(`mcp.prompt.${prompt.name}`, async (span) => {
94
- span.setAttribute('mcp.prompt.args', JSON.stringify(args));
95
- try {
96
- const result = await prompt.fn(args);
97
- span.setStatus({ code: SpanStatusCode.OK });
98
- return result;
99
- }
100
- catch (error) {
101
- log.error('Error invoking prompt:', error);
102
- span.recordException(error);
103
- span.setStatus({
104
- code: SpanStatusCode.ERROR,
105
- message: error.message,
106
- });
107
- throw error;
139
+ if (enableResources) {
140
+ for (const factory of resourceFactories) {
141
+ const resource = factory(context, featureFlags);
142
+ if (shouldSkip(resource, [enabledResources, featureFlags.enabledResources], [disabledResources, featureFlags.disabledResources])) {
143
+ continue;
108
144
  }
109
- finally {
110
- span.end();
145
+ switch (resource.type) {
146
+ case 'static': {
147
+ server.registerResource(resource.name, resource.uri, resource.config, async (uri, extra) => tracer.startActiveSpan(`mcp.resource.static.${resource.name}`, async (span) => {
148
+ span.setAttribute('mcp.resource.uri', uri.toString());
149
+ span.setAttribute('mcp.resource.extra', JSON.stringify(extra));
150
+ try {
151
+ const result = await resource.read(uri, extra);
152
+ span.setStatus({ code: SpanStatusCode.OK });
153
+ return result;
154
+ }
155
+ catch (error) {
156
+ log.error('Error invoking resource:', error);
157
+ span.recordException(error);
158
+ span.setStatus({
159
+ code: SpanStatusCode.ERROR,
160
+ message: error.message,
161
+ });
162
+ throw error;
163
+ }
164
+ finally {
165
+ span.end();
166
+ }
167
+ }));
168
+ break;
169
+ }
170
+ case 'templated': {
171
+ server.registerResource(resource.name, new ResourceTemplate(resource.uriTemplate, {
172
+ list: resource.list &&
173
+ ((extra) => tracer.startActiveSpan(`mcp.resource.templated.${resource.name}.list`, async (span) => {
174
+ try {
175
+ const result = await resource.list(extra);
176
+ span.setAttribute('mcp.resource.list.uris', result.resources.map((r) => r.uri).join(', '));
177
+ span.setStatus({ code: SpanStatusCode.OK });
178
+ return result;
179
+ }
180
+ catch (error) {
181
+ log.error('Error invoking resource list:', error);
182
+ span.recordException(error);
183
+ span.setStatus({
184
+ code: SpanStatusCode.ERROR,
185
+ message: error.message,
186
+ });
187
+ throw error;
188
+ }
189
+ finally {
190
+ span.end();
191
+ }
192
+ })),
193
+ complete: resource.complete,
194
+ }), resource.config, async (uri, variables, extra) => tracer.startActiveSpan(`mcp.resource.templated.${resource.name}`, async (span) => {
195
+ span.setAttribute('mcp.resource.uri', uri.toString());
196
+ span.setAttribute('mcp.resource.variables', JSON.stringify(variables));
197
+ span.setAttribute('mcp.resource.extra', JSON.stringify(extra));
198
+ try {
199
+ const result = await resource.read(uri, variables, extra);
200
+ span.setStatus({ code: SpanStatusCode.OK });
201
+ return result;
202
+ }
203
+ catch (error) {
204
+ log.error('Error invoking resource:', error);
205
+ span.recordException(error);
206
+ span.setStatus({
207
+ code: SpanStatusCode.ERROR,
208
+ message: error.message,
209
+ });
210
+ throw error;
211
+ }
212
+ finally {
213
+ span.end();
214
+ }
215
+ }));
216
+ break;
217
+ }
218
+ default: {
219
+ // @ts-expect-error exhaustive check
220
+ throw new Error(`Unknown resource type: ${resource.type}`);
221
+ }
111
222
  }
112
- }));
223
+ }
113
224
  }
114
- additionalSetup?.({ context, server });
225
+ additionalSetup?.({ context, server, featureFlags });
115
226
  return { server };
116
227
  };
package/dist/stdio.d.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { ApiFactory, PromptFactory } from './types.js';
2
+ import { ApiFactory, PromptFactory, ResourceFactory } from './types.js';
3
3
  import { AdditionalSetupArgs } from './mcpServer.js';
4
- export declare const stdioServerFactory: <Context extends Record<string, unknown>>({ name, version, context, apiFactories, promptFactories, additionalSetup, cleanupFn, }: {
4
+ export declare const stdioServerFactory: <Context extends Record<string, unknown>>({ name, version, context, apiFactories, promptFactories, resourceFactories, additionalSetup, cleanupFn, }: {
5
5
  name: string;
6
6
  version?: string;
7
7
  context: Context;
8
- apiFactories: readonly ApiFactory<Context, any, any>[];
8
+ apiFactories?: readonly ApiFactory<Context, any, any>[];
9
9
  promptFactories?: readonly PromptFactory<Context, any>[];
10
+ resourceFactories?: readonly ResourceFactory<Context>[];
10
11
  additionalSetup?: (args: AdditionalSetupArgs<Context>) => void;
11
12
  cleanupFn?: () => Promise<void>;
12
13
  }) => Promise<void>;
package/dist/stdio.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { mcpServerFactory } from './mcpServer.js';
4
4
  import { registerExitHandlers } from './registerExitHandlers.js';
5
- export const stdioServerFactory = async ({ name, version, context, apiFactories, promptFactories = [], additionalSetup, cleanupFn, }) => {
5
+ export const stdioServerFactory = async ({ name, version, context, apiFactories, promptFactories, resourceFactories, additionalSetup, cleanupFn, }) => {
6
6
  try {
7
7
  console.error('Starting default (STDIO) server...');
8
8
  const transport = new StdioServerTransport();
@@ -12,6 +12,7 @@ export const stdioServerFactory = async ({ name, version, context, apiFactories,
12
12
  context,
13
13
  apiFactories,
14
14
  promptFactories,
15
+ resourceFactories,
15
16
  additionalSetup,
16
17
  });
17
18
  await server.connect(transport);
package/dist/types.d.ts CHANGED
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import type { ZodRawShape, ZodTypeAny } from 'zod';
3
3
  import type { ToolAnnotations, GetPromptResult } from '@modelcontextprotocol/sdk/types.js';
4
4
  import { Router } from 'express';
5
+ import { CompleteResourceTemplateCallback, ListResourcesCallback, ReadResourceCallback, ReadResourceTemplateCallback, ResourceMetadata } from '@modelcontextprotocol/sdk/server/mcp.js';
5
6
  export type ToolConfig<InputArgs extends ZodRawShape, OutputArgs extends ZodRawShape> = {
6
7
  title?: string;
7
8
  description?: string;
@@ -18,7 +19,7 @@ export interface ApiDefinition<InputArgs extends ZodRawShape, OutputArgs extends
18
19
  fn: (args: z.objectOutputType<InputArgs, ZodTypeAny>) => Promise<z.objectOutputType<OutputArgs, ZodTypeAny>>;
19
20
  pickResult?: (result: z.objectOutputType<OutputArgs, ZodTypeAny>) => SimplifiedOutputArgs;
20
21
  }
21
- export type ApiFactory<Context extends Record<string, unknown>, Input extends ZodRawShape, Output extends ZodRawShape, RestOutput = Output> = (ctx: Context) => ApiDefinition<Input, Output, RestOutput>;
22
+ export type ApiFactory<Context extends Record<string, unknown>, Input extends ZodRawShape, Output extends ZodRawShape, RestOutput = Output> = (ctx: Context, featureFlags: McpFeatureFlags) => ApiDefinition<Input, Output, RestOutput>;
22
23
  export type RouterFactoryResult = [Router, () => void | Promise<void>];
23
24
  export type PromptConfig<InputArgs extends ZodRawShape> = {
24
25
  title?: string;
@@ -28,6 +29,44 @@ export type PromptConfig<InputArgs extends ZodRawShape> = {
28
29
  export interface PromptDefinition<InputArgs extends ZodRawShape> {
29
30
  name: string;
30
31
  config: PromptConfig<InputArgs>;
32
+ disabled?: boolean;
31
33
  fn: (args: z.objectOutputType<InputArgs, ZodTypeAny>) => Promise<GetPromptResult>;
32
34
  }
33
- export type PromptFactory<Context extends Record<string, unknown>, Input extends ZodRawShape> = (ctx: Context) => PromptDefinition<Input>;
35
+ export type PromptFactory<Context extends Record<string, unknown>, Input extends ZodRawShape> = (ctx: Context, featureFlags: McpFeatureFlags) => PromptDefinition<Input>;
36
+ export interface TemplatedResourceDefinition {
37
+ type: 'templated';
38
+ name: string;
39
+ uriTemplate: string;
40
+ list?: ListResourcesCallback;
41
+ complete?: {
42
+ [variable: string]: CompleteResourceTemplateCallback;
43
+ };
44
+ config: ResourceMetadata;
45
+ disabled?: boolean;
46
+ read: ReadResourceTemplateCallback;
47
+ }
48
+ export interface StaticResourceDefinition {
49
+ type: 'static';
50
+ name: string;
51
+ uri: string;
52
+ config: ResourceMetadata;
53
+ disabled?: boolean;
54
+ read: ReadResourceCallback;
55
+ }
56
+ export type ResourceDefinition = TemplatedResourceDefinition | StaticResourceDefinition;
57
+ export type ResourceFactory<Context extends Record<string, unknown>> = (ctx: Context, featureFlags: McpFeatureFlags) => ResourceDefinition;
58
+ export interface ParsedQs {
59
+ [key: string]: undefined | string | ParsedQs | (string | ParsedQs)[];
60
+ }
61
+ export interface McpFeatureFlags {
62
+ prompts?: boolean;
63
+ enabledPrompts?: Set<string> | null;
64
+ disabledPrompts?: Set<string> | null;
65
+ resources?: boolean;
66
+ enabledResources?: Set<string> | null;
67
+ disabledResources?: Set<string> | null;
68
+ tools?: boolean;
69
+ enabledTools?: Set<string> | null;
70
+ disabledTools?: Set<string> | null;
71
+ query?: ParsedQs;
72
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tigerdata/mcp-boilerplate",
3
- "version": "0.4.3",
3
+ "version": "0.6.0",
4
4
  "description": "MCP boilerplate code for Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "author": "TigerData",
@@ -17,6 +17,10 @@
17
17
  "import": "./dist/index.js",
18
18
  "types": "./dist/index.d.ts"
19
19
  },
20
+ "./eslintPlugin": {
21
+ "import": "./dist/eslintPlugin.js",
22
+ "types": "./dist/eslintPlugin.d.ts"
23
+ },
20
24
  "./instrumentation": {
21
25
  "import": "./dist/instrumentation.js",
22
26
  "types": "./dist/instrumentation.d.ts"
@@ -33,28 +37,28 @@
33
37
  "lint:fix": "eslint --fix"
34
38
  },
35
39
  "dependencies": {
36
- "@modelcontextprotocol/sdk": "^1.20.1",
40
+ "@modelcontextprotocol/sdk": "^1.22.0",
37
41
  "@opentelemetry/api": "^1.9.0",
38
- "@opentelemetry/auto-instrumentations-node": "^0.66.0",
39
- "@opentelemetry/exporter-trace-otlp-grpc": "^0.207.0",
40
- "@opentelemetry/instrumentation-http": "^0.207.0",
42
+ "@opentelemetry/auto-instrumentations-node": "^0.67.0",
43
+ "@opentelemetry/exporter-trace-otlp-grpc": "^0.208.0",
44
+ "@opentelemetry/instrumentation-http": "^0.208.0",
41
45
  "@opentelemetry/sdk-metrics": "^2.2.0",
42
- "@opentelemetry/sdk-node": "^0.207.0",
46
+ "@opentelemetry/sdk-node": "^0.208.0",
43
47
  "@opentelemetry/sdk-trace-node": "^2.2.0",
44
- "@opentelemetry/semantic-conventions": "^1.37.0",
48
+ "@opentelemetry/semantic-conventions": "^1.38.0",
45
49
  "express": "^5.1.0",
46
50
  "raw-body": "^3.0.1",
47
51
  "zod": "^3.23.8"
48
52
  },
49
53
  "devDependencies": {
50
- "@eslint/js": "^9.35.0",
51
- "@types/express": "^5.0.3",
52
- "@types/node": "^22.16.4",
53
- "ai": "^5.0.17",
54
- "eslint": "^9.35.0",
54
+ "@eslint/js": "^9.39.1",
55
+ "@types/express": "^5.0.5",
56
+ "@types/node": "^22.19.1",
57
+ "ai": "^5.0.93",
58
+ "eslint": "^9.39.1",
55
59
  "prettier": "^3.6.2",
56
- "typescript": "^5.8.3",
57
- "typescript-eslint": "^8.43.0"
60
+ "typescript": "^5.9.3",
61
+ "typescript-eslint": "^8.46.4"
58
62
  },
59
63
  "publishConfig": {
60
64
  "access": "public"