Scope MCP tool calls to the authenticated tenant#181
Open
vincenzopalazzo wants to merge 2 commits into
Open
Conversation
The HTTP MCP route already runs through the global auth middleware
(`authenticate_api_request` in src/server/index.ts), so every authenticated
`/mcp` request had `req.tenant` populated — but the MCP tool handlers in
`src/ai/mcp.ts` never consulted it. As a result:
- `openmemory_store` and `openmemory_store_project` wrote memories with
`user_id="anonymous"` regardless of the caller's tenant, so the rows
were invisible to REST `GET /memory/all` (which is tenant-scoped via
`WHERE user_id = $1`). This is the divergence the bug report describes.
- `openmemory_list`, `openmemory_get`, `openmemory_query`, and
`openmemory_delete` ran without a tenant filter and read/touched
memories belonging to every tenant on the deployment.
- `openmemory_reinforce` had no ownership check at all.
Thread the tenant into `create_mcp_srv(tenant?)` and have each tool resolve
the effective `user_id` via a single helper (`resolve_user_id`): when the
tool runs HTTP-bound it always uses the tenant for writes and as a forced
read filter, and rejects any explicit `user_id` arg that disagrees — the
same `tenant_mismatch` contract the REST middleware already enforces.
The stdio transport (`start_mcp_stdio`, used by local Claude Code) still
calls `create_mcp_srv()` with no tenant, preserving the existing
"anonymous"/unfiltered behaviour so local installs keep working unchanged.
Covered by `tests/mcp_per_tenant.test.ts`: the regression scenario from
the bug report (MCP store -> MCP list on the same tenant returns the row),
cross-tenant isolation, the `user_id`-forge rejection, and the stdio
back-compat path. The new spec fails on the pre-fix code (4/5 failures)
and passes after.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`packages/openmemory-js/tests/test_omnibus.ts` was renamed to `tests/omnibus.test.ts` in cbc8c84 ("port ad-hoc tsx scripts to vitest specs"), but the CI step kept calling the old path with `npx tsx`. The "Test Node.js SDK" job has been red on every PR since. Switch the step to `npm test` so the omnibus spec, the temporal / multilingual / webhook / verify suites, and any new vitest specs (e.g. the per-tenant MCP regression added in the parent commit) all run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
User story
As an operator running OpenMemory in multi-tenant HTTP mode, when I store a memory through the MCP
openmemory_storetool with my API key, I want that memory to appear in MCPopenmemory_listand in the REST/memory/all/ dashboard views scoped to my tenant — and not to appear in any other tenant's views — so that the MCP and REST surfaces share one consistent picture of "my" data, and other tenants never see mine.Today, none of that holds:
user_id="anonymous", so my tenant-scoped REST/memory/alland dashboard list don't return it. From my point of view the write looks silently lost — exactly what the bug report (bug-mcp-store-not-in-list.md) describes.After this PR, both halves hold: writes are stamped with the authenticated tenant, reads are filtered to it, and explicit
user_idargs that disagree are rejected withtenant_mismatch— the same contract REST already enforces.Summary
POST /mcp) goes through the globalauthenticate_api_requestmiddleware (src/server/index.ts:83), soreq.tenantis populated for every authenticated MCP request — but the tool handlers insrc/ai/mcp.tsnever consulted it.openmemory_store/openmemory_store_projectwrote rows withuser_id="anonymous"regardless of the authenticated tenant, which is invisible to RESTGET /memory/all(which filtersWHERE user_id = \$1). That divergence is the symptom in the bug report: a memory stored via MCP never appears in the tenant-scoped list.openmemory_list,openmemory_get,openmemory_query,openmemory_delete) ran without any tenant filter, so they returned/touched memories belonging to every tenant on the deployment — a cross-tenant data leak that survived the hardening PR (chore(openmemory-js): security & test hardening (P1+P2 close-out) #172).openmemory_reinforcehad no ownership check at all.Root cause
create_mcp_srv()was a no-arg factory and the per-requesthandle_reqnever extractedreq.tenant, so tool handlers could not see who was calling. This was a missed seam from the multi-tenant hardening that landed for the REST routes (require_tenant+reject_tenant_mismatch).Fix
create_mcp_srv(tenant?: string)accepts a tenant captured in closures.handle_reqreadsreq.tenantand threads it in.resolve_user_id(tenant, arg)helper enforces the same contract as the REST middleware: when a tenant is bound it is the source of truth; an explicituser_idarg that disagrees is rejected with atenant_mismatcherror.user_id(openmemory_query,openmemory_store,openmemory_store_project,openmemory_list,openmemory_get,openmemory_delete) now callsresolve_user_id(tenant, user_id)instead ofuid(user_id).openmemory_reinforce(which takes nouser_id) gains a tenant ownership check before the salience bump.Stdio back-compat
start_mcp_stdiostill callscreate_mcp_srv()with no tenant — there is no HTTP request to carry an API key. Local Claude Code setups keep the existing "anonymous" / unfiltered behaviour. A regression test pins this.Test plan
npm run typecheck— cleannpm test— 33/33 pass (28 pre-existing + 5 new intests/mcp_per_tenant.test.ts)npm run build— cleanNew spec covers:
openmemory_storewrites the row withuser_id = tenant(not"anonymous").openmemory_listreturns the tenant's own MCP-stored memories on the same session — the literal reproduction from the bug report.openmemory_listcalls do not cross-leak.openmemory_storewith a mismatching explicituser_idreturnsisError: trueand surfacestenant_mismatch.Drive-by CI fix (commit `8c2d056`)
.github/workflows/ci.ymlwas still invokingnpx tsx tests/test_omnibus.ts, but that file was renamed totests/omnibus.test.tsincbc8c84("port ad-hoc tsx scripts to vitest specs"). Thetest-nodejob has been red on every PR since (#172, #174, #178 all merged with it failing). Switched the step tonpm testso the omnibus spec, the existing temporal / multilingual / webhook / verify suites, and the new per-tenant MCP regression all run.🤖 Generated with Claude Code