diff --git a/CHANGELOG.md b/CHANGELOG.md index 5500e1a..feb040a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ### Added +- **`mcpl_list` reports manifest freshness.** Each loaded server now shows the + last validated manifest revision plus fetch and grant-negotiation timestamps. + Older Agent Framework versions remain legible as `manifest=unknown`, and the + server-authored revision is quoted and bounded before reaching model-facing + text. + - **`BEDROCK_BASE_URL` env hook** for the bedrock provider — mirrors `ANTHROPIC_BASE_URL`, routing bedrock-runtime calls through an inference gateway (gate.animalabs.ai/bedrock/). The gate reads the agent diff --git a/src/modules/mcpl-admin-module.ts b/src/modules/mcpl-admin-module.ts index 53f0999..bae6eb1 100644 --- a/src/modules/mcpl-admin-module.ts +++ b/src/modules/mcpl-admin-module.ts @@ -120,7 +120,8 @@ export class McplAdminModule implements Module { description: 'List all MCPL servers: connection/retry state, whether policy was established, ' + 'the effective grant, masked/denied capability paths, host-command authority, ' + - 'tool count, target, and config source.', + 'validated manifest revision/fetch/negotiation freshness, tool count, target, ' + + 'and config source.', inputSchema: { type: 'object', properties: {} }, }, { @@ -246,6 +247,11 @@ export class McplAdminModule implements Module { maskedCapabilities?: string[]; deniedCapabilities?: string[]; allowHostCommands?: boolean; + manifestState?: { + lastValidatedRevision: string | null; + lastFetchedAt: number | null; + lastNegotiatedAt: number | null; + }; } >; const overlay = readAgentOverlay(this.overlayPath); @@ -269,7 +275,8 @@ export class McplAdminModule implements Module { `grant=${formatCapabilityList(s.effectiveGrant)}, ` + `masked=${formatCapabilityList(s.maskedCapabilities)}, ` + `denied=${formatCapabilityList(s.deniedCapabilities)}, ` + - `hostCommands=${hostCommands}; ${s.toolCount} tools, ` + + `hostCommands=${hostCommands}, ` + + `manifest=${formatManifestState(s.manifestState)}; ${s.toolCount} tools, ` + `prefix=${s.toolPrefix}, source=${source}, ${target}`, ); } @@ -421,3 +428,29 @@ function formatCapabilityList(paths: string[] | undefined): string { if (paths === undefined) return 'unknown'; return `[${paths.join(',')}]`; } + +function formatManifestState(state: { + lastValidatedRevision: string | null; + lastFetchedAt: number | null; + lastNegotiatedAt: number | null; +} | undefined): string { + if (state === undefined) return 'unknown'; + return `{revision=${formatManifestRevision(state.lastValidatedRevision)},` + + `fetchedAt=${formatManifestTimestamp(state.lastFetchedAt)},` + + `negotiatedAt=${formatManifestTimestamp(state.lastNegotiatedAt)}}`; +} + +/** Bound and quote the server-authored, equality-only revision before putting + * it on a model-facing text surface. Conforming revisions fit well below the + * limit; malformed peers cannot inject control lines or unbounded text. */ +function formatManifestRevision(revision: string | null): string { + if (revision === null) return 'none'; + const bounded = revision.length <= 64 ? revision : `${revision.slice(0, 61)}...`; + return JSON.stringify(bounded); +} + +function formatManifestTimestamp(timestamp: number | null): string { + if (timestamp === null) return 'none'; + const date = new Date(timestamp); + return Number.isNaN(date.getTime()) ? 'invalid' : date.toISOString(); +} diff --git a/test/mcpl-admin-module.test.ts b/test/mcpl-admin-module.test.ts index 178e819..7be56d1 100644 --- a/test/mcpl-admin-module.test.ts +++ b/test/mcpl-admin-module.test.ts @@ -23,6 +23,11 @@ interface StubServer { maskedCapabilities: string[]; deniedCapabilities: string[]; allowHostCommands: boolean; + manifestState?: { + lastValidatedRevision: string | null; + lastFetchedAt: number | null; + lastNegotiatedAt: number | null; + }; command?: string; url?: string; } @@ -46,6 +51,11 @@ function makeStubFramework() { maskedCapabilities: ['channels.streaming'], deniedCapabilities: ['contextHooks.beforeInference.inject.system'], allowHostCommands: false, + manifestState: { + lastValidatedRevision: 'sha256:validated', + lastFetchedAt: Date.parse('2026-08-05T01:02:03.000Z'), + lastNegotiatedAt: Date.parse('2026-08-05T01:02:04.000Z'), + }, command: config.command, url: config.url, }); @@ -69,6 +79,11 @@ function makeStubFramework() { maskedCapabilities: ['channels.streaming'], deniedCapabilities: ['contextHooks.beforeInference.inject.system'], allowHostCommands: false, + manifestState: { + lastValidatedRevision: 'sha256:validated', + lastFetchedAt: Date.parse('2026-08-05T01:02:03.000Z'), + lastNegotiatedAt: Date.parse('2026-08-05T01:02:04.000Z'), + }, command: config?.command ?? prev?.command, }); }, @@ -230,7 +245,33 @@ describe('mcpl_list', () => { expect(text).toContain('masked=[channels.streaming]'); expect(text).toContain('denied=[contextHooks.beforeInference.inject.system]'); expect(text).toContain('hostCommands=deny'); + expect(text).toContain( + 'manifest={revision="sha256:validated",' + + 'fetchedAt=2026-08-05T01:02:03.000Z,' + + 'negotiatedAt=2026-08-05T01:02:04.000Z}', + ); expect(text).toContain('source=agent-overlay'); expect(text).toContain('gone: UNLOADED'); }); + + test('distinguishes older-framework unknown and bounds untrusted revisions', async () => { + const { stub, servers } = makeStubFramework(); + await (stub as unknown as { connectMcplServer: (c: { id: string; command: string }) => Promise }) + .connectMcplServer({ id: 'discord', command: 'node' }); + const mod = makeModule(stub); + + const server = servers.get('discord')!; + delete server.manifestState; + expect(String((await call(mod, 'mcpl_list')).data)).toContain('manifest=unknown'); + + server.manifestState = { + lastValidatedRevision: `unsafe\n${'x'.repeat(100)}`, + lastFetchedAt: null, + lastNegotiatedAt: null, + }; + const text = String((await call(mod, 'mcpl_list')).data); + expect(text).toContain('manifest={revision="unsafe\\n'); + expect(text).not.toContain('unsafe\n'); + expect(text).toContain('...",fetchedAt=none,negotiatedAt=none}'); + }); });