Skip to content

Commit 7d8e8d7

Browse files
committed
feat(mcp): expose server resources via list/read tools
MCP bridged only tools; servers also publish resources (files, records, docs). Both transports now speak resources/list + resources/read, the manager aggregates them across servers, and two read-only tools — list_mcp_resources / read_mcp_resource — let the agent enumerate and pull them in. /mcp lists them too. (Prompts-as-commands is the remaining MCP surface, deferred as a focused follow-up.)
1 parent 7b6c192 commit 7d8e8d7

13 files changed

Lines changed: 210 additions & 2 deletions

File tree

src/agent/agent.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,9 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
515515
},
516516
},
517517
});
518+
// Back the list_mcp_resources / read_mcp_resource tools. Assigned now
519+
// (after the manager exists); resources populate once connectMcp runs.
520+
toolContext.mcp = mcp;
518521
const connectMcp = async (): Promise<readonly McpServerStatus[]> => {
519522
await mcp.connectAll({ cwd });
520523
const mcpTools = mcp.tools();

src/commands/builtins/mcp.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ export const mcp: Command = {
4747
ctx.emit("Tools:");
4848
for (const t of tools) ctx.emit(` ${t.name}`);
4949
}
50+
const resources = ctx.bundle.mcp.resources();
51+
if (resources.length > 0) {
52+
ctx.emit("Resources (read via read_mcp_resource):");
53+
for (const r of resources) ctx.emit(` ${r.server} :: ${r.descriptor.uri}`);
54+
}
5055
return { handled: true };
5156
},
5257
};

src/mcp/__test__/mock-server.mjs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ rl.on("line", (line) => {
2323
id: msg.id,
2424
result: {
2525
protocolVersion: "2025-06-18",
26-
capabilities: { tools: {} },
26+
capabilities: { tools: {}, resources: {} },
2727
serverInfo: { name: "mock", version: "0" },
2828
},
2929
});
@@ -63,6 +63,23 @@ rl.on("line", (line) => {
6363
}
6464
return;
6565
}
66+
if (msg.method === "resources/list") {
67+
send({
68+
jsonrpc: "2.0",
69+
id: msg.id,
70+
result: { resources: [{ uri: "mock://greeting", name: "greeting", mimeType: "text/plain" }] },
71+
});
72+
return;
73+
}
74+
if (msg.method === "resources/read") {
75+
const uri = msg.params?.uri;
76+
send({
77+
jsonrpc: "2.0",
78+
id: msg.id,
79+
result: { contents: [{ uri, mimeType: "text/plain", text: "hello from mock" }] },
80+
});
81+
return;
82+
}
6683
// Unknown method → error response.
6784
if (typeof msg.id === "number") {
6885
send({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: `unknown method: ${msg.method}` } });

src/mcp/client.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { McpCallToolResult, McpToolDescriptor } from "./protocol.js";
1+
import type { McpCallToolResult, McpReadResourceResult, McpResourceDescriptor, McpToolDescriptor } from "./protocol.js";
22

33
/** What every MCP client speaks to, regardless of transport (stdio | http). */
44
export const CLIENT_INFO = { name: "codebase-cli", version: "1" } as const;
@@ -19,6 +19,10 @@ export interface McpClient {
1919
listTools(): Promise<McpToolDescriptor[]>;
2020
/** Invoke a tool by name with arguments. */
2121
callTool(name: string, args: unknown): Promise<McpCallToolResult>;
22+
/** List the server's resources. Returns [] when the server has no resources capability. */
23+
listResources(): Promise<McpResourceDescriptor[]>;
24+
/** Read one resource by URI. */
25+
readResource(uri: string): Promise<McpReadResourceResult>;
2226
/** Tear down the transport and reject any in-flight requests. */
2327
close(): void;
2428
}

src/mcp/http-client.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
type JsonRpcResponse,
66
MCP_PROTOCOL_VERSION,
77
type McpCallToolResult,
8+
type McpReadResourceResult,
9+
type McpResourceDescriptor,
810
type McpToolDescriptor,
911
parseRpcLine,
1012
} from "./protocol.js";
@@ -67,6 +69,21 @@ export class HttpMcpClient implements McpClient {
6769
return (res.result as McpCallToolResult) ?? {};
6870
}
6971

72+
async listResources(): Promise<McpResourceDescriptor[]> {
73+
try {
74+
const res = await this.request("resources/list", {});
75+
const result = res.result as { resources?: McpResourceDescriptor[] } | undefined;
76+
return Array.isArray(result?.resources) ? result.resources : [];
77+
} catch {
78+
return [];
79+
}
80+
}
81+
82+
async readResource(uri: string): Promise<McpReadResourceResult> {
83+
const res = await this.request("resources/read", { uri });
84+
return (res.result as McpReadResourceResult) ?? {};
85+
}
86+
7087
close(): void {
7188
if (this.closed) return;
7289
this.closed = true;

src/mcp/manager.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,19 @@ describe("McpManager (against the mock server)", () => {
8080
expect(manager.status()).toEqual([]);
8181
});
8282

83+
it("aggregates resources and reads one by server + uri", async () => {
84+
writeConfig({ demo: { command: process.execPath, args: [MOCK] } });
85+
manager = new McpManager();
86+
await manager.connectAll({ home, cwd });
87+
88+
const resources = manager.resources();
89+
expect(resources).toHaveLength(1);
90+
expect(resources[0]).toMatchObject({ server: "demo", descriptor: { uri: "mock://greeting", name: "greeting" } });
91+
92+
const read = await manager.readResource("demo", "mock://greeting");
93+
expect(read.contents?.[0]).toMatchObject({ text: "hello from mock" });
94+
});
95+
8396
it("connects a remote (url) server over HTTP and bridges its tools", async () => {
8497
const server = await startHttpMock();
8598
try {

src/mcp/manager.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { HttpMcpClient } from "./http-client.js";
55
import type { AuthorizeDeps } from "./oauth/flow.js";
66
import { McpOAuthProvider } from "./oauth/provider.js";
77
import { McpOAuthStore } from "./oauth/store.js";
8+
import type { McpReadResourceResult, McpResourceDescriptor } from "./protocol.js";
89
import { StdioMcpClient } from "./stdio-client.js";
910
import { mcpToAgentTool } from "./to-agent-tool.js";
1011

@@ -15,6 +16,12 @@ export interface McpServerStatus {
1516
error?: string;
1617
}
1718

19+
/** A resource plus the server that exposes it. */
20+
export interface McpResourceRef {
21+
server: string;
22+
descriptor: McpResourceDescriptor;
23+
}
24+
1825
export interface McpManagerOptions {
1926
/** Persisted OAuth sessions for remote servers. Defaults to ~/.codebase/mcp-credentials.json. */
2027
oauthStore?: McpOAuthStore;
@@ -34,8 +41,10 @@ export interface McpManagerOptions {
3441
*/
3542
export class McpManager {
3643
private readonly clients: McpClient[] = [];
44+
private readonly clientsByName = new Map<string, McpClient>();
3745
private readonly statuses: McpServerStatus[] = [];
3846
private readonly toolList: AgentTool<any>[] = [];
47+
private readonly resourceList: McpResourceRef[] = [];
3948
private readonly oauthStore: McpOAuthStore;
4049
private readonly authDeps: AuthorizeDeps;
4150

@@ -59,9 +68,14 @@ export class McpManager {
5968
await client.connect();
6069
const descriptors = await client.listTools();
6170
this.clients.push(client);
71+
this.clientsByName.set(name, client);
6272
for (const desc of descriptors) {
6373
this.toolList.push(mcpToAgentTool(name, client, desc));
6474
}
75+
// Resources are best-effort — a server without the capability
76+
// returns [], and a failure here never blocks its tools.
77+
const resources = await client.listResources().catch(() => []);
78+
for (const r of resources) this.resourceList.push({ server: name, descriptor: r });
6579
this.statuses.push({ name, connected: true, toolCount: descriptors.length });
6680
} catch (err) {
6781
client.close();
@@ -86,6 +100,18 @@ export class McpManager {
86100
return this.statuses;
87101
}
88102

103+
/** Every resource discovered across connected servers. */
104+
resources(): readonly McpResourceRef[] {
105+
return this.resourceList;
106+
}
107+
108+
/** Read a resource by server name + URI. Throws if the server isn't connected. */
109+
async readResource(server: string, uri: string): Promise<McpReadResourceResult> {
110+
const client = this.clientsByName.get(server);
111+
if (!client) throw new Error(`MCP server "${server}" is not connected`);
112+
return client.readResource(uri);
113+
}
114+
89115
/** Terminate every server connection. Idempotent. */
90116
dispose(): void {
91117
for (const client of this.clients) client.close();

src/mcp/protocol.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,27 @@ export interface McpCallToolResult {
5454
[key: string]: unknown;
5555
}
5656

57+
/** A resource as described by a server's resources/list response. */
58+
export interface McpResourceDescriptor {
59+
uri: string;
60+
name?: string;
61+
description?: string;
62+
mimeType?: string;
63+
}
64+
65+
/** One content chunk of a resources/read result — text or base64 blob. */
66+
export interface McpResourceContent {
67+
uri?: string;
68+
mimeType?: string;
69+
text?: string;
70+
blob?: string;
71+
}
72+
73+
export interface McpReadResourceResult {
74+
contents?: McpResourceContent[];
75+
[key: string]: unknown;
76+
}
77+
5778
/** Parse one line of stdio output into a JSON-RPC message, or null if unparseable. */
5879
export function parseRpcLine(line: string): JsonRpcResponse | JsonRpcNotification | null {
5980
const trimmed = line.trim();

src/mcp/stdio-client.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
type JsonRpcResponse,
77
MCP_PROTOCOL_VERSION,
88
type McpCallToolResult,
9+
type McpReadResourceResult,
10+
type McpResourceDescriptor,
911
type McpToolDescriptor,
1012
parseRpcLine,
1113
} from "./protocol.js";
@@ -89,6 +91,23 @@ export class StdioMcpClient implements McpClient {
8991
return (res.result as McpCallToolResult) ?? {};
9092
}
9193

94+
/** List resources; [] when the server lacks a resources capability. */
95+
async listResources(): Promise<McpResourceDescriptor[]> {
96+
try {
97+
const res = await this.request("resources/list", {});
98+
const result = res.result as { resources?: McpResourceDescriptor[] } | undefined;
99+
return Array.isArray(result?.resources) ? result.resources : [];
100+
} catch {
101+
return [];
102+
}
103+
}
104+
105+
/** Read one resource by URI. */
106+
async readResource(uri: string): Promise<McpReadResourceResult> {
107+
const res = await this.request("resources/read", { uri });
108+
return (res.result as McpReadResourceResult) ?? {};
109+
}
110+
92111
/** Terminate the subprocess and reject any in-flight requests. */
93112
close(): void {
94113
if (this.closed) return;

src/permissions/store.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ const ALWAYS_ALLOWED: ReadonlySet<string> = new Set([
122122
// present_copy only surfaces a click-to-copy box in the UI — no fs,
123123
// shell, or network. Prompting for it would be pure friction.
124124
"present_copy",
125+
// MCP resource reads are read-only fetches from already-trusted servers.
126+
"list_mcp_resources",
127+
"read_mcp_resource",
125128
]);
126129

127130
export interface PermissionStoreOptions {

0 commit comments

Comments
 (0)