Skip to content

Commit 6cb6287

Browse files
oratisclaude
andauthored
feat(mcp): resource support — list on connect + @server:uri expansion (#115)
Implements MCP resources (DEVELOPMENT_PLAN §3.3 / BEHAVIOR_PARITY): an MCP server's resources can now be referenced from a prompt and their content is injected for the model. core (mcp/client.ts): - connectMcpServer now lists resources on connect (capability-gated; a server without `resources` — or one that errors on resources/list — yields []). McpClientHandle gains `resources: McpResourceMeta[]`. - readMcpResource(handle, uri) — reads a resource, flattens contents to text (binary blobs → `[binary …]` placeholder). - parseResourceRefs(text) — finds `@server:scheme://path` tokens (requires `://`, so it won't match emails or `@user:pass`; dedupes). - expandMcpResourceRefs(text, handles) — reads each referenced resource and appends a `<mcp-resource server=… uri=…>` block, leaving the original token in place; unknown server / read failure surface as errors, never throw. cli: - repl.ts + headless.ts expand `@server:uri` refs in the user message after MCP connect (resolved/error lines to the user). Tests: +5 (connect→list→read→expand end-to-end against a real spawned stdio server with a `resources` capability; parseResourceRefs pure cases incl. email/`@user:pass` rejection + dedup). Core suite 569 green. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3f71f64 commit 6cb6287

6 files changed

Lines changed: 242 additions & 4 deletions

File tree

apps/cli/src/headless.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
buildSkillsDescriptionBlock,
3030
closeAllMcpServers,
3131
connectAllMcpServers,
32+
expandMcpResourceRefs,
3233
contextWindowFor,
3334
findStyle,
3435
loadMemory,
@@ -152,6 +153,16 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
152153
for (const handle of mcpServers) for (const t of handle.tools) tools.register(t);
153154
}
154155

156+
// Expand `@server:scheme://path` MCP resource references in the prompt.
157+
let userMessage = prompt;
158+
if (mcpServers.length > 0) {
159+
const { text, errors } = await expandMcpResourceRefs(prompt, mcpServers);
160+
userMessage = text;
161+
for (const e of errors) {
162+
errOutput.write(`MCP resource @${e.ref.server}:${e.ref.uri}${e.error}\n`);
163+
}
164+
}
165+
155166
// ─── system prompt assembly ─────────────────────────────────────────
156167
let systemPrompt = opts.systemPromptOverride ?? DEFAULT_SYSTEM_PROMPT;
157168
if (memory.text) systemPrompt += '\n\n' + memory.text;
@@ -229,7 +240,7 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
229240
provider,
230241
tools,
231242
systemPrompt,
232-
userMessage: prompt,
243+
userMessage,
233244
history: [],
234245
model,
235246
maxTokens,

apps/cli/src/repl.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
buildSkillsDescriptionBlock,
1818
closeAllMcpServers,
1919
connectAllMcpServers,
20+
expandMcpResourceRefs,
2021
expandCommandBody,
2122
findCustomCommand,
2223
findStyle,
@@ -330,6 +331,17 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
330331
}
331332
}
332333

334+
// Expand `@server:scheme://path` MCP resource references — read each resource
335+
// and append its content as a tagged block the model can use.
336+
if (mcpServers.length > 0) {
337+
const { text, resolved, errors } = await expandMcpResourceRefs(userInput, mcpServers);
338+
userInput = text;
339+
for (const r of resolved) output.write(` ⊞ resource @${r.server}:${r.uri}\n`);
340+
for (const e of errors) {
341+
output.write(` ⚠ resource @${e.ref.server}:${e.ref.uri}${e.error}\n`);
342+
}
343+
}
344+
333345
// Otherwise: send to agent (with mode/permission/hooks gating from M3b)
334346
const result = await runAgent({
335347
provider,

packages/core/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,11 +192,17 @@ export {
192192
serveMcpOverStdio,
193193
mcpServableTools,
194194
MCP_SERVE_EXCLUDE,
195+
readMcpResource,
196+
parseResourceRefs,
197+
expandMcpResourceRefs,
195198
type McpClientHandle,
196199
type McpToolMeta,
200+
type McpResourceMeta,
197201
type ConnectAllResult,
198202
type BuildMcpServerOpts,
199203
type ServeMcpStdioOpts,
204+
type ResourceRef,
205+
type ExpandResourcesResult,
200206
} from './mcp/index.js';
201207

202208
// Plugins (M5 — manifest + hash pin; M5.1 — subprocess runtime + RPC bridge;

packages/core/src/mcp/client.test.ts

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ import { join } from 'node:path';
1313
import {
1414
connectAllMcpServers,
1515
connectMcpServer,
16+
expandMcpResourceRefs,
1617
parseHelperOutput,
18+
parseResourceRefs,
1719
pickTransportKind,
20+
readMcpResource,
1821
} from './client.js';
1922

2023
const require_ = createRequire(import.meta.url);
@@ -32,8 +35,28 @@ const TYPES_INDEX = join(SDK_PKG_PATH, 'dist/esm/types.js');
3235
* We import the SDK by absolute path so the spawned script doesn't have to
3336
* resolve `@modelcontextprotocol/sdk` from /tmp (which lacks node_modules).
3437
*/
35-
async function writeFakeServer(dir: string, name: string, tools: object[]): Promise<string> {
38+
async function writeFakeServer(
39+
dir: string,
40+
name: string,
41+
tools: object[],
42+
resources?: Array<{ uri: string; name?: string; text: string; mimeType?: string }>,
43+
): Promise<string> {
3644
const serverPath = join(dir, `${name}.mjs`);
45+
const caps = resources ? '{ tools: {}, resources: {} }' : '{ tools: {} }';
46+
const resourceBlock = resources
47+
? `
48+
import { ListResourcesRequestSchema, ReadResourceRequestSchema } from '${TYPES_INDEX}';
49+
const RESOURCES = ${JSON.stringify(resources)};
50+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
51+
resources: RESOURCES.map((r) => ({ uri: r.uri, name: r.name, mimeType: r.mimeType })),
52+
}));
53+
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
54+
const found = RESOURCES.find((r) => r.uri === req.params.uri);
55+
if (!found) throw new Error('no such resource: ' + req.params.uri);
56+
return { contents: [{ uri: found.uri, mimeType: found.mimeType ?? 'text/plain', text: found.text }] };
57+
});
58+
`
59+
: '';
3760
await fs.writeFile(
3861
serverPath,
3962
`
@@ -43,7 +66,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '${TYPES_INDEX}';
4366
4467
const server = new Server(
4568
{ name: '${name}', version: '0.0.1' },
46-
{ capabilities: { tools: {} } },
69+
{ capabilities: ${caps} },
4770
);
4871
4972
const TOOLS = ${JSON.stringify(tools)};
@@ -55,7 +78,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
5578
content: [{ type: 'text', text: 'called: ' + req.params.name + ' args: ' + argsStr }],
5679
};
5780
});
58-
81+
${resourceBlock}
5982
await server.connect(new StdioServerTransport());
6083
`,
6184
'utf8',
@@ -192,6 +215,47 @@ describe('MCP client', () => {
192215
it('rejects a server config with neither command nor url', async () => {
193216
await expect(connectMcpServer('bad', {})).rejects.toThrow(/command.*url|url.*command/);
194217
});
218+
219+
it('lists resources on connect, reads them, and expands @server:uri refs', async () => {
220+
const serverScript = await writeFakeServer(
221+
tmp,
222+
'docs',
223+
[{ name: 'noop', description: 'd', inputSchema: { type: 'object', properties: {} } }],
224+
[
225+
{ uri: 'file:///readme.md', name: 'readme', text: '# Hello\nbody text' },
226+
{ uri: 'mem://note', name: 'note', text: 'a short note' },
227+
],
228+
);
229+
const handle = await connectMcpServer('docs', { command: 'node', args: [serverScript] });
230+
try {
231+
// resources/list populated the handle
232+
expect(handle.resources.map((r) => r.uri).sort()).toEqual([
233+
'file:///readme.md',
234+
'mem://note',
235+
]);
236+
237+
// readMcpResource flattens contents to text
238+
expect(await readMcpResource(handle, 'file:///readme.md')).toContain('# Hello');
239+
240+
// expandMcpResourceRefs appends a tagged block, keeps the original token
241+
const { text, resolved, errors } = await expandMcpResourceRefs(
242+
'please summarize @docs:file:///readme.md now',
243+
[handle],
244+
);
245+
expect(resolved).toHaveLength(1);
246+
expect(errors).toHaveLength(0);
247+
expect(text).toContain('@docs:file:///readme.md'); // original kept
248+
expect(text).toContain('<mcp-resource server="docs" uri="file:///readme.md">');
249+
expect(text).toContain('body text');
250+
251+
// unknown server + bad uri surface as errors, not throws
252+
const r2 = await expandMcpResourceRefs('@nope:file:///x and @docs:mem://missing', [handle]);
253+
expect(r2.errors).toHaveLength(2);
254+
expect(r2.resolved).toHaveLength(0);
255+
} finally {
256+
await handle.close();
257+
}
258+
}, 20_000);
195259
});
196260

197261
describe('pickTransportKind', () => {
@@ -226,6 +290,31 @@ describe('parseHelperOutput', () => {
226290
});
227291
});
228292

293+
describe('parseResourceRefs', () => {
294+
it('finds @server:scheme://path references', () => {
295+
const refs = parseResourceRefs(
296+
'look at @files:file:///etc/hosts and @db:postgres://h/t please',
297+
);
298+
expect(refs).toEqual([
299+
{ raw: '@files:file:///etc/hosts', server: 'files', uri: 'file:///etc/hosts' },
300+
{ raw: '@db:postgres://h/t', server: 'db', uri: 'postgres://h/t' },
301+
]);
302+
});
303+
304+
it('ignores @user:pass and emails (no scheme://)', () => {
305+
expect(parseResourceRefs('email me@host.com or @user:secret')).toEqual([]);
306+
});
307+
308+
it('dedupes repeated references', () => {
309+
const refs = parseResourceRefs('@a:x://1 then again @a:x://1');
310+
expect(refs).toHaveLength(1);
311+
});
312+
313+
it('returns [] when there are no references', () => {
314+
expect(parseResourceRefs('just plain text')).toEqual([]);
315+
});
316+
});
317+
229318
// Silence unused-import warning — Server/Transport are used via the spawned script
230319
void Server;
231320
void StdioServerTransport;

packages/core/src/mcp/client.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,23 @@ export interface McpToolMeta {
2828

2929
export type McpTransportKind = 'stdio' | 'http' | 'sse';
3030

31+
/** A resource a server exposes (from resources/list). */
32+
export interface McpResourceMeta {
33+
uri: string;
34+
name?: string;
35+
description?: string;
36+
mimeType?: string;
37+
}
38+
3139
export interface McpClientHandle {
3240
serverName: string;
3341
client: Client;
3442
transport: Transport;
3543
/** Which transport this connection uses. */
3644
transportKind: McpTransportKind;
3745
tools: ToolHandler[];
46+
/** Resources the server advertised (empty if it has no `resources` capability). */
47+
resources: McpResourceMeta[];
3848
close(): Promise<void>;
3949
}
4050

@@ -176,18 +186,122 @@ export async function connectMcpServer(
176186
};
177187
});
178188

189+
// Resources (best-effort, capability-gated). A server without the `resources`
190+
// capability — or one that errors on resources/list — just yields [].
191+
let resources: McpResourceMeta[] = [];
192+
if (client.getServerCapabilities()?.resources) {
193+
try {
194+
const r = await client.listResources();
195+
resources = (r.resources ?? []).map((res) => ({
196+
uri: res.uri,
197+
name: res.name,
198+
description: res.description,
199+
mimeType: res.mimeType,
200+
}));
201+
} catch {
202+
/* server advertised resources but list failed — degrade to none */
203+
}
204+
}
205+
179206
return {
180207
serverName,
181208
client,
182209
transport,
183210
transportKind: kind,
184211
tools,
212+
resources,
185213
async close() {
186214
await client.close();
187215
},
188216
};
189217
}
190218

219+
/**
220+
* Read an MCP resource by URI and flatten its contents to text. Binary blobs are
221+
* rendered as a `[binary …]` placeholder (the model can't use raw base64).
222+
*/
223+
export async function readMcpResource(handle: McpClientHandle, uri: string): Promise<string> {
224+
const result = await handle.client.readResource({ uri });
225+
const parts = (result.contents ?? []).map((c) => {
226+
if ('text' in c && typeof c.text === 'string') return c.text;
227+
if ('blob' in c && typeof c.blob === 'string') {
228+
return `[binary ${c.mimeType ?? 'application/octet-stream'} ${c.uri}]`;
229+
}
230+
return '';
231+
});
232+
return parts.filter(Boolean).join('\n');
233+
}
234+
235+
/** A parsed `@server:scheme://path` resource reference found in user text. */
236+
export interface ResourceRef {
237+
/** The full matched token, e.g. `@files:file:///etc/hosts`. */
238+
raw: string;
239+
server: string;
240+
uri: string;
241+
}
242+
243+
// `@<server>:<scheme>://<rest>` — requires `://` so it can't match `@user:pass`
244+
// or an email. Server names are the settings.mcpServers keys (word chars + `-`).
245+
const RESOURCE_REF_RE = /@([A-Za-z0-9_-]+):([A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s]+)/g;
246+
247+
/** Find all `@server:scheme://path` references in `text` (deduped by raw token). */
248+
export function parseResourceRefs(text: string): ResourceRef[] {
249+
const out: ResourceRef[] = [];
250+
const seen = new Set<string>();
251+
for (const m of text.matchAll(RESOURCE_REF_RE)) {
252+
const raw = m[0];
253+
if (seen.has(raw)) continue;
254+
seen.add(raw);
255+
out.push({ raw, server: m[1]!, uri: m[2]! });
256+
}
257+
return out;
258+
}
259+
260+
export interface ExpandResourcesResult {
261+
/** Original text with resolved resource contents appended as tagged blocks. */
262+
text: string;
263+
resolved: ResourceRef[];
264+
errors: Array<{ ref: ResourceRef; error: string }>;
265+
}
266+
267+
/**
268+
* Expand `@server:scheme://path` references by reading each resource and
269+
* appending its content as a `<mcp-resource>` block after the user's text. The
270+
* original tokens are left in place so the model sees what was referenced.
271+
*/
272+
export async function expandMcpResourceRefs(
273+
text: string,
274+
handles: McpClientHandle[],
275+
): Promise<ExpandResourcesResult> {
276+
const refs = parseResourceRefs(text);
277+
if (refs.length === 0) return { text, resolved: [], errors: [] };
278+
279+
const byName = new Map(handles.map((h) => [h.serverName, h]));
280+
const blocks: string[] = [];
281+
const resolved: ResourceRef[] = [];
282+
const errors: Array<{ ref: ResourceRef; error: string }> = [];
283+
284+
for (const ref of refs) {
285+
const handle = byName.get(ref.server);
286+
if (!handle) {
287+
errors.push({ ref, error: `unknown MCP server "${ref.server}"` });
288+
continue;
289+
}
290+
try {
291+
const content = await readMcpResource(handle, ref.uri);
292+
blocks.push(
293+
`<mcp-resource server="${ref.server}" uri="${ref.uri}">\n${content}\n</mcp-resource>`,
294+
);
295+
resolved.push(ref);
296+
} catch (err) {
297+
errors.push({ ref, error: (err as Error).message });
298+
}
299+
}
300+
301+
const expanded = blocks.length > 0 ? `${text}\n\n${blocks.join('\n\n')}` : text;
302+
return { text: expanded, resolved, errors };
303+
}
304+
191305
/**
192306
* Connect to many MCP servers — used at session start by the CLI.
193307
* Failures are individual (one bad server doesn't kill the rest).

packages/core/src/mcp/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,16 @@ export {
99
closeAllMcpServers,
1010
pickTransportKind,
1111
parseHelperOutput,
12+
readMcpResource,
13+
parseResourceRefs,
14+
expandMcpResourceRefs,
1215
type McpClientHandle,
1316
type McpToolMeta,
17+
type McpResourceMeta,
1418
type McpTransportKind,
1519
type ConnectAllResult,
20+
type ResourceRef,
21+
type ExpandResourcesResult,
1622
} from './client.js';
1723

1824
export {

0 commit comments

Comments
 (0)