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
24 changes: 14 additions & 10 deletions src/mcp/standalone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createStdioTransport } from "./transport.js";
import { getVisibleTools } from "./tools-registry.js";
import { getStandalonePersistPath } from "../config.js";
import { VERSION } from "../version.js";
import { generateId } from "../state/schema.js";

const IMPLEMENTED_TOOLS = new Set([
"memory_save",
Expand All @@ -22,16 +23,18 @@ const SERVER_INFO = {

const kv = new InMemoryKV(getStandalonePersistPath());

async function handleToolCall(
export async function handleToolCall(
toolName: string,
args: Record<string, unknown>,
kvInstance: InMemoryKV = kv,
): Promise<{ content: Array<{ type: string; text: string }> }> {
switch (toolName) {
case "memory_save": {
const content = args.content as string;
if (!content?.trim()) throw new Error("content is required");
const id = `mem_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
await kv.set("mem:memories", id, {
const id = generateId("mem");
const isoNow = new Date().toISOString();
await kvInstance.set("mem:memories", id, {
id,
type: (args.type as string) || "fact",
title: content.slice(0, 80),
Expand All @@ -42,13 +45,14 @@ async function handleToolCall(
files: args.files
? (args.files as string).split(",").map((f) => f.trim())
: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
createdAt: isoNow,
updatedAt: isoNow,
strength: 7,
version: 1,
isLatest: true,
sessionIds: [],
});
kvInstance.persist();
return {
content: [{ type: "text", text: JSON.stringify({ saved: id }) }],
};
Expand All @@ -57,7 +61,7 @@ async function handleToolCall(
case "memory_recall": {
const query = (args.query as string)?.toLowerCase() || "";
const limit = (args.limit as number) || 10;
const all = await kv.list<Record<string, unknown>>("mem:memories");
const all = await kvInstance.list<Record<string, unknown>>("mem:memories");
const results = all
.filter((m) => {
const text = `${m.title} ${m.content}`.toLowerCase();
Expand All @@ -70,7 +74,7 @@ async function handleToolCall(
}

case "memory_sessions": {
const sessions = await kv.list("mem:sessions");
const sessions = await kvInstance.list("mem:sessions");
return {
content: [
{ type: "text", text: JSON.stringify({ sessions }, null, 2) },
Expand All @@ -79,8 +83,8 @@ async function handleToolCall(
}

case "memory_export": {
const memories = await kv.list("mem:memories");
const sessions = await kv.list("mem:sessions");
const memories = await kvInstance.list("mem:memories");
const sessions = await kvInstance.list("mem:sessions");
return {
content: [
{
Expand All @@ -96,7 +100,7 @@ async function handleToolCall(
}

case "memory_audit": {
const entries = await kv.list("mem:audit");
const entries = await kvInstance.list("mem:audit");
const limit = (args.limit as number) || 50;
return {
content: [
Expand Down
58 changes: 58 additions & 0 deletions test/mcp-standalone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,21 @@ vi.mock("node:fs", () => ({
mkdirSync: vi.fn(),
}));

vi.mock("../src/mcp/transport.js", () => ({
createStdioTransport: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
}));

vi.mock("../src/config.js", () => ({
getStandalonePersistPath: vi.fn(() => "/tmp/test-standalone.json"),
}));

import {
getAllTools,
CORE_TOOLS,
V040_TOOLS,
} from "../src/mcp/tools-registry.js";
import { InMemoryKV } from "../src/mcp/in-memory-kv.js";
import { handleToolCall } from "../src/mcp/standalone.js";
import { writeFileSync } from "node:fs";

describe("Tools Registry", () => {
Expand Down Expand Up @@ -111,3 +120,52 @@ describe("InMemoryKV", () => {
expect(list.length).toBe(1);
});
});

describe("handleToolCall", () => {
beforeEach(() => {
vi.mocked(writeFileSync).mockClear();
});

it("memory_save persists to disk immediately after saving", async () => {
const kv = new InMemoryKV("/tmp/test-handle.json");
const result = await handleToolCall(
"memory_save",
{ content: "Test memory content" },
kv,
);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.saved).toMatch(/^mem_/);
expect(writeFileSync).toHaveBeenCalledWith(
"/tmp/test-handle.json",
expect.any(String),
"utf-8",
);
});

it("memory_save without persist path does not call writeFileSync", async () => {
const kv = new InMemoryKV();
await handleToolCall("memory_save", { content: "No persist path" }, kv);
expect(writeFileSync).not.toHaveBeenCalled();
});

it("memory_save throws when content is missing", async () => {
const kv = new InMemoryKV();
await expect(
handleToolCall("memory_save", {}, kv),
).rejects.toThrow("content is required");
});

it("memory_recall returns matching memories", async () => {
const kv = new InMemoryKV();
await handleToolCall("memory_save", { content: "TypeScript is great" }, kv);
await handleToolCall("memory_save", { content: "Python is also great" }, kv);
const result = await handleToolCall(
"memory_recall",
{ query: "typescript" },
kv,
);
const memories = JSON.parse(result.content[0].text);
expect(memories).toHaveLength(1);
expect(memories[0].content).toBe("TypeScript is great");
});
});