Files
oh-my-pi/packages/coding-agent/test/client-resources.test.ts
T
Miroslav Drbal [ApoC] 63b203b937 feat(mcp): resource notifications, subscriptions, and read_resource builtin tool (#254)
* feat(mcp): resource notifications, subscriptions, and read_resource builtin tool

- Add MCP resource subscription lifecycle (subscribe/unsubscribe on connect/disconnect)
- Wire mcp.notifications setting with live toggle support
- Add debounced followUp injection for resource change notifications
- Add global read_resource builtin tool with server resolution by URI/template scheme
- Add MCP prompt commands (buildMCPPromptCommands) with array content support
- Add server instructions injection into system prompt with attribution
- Add mcp.notificationDebounceMs configurable setting

Client (client.ts):
  listResources, listResourceTemplates, readResource with pagination
  subscribeToResources, unsubscribeFromResources
  listPrompts, getPrompt, serverSupportsPrompts
  serverSupportsResources, serverSupportsResourceSubscriptions

Manager (manager.ts):
  Notification dispatch with subscribed-URI guard
  Concurrent refresh deduplication via pending promise map
  setNotificationsEnabled with subscribe/unsubscribe toggle

Tests:
  client-resources.test.ts (31 tests)
  client-prompts.test.ts (20 tests)
  mcp-read-resource.test.ts (13 tests)

* fix(mcp): address PR review - eager prompt init and stale subscription cleanup

P1: Make setOnPromptsChanged eagerly fire for servers that already
have prompts loaded. The callback is registered after MCP discovery
has already loaded prompts and fired the hook, so without this the
handler is never called on the common startup path. The fix is in
the manager itself (not the caller), eliminating the race condition
regardless of when the callback is wired.

P2: Unsubscribe removed resource URIs on resource refresh.
refreshServerResources was subscribing to the new URI set and
overwriting #subscribedResources without unsubscribing URIs that
were previously subscribed but no longer present, leaving stale
subscriptions active on the server.

* fix(mcp): add resources and prompts to /mcp help text and subcommand completions

* feat(mcp): add /mcp notifications command

Shows per-server notification capabilities with subscription state:
- Lists supported notification types (tools/list_changed, resources/list_changed,
  prompts/list_changed) with check marks
- Shows resources/subscribe status with active subscription count
- Lists subscribed URIs with green ticks when notifications are enabled
- Displays overall enabled/disabled state (mcp.notifications setting)

* fix(mcp): address PR review comments on race conditions and stale state

- Await subscribe/unsubscribe in refreshServerResources so the refresh
  promise doesn't resolve before subscriptions are settled, preventing
  a second refresh from racing and overwriting tracking state (P2 #3)

- Guard setNotificationsEnabled subscribe .then() against a disable
  that happens while the subscribe request is in-flight (P2 #5)

- Re-check mcp.notifications setting inside debounce setTimeout
  callback so toggling off mid-window actually suppresses the
  follow-up message (P2 #4)

- Fire onToolsChanged and onPromptsChanged callbacks in
  disconnectServer so stale slash commands and tool registrations
  are cleaned up when a server is removed (P2 #2)

---------

Co-authored-by: Miroslav Drbal <miroslav.drbal@gendigital.com>
2026-03-03 03:26:07 +01:00

222 lines
8.3 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import {
listResources,
listResourceTemplates,
readResource,
serverSupportsResourceSubscriptions,
serverSupportsResources,
subscribeToResources,
unsubscribeFromResources,
} from "../src/mcp/client";
import type {
MCPResource,
MCPResourceReadResult,
MCPResourcesListResult,
MCPResourceTemplate,
MCPResourceTemplatesListResult,
MCPTransport,
} from "../src/mcp/types";
import { createMockConnection, createMockTransport } from "./mcp-test-utils";
describe("listResources", () => {
it("returns empty array when server does not support resources", async () => {
const transport = createMockTransport(new Map());
const conn = createMockConnection({}, transport);
const result = await listResources(conn);
expect(result).toEqual([]);
});
it("fetches and caches resources on first call", async () => {
const resources: MCPResource[] = [
{ uri: "file:///a.txt", name: "a.txt" },
{ uri: "file:///b.txt", name: "b.txt" },
];
const page: MCPResourcesListResult = { resources };
const transport = createMockTransport(new Map([["resources/list", [page]]]));
const conn = createMockConnection({ resources: {} }, transport);
const result = await listResources(conn);
expect(result).toHaveLength(2);
expect(result[0].uri).toBe("file:///a.txt");
expect(result[1].uri).toBe("file:///b.txt");
expect(conn.resources).toBe(result);
});
it("returns cached resources on second call without making another request", async () => {
const resources: MCPResource[] = [{ uri: "file:///c.txt", name: "c.txt" }];
const page: MCPResourcesListResult = { resources };
// Only one response queued — second transport hit would throw
const transport = createMockTransport(new Map([["resources/list", [page]]]));
const conn = createMockConnection({ resources: {} }, transport);
const first = await listResources(conn);
const second = await listResources(conn);
expect(second).toBe(first);
});
it("handles pagination with multiple pages", async () => {
const page1: MCPResourcesListResult = {
resources: [{ uri: "file:///p1.txt", name: "p1.txt" }],
nextCursor: "c1",
};
const page2: MCPResourcesListResult = {
resources: [{ uri: "file:///p2.txt", name: "p2.txt" }],
};
const transport = createMockTransport(new Map([["resources/list", [page1, page2]]]));
const conn = createMockConnection({ resources: {} }, transport);
const result = await listResources(conn);
expect(result).toHaveLength(2);
expect(result[0].uri).toBe("file:///p1.txt");
expect(result[1].uri).toBe("file:///p2.txt");
});
});
describe("listResourceTemplates", () => {
it("returns empty array when server does not support resources", async () => {
const transport = createMockTransport(new Map());
const conn = createMockConnection({}, transport);
const result = await listResourceTemplates(conn);
expect(result).toEqual([]);
});
it("fetches and caches templates", async () => {
const templates: MCPResourceTemplate[] = [{ uriTemplate: "file:///{path}", name: "path-template" }];
const page: MCPResourceTemplatesListResult = { resourceTemplates: templates };
const transport = createMockTransport(new Map([["resources/templates/list", [page]]]));
const conn = createMockConnection({ resources: {} }, transport);
const result = await listResourceTemplates(conn);
expect(result).toHaveLength(1);
expect(result[0].uriTemplate).toBe("file:///{path}");
expect(conn.resourceTemplates).toBe(result);
// Second call should return cached value without hitting transport
const second = await listResourceTemplates(conn);
expect(second).toBe(result);
});
});
describe("readResource", () => {
it("sends resources/read with URI and returns contents", async () => {
const readResult: MCPResourceReadResult = {
contents: [{ uri: "file:///a.txt", mimeType: "text/plain", text: "hello" }],
};
const transport = createMockTransport(new Map([["resources/read", [readResult]]]));
const conn = createMockConnection({ resources: {} }, transport);
const result = await readResource(conn, "file:///a.txt");
expect(result.contents).toHaveLength(1);
expect(result.contents[0].text).toBe("hello");
expect(result.contents[0].mimeType).toBe("text/plain");
});
it("handles binary blobs", async () => {
const readResult: MCPResourceReadResult = {
contents: [{ uri: "file:///img.png", mimeType: "image/png", blob: "base64data" }],
};
const transport = createMockTransport(new Map([["resources/read", [readResult]]]));
const conn = createMockConnection({ resources: {} }, transport);
const result = await readResource(conn, "file:///img.png");
expect(result.contents).toHaveLength(1);
expect(result.contents[0].blob).toBe("base64data");
expect(result.contents[0].text).toBeUndefined();
});
});
describe("serverSupportsResources", () => {
it("returns true when resources capability exists", () => {
expect(serverSupportsResources({ resources: {} })).toBe(true);
expect(serverSupportsResources({ resources: { subscribe: true } })).toBe(true);
expect(serverSupportsResources({ resources: { listChanged: true } })).toBe(true);
});
it("returns false when resources capability is absent", () => {
expect(serverSupportsResources({})).toBe(false);
expect(serverSupportsResources({ tools: {} })).toBe(false);
});
});
describe("serverSupportsResourceSubscriptions", () => {
it("returns true when capabilities.resources.subscribe is true", () => {
expect(serverSupportsResourceSubscriptions({ resources: { subscribe: true } })).toBe(true);
});
it("returns false when resources capability exists but subscribe is absent", () => {
expect(serverSupportsResourceSubscriptions({ resources: {} })).toBe(false);
});
it("returns false when no resources capability", () => {
expect(serverSupportsResourceSubscriptions({})).toBe(false);
});
});
describe("subscribeToResources", () => {
it("no-ops on empty URI array", async () => {
const transport = createMockTransport(new Map());
const conn = createMockConnection({ resources: { subscribe: true } }, transport);
await subscribeToResources(conn, []);
});
it("no-ops when server lacks subscribe capability", async () => {
const transport = createMockTransport(new Map());
const conn = createMockConnection({ resources: {} }, transport);
await subscribeToResources(conn, ["test://a"]);
});
it("sends resources/subscribe for each URI", async () => {
const transport = createMockTransport(new Map([["resources/subscribe", [{}, {}]]]));
const conn = createMockConnection({ resources: { subscribe: true } }, transport);
await subscribeToResources(conn, ["test://a", "test://b"]);
});
it("does not throw when one subscription fails", async () => {
const transport: MCPTransport = {
connected: true,
async request<T>(_method: string, params?: Record<string, unknown>): Promise<T> {
if (params?.uri === "fail://x") throw new Error("boom");
return {} as T;
},
async notify() {},
async close() {},
};
const conn = createMockConnection({ resources: { subscribe: true } }, transport);
await subscribeToResources(conn, ["test://ok", "fail://x"]);
});
});
describe("unsubscribeFromResources", () => {
it("no-ops on empty URI array", async () => {
const transport = createMockTransport(new Map());
const conn = createMockConnection({ resources: { subscribe: true } }, transport);
await unsubscribeFromResources(conn, []);
});
it("no-ops when server lacks subscribe capability", async () => {
const transport = createMockTransport(new Map());
const conn = createMockConnection({ resources: {} }, transport);
await unsubscribeFromResources(conn, ["test://a"]);
});
it("sends resources/unsubscribe for each URI", async () => {
const transport = createMockTransport(new Map([["resources/unsubscribe", [{}, {}]]]));
const conn = createMockConnection({ resources: { subscribe: true } }, transport);
await unsubscribeFromResources(conn, ["test://a", "test://b"]);
});
it("does not throw when one unsubscription fails", async () => {
const transport: MCPTransport = {
connected: true,
async request<T>(_method: string, params?: Record<string, unknown>): Promise<T> {
if (params?.uri === "fail://x") throw new Error("boom");
return {} as T;
},
async notify() {},
async close() {},
};
const conn = createMockConnection({ resources: { subscribe: true } }, transport);
await unsubscribeFromResources(conn, ["test://ok", "fail://x"]);
});
});