Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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"
},
Expand Down
8 changes: 6 additions & 2 deletions src/server/register-tools.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<CallToolResult>): Promise<CallToolResult> => {
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;
}
};
Expand Down
113 changes: 113 additions & 0 deletions test/restricted-and-budget.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});