From 11029b72317ede4a07ccea407ea5e4d56c2647f2 Mon Sep 17 00:00:00 2001 From: vreshch Date: Mon, 6 Jul 2026 11:35:23 +0200 Subject: [PATCH] feat: surface engine secret refusal as an isError tool result The write/edit tool descriptions promise secrets are refused; the engine (@agentage/memory-core 0.1.3) now enforces it and throws RestrictedContentError. Map it in the tool guard to an isError result carrying the canonical refusal message, alongside the existing unknown-vault handling. Read output is bounded by the engine, so the read handler needs no change. Bumps @agentage/memory-core to ^0.1.3 and version to 0.0.3. --- package-lock.json | 14 ++-- package.json | 4 +- src/server/register-tools.ts | 8 +- test/restricted-and-budget.test.ts | 113 +++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 11 deletions(-) create mode 100644 test/restricted-and-budget.test.ts diff --git a/package-lock.json b/package-lock.json index 21a7a09..9a4334e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@agentage/server-memory", - "version": "0.0.2", + "version": "0.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agentage/server-memory", - "version": "0.0.2", + "version": "0.0.3", "license": "MIT", "dependencies": { - "@agentage/memory-core": "^0.1.0", + "@agentage/memory-core": "^0.1.3", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.4.3" }, @@ -34,10 +34,10 @@ } }, "node_modules/@agentage/memory-core": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@agentage/memory-core/-/memory-core-0.1.0.tgz", - "integrity": "sha512-ontC1Zs1YRgmGtrBuZ9qIB5VYPabYdiJhr/FSjsFPvnT6Gn3jaLZJP/QIdJzNlB8upNydByiuP7BfUkjRDc0VA==", - "license": "UNLICENSED", + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@agentage/memory-core/-/memory-core-0.1.3.tgz", + "integrity": "sha512-BFzh/VoP5zm9j7CwazCRJRyqHT6PzImbaDqmKi8Iay3QX6TKYmWCIg1pXxy02yuJdSou4NR6FX5r8ezVSmBhhA==", + "license": "MIT", "dependencies": { "yaml": "^2.7.0", "zod": "^4.4.3" diff --git a/package.json b/package.json index 5f85b32..edc3c11 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agentage/server-memory", - "version": "0.0.2", + "version": "0.0.3", "description": "The agentage Memory MCP server: exposes your local vaults as the frozen 6 memory__* tools over stdio. The open, cross-vendor counterpart to @modelcontextprotocol/server-memory.", "type": "module", "license": "MIT", @@ -41,7 +41,7 @@ "prepublishOnly": "npm run verify" }, "dependencies": { - "@agentage/memory-core": "^0.1.0", + "@agentage/memory-core": "^0.1.3", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.4.3" }, diff --git a/src/server/register-tools.ts b/src/server/register-tools.ts index 53a87a9..6c541f0 100644 --- a/src/server/register-tools.ts +++ b/src/server/register-tools.ts @@ -1,6 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { + RestrictedContentError, UnknownVaultError, type EditInput, type ListQuery, @@ -44,12 +45,15 @@ const notFound = ( hint = 'Use memory__search to find the right path, or memory__list to browse.' ): CallToolResult => isError(`No memory at path "${path}". ${hint}`); -// Map an UnknownVaultError to a tool error; let other throws propagate to the SDK. +// Map an unknown-vault or restricted-content refusal to a tool error (the engine's +// write/edit path refuses secrets); let other throws propagate to the SDK. const guard = async (fn: () => Promise): Promise => { try { return await fn(); } catch (e) { - if (e instanceof UnknownVaultError) return isError(e.message); + if (e instanceof UnknownVaultError || e instanceof RestrictedContentError) { + return isError(e.message); + } throw e; } }; diff --git a/test/restricted-and-budget.test.ts b/test/restricted-and-budget.test.ts new file mode 100644 index 0000000..3833a2e --- /dev/null +++ b/test/restricted-and-budget.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import { READ_BODY_BUDGET, restrictedMessage } from '@agentage/memory-core'; +import { call, connect } from './fixtures/mcp.js'; +import { tmpVault } from './fixtures/index.js'; + +// The engine refuses secrets on write/edit; the MCP layer surfaces the refusal as an +// isError tool result carrying the canonical message (never a raw protocol crash). +describe('secret refusal over MCP', () => { + it('write with a secret returns isError + the canonical message', async () => { + const c = await connect({ work: { path: tmpVault() } }, { default: 'work' }); + try { + const w = await call(c.client, 'memory__write', { + path: 'creds.md', + body: 'the key is AKIAIOSFODNN7EXAMPLE, keep it safe', + }); + expect(w.isError).toBe(true); + expect(w.text).toBe(restrictedMessage('an API key or access token')); + + // nothing was persisted + const r = await call(c.client, 'memory__read', { path: 'creds.md' }); + expect(r.isError).toBe(true); + } finally { + await c.close(); + } + }); + + it('write with a secret in frontmatter is refused', async () => { + const c = await connect({ work: { path: tmpVault() } }, { default: 'work' }); + try { + const w = await call(c.client, 'memory__write', { + path: 'meta.md', + body: 'clean body', + frontmatter: { api_key: 'sk-abcdefghijklmnopqrstuvwxyz012345' }, + }); + expect(w.isError).toBe(true); + expect(w.text).toBe(restrictedMessage('an API key or access token')); + } finally { + await c.close(); + } + }); + + it('edit str_replace injecting a secret is refused, note untouched', async () => { + const c = await connect( + { work: { path: tmpVault({ 'n.md': 'placeholder' }) } }, + { default: 'work' } + ); + try { + const e = await call(c.client, 'memory__edit', { + path: 'n.md', + mode: 'str_replace', + old_str: 'placeholder', + new_str: 'password: hunter2secret', + }); + expect(e.isError).toBe(true); + expect(e.text).toBe(restrictedMessage('a password or secret value')); + + const r = await call(c.client, 'memory__read', { path: 'n.md' }); + expect((r.structured as { body: string }).body).toBe('placeholder'); + } finally { + await c.close(); + } + }); + + it('clean write and edit still succeed', async () => { + const c = await connect({ work: { path: tmpVault() } }, { default: 'work' }); + try { + const w = await call(c.client, 'memory__write', { + path: 'ok.md', + body: 'notes on the login flow', + }); + expect(w.isError).toBe(false); + const e = await call(c.client, 'memory__edit', { + path: 'ok.md', + mode: 'append', + body: 'more clean notes', + }); + expect(e.isError).toBe(false); + } finally { + await c.close(); + } + }); +}); + +// read output is bounded to the engine budget in BOTH channels (text + structuredContent). +describe('read output budget over MCP', () => { + it('clamps an oversized body and marks it; the stored file stays complete', async () => { + const big = 'x'.repeat(READ_BODY_BUDGET + 8192); + const c = await connect({ work: { path: tmpVault({ 'big.md': big }) } }, { default: 'work' }); + try { + const r = await call(c.client, 'memory__read', { path: 'big.md' }); + const body = (r.structured as { body: string }).body; + expect(Buffer.byteLength(body, 'utf8')).toBeLessThan(READ_BODY_BUDGET + 512); + expect(body).toContain('The stored memory file is complete and unchanged.'); + expect(r.text).toContain('The stored memory file is complete and unchanged.'); + } finally { + await c.close(); + } + }); + + it('returns a small body verbatim (no marker)', async () => { + const c = await connect( + { work: { path: tmpVault({ 'small.md': 'tiny note' }) } }, + { default: 'work' } + ); + try { + const r = await call(c.client, 'memory__read', { path: 'small.md' }); + expect((r.structured as { body: string }).body).toBe('tiny note'); + expect(r.text).not.toContain('Truncated for display'); + } finally { + await c.close(); + } + }); +});