|
|
@@ -19,6 +19,18 @@ interface MockClientState {
|
|
|
listResourcesShouldFail: boolean
|
|
|
prompts: Array<{ name: string; description?: string }>
|
|
|
resources: Array<{ name: string; uri: string; description?: string }>
|
|
|
+ toolPages: Record<
|
|
|
+ string,
|
|
|
+ {
|
|
|
+ tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }>
|
|
|
+ nextCursor?: string
|
|
|
+ }
|
|
|
+ >
|
|
|
+ promptPages: Record<string, { prompts: Array<{ name: string; description?: string }>; nextCursor?: string }>
|
|
|
+ resourcePages: Record<
|
|
|
+ string,
|
|
|
+ { resources: Array<{ name: string; uri: string; description?: string }>; nextCursor?: string }
|
|
|
+ >
|
|
|
closed: boolean
|
|
|
notificationHandlers: Map<unknown, (...args: any[]) => any>
|
|
|
}
|
|
|
@@ -50,6 +62,9 @@ function getOrCreateClientState(name?: string): MockClientState {
|
|
|
listResourcesShouldFail: false,
|
|
|
prompts: [],
|
|
|
resources: [],
|
|
|
+ toolPages: {},
|
|
|
+ promptPages: {},
|
|
|
+ resourcePages: {},
|
|
|
closed: false,
|
|
|
notificationHandlers: new Map(),
|
|
|
}
|
|
|
@@ -143,33 +158,48 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
|
|
|
return this._state?.capabilities
|
|
|
}
|
|
|
|
|
|
- async listTools() {
|
|
|
+ async listTools(params?: { cursor?: string }) {
|
|
|
if (this._state) this._state.listToolsCalls++
|
|
|
if (this._state?.listToolsShouldFail) {
|
|
|
throw new Error(this._state.listToolsError)
|
|
|
}
|
|
|
+ const page = this._state?.toolPages[params === undefined ? "initial" : (params.cursor ?? "")]
|
|
|
+ if (page) return page
|
|
|
return { tools: this._state?.tools ?? [] }
|
|
|
}
|
|
|
|
|
|
- async request(request: { method: string }, schema: { parse: (value: unknown) => unknown }) {
|
|
|
+ async request(
|
|
|
+ request: { method: string; params?: { cursor?: string } },
|
|
|
+ schema: { parse: (value: unknown) => unknown },
|
|
|
+ ) {
|
|
|
if (this._state) this._state.requestCalls++
|
|
|
- if (request.method === "tools/list") return schema.parse({ tools: this._state?.tools ?? [] })
|
|
|
+ if (request.method === "tools/list") {
|
|
|
+ return schema.parse(
|
|
|
+ this._state?.toolPages[request.params === undefined ? "initial" : (request.params.cursor ?? "")] ?? {
|
|
|
+ tools: this._state?.tools ?? [],
|
|
|
+ },
|
|
|
+ )
|
|
|
+ }
|
|
|
throw new Error(`unsupported request: ${request.method}`)
|
|
|
}
|
|
|
|
|
|
- async listPrompts() {
|
|
|
+ async listPrompts(params?: { cursor?: string }) {
|
|
|
if (this._state) this._state.listPromptsCalls++
|
|
|
if (this._state?.listPromptsShouldFail) {
|
|
|
throw new Error("listPrompts failed")
|
|
|
}
|
|
|
+ const page = this._state?.promptPages[params === undefined ? "initial" : (params.cursor ?? "")]
|
|
|
+ if (page) return page
|
|
|
return { prompts: this._state?.prompts ?? [] }
|
|
|
}
|
|
|
|
|
|
- async listResources() {
|
|
|
+ async listResources(params?: { cursor?: string }) {
|
|
|
if (this._state) this._state.listResourcesCalls++
|
|
|
if (this._state?.listResourcesShouldFail) {
|
|
|
throw new Error("listResources failed")
|
|
|
}
|
|
|
+ const page = this._state?.resourcePages[params === undefined ? "initial" : (params.cursor ?? "")]
|
|
|
+ if (page) return page
|
|
|
return { resources: this._state?.resources ?? [] }
|
|
|
}
|
|
|
|
|
|
@@ -234,6 +264,96 @@ it.instance(
|
|
|
{ config: { mcp: {} } },
|
|
|
)
|
|
|
|
|
|
+it.instance(
|
|
|
+ "follows cursors when listing tools, prompts, and resources",
|
|
|
+ () =>
|
|
|
+ MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ lastCreatedClientName = "paged-server"
|
|
|
+ const serverState = getOrCreateClientState("paged-server")
|
|
|
+ serverState.toolPages = {
|
|
|
+ initial: {
|
|
|
+ tools: [{ name: "tool-one", inputSchema: { type: "object", properties: {} } }],
|
|
|
+ nextCursor: "tools-2",
|
|
|
+ },
|
|
|
+ "tools-2": { tools: [{ name: "tool-two", inputSchema: { type: "object", properties: {} } }] },
|
|
|
+ }
|
|
|
+ serverState.promptPages = {
|
|
|
+ initial: { prompts: [{ name: "prompt-one" }], nextCursor: "prompts-2" },
|
|
|
+ "prompts-2": { prompts: [{ name: "prompt-two" }] },
|
|
|
+ }
|
|
|
+ serverState.resourcePages = {
|
|
|
+ initial: { resources: [{ name: "resource-one", uri: "test://one" }], nextCursor: "resources-2" },
|
|
|
+ "resources-2": { resources: [{ name: "resource-two", uri: "test://two" }] },
|
|
|
+ }
|
|
|
+
|
|
|
+ yield* mcp.add("paged-server", {
|
|
|
+ type: "local",
|
|
|
+ command: ["echo", "test"],
|
|
|
+ })
|
|
|
+
|
|
|
+ expect(Object.keys(yield* mcp.tools())).toEqual(["paged-server_tool-one", "paged-server_tool-two"])
|
|
|
+ expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"])
|
|
|
+ expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:resource-one", "paged-server:resource-two"])
|
|
|
+ expect(serverState.listToolsCalls).toBe(2)
|
|
|
+ expect(serverState.listPromptsCalls).toBe(2)
|
|
|
+ expect(serverState.listResourcesCalls).toBe(2)
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ { config: { mcp: {} } },
|
|
|
+)
|
|
|
+
|
|
|
+it.instance(
|
|
|
+ "stops listing when a server repeats a cursor",
|
|
|
+ () =>
|
|
|
+ MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ lastCreatedClientName = "looping-server"
|
|
|
+ const serverState = getOrCreateClientState("looping-server")
|
|
|
+ serverState.toolPages = {
|
|
|
+ initial: { tools: [], nextCursor: "repeat" },
|
|
|
+ repeat: { tools: [], nextCursor: "repeat" },
|
|
|
+ }
|
|
|
+
|
|
|
+ yield* mcp.add("looping-server", {
|
|
|
+ type: "local",
|
|
|
+ command: ["echo", "test"],
|
|
|
+ })
|
|
|
+
|
|
|
+ expect(serverState.listToolsCalls).toBe(2)
|
|
|
+ expect(yield* mcp.tools()).toEqual({})
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ { config: { mcp: {} } },
|
|
|
+)
|
|
|
+
|
|
|
+it.instance(
|
|
|
+ "follows empty cursors",
|
|
|
+ () =>
|
|
|
+ MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ lastCreatedClientName = "empty-cursor-server"
|
|
|
+ const serverState = getOrCreateClientState("empty-cursor-server")
|
|
|
+ serverState.promptPages = {
|
|
|
+ initial: { prompts: [{ name: "prompt-one" }], nextCursor: "" },
|
|
|
+ "": { prompts: [{ name: "prompt-two" }] },
|
|
|
+ }
|
|
|
+
|
|
|
+ yield* mcp.add("empty-cursor-server", {
|
|
|
+ type: "local",
|
|
|
+ command: ["echo", "test"],
|
|
|
+ })
|
|
|
+
|
|
|
+ expect(Object.keys(yield* mcp.prompts())).toEqual([
|
|
|
+ "empty-cursor-server:prompt-one",
|
|
|
+ "empty-cursor-server:prompt-two",
|
|
|
+ ])
|
|
|
+ expect(serverState.listPromptsCalls).toBe(2)
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ { config: { mcp: {} } },
|
|
|
+)
|
|
|
+
|
|
|
// ========================================================================
|
|
|
// Test: tool change notifications refresh the cache
|
|
|
// ========================================================================
|