Skip to content

Commit 66127b8

Browse files
committed
feat(js/plugins/anthropic): validate MCP server referenced by exactly one toolset
Per Anthropic MCP Connector documentation, each MCP server must be referenced by exactly one MCPToolset in the tools array. - Add validation: server not referenced by any toolset → error - Add validation: server referenced by multiple toolsets → error - Add 7 new tests for MCP configuration validation - Update README with new validation rule
1 parent ce72503 commit 66127b8

File tree

3 files changed

+203
-1
lines changed

3 files changed

+203
-1
lines changed

js/plugins/anthropic/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ The plugin validates MCP configuration at runtime:
166166
- MCP server URLs must use HTTPS protocol
167167
- MCP server names must be unique
168168
- MCP toolsets must reference servers defined in `mcp_servers`
169+
- Each MCP server must be referenced by exactly one toolset
169170

170171
### Beta API Limitations
171172

js/plugins/anthropic/src/types.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ export const AnthropicThinkingConfigSchema = AnthropicBaseConfigSchema.extend({
211211
* Validates MCP configuration:
212212
* - MCP server names must be unique
213213
* - MCP toolsets must reference servers defined in mcp_servers
214+
* - Each MCP server must be referenced by exactly one toolset
214215
*/
215216
function validateMcpConfig(
216217
config: z.infer<typeof AnthropicThinkingConfigSchema>,
@@ -250,6 +251,35 @@ function validateMcpConfig(
250251
}
251252
);
252253
}
254+
255+
// Validate each MCP server is referenced by exactly one toolset
256+
if (config.mcp_servers && config.mcp_servers.length > 0) {
257+
const toolsetReferences = new Map<string, number>();
258+
(config.mcp_toolsets ?? []).forEach(
259+
(t: z.infer<typeof McpToolsetSchema>) => {
260+
const count = toolsetReferences.get(t.mcp_server_name) ?? 0;
261+
toolsetReferences.set(t.mcp_server_name, count + 1);
262+
}
263+
);
264+
config.mcp_servers.forEach(
265+
(server: z.infer<typeof McpServerConfigSchema>, i: number) => {
266+
const refCount = toolsetReferences.get(server.name) ?? 0;
267+
if (refCount === 0) {
268+
ctx.addIssue({
269+
code: z.ZodIssueCode.custom,
270+
path: ['mcp_servers', i, 'name'],
271+
message: `MCP server '${server.name}' is not referenced by any toolset. Each server must be referenced by exactly one mcp_toolset.`,
272+
});
273+
} else if (refCount > 1) {
274+
ctx.addIssue({
275+
code: z.ZodIssueCode.custom,
276+
path: ['mcp_servers', i, 'name'],
277+
message: `MCP server '${server.name}' is referenced by ${refCount} toolsets. Each server must be referenced by exactly one mcp_toolset.`,
278+
});
279+
}
280+
}
281+
);
282+
}
253283
}
254284

255285
export const AnthropicConfigSchema =

js/plugins/anthropic/tests/types_test.ts

Lines changed: 172 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@
1717
import * as assert from 'assert';
1818
import { z } from 'genkit';
1919
import { describe, it } from 'node:test';
20-
import { AnthropicConfigSchema, resolveBetaEnabled } from '../src/types.js';
20+
import {
21+
AnthropicConfigSchema,
22+
McpServerConfigSchema,
23+
resolveBetaEnabled,
24+
} from '../src/types.js';
2125

2226
describe('resolveBetaEnabled', () => {
2327
it('should return true when config.apiVersion is beta', () => {
@@ -87,3 +91,170 @@ describe('resolveBetaEnabled', () => {
8791
assert.strictEqual(resolveBetaEnabled(config, 'beta'), true);
8892
});
8993
});
94+
95+
describe('McpServerConfigSchema', () => {
96+
it('should require HTTPS URL', () => {
97+
const httpConfig = {
98+
type: 'url' as const,
99+
url: 'http://example.com/mcp',
100+
name: 'test-server',
101+
};
102+
const result = McpServerConfigSchema.safeParse(httpConfig);
103+
assert.strictEqual(result.success, false);
104+
if (!result.success) {
105+
assert.ok(
106+
result.error.issues.some((i) =>
107+
i.message.includes('HTTPS')
108+
)
109+
);
110+
}
111+
});
112+
113+
it('should accept HTTPS URL', () => {
114+
const httpsConfig = {
115+
type: 'url' as const,
116+
url: 'https://example.com/mcp',
117+
name: 'test-server',
118+
};
119+
const result = McpServerConfigSchema.safeParse(httpsConfig);
120+
assert.strictEqual(result.success, true);
121+
});
122+
});
123+
124+
describe('AnthropicConfigSchema MCP validation', () => {
125+
it('should fail when server is not referenced by any toolset', () => {
126+
const config = {
127+
mcp_servers: [
128+
{
129+
type: 'url' as const,
130+
url: 'https://example.com/mcp',
131+
name: 'orphan-server',
132+
},
133+
],
134+
// No mcp_toolsets
135+
};
136+
const result = AnthropicConfigSchema.safeParse(config);
137+
assert.strictEqual(result.success, false);
138+
if (!result.success) {
139+
assert.ok(
140+
result.error.issues.some((i) =>
141+
i.message.includes('not referenced by any toolset')
142+
)
143+
);
144+
}
145+
});
146+
147+
it('should fail when server is referenced by multiple toolsets', () => {
148+
const config = {
149+
mcp_servers: [
150+
{
151+
type: 'url' as const,
152+
url: 'https://example.com/mcp',
153+
name: 'multi-ref-server',
154+
},
155+
],
156+
mcp_toolsets: [
157+
{
158+
type: 'mcp_toolset' as const,
159+
mcp_server_name: 'multi-ref-server',
160+
},
161+
{
162+
type: 'mcp_toolset' as const,
163+
mcp_server_name: 'multi-ref-server',
164+
},
165+
],
166+
};
167+
const result = AnthropicConfigSchema.safeParse(config);
168+
assert.strictEqual(result.success, false);
169+
if (!result.success) {
170+
assert.ok(
171+
result.error.issues.some((i) =>
172+
i.message.includes('referenced by 2 toolsets')
173+
)
174+
);
175+
}
176+
});
177+
178+
it('should pass when server is referenced by exactly one toolset', () => {
179+
const config = {
180+
mcp_servers: [
181+
{
182+
type: 'url' as const,
183+
url: 'https://example.com/mcp',
184+
name: 'valid-server',
185+
},
186+
],
187+
mcp_toolsets: [
188+
{
189+
type: 'mcp_toolset' as const,
190+
mcp_server_name: 'valid-server',
191+
},
192+
],
193+
};
194+
const result = AnthropicConfigSchema.safeParse(config);
195+
assert.strictEqual(result.success, true);
196+
});
197+
198+
it('should fail when mcp_server names are not unique', () => {
199+
const config = {
200+
mcp_servers: [
201+
{
202+
type: 'url' as const,
203+
url: 'https://example.com/mcp1',
204+
name: 'duplicate-name',
205+
},
206+
{
207+
type: 'url' as const,
208+
url: 'https://example.com/mcp2',
209+
name: 'duplicate-name',
210+
},
211+
],
212+
mcp_toolsets: [
213+
{
214+
type: 'mcp_toolset' as const,
215+
mcp_server_name: 'duplicate-name',
216+
},
217+
],
218+
};
219+
const result = AnthropicConfigSchema.safeParse(config);
220+
assert.strictEqual(result.success, false);
221+
if (!result.success) {
222+
assert.ok(
223+
result.error.issues.some((i) =>
224+
i.message.includes('must be unique')
225+
)
226+
);
227+
}
228+
});
229+
230+
it('should fail when toolset references unknown server', () => {
231+
const config = {
232+
mcp_servers: [
233+
{
234+
type: 'url' as const,
235+
url: 'https://example.com/mcp',
236+
name: 'real-server',
237+
},
238+
],
239+
mcp_toolsets: [
240+
{
241+
type: 'mcp_toolset' as const,
242+
mcp_server_name: 'real-server',
243+
},
244+
{
245+
type: 'mcp_toolset' as const,
246+
mcp_server_name: 'unknown-server',
247+
},
248+
],
249+
};
250+
const result = AnthropicConfigSchema.safeParse(config);
251+
assert.strictEqual(result.success, false);
252+
if (!result.success) {
253+
assert.ok(
254+
result.error.issues.some((i) =>
255+
i.message.includes("unknown server 'unknown-server'")
256+
)
257+
);
258+
}
259+
});
260+
});

0 commit comments

Comments
 (0)