From 36ab388275af480c678223df3c84d0d71ef8fc85 Mon Sep 17 00:00:00 2001 From: Willi Budzinski Date: Wed, 17 Jun 2026 18:25:13 +0200 Subject: [PATCH] fix: preserve MCP project scope --- .../plan.md | 191 ++++++++++++++++++ .../todo.md | 190 +++++++++++++++++ .../skills/agentmemory-mcp-tools/REFERENCE.md | 4 +- src/mcp/server.ts | 4 + src/mcp/standalone.ts | 19 +- src/mcp/tools-registry.ts | 8 + test/mcp-server-tools.test.ts | 128 ++++++++++++ test/mcp-standalone-proxy.test.ts | 50 ++++- test/mcp-standalone.test.ts | 75 +++++++ 9 files changed, 665 insertions(+), 4 deletions(-) create mode 100644 docs/todos/2026-06-17-issue-926-mcp-project-scope/plan.md create mode 100644 docs/todos/2026-06-17-issue-926-mcp-project-scope/todo.md create mode 100644 test/mcp-server-tools.test.ts diff --git a/docs/todos/2026-06-17-issue-926-mcp-project-scope/plan.md b/docs/todos/2026-06-17-issue-926-mcp-project-scope/plan.md new file mode 100644 index 000000000..279309466 --- /dev/null +++ b/docs/todos/2026-06-17-issue-926-mcp-project-scope/plan.md @@ -0,0 +1,191 @@ +# Issue 926 MCP Project Scope Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Preserve the optional `project` argument across MCP save and recall/search paths. + +**Architecture:** Keep the fix at MCP boundary layers because REST and core memory/search functions already understand `project`. Add optional schema fields, normalize once at MCP validation boundaries, and forward/store/filter the value without changing tool counts, endpoint counts, storage schema, or auth behavior. + +**Tech Stack:** TypeScript ESM, Vitest, iii-sdk mocked tests, MCP standalone shim, REST proxy wrapper. + +--- + +## Files + +- Modify: `src/mcp/standalone.ts` +- Modify: `src/mcp/tools-registry.ts` +- Modify: `src/mcp/server.ts` +- Modify: `test/mcp-standalone.test.ts` +- Modify: `test/mcp-standalone-proxy.test.ts` +- Create: `test/mcp-server-tools.test.ts` +- Modify: `docs/todos/2026-06-17-issue-926-mcp-project-scope/todo.md` + +## Spec Source + +No separate product spec exists. Source of truth is the user delegation, Issue #926 summary, repo instructions, and `todo.md` Sprint Contract. + +## Subagent Work + +- Read-only Explorer already spawned to validate issue #926 independently. Main agent owns implementation and verification because the code surface is small and overlapping. + +## Task 1: Add Red Regression Tests + +**Files:** +- Modify: `test/mcp-standalone.test.ts` +- Modify: `test/mcp-standalone-proxy.test.ts` +- Create: `test/mcp-server-tools.test.ts` + +- [ ] **Step 1: Add local fallback project persistence/filter tests** + +Add a test near the standalone local save/recall tests proving: + +```ts +const api = await handleToolCall("memory_save", { + content: "Use request-scoped JWT middleware", + project: "api", +}, kv); +const web = await handleToolCall("memory_save", { + content: "Use request-scoped JWT middleware", + project: "web", +}, kv); +await handleToolCall("memory_save", { + content: "Use request-scoped JWT middleware legacy", +}, kv); +const apiMemory = await kv.get<{ project?: string }>("mem:memories", JSON.parse(api.content[0].text).saved); +const webMemory = await kv.get<{ project?: string }>("mem:memories", JSON.parse(web.content[0].text).saved); +expect(apiMemory?.project).toBe("api"); +expect(webMemory?.project).toBe("web"); +const recall = JSON.parse((await handleToolCall("memory_recall", { + query: "request-scoped JWT", + project: "api", +}, kv)).content[0].text); +expect(recall.results.map((m: { project?: string }) => m.project)).toEqual(["api", undefined]); +``` + +Also assert `memory_smart_search` with `project: "web"` returns the web and legacy memories, not the api memory. + +- [ ] **Step 2: Add proxy forwarding tests** + +Extend existing proxy tests to assert request bodies include `project`: + +```ts +await handleToolCall("memory_save", { + content: "proxy scoped", + project: "api", +}); +expect(rememberCall?.body).toMatchObject({ content: "proxy scoped", project: "api" }); +``` + +Update recall expected body to include `project: "api"` when passed, and update smart-search test to capture/expect `project`. + +- [ ] **Step 3: Add server MCP forwarding tests** + +Create `test/mcp-server-tools.test.ts` using the same `registerMcpEndpoints(sdk, kv)` pattern as `test/mcp-prompts.test.ts` and `test/mcp-resources.test.ts`. Add assertions that `memory_recall` calls `mem::search` with `project: "api"` and `memory_smart_search` calls `mem::smart-search` with `project: "api"`. Keep blank project behavior out of payload. + +- [ ] **Step 4: Add schema assertions** + +Assert `CORE_TOOLS` schema for `memory_recall` and `memory_smart_search` includes a `project` string property. + +- [ ] **Step 5: Run focused red tests** + +Run: + +```bash +npx vitest run test/mcp-standalone.test.ts test/mcp-standalone-proxy.test.ts test/mcp-server-tools.test.ts +``` + +Expected: failures showing missing `project` persistence/forwarding/schema fields. + +## Task 2: Implement MCP Boundary Project Forwarding + +**Files:** +- Modify: `src/mcp/standalone.ts` +- Modify: `src/mcp/tools-registry.ts` +- Modify: `src/mcp/server.ts` + +- [ ] **Step 1: Add project to standalone validation model** + +Add `project?: string` to `Validated` and assign a trimmed non-empty value for `memory_save`, `memory_recall`, and `memory_smart_search`. + +- [ ] **Step 2: Forward project in standalone proxy mode** + +Add conditional `project` fields to bodies for `/agentmemory/remember`, `/agentmemory/search`, and `/agentmemory/smart-search`. + +- [ ] **Step 3: Persist and filter project in standalone local mode** + +Add `project` to saved local memory objects when present. In local recall/smart-search, if `v.project` is set, exclude memories whose own `project` is a different non-empty string and keep legacy unscoped memories visible. + +- [ ] **Step 4: Add project to read tool schemas** + +Add optional `project` string properties to `memory_recall` and `memory_smart_search` in `src/mcp/tools-registry.ts`. + +- [ ] **Step 5: Forward project in full MCP server handlers** + +In `src/mcp/server.ts`, parse a trimmed optional `project` for `memory_recall` and `memory_smart_search`, then include it conditionally in the downstream payload. + +## Task 3: Green Verification And Cleanup + +**Files:** +- Modify: touched source/tests only as needed. +- Modify: `docs/todos/2026-06-17-issue-926-mcp-project-scope/todo.md` + +- [ ] **Step 1: Run focused tests** + +Run: + +```bash +npx vitest run test/mcp-standalone.test.ts test/mcp-standalone-proxy.test.ts test/mcp-server-tools.test.ts +``` + +Expected: pass. + +- [ ] **Step 2: Run build** + +Run: + +```bash +npm run build +``` + +Expected: exit 0. + +- [ ] **Step 3: Run repo-native tests if dependencies are available** + +Run: + +```bash +npm test +``` + +Expected: exit 0, or record blocker/failing tests with evidence. + +- [ ] **Step 4: Simplification pass** + +Review the active diff for avoidable duplication or unclear local names while preserving MCP schemas, REST routes, auth, storage, and search semantics. + +- [ ] **Step 5: Security gates** + +Run Semgrep for non-trivial code changes: + +```bash +semgrep scan --config p/default --error --metrics=off . +``` + +Before any commit, stage only task-owned files and run: + +```bash +gitleaks protect --staged --redact +``` + +OSV is not required unless dependency, lockfile, container, vendored, or third-party package surfaces change. + +- [ ] **Step 6: Update task record and handoff** + +Update `todo.md` with Explorer result, verification evidence, security gate results or blockers, final matrix statuses, and residual risks. + +## Self-Review + +- No placeholder paths remain. +- Acceptance criteria map to Task 1 tests and Task 2 implementation. +- No plan step requires remote operations. +- No dependency changes are planned. diff --git a/docs/todos/2026-06-17-issue-926-mcp-project-scope/todo.md b/docs/todos/2026-06-17-issue-926-mcp-project-scope/todo.md new file mode 100644 index 000000000..b5b9de7d0 --- /dev/null +++ b/docs/todos/2026-06-17-issue-926-mcp-project-scope/todo.md @@ -0,0 +1,190 @@ +# Issue 926 MCP Project Scope + +## Scope + +- Repository: `agentmemory` +- Worktree: `/Users/A1538552/.codex/worktrees/7d3c/agentmemory` +- Branch: `issue/926-mcp-project-scope` +- Base commit at start: `f6f9e3cb` +- GitHub issue: #926, `memory_save` and read tools dropping the MCP `project` argument. +- Owner scope: MCP standalone shim, MCP tool registry, MCP server handler, and focused tests. + +## Active Instructions And Boundaries + +- Repo instructions: `AGENTS.md` read. +- Global/delegation instructions: no fetch, pull, push, PR creation, merge, deployment, credentialed browser/API action, destructive cleanup, or remote state change without separate current-turn approval. +- The full GitHub feature-loop invocation authorizes only local PR-prep work on task-owned surfaces; remote operations remain withheld. +- `github-push-prepare` is referenced by `github-feature-loop` but is not available as a skill in this session; this is a loop-completion blocker to record in handoff. +- Detached HEAD was converted to local branch `issue/926-mcp-project-scope` before source edits. +- Preserve unrelated changes. Initial status was clean: `git status -sb --untracked-files=all` returned `## HEAD (no branch)`. + +## Validation Evidence + +- `src/mcp/tools-registry.ts` advertised `project` for `memory_save`, but not for `memory_recall` or `memory_smart_search`. +- `src/mcp/standalone.ts` `Validated` did not include `project`; `validate()` did not extract it for save/search tools. +- `src/mcp/standalone.ts` proxy paths for `/agentmemory/remember`, `/agentmemory/search`, and `/agentmemory/smart-search` built bodies without `project`. +- `src/mcp/standalone.ts` local fallback saved memories without `project` and searched all local memories without project filtering. +- `src/mcp/server.ts` already forwarded `project` for `memory_save`, but not for `memory_recall` or `memory_smart_search`. +- `src/triggers/api.ts` and `src/functions/remember.ts` already whitelist/normalize/save `project` for REST remember. +- `src/functions/search.ts` already supports `project` filtering for observations and saved memories. +- `src/functions/smart-search.ts` already accepts `project` and forwards it to lesson recall; this task forwards the MCP argument into that function. + +## Sprint Contract + +**Goal:** Preserve the MCP `project` argument from `memory_save`, `memory_recall`, and `memory_smart_search` through standalone proxy mode, standalone local mode, and full server MCP handlers. + +**Scope:** +- Add `project` to core MCP schemas for recall and smart search. +- Extract/normalize optional `project` in `src/mcp/standalone.ts`. +- Forward `project` in standalone proxy request bodies. +- Store `project` on local fallback memories and filter local fallback recall/smart-search by project when provided. +- Forward `project` in `src/mcp/server.ts` handlers for `memory_recall` and `memory_smart_search`. +- Add focused regression tests. + +**Non-goals:** +- No REST endpoint count or MCP tool count changes. +- No new tools, endpoints, dependencies, migrations, auth changes, storage model changes, or remote operations. +- No broad docs updates unless tests reveal advertised counts or public behavior text is stale. +- No changes to `memory_sessions` or `memory_export` behavior unless validation finds a project-specific drop with an advertised schema. + +**Acceptance Criteria:** +- `memory_save` with `project` persists that project in standalone local fallback. +- Local fallback `memory_recall` and `memory_smart_search` with `project` return matching scoped and legacy unscoped memories, and exclude differently scoped memories. +- Standalone proxy mode forwards `project` to `/agentmemory/remember`, `/agentmemory/search`, and `/agentmemory/smart-search`. +- Full MCP server `memory_recall` and `memory_smart_search` forward non-blank trimmed `project` to `mem::search` and `mem::smart-search`. +- Tool schemas expose `project` for `memory_recall`, `memory_save`, and `memory_smart_search`. +- Focused tests pass; broader project-native checks are run or blocked with evidence. + +**Intended Verification:** +- Red tests before source edits: + - `npx vitest run test/mcp-standalone.test.ts test/mcp-standalone-proxy.test.ts test/mcp-server.test.ts --runInBand` or nearest existing filenames if the server test path differs. +- Green focused tests after implementation: + - `npx vitest run test/mcp-standalone.test.ts test/mcp-standalone-proxy.test.ts test/mcp-server.test.ts --runInBand` +- Type/build check: + - `npm run build` +- Repo-native full test if dependencies are available: + - `npm test` +- Security gates for non-trivial code changes: + - `semgrep scan --config p/default --error --metrics=off .` + - `gitleaks protect --staged --redact` before any commit, after staging intended files. + - OSV skipped unless dependency/lock/container/vendored surfaces change. + +**Known Boundaries:** +- Public MCP schemas change by adding optional schema fields; this matches advertised/implemented project scoping and does not add a new tool or endpoint. +- No migration needed because legacy unscoped local memories remain visible to project-scoped reads. +- `memory_sessions` and `memory_export` local/proxy paths remain unchanged unless evidence shows they advertise and drop `project`. + +**Stop Conditions:** +- A required fix would change persistence schema, auth, routing, endpoint counts, tool counts, or remote state. +- Subagent validation contradicts the local evidence in a way that changes acceptance criteria. +- Verification fails twice for the same unexplained reason. +- Dependency setup or security gates require credentials, private registry access, or tool installation without approval. + +## Feature / Verification Matrix + +| Change | Verification Method | Status | Evidence | +| --- | --- | --- | --- | +| Validate issue on current code | Main inspection plus read-only Explorer | In progress | Main evidence recorded above; Explorer pending | +| Standalone local save/search preserve project | TDD regression in `test/mcp-standalone.test.ts` | Pending | Not implemented | +| Standalone proxy forwards project | TDD regression in `test/mcp-standalone-proxy.test.ts` | Pending | Not implemented | +| Full MCP server forwards project for recall/smart-search | TDD regression in MCP server tests | Pending | Not implemented | +| Schemas expose project for read tools | Registry assertions | Pending | Not implemented | +| Focused verification | Targeted vitest command | Pending | Not run | +| Build/full test/security gates | `npm run build`, `npm test`, Semgrep/Gitleaks as applicable | Pending | Not run | + +## Subagent Ledger + +| Workstream | Scope | Edits Allowed | Expected Output | Result | Residual Risk | +| --- | --- | --- | --- | --- | --- | +| Issue validity evaluator | `src/mcp`, `src/triggers`, `src/functions`, `test` read-only | No | Valid/invalid finding, files/commands/evidence/risks | Valid; evidence aligned with main inspection | Backend smart-search project filtering remains broader residual risk | + +## Progress + +- [x] Read repo instructions and user delegation. +- [x] Checked initial git status. +- [x] Read README excerpt, package scripts, CI, MCP source, search/remember functions, and focused tests. +- [x] Spawned read-only Explorer for independent validation. +- [x] Created local branch from detached HEAD. +- [x] Write plan. +- [x] Add failing regression tests. +- [x] Implement minimal fix. +- [x] Run targeted and repo-native verification. +- [x] Run required security gates or record blockers. +- [x] Update task record with final evidence and handoff. + +## Implementation Summary + +- Added optional `project` schema fields to `memory_recall` and `memory_smart_search`. +- Regenerated `plugin/skills/agentmemory-mcp-tools/REFERENCE.md` after schema drift. +- `src/mcp/standalone.ts` now normalizes optional `project` for `memory_save`, `memory_recall`, and `memory_smart_search`. +- Standalone proxy mode now forwards `project` to `/agentmemory/remember`, `/agentmemory/search`, and `/agentmemory/smart-search`. +- Standalone local fallback now stores `project` on saved memories and, when a read project is provided, returns matching project memories plus legacy unscoped memories while excluding other project scopes. +- Full MCP server handlers now forward trimmed `project` for `memory_recall` and `memory_smart_search`. + +## Subagent Result + +Explorer `019ed657-f2e8-7f92-b937-9816a2529308` independently found the issue valid: + +- Standalone validation did not carry `project`. +- Standalone proxy bodies for remember/search/smart-search omitted `project`. +- Standalone local save/search ignored project. +- Full MCP `memory_save` already forwarded `project`. +- Full MCP `memory_recall` and `memory_smart_search` omitted `project`. +- REST/backend remember/search were already project-aware. +- `memory_sessions` and `memory_export` do not advertise project and were left unchanged. +- Residual risk noted: backend `mem::smart-search` accepts `project` but currently applies it to lesson recall, not hybrid observation/memory results. This task intentionally fixed MCP schema/forwarding/local fallback only. + +## Feature / Verification Matrix Final + +| Change | Verification Method | Status | Evidence | +| --- | --- | --- | --- | +| Validate issue on current code | Main inspection plus read-only Explorer | Pass | Main + Explorer agreed issue is valid with full-MCP-save nuance | +| Standalone local save/search preserve project | TDD regression in `test/mcp-standalone.test.ts` | Pass | Red failure, then green focused Vitest | +| Standalone proxy forwards project | TDD regression in `test/mcp-standalone-proxy.test.ts` | Pass | Red failure, then green focused Vitest | +| Full MCP server forwards project for recall/smart-search | TDD regression in `test/mcp-server-tools.test.ts` | Pass | Red failure, then green focused Vitest | +| Schemas expose project for read tools | Registry assertion + generated reference | Pass | `skills:check` passes after `skills:gen` | +| Focused verification | `npx vitest run test/mcp-standalone.test.ts test/mcp-standalone-proxy.test.ts test/mcp-server-tools.test.ts` | Pass | 3 files, 46 tests passed | +| Build and skill docs | `npm run build`, `npm run skills:check` | Pass with existing warnings | Build exit 0; skills lint passed, 15 skills checked | +| Full test suite | `npm test` | Blocked by unrelated existing/local failures | 127 files passed, 2 failed; `test/fs-watcher.test.ts` 3 watcher event failures, `test/retention.test.ts` timeout in full run. Isolated rerun: retention passed, fs-watcher still 3 failures | +| Security gates | Semgrep + Gitleaks | Patch-clean, full-repo blocked | Targeted Semgrep on changed files: 0 findings. `gitleaks protect --staged --redact`: no leaks. Full Semgrep: 19 pre-existing findings outside touched files. Full/worktree Gitleaks: historical findings and one pre-existing JWT fixture in `test/fs-watcher.test.ts:287` | + +## Verification Evidence + +- Red test run before source edits: + - `npm exec -- vitest run test/mcp-standalone.test.ts test/mcp-standalone-proxy.test.ts test/mcp-server-tools.test.ts` + - Expected failures observed: 7 project/schema forwarding failures. +- Focused green runs: + - `npm exec -- vitest run test/mcp-standalone.test.ts test/mcp-standalone-proxy.test.ts test/mcp-server-tools.test.ts` -> 3 files passed, 46 tests passed. + - `npx vitest run test/mcp-standalone.test.ts test/mcp-standalone-proxy.test.ts test/mcp-server-tools.test.ts` -> 3 files passed, 46 tests passed. +- Dependency setup for verification: + - `.npmrc` absent. + - `package-lock.json` and `node_modules/` are gitignored. + - `npm install --package-lock-only --legacy-peer-deps --no-audit --no-fund` -> exit 0, npm warned about pending install-script approvals. + - `npm ci --legacy-peer-deps --no-audit --no-fund` -> exit 0, npm warned about pending install-script approvals. +- Build/docs: + - Initial `npm run build` failed because `tsdown` was not installed before dependency setup. + - After dependency setup, `npm run build` -> exit 0 with existing tsdown deprecation/plugin timing warnings. + - `npm run skills:check` initially failed with generated reference drift. + - `npm run skills:gen` regenerated `plugin/skills/agentmemory-mcp-tools/REFERENCE.md`. + - `npm run skills:check` rerun -> exit 0, 15 skills checked. +- Full suite: + - `npm test` -> exit 1, 1416 passed / 4 failed. + - `npx vitest run test/fs-watcher.test.ts test/retention.test.ts` -> exit 1; retention passed, fs-watcher retained 3 event-capture failures. + +## Security Evidence + +- `semgrep scan --config p/default --error --metrics=off .` completed and returned 19 blocking findings in pre-existing unrelated files: deploy Dockerfiles, `integrations/filesystem-watcher/watcher.mjs`, `integrations/hermes/__init__.py`, `plugin/opencode/agentmemory-capture.ts`, `src/cli.ts`, `src/functions/compress-synthetic.ts`, `src/functions/flow-compress.ts`, `src/functions/sentinels.ts`, `src/prompts/xml.ts`, and `src/viewer/server.ts`. +- `semgrep scan --config p/default --error --metrics=off src/mcp/standalone.ts src/mcp/server.ts src/mcp/tools-registry.ts test/mcp-standalone.test.ts test/mcp-standalone-proxy.test.ts test/mcp-server-tools.test.ts plugin/skills/agentmemory-mcp-tools/REFERENCE.md` -> 0 findings. +- `gitleaks detect --source . --redact` scanned 794 commits and found 15 historical leaks. +- `gitleaks detect --source . --redact --no-git --verbose` found one pre-existing redacted JWT fixture in `test/fs-watcher.test.ts:287`, outside task files. +- `gitleaks protect --staged --redact` after staging only task-owned files -> no leaks found. +- OSV skipped: no dependency, lockfile, container, vendored, or third-party package surface is part of the intended patch. A gitignored `package-lock.json` and `node_modules/` were created only for local verification and are not staged. + +## Handoff Notes + +- Issue status: valid and fixed in intended MCP scope. +- No commit created. Commit is blocked by required security/full-test gates unless the user explicitly accepts the pre-existing full-repo Semgrep/Gitleaks findings and local fs-watcher failures for this turn. +- `github-push-prepare` is not available as a skill in this session, so the mandatory GitHub feature-loop final phase cannot be fully executed. +- No fetch, pull, push, PR creation, merge, deployment, migration, or remote write was performed. +- Current branch: `issue/926-mcp-project-scope`. +- Task-owned files are staged for review and staged Gitleaks evidence. diff --git a/plugin/skills/agentmemory-mcp-tools/REFERENCE.md b/plugin/skills/agentmemory-mcp-tools/REFERENCE.md index 0c556fc77..ccdca5b07 100644 --- a/plugin/skills/agentmemory-mcp-tools/REFERENCE.md +++ b/plugin/skills/agentmemory-mcp-tools/REFERENCE.md @@ -35,7 +35,7 @@ agentmemory exposes 53 MCP tools. 8 are in the lean core set (`--tools core` or | `memory_obsidian_export` | | `vaultDir`: string, `types`: string | Export memories, lessons, and crystals as Obsidian-compatible Markdown files with YAML frontmatter and wikilinks for graph view. | | `memory_patterns` | | `project`: string | Detect recurring patterns across sessions. | | `memory_profile` | | `project`*: string, `refresh`: string | User/project profile with top concepts and file patterns. | -| `memory_recall` | yes | `query`*: string, `limit`: number, `format`: string, `token_budget`: number | Search past session observations for relevant context. Use when you need to recall what happened in previous sessions, find past decisions, or look up how a file was modified before. | +| `memory_recall` | yes | `query`*: string, `limit`: number, `format`: string, `token_budget`: number, `project`: string | Search past session observations for relevant context. Use when you need to recall what happened in previous sessions, find past decisions, or look up how a file was modified before. | | `memory_reflect` | yes | `project`: string, `maxClusters`: number | Traverse the knowledge graph, group related memories by concept clusters, and synthesize higher-order insights via LLM. Returns new and reinforced insights. | | `memory_relations` | | `memoryId`*: string, `maxHops`: number, `minConfidence`: number | Query the memory relationship graph. | | `memory_routine_run` | | `routineId`*: string, `project`: string, `initiatedBy`: string | Instantiate a frozen workflow routine, creating actions for each step with proper dependencies. | @@ -53,7 +53,7 @@ agentmemory exposes 53 MCP tools. 8 are in the lean core set (`--tools core` or | `memory_slot_get` | | `label`*: string | Read a single slot by label. | | `memory_slot_list` | | none | List all memory slots (pinned + project + global). Slots are editable, size-limited memory units the agent can read and modify across sessions. | | `memory_slot_replace` | | `label`*: string, `content`*: string | Replace slot content in place. Fails if content exceeds sizeLimit. | -| `memory_smart_search` | yes | `query`*: string, `expandIds`: string, `limit`: number | Hybrid semantic+keyword search with progressive disclosure. | +| `memory_smart_search` | yes | `query`*: string, `expandIds`: string, `limit`: number, `project`: string | Hybrid semantic+keyword search with progressive disclosure. | | `memory_snapshot_create` | | `message`: string | Create a git-versioned snapshot of current memory state. | | `memory_team_feed` | | `limit`: number | Get recent shared items from all team members. | | `memory_team_share` | | `itemId`*: string, `itemType`*: string | Share a memory or observation with team members. | diff --git a/src/mcp/server.ts b/src/mcp/server.ts index dbca07d9b..f8cc6331f 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -121,12 +121,14 @@ export function registerMcpEndpoints( typeof args.agentId === "string" && args.agentId.trim().length > 0 ? (args.agentId as string).trim() : undefined; + const project = asNonEmptyString(args.project); const result = await sdk.trigger({ function_id: "mem::search", payload: { query: args.query, limit: typeof args.limit === "number" ? args.limit : 10, format, token_budget: tokenBudget, agentId: recallAgentId, + ...(project !== undefined && { project }), } }); const text = format === "narrative" && @@ -273,12 +275,14 @@ export function registerMcpEndpoints( } const expandIds = parseCsvList(args.expandIds).slice(0, 20); const limit = Math.max(1, Math.min(100, asNumber(args.limit, 10) ?? 10)); + const project = asNonEmptyString(args.project); const result = await sdk.trigger({ function_id: "mem::smart-search", payload: { query: args.query, expandIds, limit, + ...(project !== undefined && { project }), }, }); return { diff --git a/src/mcp/standalone.ts b/src/mcp/standalone.ts index dd66ecb1d..9dbaf870b 100644 --- a/src/mcp/standalone.ts +++ b/src/mcp/standalone.ts @@ -74,6 +74,12 @@ function normalizeList(value: unknown): string[] { return []; } +function normalizeProject(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; +} + const DEFAULT_LIMIT = 10; const MAX_LIMIT = 100; function parseLimit(raw: unknown, fallback = DEFAULT_LIMIT): number { @@ -103,6 +109,7 @@ interface Validated { limit?: number; format?: string; tokenBudget?: number; + project?: string; memoryIds?: string[]; reason?: string; } @@ -122,6 +129,7 @@ function validate(toolName: string, args: Record): Validated { v.type = (args["type"] as string) || "fact"; v.concepts = normalizeList(args["concepts"]); v.files = normalizeList(args["files"]); + v.project = normalizeProject(args["project"]); return v; } case "memory_recall": @@ -143,6 +151,7 @@ function validate(toolName: string, args: Record): Validated { const n = Number(budget); if (Number.isFinite(n) && n > 0) v.tokenBudget = Math.floor(n); } + v.project = normalizeProject(args["project"]); return v; } case "memory_sessions": { @@ -180,6 +189,7 @@ async function handleProxy( type: v.type, concepts: v.concepts, files: v.files, + ...(v.project !== undefined && { project: v.project }), }), }); return textResponse(result); @@ -191,6 +201,7 @@ async function handleProxy( format: v.format ?? "full", }; if (v.tokenBudget != null) body["token_budget"] = v.tokenBudget; + if (v.project != null) body["project"] = v.project; const result = await handle.call("/agentmemory/search", { method: "POST", body: JSON.stringify(body), @@ -201,6 +212,7 @@ async function handleProxy( const body: Record = { query: v.query, limit: v.limit }; if (v.format != null) body["format"] = v.format; if (v.tokenBudget != null) body["token_budget"] = v.tokenBudget; + if (v.project != null) body["project"] = v.project; const result = await handle.call("/agentmemory/smart-search", { method: "POST", body: JSON.stringify(body), @@ -258,6 +270,7 @@ async function handleLocal( version: 1, isLatest: true, sessionIds: [], + ...(v.project !== undefined && { project: v.project }), }); kvInstance.persist(); return textResponse({ saved: id }); @@ -281,7 +294,11 @@ async function handleLocal( ] .join(" ") .toLowerCase(); - return query.split(/\s+/).every((word) => text.includes(word)); + const matchesQuery = query.split(/\s+/).every((word) => text.includes(word)); + if (!matchesQuery) return false; + if (v.project === undefined) return true; + const memoryProject = normalizeProject(m["project"]); + return memoryProject === undefined || memoryProject === v.project; }) .slice(0, limit); return textResponse({ mode: "compact", results }, true); diff --git a/src/mcp/tools-registry.ts b/src/mcp/tools-registry.ts index c4df3499c..74d1be23a 100644 --- a/src/mcp/tools-registry.ts +++ b/src/mcp/tools-registry.ts @@ -32,6 +32,10 @@ export const CORE_TOOLS: McpToolDef[] = [ type: "number", description: "Optional token budget to trim returned results", }, + project: { + type: "string", + description: "Filter by stable canonical project identifier", + }, }, required: ["query"], }, @@ -130,6 +134,10 @@ export const CORE_TOOLS: McpToolDef[] = [ description: "Comma-separated observation IDs to expand", }, limit: { type: "number", description: "Max results (default 10)" }, + project: { + type: "string", + description: "Filter by stable canonical project identifier", + }, }, required: ["query"], }, diff --git a/test/mcp-server-tools.test.ts b/test/mcp-server-tools.test.ts new file mode 100644 index 000000000..c3e612840 --- /dev/null +++ b/test/mcp-server-tools.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerMcpEndpoints } from "../src/mcp/server.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + const triggerOverrides = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async ( + idOrInput: string | { function_id: string; payload: unknown }, + data?: unknown, + ) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + if (triggerOverrides.has(id)) { + return triggerOverrides.get(id)!(payload); + } + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + overrideTrigger: (id: string, handler: Function) => { + triggerOverrides.set(id, handler); + }, + getFunction: (id: string) => functions.get(id), + }; +} + +function makeReq(body?: unknown, headers?: Record) { + return { + body, + headers: headers || {}, + query_params: {}, + }; +} + +describe("MCP tool call handlers", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + registerMcpEndpoints(sdk as never, kv as never); + }); + + it("forwards project from memory_recall to mem::search (#926)", async () => { + let receivedPayload: Record | undefined; + sdk.overrideTrigger("mem::search", async (payload: Record) => { + receivedPayload = payload; + return { results: [] }; + }); + + const fn = sdk.getFunction("mcp::tools::call")!; + const result = (await fn( + makeReq({ + name: "memory_recall", + arguments: { + query: "auth", + project: " api ", + }, + }), + )) as { status_code: number }; + + expect(result.status_code).toBe(200); + expect(receivedPayload).toMatchObject({ + query: "auth", + project: "api", + }); + }); + + it("forwards project from memory_smart_search to mem::smart-search (#926)", async () => { + let receivedPayload: Record | undefined; + sdk.overrideTrigger("mem::smart-search", async (payload: Record) => { + receivedPayload = payload; + return { mode: "compact", results: [] }; + }); + + const fn = sdk.getFunction("mcp::tools::call")!; + const result = (await fn( + makeReq({ + name: "memory_smart_search", + arguments: { + query: "auth", + limit: 5, + project: " api ", + }, + }), + )) as { status_code: number }; + + expect(result.status_code).toBe(200); + expect(receivedPayload).toMatchObject({ + query: "auth", + limit: 5, + project: "api", + }); + }); +}); diff --git a/test/mcp-standalone-proxy.test.ts b/test/mcp-standalone-proxy.test.ts index dc08a024e..7ee7f9ca2 100644 --- a/test/mcp-standalone-proxy.test.ts +++ b/test/mcp-standalone-proxy.test.ts @@ -54,10 +54,12 @@ describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { }); it("proxies memory_smart_search to POST /agentmemory/smart-search", async () => { + let smartSearchBody: Record | undefined; installFetch((url, init) => { if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 }); if (url.endsWith("/agentmemory/smart-search")) { const body = JSON.parse((init?.body as string) || "{}"); + smartSearchBody = body; return new Response( JSON.stringify({ mode: "compact", @@ -69,10 +71,19 @@ describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { } return new Response("", { status: 404 }); }); - const res = await handleToolCall("memory_smart_search", { query: "auth bug", limit: 5 }); + const res = await handleToolCall("memory_smart_search", { + query: "auth bug", + limit: 5, + project: "api", + }); const body = JSON.parse(res.content[0].text); expect(body.query).toBe("auth bug"); expect(body.results[0].id).toBe("m1"); + expect(smartSearchBody).toMatchObject({ + query: "auth bug", + limit: 5, + project: "api", + }); }); it("proxies memory_recall to POST /agentmemory/search and forwards format/token_budget (#507)", async () => { @@ -100,6 +111,7 @@ describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { limit: 5, format: "full", token_budget: 800, + project: "api", }); const body = JSON.parse(res.content[0].text); expect(body.mode).toBe("full"); @@ -111,10 +123,46 @@ describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { limit: 5, format: "full", token_budget: 800, + project: "api", }); expect(calls.find((c) => c.url.endsWith("/agentmemory/smart-search"))).toBeUndefined(); }); + it("proxies memory_save to POST /agentmemory/remember with project (#926)", async () => { + const calls: Array<{ url: string; body?: unknown }> = []; + installFetch((url, init) => { + if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 }); + const body = init?.body ? JSON.parse(init.body as string) : undefined; + calls.push({ url, body }); + if (url.endsWith("/agentmemory/remember")) { + return new Response(JSON.stringify({ success: true, memory: { id: "mem_1" } }), { + status: 201, + headers: { "content-type": "application/json" }, + }); + } + return new Response("not found", { status: 404 }); + }); + + await handleToolCall("memory_save", { + content: "proxy scoped memory", + concepts: "mcp, project", + files: "src/mcp/standalone.ts", + project: "api", + }); + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + url: `${BASE}/agentmemory/remember`, + body: { + content: "proxy scoped memory", + type: "fact", + concepts: ["mcp", "project"], + files: ["src/mcp/standalone.ts"], + project: "api", + }, + }); + }); + it("memory_recall defaults format to 'full' when omitted (#507)", async () => { let recallBody: Record | undefined; installFetch((url, init) => { diff --git a/test/mcp-standalone.test.ts b/test/mcp-standalone.test.ts index b48eade96..7586d4fb6 100644 --- a/test/mcp-standalone.test.ts +++ b/test/mcp-standalone.test.ts @@ -89,6 +89,15 @@ describe("Tools Registry", () => { expect(tool.inputSchema.properties).toBeDefined(); } }); + + it("core memory save and read tools expose project scope inputs (#926)", () => { + const tools = new Map(CORE_TOOLS.map((tool) => [tool.name, tool])); + for (const name of ["memory_save", "memory_recall", "memory_smart_search"]) { + expect(tools.get(name)?.inputSchema.properties.project).toMatchObject({ + type: "string", + }); + } + }); }); describe("InMemoryKV", () => { @@ -222,6 +231,72 @@ describe("handleToolCall", () => { expect(parsed.results[0].content).toBe("TypeScript is great"); }); + it("memory_save stores project and local project reads keep matching plus legacy memories (#926)", async () => { + const kv = new InMemoryKV(); + const api = JSON.parse( + ( + await handleToolCall( + "memory_save", + { + content: "API request-scoped JWT middleware", + project: " api ", + }, + kv, + ) + ).content[0].text, + ) as { saved: string }; + const web = JSON.parse( + ( + await handleToolCall( + "memory_save", + { + content: "Web request-scoped JWT middleware", + project: "web", + }, + kv, + ) + ).content[0].text, + ) as { saved: string }; + await handleToolCall( + "memory_save", + { content: "Legacy request-scoped JWT middleware" }, + kv, + ); + + const apiMemory = await kv.get<{ project?: string }>("mem:memories", api.saved); + const webMemory = await kv.get<{ project?: string }>("mem:memories", web.saved); + expect(apiMemory?.project).toBe("api"); + expect(webMemory?.project).toBe("web"); + + const apiRecall = JSON.parse( + ( + await handleToolCall( + "memory_recall", + { query: "request-scoped JWT", project: "api" }, + kv, + ) + ).content[0].text, + ) as { results: Array<{ content: string; project?: string }> }; + expect(apiRecall.results.map((m) => m.content)).toEqual([ + "API request-scoped JWT middleware", + "Legacy request-scoped JWT middleware", + ]); + + const webSmartSearch = JSON.parse( + ( + await handleToolCall( + "memory_smart_search", + { query: "request-scoped JWT", project: "web" }, + kv, + ) + ).content[0].text, + ) as { results: Array<{ content: string; project?: string }> }; + expect(webSmartSearch.results.map((m) => m.content)).toEqual([ + "Web request-scoped JWT middleware", + "Legacy request-scoped JWT middleware", + ]); + }); + it("memory_save accepts concepts/files as arrays (plugin skill format, #139)", async () => { const kv = new InMemoryKV(); const result = await handleToolCall(