|
5 | 5 | // fake client here), listing its tools, and mapping them to actions. |
6 | 6 |
|
7 | 7 | import { describe, it, expect } from 'vitest'; |
| 8 | +import { LiteKernel } from '@objectstack/core'; |
| 9 | +import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation'; |
8 | 10 | import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'; |
9 | 11 | import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; |
10 | 12 | import { McpError } from '@modelcontextprotocol/sdk/types.js'; |
11 | 13 | import type { ConnectorProviderContext } from '@objectstack/spec/integration'; |
12 | 14 | import { isConnectorCredentialRejected, isConnectorUpstreamUnavailable } from '@objectstack/spec/integration'; |
13 | 15 | import type { McpClientLike, McpToolDescriptor, McpTransport } from './mcp-connector.js'; |
| 16 | +import { ConnectorMcpPlugin } from './connector-mcp-plugin.js'; |
14 | 17 | import { createMcpProviderFactory, MCP_PROVIDER_KEY } from './mcp-provider.js'; |
15 | 18 |
|
16 | 19 | const TOOLS: McpToolDescriptor[] = [ |
@@ -364,3 +367,143 @@ describe('mcp provider: declarative stdio working directory (#22423)', () => { |
364 | 367 | expect(seen.transport).toBeUndefined(); |
365 | 368 | }); |
366 | 369 | }); |
| 370 | + |
| 371 | +// #27402 — `providerConfig.include` is the allowlist that keeps destructive |
| 372 | +// tools away from flows and agents, with the `openapi` provider's one meaning |
| 373 | +// (#26709, #27387): a shape it cannot read is refused before connecting, an |
| 374 | +// entry naming a tool the server does not list is refused after listing, and |
| 375 | +// the instance never falls back to exposing every tool. |
| 376 | +describe('mcp provider: providerConfig.include refuses loudly (#27402)', () => { |
| 377 | + const SERVER_TOOLS: McpToolDescriptor[] = [ |
| 378 | + { name: 'web_search', description: 'Search the web' }, |
| 379 | + { name: 'delete_everything', description: 'Destructive' }, |
| 380 | + ]; |
| 381 | + const http = { kind: 'http', url: 'https://mcp.vendor.example/mcp' }; |
| 382 | + |
| 383 | + /** A fake server listing SERVER_TOOLS; records whether it was connected and closed. */ |
| 384 | + function server() { |
| 385 | + const state = { connected: false, closed: false }; |
| 386 | + const clientFactory = async (): Promise<McpClientLike> => { |
| 387 | + state.connected = true; |
| 388 | + return { |
| 389 | + listTools: async () => SERVER_TOOLS, |
| 390 | + callTool: async () => ({ content: [] }), |
| 391 | + close: async () => { state.closed = true; }, |
| 392 | + }; |
| 393 | + }; |
| 394 | + return { clientFactory, state }; |
| 395 | + } |
| 396 | + |
| 397 | + async function refusal(include: unknown) { |
| 398 | + const { clientFactory, state } = server(); |
| 399 | + const factory = createMcpProviderFactory({ clientFactory }); |
| 400 | + const err: unknown = await Promise.resolve( |
| 401 | + factory(ctx({ name: 'vendor', providerConfig: { transport: http, include } })), |
| 402 | + ).then( |
| 403 | + (mat) => { throw new Error(`expected a refusal, got actions ${Object.keys(mat.handlers).join(', ')}`); }, |
| 404 | + (e: unknown) => e, |
| 405 | + ); |
| 406 | + return { err, state }; |
| 407 | + } |
| 408 | + |
| 409 | + /** A configuration fault: plain, neither retryable nor a credential refusal. */ |
| 410 | + function expectConfigurationFault(err: unknown) { |
| 411 | + expect(err).toBeInstanceOf(Error); |
| 412 | + expect(isConnectorUpstreamUnavailable(err)).toBe(false); |
| 413 | + expect(isConnectorCredentialRejected(err)).toBe(false); |
| 414 | + } |
| 415 | + |
| 416 | + it.each([ |
| 417 | + ['a string', 'web_search'], |
| 418 | + ['an array with a non-string entry', ['web_search', 7]], |
| 419 | + ['an array with an empty name', ['web_search', '']], |
| 420 | + ['an object', { web_search: true }], |
| 421 | + ['null', null], |
| 422 | + ])('refuses an include that is %s, before connecting', async (_shape, include) => { |
| 423 | + const { err, state } = await refusal(include); |
| 424 | + expectConfigurationFault(err); |
| 425 | + expect((err as Error).message).toMatch( |
| 426 | + /^connector-mcp provider: connector 'vendor' providerConfig\.include must be an array of tool names \(strings\)/, |
| 427 | + ); |
| 428 | + expect(state.connected).toBe(false); |
| 429 | + }); |
| 430 | + |
| 431 | + it('refuses an entry naming a tool the server does not list, naming it and the listed tools, and closes the client', async () => { |
| 432 | + const { err, state } = await refusal(['nope']); |
| 433 | + expectConfigurationFault(err); |
| 434 | + expect((err as Error).message).toMatch( |
| 435 | + /^connector-mcp provider: connector 'vendor' providerConfig\.include names 'nope', which the MCP server does not list\./, |
| 436 | + ); |
| 437 | + expect((err as Error).message).toContain("the server lists 2 tool(s): 'web_search', 'delete_everything'."); |
| 438 | + expect(state.closed).toBe(true); |
| 439 | + }); |
| 440 | + |
| 441 | + it('refuses when one entry is listed and another is not: no partial instance', async () => { |
| 442 | + const { err } = await refusal(['web_search', 'nope', 'nope']); |
| 443 | + expectConfigurationFault(err); |
| 444 | + expect((err as Error).message).toContain("providerConfig.include names 'nope', which"); |
| 445 | + }); |
| 446 | + |
| 447 | + it('an include of listed tools exposes exactly those tools', async () => { |
| 448 | + const { clientFactory, state } = server(); |
| 449 | + const mat = await createMcpProviderFactory({ clientFactory })( |
| 450 | + ctx({ name: 'vendor', providerConfig: { transport: http, include: ['web_search'] } }), |
| 451 | + ); |
| 452 | + expect(Object.keys(mat.handlers)).toEqual(['web_search']); |
| 453 | + expect(mat.def.actions?.map((a) => a.key)).toEqual(['web_search']); |
| 454 | + expect(state.closed).toBe(false); |
| 455 | + }); |
| 456 | + |
| 457 | + it('control: no include exposes every tool the server lists', async () => { |
| 458 | + const { clientFactory } = server(); |
| 459 | + const mat = await createMcpProviderFactory({ clientFactory })( |
| 460 | + ctx({ name: 'vendor', providerConfig: { transport: http } }), |
| 461 | + ); |
| 462 | + expect(Object.keys(mat.handlers)).toEqual(['web_search', 'delete_everything']); |
| 463 | + }); |
| 464 | + |
| 465 | + // Through the materializer: a refused include fails the boot naming the |
| 466 | + // instance and the fault, and no connector exposing the server's tools is |
| 467 | + // ever registered under the declared name. |
| 468 | + it('a declarative instance with a string include fails boot, never registering every tool', async () => { |
| 469 | + const { clientFactory, state } = server(); |
| 470 | + const declared = [ |
| 471 | + { |
| 472 | + name: 'vendor_mcp', |
| 473 | + label: 'Vendor MCP', |
| 474 | + type: 'api', |
| 475 | + provider: 'mcp', |
| 476 | + providerConfig: { transport: http, include: 'web_search' }, |
| 477 | + }, |
| 478 | + ]; |
| 479 | + const kernel = new LiteKernel({ logger: { level: 'silent' } } as never); |
| 480 | + kernel.use(new AutomationServicePlugin()); |
| 481 | + kernel.use({ |
| 482 | + name: 'test.connector-metadata', |
| 483 | + type: 'standard', |
| 484 | + version: '1.0.0', |
| 485 | + dependencies: ['com.objectstack.service-automation'], |
| 486 | + async init(c: { registerService(name: string, service: unknown): void }) { |
| 487 | + c.registerService('objectql', { |
| 488 | + registry: { listItems: (type: string) => (type === 'connector' ? declared : []) }, |
| 489 | + }); |
| 490 | + }, |
| 491 | + async start() {}, |
| 492 | + } as never); |
| 493 | + kernel.use(new ConnectorMcpPlugin({ clientFactory })); |
| 494 | + try { |
| 495 | + const booted: unknown = await kernel.bootstrap().then(() => undefined, (e: unknown) => e); |
| 496 | + expect(booted).toBeInstanceOf(Error); |
| 497 | + expect((booted as Error).message).toContain( |
| 498 | + "failed to materialize connector instance 'vendor_mcp' via provider 'mcp'", |
| 499 | + ); |
| 500 | + expect((booted as Error).message).toContain('providerConfig.include must be an array of tool names'); |
| 501 | + expect(state.connected).toBe(false); |
| 502 | + const engine = kernel.getService('automation') as AutomationEngine | undefined; |
| 503 | + const descriptor = engine?.getConnectorDescriptors().find((d) => d.name === 'vendor_mcp'); |
| 504 | + expect(descriptor?.actions ?? []).toEqual([]); |
| 505 | + } finally { |
| 506 | + await kernel.shutdown().catch(() => {}); |
| 507 | + } |
| 508 | + }); |
| 509 | +}); |
0 commit comments