feat: add memory.patch, trim memory.get, unify the memory write path
memory.get no longer returns the embedding and content_tsv ---------------------------------------------------------- It used a bare select() and returned the raw DB row, while memory.list and memory.search already projected an explicit 9-field shape. On a ~13k-char memory those two internal columns were 55% of the response and pushed it past the MCP tool-output cap, so large memories could not be fetched inline at all. memory.get now returns the same 9 fields as its siblings; user_id is still selected for the authorization check and stripped before responding. memory.patch ------------ memory.update only accepts full replacement, so adding one line to a large document meant resending the whole document — expensive enough that edits were being skipped rather than risk silently truncating shared team documents. memory.patch replaces one exact occurrence of old_string. An absent or ambiguous match is an error, never a silent no-op and never an arbitrary pick; that refusal is what makes the operation safe to hand to an agent. The semantics live in lib/memory-patch.ts as a pure function, free of DB and auth, so both surfaces share them. Shared mutation layer --------------------- The MCP tools and the Web UI Server Actions each reimplemented authorize -> mutate -> re-embed -> CAS -> audit, and had drifted. Both now route through lib/memory-mutations.ts. BEHAVIOUR CHANGE: memory.delete over MCP skipped the project ACL whenever the caller authored the row, so a memory written while a share was rw stayed deletable by its author after an owner downgraded that share to ro. memory.update and the whole Web UI always checked. Authoring a row now grants no standing write privilege on any path. The one deliberate difference between the surfaces is injected as a ProjectResolver: MCP refuses an unknown project key so an agent cannot spawn near-miss projects off a typo, while the Web UI creates one because a person typing a name into a form means to. Tests and lint -------------- Adds vitest. The integration tests run against a real Postgres rather than a mocked DB. The embedder sidecar is the only stub and it is deterministic per-text, so re-embedding is verified by asserting the stored vector actually changed rather than that a mock was called. One test pins that content_tsv is a generated column and therefore cannot rot after a patch — only the embedding needs an explicit recompute. pnpm lint previously dropped into an interactive `next lint` setup prompt and exited 1; ESLint had never been configured here. Replaced with the ESLint CLI and a flat config bridging eslint-config-next through FlatCompat. Clean at --max-warnings=0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Integration tests for memory.get and memory.patch against a REAL
|
||||
* Postgres (pgvector). See CONTRIBUTING/README for spinning up the test
|
||||
* database; without it these tests fail to connect rather than silently
|
||||
* passing.
|
||||
*
|
||||
* The embedder sidecar is the one thing stubbed — it's an external HTTP
|
||||
* service running an ML model. The stub is deterministic per-text, which
|
||||
* lets the re-embedding test assert on the STORED VECTOR CHANGING (real
|
||||
* DB state) rather than on "was the mock called".
|
||||
*/
|
||||
vi.mock("@/lib/embedder", () => ({
|
||||
embedText: async (text: string) => {
|
||||
// Deterministic pseudo-vector: distinct texts produce distinct vectors.
|
||||
let h = 0;
|
||||
for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0;
|
||||
return Array.from({ length: 384 }, (_, i) => ((h + i * 7919) % 1000) / 1000);
|
||||
},
|
||||
embedTexts: async (texts: string[]) => texts.map(() => Array(384).fill(0.1)),
|
||||
embedderReady: async () => true,
|
||||
EmbedderError: class extends Error {},
|
||||
}));
|
||||
|
||||
const { db, pg } = await import("@/lib/db/client");
|
||||
const { memories, projects, users } = await import("@/lib/db/schema");
|
||||
const { toolMap } = await import("@/lib/mcp/tools");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
type UserContext = import("@/lib/mcp/context").UserContext;
|
||||
|
||||
const ORIGINAL = [
|
||||
"# Roadmap",
|
||||
"",
|
||||
"## RECENTLY SHIPPED",
|
||||
"- v1.0 initial release",
|
||||
"",
|
||||
"## IN PROGRESS",
|
||||
"- patch primitive",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
let userId: string;
|
||||
let projectId: string;
|
||||
let memoryId: string;
|
||||
let ctx: UserContext;
|
||||
|
||||
async function seedMemory(content = ORIGINAL): Promise<string> {
|
||||
const row = await db
|
||||
.insert(memories)
|
||||
.values({
|
||||
userId,
|
||||
projectId,
|
||||
scope: "project",
|
||||
content,
|
||||
tags: ["roadmap"],
|
||||
embedding: Array(384).fill(0.5),
|
||||
})
|
||||
.returning({ id: memories.id });
|
||||
return row[0]!.id;
|
||||
}
|
||||
|
||||
async function readContent(id: string): Promise<string> {
|
||||
const r = await db
|
||||
.select({ content: memories.content })
|
||||
.from(memories)
|
||||
.where(eq(memories.id, id));
|
||||
return r[0]!.content;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const u = await db
|
||||
.insert(users)
|
||||
.values({ oidcSub: "test-sub", oidcIss: "http://test", email: "t@example.com" })
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: users.id });
|
||||
userId =
|
||||
u[0]?.id ??
|
||||
(await db.select({ id: users.id }).from(users).limit(1))[0]!.id;
|
||||
|
||||
const p = await db
|
||||
.insert(projects)
|
||||
.values({ userId, key: "test-project", displayName: "Test Project" })
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: projects.id });
|
||||
projectId =
|
||||
p[0]?.id ??
|
||||
(await db.select({ id: projects.id }).from(projects).limit(1))[0]!.id;
|
||||
|
||||
ctx = {
|
||||
userId,
|
||||
sub: "test-sub",
|
||||
iss: "http://test",
|
||||
email: null,
|
||||
name: null,
|
||||
groups: [],
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
memoryId = await seedMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.delete(memories);
|
||||
await pg.end();
|
||||
});
|
||||
|
||||
describe("memory.get response shape (P1)", () => {
|
||||
test("does not leak the embedding or the tsvector to the caller", async () => {
|
||||
const res = await toolMap["memory.get"]!.handler({ id: memoryId }, ctx);
|
||||
const fields = Object.keys(res.structuredContent as object);
|
||||
|
||||
expect(fields).not.toContain("embedding");
|
||||
expect(fields).not.toContain("contentTsv");
|
||||
});
|
||||
|
||||
test("returns exactly the same 9 fields as memory.list", async () => {
|
||||
const res = await toolMap["memory.get"]!.handler({ id: memoryId }, ctx);
|
||||
const fields = Object.keys(res.structuredContent as object).sort();
|
||||
|
||||
expect(fields).toEqual(
|
||||
[
|
||||
"content",
|
||||
"createdAt",
|
||||
"id",
|
||||
"lastEditedBy",
|
||||
"projectId",
|
||||
"scope",
|
||||
"tags",
|
||||
"updatedAt",
|
||||
"version",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test("still returns the full content", async () => {
|
||||
const res = await toolMap["memory.get"]!.handler({ id: memoryId }, ctx);
|
||||
expect((res.structuredContent as { content: string }).content).toBe(ORIGINAL);
|
||||
});
|
||||
});
|
||||
|
||||
describe("memory.patch (P2)", () => {
|
||||
test("applies a unique patch and increments version by exactly 1", async () => {
|
||||
const before = await db
|
||||
.select({ version: memories.version })
|
||||
.from(memories)
|
||||
.where(eq(memories.id, memoryId));
|
||||
|
||||
const res = await toolMap["memory.patch"]!.handler(
|
||||
{
|
||||
id: memoryId,
|
||||
old_string: "## RECENTLY SHIPPED",
|
||||
new_string: "## RECENTLY SHIPPED\n- v1.1 patch primitive",
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(res.isError).toBeFalsy();
|
||||
const after = res.structuredContent as { version: number };
|
||||
expect(after.version).toBe(before[0]!.version + 1);
|
||||
expect(await readContent(memoryId)).toContain("- v1.1 patch primitive");
|
||||
// The rest of the document survived.
|
||||
expect(await readContent(memoryId)).toContain("- v1.0 initial release");
|
||||
expect(await readContent(memoryId)).toContain("## IN PROGRESS");
|
||||
});
|
||||
|
||||
test("refuses an absent old_string and leaves content byte-identical", async () => {
|
||||
const res = await toolMap["memory.patch"]!.handler(
|
||||
{ id: memoryId, old_string: "## NOT PRESENT", new_string: "x" },
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(res.isError).toBe(true);
|
||||
expect(await readContent(memoryId)).toBe(ORIGINAL);
|
||||
});
|
||||
|
||||
test("refuses an ambiguous old_string, naming the count, leaving content unchanged", async () => {
|
||||
const id = await seedMemory("alpha\nalpha\nbeta\n");
|
||||
|
||||
const res = await toolMap["memory.patch"]!.handler(
|
||||
{ id, old_string: "alpha", new_string: "gamma" },
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(res.isError).toBe(true);
|
||||
expect(res.content[0]!.text).toMatch(/2/);
|
||||
expect(await readContent(id)).toBe("alpha\nalpha\nbeta\n");
|
||||
});
|
||||
|
||||
test("refuses a stale version and leaves content unchanged", async () => {
|
||||
const current = await db
|
||||
.select({ version: memories.version })
|
||||
.from(memories)
|
||||
.where(eq(memories.id, memoryId));
|
||||
|
||||
const res = await toolMap["memory.patch"]!.handler(
|
||||
{
|
||||
id: memoryId,
|
||||
old_string: "## IN PROGRESS",
|
||||
new_string: "## DONE",
|
||||
version: current[0]!.version + 99,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(res.isError).toBe(true);
|
||||
expect(await readContent(memoryId)).toBe(ORIGINAL);
|
||||
});
|
||||
|
||||
test("rejects a patch that would push content past the 64,000-char limit", async () => {
|
||||
const id = await seedMemory("A".repeat(63_950) + "ANCHOR");
|
||||
|
||||
const res = await toolMap["memory.patch"]!.handler(
|
||||
{ id, old_string: "ANCHOR", new_string: "B".repeat(100) },
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(res.isError).toBe(true);
|
||||
expect(await readContent(id)).toBe("A".repeat(63_950) + "ANCHOR");
|
||||
});
|
||||
|
||||
test("re-embeds: the stored vector changes after a patch", async () => {
|
||||
const before = await pg<{ embedding: string }[]>`
|
||||
SELECT embedding::text AS embedding FROM memories WHERE id = ${memoryId}
|
||||
`;
|
||||
|
||||
await toolMap["memory.patch"]!.handler(
|
||||
{ id: memoryId, old_string: "- patch primitive", new_string: "- shipped it" },
|
||||
ctx,
|
||||
);
|
||||
|
||||
const after = await pg<{ embedding: string }[]>`
|
||||
SELECT embedding::text AS embedding FROM memories WHERE id = ${memoryId}
|
||||
`;
|
||||
|
||||
expect(after[0]!.embedding).not.toBe(before[0]!.embedding);
|
||||
});
|
||||
|
||||
test("full-text index updates itself, because content_tsv is a generated column", async () => {
|
||||
// This is the claim that a patch cannot rot FTS. Postgres maintains
|
||||
// content_tsv; only the embedding needs an explicit recompute.
|
||||
await toolMap["memory.patch"]!.handler(
|
||||
{
|
||||
id: memoryId,
|
||||
old_string: "- patch primitive",
|
||||
new_string: "- kumquat marmalade",
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
const hit = await pg<{ n: number }[]>`
|
||||
SELECT count(*)::int AS n FROM memories
|
||||
WHERE id = ${memoryId} AND content_tsv @@ plainto_tsquery('english', 'kumquat')
|
||||
`;
|
||||
expect(hit[0]!.n).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Integration cover for the memory MUTATION paths (write / update / delete)
|
||||
* against a real Postgres. These exist mainly as a safety net for the
|
||||
* shared-mutation refactor: the MCP tools and the Web UI Server Actions
|
||||
* used to reimplement the same authorize → CAS → re-embed → audit sequence
|
||||
* separately, and these assertions pin the behaviour that must survive
|
||||
* being pulled into one place.
|
||||
*/
|
||||
vi.mock("@/lib/embedder", () => ({
|
||||
embedText: async (text: string) => {
|
||||
let h = 0;
|
||||
for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0;
|
||||
return Array.from({ length: 384 }, (_, i) => ((h + i * 7919) % 1000) / 1000);
|
||||
},
|
||||
embedTexts: async (texts: string[]) => texts.map(() => Array(384).fill(0.1)),
|
||||
embedderReady: async () => true,
|
||||
EmbedderError: class extends Error {},
|
||||
}));
|
||||
|
||||
const { db, pg } = await import("@/lib/db/client");
|
||||
const { memories, projects, users, groups, userGroups, projectShares } = await import(
|
||||
"@/lib/db/schema"
|
||||
);
|
||||
const { toolMap } = await import("@/lib/mcp/tools");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
type UserContext = import("@/lib/mcp/context").UserContext;
|
||||
|
||||
const ISS = "http://test";
|
||||
|
||||
let author: UserContext;
|
||||
let projectOwnerId: string;
|
||||
let ownProjectId: string;
|
||||
let sharedProjectId: string;
|
||||
let sharedGroupId: string;
|
||||
|
||||
function ctxFor(userId: string, sub: string, groupNames: string[] = []): UserContext {
|
||||
return { userId, sub, iss: ISS, email: null, name: null, groups: groupNames };
|
||||
}
|
||||
|
||||
async function upsertUser(sub: string): Promise<string> {
|
||||
const r = await db
|
||||
.insert(users)
|
||||
.values({ oidcSub: sub, oidcIss: ISS })
|
||||
.onConflictDoUpdate({ target: [users.oidcIss, users.oidcSub], set: { oidcSub: sub } })
|
||||
.returning({ id: users.id });
|
||||
return r[0]!.id;
|
||||
}
|
||||
|
||||
async function seedMemory(
|
||||
userId: string,
|
||||
projectId: string | null,
|
||||
content = "seed content",
|
||||
): Promise<string> {
|
||||
const r = await db
|
||||
.insert(memories)
|
||||
.values({
|
||||
userId,
|
||||
projectId,
|
||||
scope: projectId ? "project" : "user",
|
||||
content,
|
||||
tags: [],
|
||||
embedding: Array(384).fill(0.5),
|
||||
lastEditedBy: userId,
|
||||
})
|
||||
.returning({ id: memories.id });
|
||||
return r[0]!.id;
|
||||
}
|
||||
|
||||
async function setShareAccess(access: "ro" | "rw") {
|
||||
await db
|
||||
.insert(projectShares)
|
||||
.values({ projectId: sharedProjectId, groupId: sharedGroupId, access })
|
||||
.onConflictDoUpdate({
|
||||
target: [projectShares.projectId, projectShares.groupId],
|
||||
set: { access },
|
||||
});
|
||||
}
|
||||
|
||||
async function isDeleted(id: string): Promise<boolean> {
|
||||
const r = await db
|
||||
.select({ deletedAt: memories.deletedAt })
|
||||
.from(memories)
|
||||
.where(eq(memories.id, id));
|
||||
return r[0]!.deletedAt !== null;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const authorId = await upsertUser("author-sub");
|
||||
projectOwnerId = await upsertUser("owner-sub");
|
||||
|
||||
const own = await db
|
||||
.insert(projects)
|
||||
.values({ userId: authorId, key: "author-own", displayName: "Author Own" })
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: projects.id });
|
||||
ownProjectId =
|
||||
own[0]?.id ??
|
||||
(
|
||||
await db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(eq(projects.key, "author-own"))
|
||||
)[0]!.id;
|
||||
|
||||
const shared = await db
|
||||
.insert(projects)
|
||||
.values({ userId: projectOwnerId, key: "team-shared", displayName: "Team Shared" })
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: projects.id });
|
||||
sharedProjectId =
|
||||
shared[0]?.id ??
|
||||
(
|
||||
await db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(eq(projects.key, "team-shared"))
|
||||
)[0]!.id;
|
||||
|
||||
const g = await db
|
||||
.insert(groups)
|
||||
.values({ oidcIss: ISS, name: "team" })
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: groups.id });
|
||||
sharedGroupId =
|
||||
g[0]?.id ??
|
||||
(await db.select({ id: groups.id }).from(groups).where(eq(groups.name, "team")))[0]!
|
||||
.id;
|
||||
|
||||
await db
|
||||
.insert(userGroups)
|
||||
.values({ userId: authorId, groupId: sharedGroupId })
|
||||
.onConflictDoNothing();
|
||||
|
||||
author = ctxFor(authorId, "author-sub", ["team"]);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete(memories);
|
||||
await setShareAccess("rw");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.delete(memories);
|
||||
await pg.end();
|
||||
});
|
||||
|
||||
describe("memory.write", () => {
|
||||
test("writes into a project the caller owns", async () => {
|
||||
const res = await toolMap["memory.write"]!.handler(
|
||||
{ content: "hello", scope: "project", project: "author-own" },
|
||||
author,
|
||||
);
|
||||
expect(res.isError).toBeFalsy();
|
||||
});
|
||||
|
||||
test("refuses an unknown project rather than creating one", async () => {
|
||||
const res = await toolMap["memory.write"]!.handler(
|
||||
{ content: "hello", scope: "project", project: "does-not-exist" },
|
||||
author,
|
||||
);
|
||||
|
||||
expect(res.isError).toBe(true);
|
||||
expect(res.content[0]!.text).toMatch(/project\.identify/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("memory.update", () => {
|
||||
test("updates content and increments version", async () => {
|
||||
const id = await seedMemory(author.userId, ownProjectId);
|
||||
const before = await db
|
||||
.select({ version: memories.version })
|
||||
.from(memories)
|
||||
.where(eq(memories.id, id));
|
||||
|
||||
const res = await toolMap["memory.update"]!.handler(
|
||||
{ id, content: "revised content" },
|
||||
author,
|
||||
);
|
||||
|
||||
expect(res.isError).toBeFalsy();
|
||||
expect((res.structuredContent as { version: number }).version).toBe(
|
||||
before[0]!.version + 1,
|
||||
);
|
||||
});
|
||||
|
||||
test("refuses a stale version", async () => {
|
||||
const id = await seedMemory(author.userId, ownProjectId);
|
||||
|
||||
const res = await toolMap["memory.update"]!.handler(
|
||||
{ id, content: "revised", version: 99 },
|
||||
author,
|
||||
);
|
||||
|
||||
expect(res.isError).toBe(true);
|
||||
});
|
||||
|
||||
test("denies updating a memory in a project shared read-only", async () => {
|
||||
const id = await seedMemory(author.userId, sharedProjectId);
|
||||
await setShareAccess("ro");
|
||||
|
||||
const res = await toolMap["memory.update"]!.handler(
|
||||
{ id, content: "sneaky edit" },
|
||||
author,
|
||||
);
|
||||
|
||||
expect(res.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("memory.delete authorization", () => {
|
||||
test("allows deleting a memory in a project shared read-write", async () => {
|
||||
const id = await seedMemory(author.userId, sharedProjectId);
|
||||
|
||||
const res = await toolMap["memory.delete"]!.handler({ id }, author);
|
||||
|
||||
expect(res.isError).toBeFalsy();
|
||||
expect(await isDeleted(id)).toBe(true);
|
||||
});
|
||||
|
||||
test("denies deleting a memory in a project shared read-only, even to its author", async () => {
|
||||
// The realistic path here: the memory was written while the share was
|
||||
// rw, then an owner downgraded the group to ro. Authoring the row must
|
||||
// not grant a standing write privilege the project ACL has revoked —
|
||||
// memory.update already refuses this, and delete must agree.
|
||||
const id = await seedMemory(author.userId, sharedProjectId);
|
||||
await setShareAccess("ro");
|
||||
|
||||
const res = await toolMap["memory.delete"]!.handler({ id }, author);
|
||||
|
||||
expect(res.isError).toBe(true);
|
||||
expect(await isDeleted(id)).toBe(false);
|
||||
});
|
||||
|
||||
test("denies deleting another user's user-scope memory", async () => {
|
||||
const id = await seedMemory(projectOwnerId, null);
|
||||
|
||||
const res = await toolMap["memory.delete"]!.handler({ id }, author);
|
||||
|
||||
expect(res.isError).toBe(true);
|
||||
expect(await isDeleted(id)).toBe(false);
|
||||
});
|
||||
});
|
||||
+109
-222
@@ -11,6 +11,7 @@ import {
|
||||
MemoryIdInput,
|
||||
MemoryDeleteInput,
|
||||
MemoryListInput,
|
||||
MemoryPatchInput,
|
||||
MemorySearchInput,
|
||||
MemoryUpdateInput,
|
||||
MemoryWriteInput,
|
||||
@@ -20,20 +21,22 @@ import {
|
||||
SnippetListInput,
|
||||
SnippetDeleteInput,
|
||||
} from "@shared-memory/schemas";
|
||||
import { embedText } from "@/lib/embedder";
|
||||
import { searchMemories } from "@/lib/memories";
|
||||
import {
|
||||
createMemory,
|
||||
patchMemory,
|
||||
softDeleteMemory,
|
||||
updateMemory,
|
||||
type Actor,
|
||||
type ProjectResolver,
|
||||
} from "@/lib/memory-mutations";
|
||||
import {
|
||||
getSnippet,
|
||||
putSnippet,
|
||||
listSnippets,
|
||||
softDeleteSnippet,
|
||||
} from "@/lib/snippets";
|
||||
import {
|
||||
CONCURRENT_EDIT_ERROR,
|
||||
canWriteProject,
|
||||
getProjectAccess,
|
||||
readableProjectIds,
|
||||
} from "@/lib/access";
|
||||
import { getProjectAccess, readableProjectIds } from "@/lib/access";
|
||||
import type { UserContext } from "./context";
|
||||
|
||||
/**
|
||||
@@ -151,6 +154,27 @@ function withDefaultProject(
|
||||
return { ...obj, project: ctx.defaultProjectKey };
|
||||
}
|
||||
|
||||
/** Adapt an MCP request context to the shared mutation layer. */
|
||||
function mcpActor(ctx: UserContext): Actor {
|
||||
return { userId: ctx.userId, groups: ctx.groups, via: "mcp" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Project resolution for MCP writes. Unlike the Web UI, the MCP surface
|
||||
* never auto-creates a project — an unknown key is an error telling the
|
||||
* caller to run project.identify first, which keeps agents from silently
|
||||
* spawning near-miss projects off a typo'd key.
|
||||
*/
|
||||
function mcpProjectResolver(ctx: UserContext): ProjectResolver {
|
||||
return async (key: string) => {
|
||||
const id = await resolveProjectId(ctx, key);
|
||||
if (!id) {
|
||||
return { ok: false, error: `unknown project '${key}'; call project.identify first` };
|
||||
}
|
||||
return { ok: true, value: id };
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- tools ----------
|
||||
|
||||
const projectIdentify: ToolDef = {
|
||||
@@ -373,55 +397,18 @@ const memoryWrite: ToolDef = {
|
||||
const parsed = MemoryWriteInput.safeParse(withDefaultProject(args, ctx));
|
||||
if (!parsed.success) return err(parsed.error.message);
|
||||
|
||||
const scope = parsed.data.scope;
|
||||
let projectId: string | null = null;
|
||||
let projectKey: string | undefined = undefined;
|
||||
if (scope === "project") {
|
||||
projectKey = projectKeyOrDefault(ctx, parsed.data.project);
|
||||
if (!projectKey) {
|
||||
return err("scope=project requires `project` key (or X-Project-Key header)");
|
||||
}
|
||||
projectId = await resolveProjectId(ctx, projectKey);
|
||||
if (!projectId) {
|
||||
return err(`unknown project '${projectKey}'; call project.identify first`);
|
||||
}
|
||||
// Authorize write. Owner always allowed; otherwise require rw.
|
||||
const allowed = await canWriteProject(ctx.userId, ctx.groups, projectId);
|
||||
if (!allowed) {
|
||||
return err(`no write access to project '${projectKey}'`);
|
||||
}
|
||||
// Fold the X-Project-Key fallback in before the shared path sees it.
|
||||
const input = {
|
||||
...parsed.data,
|
||||
project: projectKeyOrDefault(ctx, parsed.data.project),
|
||||
};
|
||||
if (input.scope === "project" && !input.project) {
|
||||
return err("scope=project requires `project` key (or X-Project-Key header)");
|
||||
}
|
||||
|
||||
// Embed inline so the new memory is searchable immediately. Slower
|
||||
// writes (~50–150 ms) are an acceptable price for that guarantee; if
|
||||
// embedder pressure ever forces an async path, only this section
|
||||
// needs to change.
|
||||
const embedding = await embedText(parsed.data.content);
|
||||
|
||||
const inserted = await db
|
||||
.insert(memories)
|
||||
.values({
|
||||
userId: ctx.userId,
|
||||
projectId,
|
||||
scope,
|
||||
content: parsed.data.content,
|
||||
tags: parsed.data.tags ?? [],
|
||||
embedding,
|
||||
lastEditedBy: ctx.userId,
|
||||
})
|
||||
.returning({ id: memories.id, createdAt: memories.createdAt });
|
||||
|
||||
const m = inserted[0]!;
|
||||
await db.insert(auditLog).values({
|
||||
userId: ctx.userId,
|
||||
actor: "mcp",
|
||||
action: "memory.write",
|
||||
entityType: "memory",
|
||||
entityId: m.id,
|
||||
payload: { scope, projectKey: projectKey ?? null, tags: parsed.data.tags ?? [] },
|
||||
});
|
||||
|
||||
return ok({ id: m.id, createdAt: m.createdAt }, `wrote memory ${m.id}`);
|
||||
const res = await createMemory(mcpActor(ctx), input, mcpProjectResolver(ctx));
|
||||
if (!res.ok) return err(res.error);
|
||||
return ok(res.value, `wrote memory ${res.value.id}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -508,8 +495,28 @@ const memoryGet: ToolDef = {
|
||||
const parsed = MemoryIdInput.safeParse(args);
|
||||
if (!parsed.success) return err(parsed.error.message);
|
||||
|
||||
// Project explicitly rather than `select()`-ing the raw row. The
|
||||
// table carries `embedding` (384 floats) and `content_tsv` (the full
|
||||
// lexeme index, which outgrows `content` itself on large memories) —
|
||||
// both are Postgres retrieval internals that no MCP client can use,
|
||||
// and together they were the majority of every response. Returning
|
||||
// them also pushed large memories past the tool-output cap. This is
|
||||
// the same 9-field shape memory.list and memory.search return.
|
||||
const row = await db
|
||||
.select()
|
||||
.select({
|
||||
id: memories.id,
|
||||
scope: memories.scope,
|
||||
projectId: memories.projectId,
|
||||
content: memories.content,
|
||||
tags: memories.tags,
|
||||
version: memories.version,
|
||||
lastEditedBy: memories.lastEditedBy,
|
||||
createdAt: memories.createdAt,
|
||||
updatedAt: memories.updatedAt,
|
||||
// Needed for the authorization check below; stripped before the
|
||||
// response so the payload matches list/search exactly.
|
||||
userId: memories.userId,
|
||||
})
|
||||
.from(memories)
|
||||
.where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt)))
|
||||
.limit(1);
|
||||
@@ -518,8 +525,8 @@ const memoryGet: ToolDef = {
|
||||
|
||||
// Authorize read: own row, OR project-scope row in an accessible
|
||||
// project. Anything else looks "not found" to the caller.
|
||||
const m = row[0];
|
||||
if (m.userId !== ctx.userId) {
|
||||
const { userId, ...m } = row[0];
|
||||
if (userId !== ctx.userId) {
|
||||
if (!m.projectId) return err("not found");
|
||||
const access = await getProjectAccess(ctx.userId, ctx.groups, m.projectId);
|
||||
if (access === null) return err("not found");
|
||||
@@ -550,57 +557,9 @@ const memoryDelete: ToolDef = {
|
||||
const parsed = MemoryDeleteInput.safeParse(args);
|
||||
if (!parsed.success) return err(parsed.error.message);
|
||||
|
||||
// Look up the row first to authorize and capture its current version
|
||||
// for the CAS. Shared-project writes need a per-project access check.
|
||||
const target = await db
|
||||
.select({
|
||||
id: memories.id,
|
||||
userId: memories.userId,
|
||||
projectId: memories.projectId,
|
||||
scope: memories.scope,
|
||||
version: memories.version,
|
||||
})
|
||||
.from(memories)
|
||||
.where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt)))
|
||||
.limit(1);
|
||||
const m = target[0];
|
||||
if (!m) return err("not found");
|
||||
|
||||
if (m.userId !== ctx.userId) {
|
||||
// Not the owner. User-scope memories can only be deleted by their
|
||||
// owner; project-scope require rw access on the project.
|
||||
if (m.scope === "user" || !m.projectId) return err("not found");
|
||||
const allowed = await canWriteProject(ctx.userId, ctx.groups, m.projectId);
|
||||
if (!allowed) return err("no write access to this project");
|
||||
}
|
||||
|
||||
// Optimistic-lock CAS: pin to the caller-supplied version when given,
|
||||
// else the version we just read in this handler. The 0-row response
|
||||
// tells us a peer raced us.
|
||||
const expectedVersion = parsed.data.version ?? m.version;
|
||||
const updated = await db
|
||||
.update(memories)
|
||||
.set({ deletedAt: new Date(), lastEditedBy: ctx.userId })
|
||||
.where(
|
||||
and(
|
||||
eq(memories.id, parsed.data.id),
|
||||
eq(memories.version, expectedVersion),
|
||||
isNull(memories.deletedAt),
|
||||
),
|
||||
)
|
||||
.returning({ id: memories.id });
|
||||
|
||||
if (!updated[0]) return err(CONCURRENT_EDIT_ERROR);
|
||||
|
||||
await db.insert(auditLog).values({
|
||||
userId: ctx.userId,
|
||||
actor: "mcp",
|
||||
action: "memory.delete",
|
||||
entityType: "memory",
|
||||
entityId: updated[0].id,
|
||||
});
|
||||
|
||||
return ok({ id: updated[0].id, deleted: true }, `deleted memory ${updated[0].id}`);
|
||||
const res = await softDeleteMemory(mcpActor(ctx), parsed.data);
|
||||
if (!res.ok) return err(res.error);
|
||||
return ok({ id: res.value.id, deleted: true }, `deleted memory ${res.value.id}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -638,124 +597,51 @@ const memoryUpdate: ToolDef = {
|
||||
const parsed = MemoryUpdateInput.safeParse(withDefaultProject(args, ctx));
|
||||
if (!parsed.success) return err(parsed.error.message);
|
||||
|
||||
const existingRows = await db
|
||||
.select({
|
||||
id: memories.id,
|
||||
content: memories.content,
|
||||
scope: memories.scope,
|
||||
projectId: memories.projectId,
|
||||
projectKey: projects.key,
|
||||
version: memories.version,
|
||||
userId: memories.userId,
|
||||
})
|
||||
.from(memories)
|
||||
.leftJoin(projects, eq(memories.projectId, projects.id))
|
||||
.where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt)))
|
||||
.limit(1);
|
||||
const existing = existingRows[0];
|
||||
if (!existing) return err("not found");
|
||||
const res = await updateMemory(mcpActor(ctx), parsed.data, mcpProjectResolver(ctx));
|
||||
if (!res.ok) return err(res.error);
|
||||
return ok(res.value, `updated memory ${res.value.id}`);
|
||||
},
|
||||
};
|
||||
|
||||
// Authorize write.
|
||||
if (existing.scope === "user") {
|
||||
if (existing.userId !== ctx.userId) return err("not found");
|
||||
} else if (existing.projectId) {
|
||||
const allowed = await canWriteProject(ctx.userId, ctx.groups, existing.projectId);
|
||||
if (!allowed) return err("no write access to this project");
|
||||
}
|
||||
const memoryPatch: ToolDef = {
|
||||
name: "memory.patch",
|
||||
description:
|
||||
"Replace one exact snippet of a memory's content, leaving the rest untouched — the same mental model as editing a file. Use this INSTEAD of memory.update whenever you're making a small edit to a large memory: adding an entry under a heading, correcting a line, updating a status. memory.update requires you to resend the entire document, which risks silently dropping content you didn't mean to touch; memory.patch only needs the fragment you're changing. `old_string` must appear EXACTLY once — if it's missing or ambiguous the call fails and nothing is changed, so include enough surrounding context to make it unique. Pass an empty `new_string` to delete the matched text. Re-embeds automatically, preserves the memory's id, and accepts `version` for the same concurrent-edit protection as memory.update.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", format: "uuid" },
|
||||
old_string: {
|
||||
type: "string",
|
||||
description:
|
||||
"The exact text to replace. Must occur exactly once in the memory's content — include surrounding lines if the fragment alone would be ambiguous.",
|
||||
},
|
||||
new_string: {
|
||||
type: "string",
|
||||
description:
|
||||
"The replacement text. May be empty to delete the matched text (the memory itself may not be left empty).",
|
||||
},
|
||||
version: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
description:
|
||||
"Optimistic-locking token from memory.get / memory.list. When supplied, the patch is rejected if the row was edited by someone else since you read it.",
|
||||
},
|
||||
},
|
||||
required: ["id", "old_string", "new_string"],
|
||||
},
|
||||
async handler(args, ctx) {
|
||||
const parsed = MemoryPatchInput.safeParse(args);
|
||||
if (!parsed.success) return err(parsed.error.message);
|
||||
|
||||
const update: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
lastEditedBy: ctx.userId,
|
||||
version: existing.version + 1,
|
||||
};
|
||||
if (parsed.data.tags !== undefined) update.tags = parsed.data.tags;
|
||||
if (parsed.data.content !== undefined && parsed.data.content !== existing.content) {
|
||||
update.content = parsed.data.content;
|
||||
update.embedding = await embedText(parsed.data.content);
|
||||
}
|
||||
const res = await patchMemory(mcpActor(ctx), parsed.data);
|
||||
if (!res.ok) return err(res.error);
|
||||
|
||||
let scopeChanged = false;
|
||||
let projectChanged = false;
|
||||
let newProjectKey: string | null = existing.projectKey ?? null;
|
||||
|
||||
if (parsed.data.scope !== undefined) {
|
||||
if (parsed.data.scope === "user") {
|
||||
if (existing.scope !== "user") {
|
||||
update.scope = "user";
|
||||
scopeChanged = true;
|
||||
}
|
||||
if (existing.projectId !== null) {
|
||||
update.projectId = null;
|
||||
projectChanged = true;
|
||||
newProjectKey = null;
|
||||
}
|
||||
} else {
|
||||
// scope === 'project' — schema refine guarantees `project` is set.
|
||||
const projectKey = parsed.data.project!;
|
||||
const projectId = await resolveProjectId(ctx, projectKey);
|
||||
if (!projectId) {
|
||||
return err(`unknown project '${projectKey}'; call project.identify first`);
|
||||
}
|
||||
// Moving INTO a project requires write access there.
|
||||
const allowedTarget = await canWriteProject(ctx.userId, ctx.groups, projectId);
|
||||
if (!allowedTarget) {
|
||||
return err(`no write access to project '${projectKey}'`);
|
||||
}
|
||||
if (existing.scope !== "project") {
|
||||
update.scope = "project";
|
||||
scopeChanged = true;
|
||||
}
|
||||
if (existing.projectId !== projectId) {
|
||||
update.projectId = projectId;
|
||||
projectChanged = true;
|
||||
newProjectKey = projectKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const expectedVersion = parsed.data.version ?? existing.version;
|
||||
const updated = await db
|
||||
.update(memories)
|
||||
.set(update)
|
||||
.where(
|
||||
and(
|
||||
eq(memories.id, parsed.data.id),
|
||||
eq(memories.version, expectedVersion),
|
||||
),
|
||||
)
|
||||
.returning({
|
||||
id: memories.id,
|
||||
updatedAt: memories.updatedAt,
|
||||
version: memories.version,
|
||||
});
|
||||
|
||||
if (!updated[0]) return err(CONCURRENT_EDIT_ERROR);
|
||||
|
||||
const auditFields = Object.keys(update).filter(
|
||||
(k) => k !== "updatedAt" && k !== "version" && k !== "lastEditedBy",
|
||||
const { id, delta, contentLength } = res.value;
|
||||
return ok(
|
||||
res.value,
|
||||
`patched memory ${id} (${delta >= 0 ? "+" : ""}${delta} chars, now ${contentLength})`,
|
||||
);
|
||||
const auditPayload: Record<string, unknown> = { fields: auditFields };
|
||||
if (scopeChanged || projectChanged) {
|
||||
auditPayload.scope = {
|
||||
from: existing.scope,
|
||||
to: update.scope ?? existing.scope,
|
||||
};
|
||||
auditPayload.projectKey = {
|
||||
from: existing.projectKey ?? null,
|
||||
to: newProjectKey,
|
||||
};
|
||||
}
|
||||
|
||||
await db.insert(auditLog).values({
|
||||
userId: ctx.userId,
|
||||
actor: "mcp",
|
||||
action: "memory.update",
|
||||
entityType: "memory",
|
||||
entityId: updated[0]!.id,
|
||||
payload: auditPayload,
|
||||
});
|
||||
|
||||
return ok(updated[0]!, `updated memory ${updated[0]!.id}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1113,6 +999,7 @@ export const tools: ToolDef[] = [
|
||||
projectIdentify,
|
||||
memoryWrite,
|
||||
memoryUpdate,
|
||||
memoryPatch,
|
||||
memoryList,
|
||||
memoryGet,
|
||||
memorySearch,
|
||||
|
||||
Reference in New Issue
Block a user