Skip to content

Commit ad01651

Browse files
committed
fix(devframe): enforce MCP state exposure policy
1 parent a1eb300 commit ad01651

3 files changed

Lines changed: 88 additions & 32 deletions

File tree

‎packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts‎

Lines changed: 77 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ function nullHost(): DevframeHost {
1212
}
1313
}
1414

15-
async function bootPair() {
15+
async function bootPair(exposeSharedState: boolean | ((key: string) => boolean) = true) {
1616
const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() })
1717

1818
const server = buildMcpServerFromContext(ctx, {
1919
serverName: 'test',
2020
serverVersion: '0.0.0-test',
21-
exposeSharedState: true,
21+
exposeSharedState,
2222
})
2323

2424
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
@@ -312,49 +312,98 @@ describe('mcp adapter (in-memory)', () => {
312312
})
313313

314314
it('hides devframe:state:read when shared-state exposure is disabled', async () => {
315-
const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() })
316-
const server = buildMcpServerFromContext(ctx, {
317-
serverName: 'test',
318-
serverVersion: '0.0.0-test',
319-
exposeSharedState: false,
320-
})
321-
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
322-
await server.connect(serverTransport)
323-
const client = new Client({ name: 'test-client', version: '0.0.0' })
324-
await client.connect(clientTransport)
315+
const { client, cleanup } = await bootPair(false)
325316
try {
326317
const listed = await client.listTools()
327318
expect(listed.tools.map(t => t.name)).not.toContain('devframe_state_read')
328319
}
329320
finally {
330-
await client.close()
331-
await server.close()
321+
await cleanup()
332322
}
333323
})
334324

335325
it('respects the shared-state filter in devframe:state:read', async () => {
336-
const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() })
337-
await ctx.rpc.sharedState.get('visible:key', { initialValue: { n: 1 } })
338-
await ctx.rpc.sharedState.get('hidden:key', { initialValue: { n: 2 } })
339-
const server = buildMcpServerFromContext(ctx, {
340-
serverName: 'test',
341-
serverVersion: '0.0.0-test',
342-
exposeSharedState: key => key.startsWith('visible:'),
343-
})
344-
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
345-
await server.connect(serverTransport)
346-
const client = new Client({ name: 'test-client', version: '0.0.0' })
347-
await client.connect(clientTransport)
326+
const { ctx, client, cleanup } = await bootPair(key => key.startsWith('visible:'))
348327
try {
328+
await ctx.rpc.sharedState.get('visible:key', { initialValue: { n: 1 } })
329+
await ctx.rpc.sharedState.get('hidden:key', { initialValue: { n: 2 } })
330+
349331
const keys = await client.callTool({ name: 'devframe_state_read', arguments: {} })
350332
expect(keys.structuredContent).toEqual({ keys: ['visible:key'] })
351333

352334
const hidden = await client.callTool({ name: 'devframe_state_read', arguments: { key: 'hidden:key' } })
353335
expect(hidden.isError).toBe(true)
354336
}
355337
finally {
356-
await client.close()
357-
await server.close()
338+
await cleanup()
339+
}
340+
})
341+
342+
it('omits state resources and rejects a direct URI read when exposure is disabled', async () => {
343+
const { ctx, client, cleanup } = await bootPair(false)
344+
try {
345+
await ctx.rpc.sharedState.get('alpha:key', { initialValue: { n: 1 } })
346+
347+
const listed = await client.listResources()
348+
expect(listed.resources.some(r => r.uri.startsWith('devframe://state/'))).toBe(false)
349+
350+
// A caller that knows the key must not bypass the policy by reading the
351+
// URI directly.
352+
await expect(
353+
client.readResource({ uri: `devframe://state/${encodeURIComponent('alpha:key')}` }),
354+
).rejects.toThrow()
355+
}
356+
finally {
357+
await cleanup()
358+
}
359+
})
360+
361+
it('applies the filter to direct URI reads, not only discovery', async () => {
362+
const { ctx, client, cleanup } = await bootPair(key => key.startsWith('visible:'))
363+
try {
364+
await ctx.rpc.sharedState.get('visible:key', { initialValue: { n: 1 } })
365+
await ctx.rpc.sharedState.get('hidden:key', { initialValue: { n: 2 } })
366+
367+
const listed = await client.listResources()
368+
const stateUris = listed.resources.filter(r => r.uri.startsWith('devframe://state/')).map(r => r.uri)
369+
expect(stateUris).toEqual([`devframe://state/${encodeURIComponent('visible:key')}`])
370+
371+
const allowed = await client.readResource({ uri: `devframe://state/${encodeURIComponent('visible:key')}` })
372+
const c = allowed.contents[0] as { text: string }
373+
expect(JSON.parse(c.text)).toEqual({ n: 1 })
374+
375+
// Known key, denied by predicate — rejected before storage access.
376+
await expect(
377+
client.readResource({ uri: `devframe://state/${encodeURIComponent('hidden:key')}` }),
378+
).rejects.toThrow()
379+
}
380+
finally {
381+
await cleanup()
382+
}
383+
})
384+
385+
it('agrees between the state-read tool and the resource path for the same policy', async () => {
386+
const { ctx, client, cleanup } = await bootPair(key => key.startsWith('visible:'))
387+
try {
388+
await ctx.rpc.sharedState.get('visible:key', { initialValue: { n: 1 } })
389+
await ctx.rpc.sharedState.get('hidden:key', { initialValue: { n: 2 } })
390+
391+
// Allowed key: both paths return the same value.
392+
const toolValue = await client.callTool({ name: 'devframe_state_read', arguments: { key: 'visible:key' } })
393+
expect(toolValue.structuredContent).toEqual({ key: 'visible:key', value: { n: 1 } })
394+
const resourceValue = await client.readResource({ uri: `devframe://state/${encodeURIComponent('visible:key')}` })
395+
const c = resourceValue.contents[0] as { text: string }
396+
expect(JSON.parse(c.text)).toEqual({ n: 1 })
397+
398+
// Denied key: both paths reject.
399+
const toolHidden = await client.callTool({ name: 'devframe_state_read', arguments: { key: 'hidden:key' } })
400+
expect(toolHidden.isError).toBe(true)
401+
await expect(
402+
client.readResource({ uri: `devframe://state/${encodeURIComponent('hidden:key')}` }),
403+
).rejects.toThrow()
404+
}
405+
finally {
406+
await cleanup()
358407
}
359408
})
360409
})

‎packages/devframe/src/adapters/mcp/build-server.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,8 @@ function registerResourceHandlers(
332332
ctx: DevframeNodeContext,
333333
exposeSharedState: boolean | ((key: string) => boolean),
334334
): void {
335+
const stateFilter = sharedStateFilter(exposeSharedState)
336+
335337
server.setRequestHandler('resources/list', async () => {
336338
const resources = ctx.agent.list().resources.map(resource => ({
337339
uri: resource.uri,
@@ -340,10 +342,9 @@ function registerResourceHandlers(
340342
mimeType: resource.mimeType,
341343
}))
342344

343-
if (exposeSharedState !== false) {
344-
const filter = typeof exposeSharedState === 'function' ? exposeSharedState : () => true
345+
if (stateFilter) {
345346
for (const key of ctx.rpc.sharedState.keys()) {
346-
if (!filter(key))
347+
if (!stateFilter(key))
347348
continue
348349
resources.push({
349350
uri: `devframe://state/${encodeURIComponent(key)}`,
@@ -375,6 +376,12 @@ function registerResourceHandlers(
375376
}
376377

377378
if (parsed.kind === 'state') {
379+
// Apply the exposure policy at the read, not only during discovery:
380+
// a caller that knows a filtered key must not bypass it. Deny with the
381+
// same DF0048 the built-in read tool uses, so a denied key is
382+
// indistinguishable from a missing one.
383+
if (!stateFilter || !stateFilter(parsed.key))
384+
throw diagnostics.DF0048({ key: parsed.key })
378385
const state = await ctx.rpc.sharedState.get(parsed.key)
379386
return {
380387
contents: [

‎plans/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Generated by the improve skill on 2026-09-01 at commit `2d978f84`. Execute in th
88
|---|---|---|---|---|---|
99
| 001 | Pin privileged GitHub Actions dependencies | P1 | S | - | TODO |
1010
| 002 | Require authentication on route-based MCP | P1 | M | 001 | TODO |
11-
| 003 | Enforce shared-state exposure policy on direct MCP reads | P1 | S | 002 | TODO |
11+
| 003 | Enforce shared-state exposure policy on direct MCP reads | P1 | S | 002 | DONE |
1212
| 004 | Contain remote asset materialization | P1 | S | - | DONE |
1313
| 005 | Block Data Inspector prototype-chain writes | P1 | S | - | DONE |
1414
| 006 | Validate request-derived authentication-link origins | P1 | M | - | DONE |

0 commit comments

Comments
 (0)