|
1 | 1 | // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | 3 | import { describe, it, expect, vi, beforeEach } from 'vitest'; |
| 4 | +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; |
| 5 | +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; |
4 | 6 | import { MCPServerRuntime } from '../mcp-server-runtime.js'; |
5 | 7 | import type { MCPServerRuntimeConfig } from '../mcp-server-runtime.js'; |
6 | 8 | import type { AIToolDefinition, ToolCallPart } from '@objectstack/spec/contracts'; |
@@ -83,15 +85,38 @@ function createMockMetadataService() { |
83 | 85 | }, |
84 | 86 | }; |
85 | 87 |
|
| 88 | + const skills: Record<string, any> = { |
| 89 | + case_management: { |
| 90 | + name: 'case_management', |
| 91 | + label: 'Case Management', |
| 92 | + description: 'Handles support case lifecycle', |
| 93 | + surface: 'ask', |
| 94 | + instructions: 'Triage the case, then resolve it once the caller confirms.', |
| 95 | + tools: ['action_resolve_case'], |
| 96 | + active: true, |
| 97 | + }, |
| 98 | + // No `instructions` half — nothing for an MCP client to fetch, so it is |
| 99 | + // deliberately not projected (#3905). |
| 100 | + toolbox: { |
| 101 | + name: 'toolbox', |
| 102 | + label: 'Toolbox', |
| 103 | + surface: 'both', |
| 104 | + tools: ['query_records'], |
| 105 | + active: true, |
| 106 | + }, |
| 107 | + }; |
| 108 | + |
86 | 109 | return { |
87 | 110 | listObjects: vi.fn(async () => Object.values(objects)), |
88 | 111 | getObject: vi.fn(async (name: string) => objects[name] ?? null), |
89 | 112 | get: vi.fn(async (type: string, name: string) => { |
90 | 113 | if (type === 'agent') return agents[name] ?? null; |
| 114 | + if (type === 'skill') return skills[name] ?? null; |
91 | 115 | return null; |
92 | 116 | }), |
93 | 117 | list: vi.fn(async (type: string) => { |
94 | 118 | if (type === 'agent') return Object.values(agents); |
| 119 | + if (type === 'skill') return Object.values(skills); |
95 | 120 | return []; |
96 | 121 | }), |
97 | 122 | exists: vi.fn(async (type: string, name: string) => { |
@@ -225,11 +250,61 @@ describe('MCPServerRuntime', () => { |
225 | 250 | }); |
226 | 251 |
|
227 | 252 | describe('bridgePrompts', () => { |
228 | | - it('should register agent prompt', () => { |
| 253 | + it('should register agent prompt', async () => { |
| 254 | + const metadataService = createMockMetadataService(); |
| 255 | + await runtime.bridgePrompts(metadataService as any); |
| 256 | + |
| 257 | + expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Agent prompts bridged'); |
| 258 | + }); |
| 259 | + |
| 260 | + it('projects every skill that carries instructions, and only those (#3905)', async () => { |
| 261 | + const metadataService = createMockMetadataService(); |
| 262 | + await runtime.bridgePrompts(metadataService as any); |
| 263 | + |
| 264 | + // `case_management` has instructions; `toolbox` does not. |
| 265 | + expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Bridged 1 skill prompts'); |
| 266 | + |
| 267 | + // Drive the real wire: an MCP client sees the skill on prompts/list and |
| 268 | + // gets its instructions back from prompts/get. |
| 269 | + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); |
| 270 | + const client = new Client({ name: 'test-client', version: '0.0.0' }); |
| 271 | + await Promise.all([ |
| 272 | + runtime.server.connect(serverTransport), |
| 273 | + client.connect(clientTransport), |
| 274 | + ]); |
| 275 | + |
| 276 | + const listed = await client.listPrompts(); |
| 277 | + const names = listed.prompts.map((p) => p.name); |
| 278 | + expect(names).toContain('case_management'); |
| 279 | + expect(names).not.toContain('toolbox'); |
| 280 | + expect(listed.prompts.find((p) => p.name === 'case_management')?.description).toBe( |
| 281 | + 'Handles support case lifecycle', |
| 282 | + ); |
| 283 | + |
| 284 | + const fetched = await client.getPrompt({ name: 'case_management' }); |
| 285 | + expect(fetched.messages[0].content).toEqual({ |
| 286 | + type: 'text', |
| 287 | + text: 'Triage the case, then resolve it once the caller confirms.', |
| 288 | + }); |
| 289 | + |
| 290 | + await client.close(); |
| 291 | + await runtime.stop().catch(() => {}); |
| 292 | + }); |
| 293 | + |
| 294 | + it('survives a metadata service that cannot list skills', async () => { |
229 | 295 | const metadataService = createMockMetadataService(); |
230 | | - runtime.bridgePrompts(metadataService as any); |
| 296 | + metadataService.list = vi.fn(async (type: string) => { |
| 297 | + if (type === 'skill') throw new Error('unknown metadata type'); |
| 298 | + return []; |
| 299 | + }) as any; |
231 | 300 |
|
| 301 | + await runtime.bridgePrompts(metadataService as any); |
| 302 | + |
| 303 | + // Agent prompts still bridged; the failure is reported, not swallowed. |
232 | 304 | expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Agent prompts bridged'); |
| 305 | + expect(mockLogger.warn).toHaveBeenCalledWith( |
| 306 | + expect.stringContaining('Could not read skill metadata'), |
| 307 | + ); |
233 | 308 | }); |
234 | 309 | }); |
235 | 310 |
|
|
0 commit comments