|
| 1 | +import { Client } from '@modelcontextprotocol/client'; |
| 2 | +import { InMemoryTransport, ProtocolErrorCode } from '@modelcontextprotocol/core'; |
| 3 | +import { McpServer } from '@modelcontextprotocol/server'; |
| 4 | +import { describe, expect, test } from 'vitest'; |
| 5 | +import * as z from 'zod/v4'; |
| 6 | + |
| 7 | +async function connect(mcpServer: McpServer): Promise<Client> { |
| 8 | + const client = new Client({ name: 'test client', version: '1.0' }); |
| 9 | + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); |
| 10 | + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); |
| 11 | + return client; |
| 12 | +} |
| 13 | + |
| 14 | +describe('declared capabilities answer list methods (draft spec)', () => { |
| 15 | + /*** |
| 16 | + * Test: a server that declares a primitive capability MUST respond to its list method |
| 17 | + * (with an empty result) even if nothing has been registered yet, rather than |
| 18 | + * returning "Method not found". |
| 19 | + */ |
| 20 | + test('declared-but-empty tools/resources/prompts capabilities answer list methods with empty arrays', async () => { |
| 21 | + const mcpServer = new McpServer( |
| 22 | + { name: 'test server', version: '1.0' }, |
| 23 | + { capabilities: { tools: {}, resources: {}, prompts: {} } } |
| 24 | + ); |
| 25 | + |
| 26 | + const client = await connect(mcpServer); |
| 27 | + |
| 28 | + await expect(client.listTools()).resolves.toEqual({ tools: [] }); |
| 29 | + await expect(client.listResources()).resolves.toEqual({ resources: [] }); |
| 30 | + await expect(client.listResourceTemplates()).resolves.toEqual({ resourceTemplates: [] }); |
| 31 | + await expect(client.listPrompts()).resolves.toEqual({ prompts: [] }); |
| 32 | + }); |
| 33 | + |
| 34 | + /*** |
| 35 | + * Test: calling an unknown tool on a declared-but-empty tools capability returns |
| 36 | + * an "Invalid params" error, not "Method not found". |
| 37 | + */ |
| 38 | + test('tools/call for an unknown tool returns InvalidParams when tools capability is declared', async () => { |
| 39 | + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }, { capabilities: { tools: {} } }); |
| 40 | + |
| 41 | + const client = await connect(mcpServer); |
| 42 | + |
| 43 | + await expect(client.callTool({ name: 'nonexistent' })).rejects.toMatchObject({ |
| 44 | + code: ProtocolErrorCode.InvalidParams |
| 45 | + }); |
| 46 | + }); |
| 47 | + |
| 48 | + /*** |
| 49 | + * Test: capabilities that were NOT declared (and have no registrations) still return |
| 50 | + * "Method not found" on the wire. Raw requests are used because the Client's |
| 51 | + * convenience list methods short-circuit locally when the server does not advertise |
| 52 | + * the corresponding capability. |
| 53 | + */ |
| 54 | + test('undeclared capabilities still return MethodNotFound', async () => { |
| 55 | + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }, { capabilities: { tools: {} } }); |
| 56 | + |
| 57 | + const client = await connect(mcpServer); |
| 58 | + |
| 59 | + await expect(client.listTools()).resolves.toEqual({ tools: [] }); |
| 60 | + await expect(client.request({ method: 'resources/list' })).rejects.toMatchObject({ |
| 61 | + code: ProtocolErrorCode.MethodNotFound |
| 62 | + }); |
| 63 | + await expect(client.request({ method: 'prompts/list' })).rejects.toMatchObject({ |
| 64 | + code: ProtocolErrorCode.MethodNotFound |
| 65 | + }); |
| 66 | + }); |
| 67 | + |
| 68 | + /*** |
| 69 | + * Test: a server constructed without declared capabilities behaves as before — |
| 70 | + * list handlers are installed lazily on first registration. |
| 71 | + */ |
| 72 | + test('no declared capabilities and no registrations returns MethodNotFound for all list methods', async () => { |
| 73 | + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }); |
| 74 | + |
| 75 | + const client = await connect(mcpServer); |
| 76 | + |
| 77 | + await expect(client.request({ method: 'tools/list' })).rejects.toMatchObject({ |
| 78 | + code: ProtocolErrorCode.MethodNotFound |
| 79 | + }); |
| 80 | + await expect(client.request({ method: 'resources/list' })).rejects.toMatchObject({ |
| 81 | + code: ProtocolErrorCode.MethodNotFound |
| 82 | + }); |
| 83 | + await expect(client.request({ method: 'prompts/list' })).rejects.toMatchObject({ |
| 84 | + code: ProtocolErrorCode.MethodNotFound |
| 85 | + }); |
| 86 | + }); |
| 87 | + |
| 88 | + /*** |
| 89 | + * Test: registering primitives after declaring the capability up front continues to work |
| 90 | + * (the eagerly installed handlers list later registrations). |
| 91 | + */ |
| 92 | + test('registrations made after construction are listed by the eagerly installed handlers', async () => { |
| 93 | + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }, { capabilities: { tools: {} } }); |
| 94 | + |
| 95 | + mcpServer.registerTool('greet', { description: 'Greets' }, () => ({ |
| 96 | + content: [{ type: 'text', text: 'hi' }] |
| 97 | + })); |
| 98 | + |
| 99 | + const client = await connect(mcpServer); |
| 100 | + |
| 101 | + const result = await client.listTools(); |
| 102 | + expect(result.tools.map(t => t.name)).toEqual(['greet']); |
| 103 | + }); |
| 104 | +}); |
| 105 | + |
| 106 | +describe('deterministic tools/list ordering (draft spec)', () => { |
| 107 | + /*** |
| 108 | + * Test: tools/list SHOULD return tools in a deterministic order when the underlying |
| 109 | + * tool set has not changed. The SDK lists tools in registration (insertion) order. |
| 110 | + */ |
| 111 | + test('tools/list returns an identical order across repeated requests', async () => { |
| 112 | + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }); |
| 113 | + |
| 114 | + const names = ['zeta', 'alpha', 'mid', 'omega', 'beta']; |
| 115 | + for (const name of names) { |
| 116 | + mcpServer.registerTool(name, { inputSchema: z.object({ value: z.string() }) }, ({ value }) => ({ |
| 117 | + content: [{ type: 'text', text: `${name}:${value}` }] |
| 118 | + })); |
| 119 | + } |
| 120 | + |
| 121 | + const client = await connect(mcpServer); |
| 122 | + |
| 123 | + const first = await client.listTools(); |
| 124 | + const second = await client.listTools(); |
| 125 | + |
| 126 | + expect(first.tools.map(t => t.name)).toEqual(names); |
| 127 | + expect(second.tools.map(t => t.name)).toEqual(names); |
| 128 | + }); |
| 129 | + |
| 130 | + test('tools/list ordering stays stable across disable/enable toggles', async () => { |
| 131 | + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }); |
| 132 | + |
| 133 | + const names = ['zeta', 'alpha', 'mid', 'omega', 'beta']; |
| 134 | + const registered = names.map(name => |
| 135 | + mcpServer.registerTool(name, {}, () => ({ |
| 136 | + content: [{ type: 'text', text: name }] |
| 137 | + })) |
| 138 | + ); |
| 139 | + |
| 140 | + const client = await connect(mcpServer); |
| 141 | + |
| 142 | + // Disable a tool in the middle: relative order of the remaining tools is unchanged. |
| 143 | + registered[2].disable(); |
| 144 | + const whileDisabled = await client.listTools(); |
| 145 | + expect(whileDisabled.tools.map(t => t.name)).toEqual(['zeta', 'alpha', 'omega', 'beta']); |
| 146 | + |
| 147 | + // Re-enable it: the original insertion order is restored, not appended at the end. |
| 148 | + registered[2].enable(); |
| 149 | + const afterReenable = await client.listTools(); |
| 150 | + expect(afterReenable.tools.map(t => t.name)).toEqual(names); |
| 151 | + }); |
| 152 | +}); |
0 commit comments