{"record":{"id":"1ed58b205e7c2198","repo":"thedotmack/claude-mem","slug":"api-key-is-scoped-to-a-different-project","errorCode":null,"errorMessage":"API key is scoped to a different project","messagePattern":"API key is scoped to a different project","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"src/server/routes/v1/ServerV1PostgresRoutes.ts","lineNumber":1017,"sourceCode":"        });\n      },\n    ));\n\n    // Remote authenticated MCP endpoint. The \"secure MCP link\" a user pastes\n    // into Claude Code (or any MCP client) to recall their cloud memory:\n    //   claude mcp add --transport http claude-mem <base>/v1/mcp \\\n    //     --header \"Authorization: Bearer cm_...\"\n    // Same readAuth (memories:read) + team/project scoping + audit trail as\n    // /v1/search, so it reads identical data through identical guards. Stateless\n    // streamable-HTTP: one transport + server per request, bound to this key's team.\n    const mcpHandler = this.asyncHandler(async (req, res) => {\n      const teamId = this.requireTeamId(req, res);\n      if (!teamId) return;\n      const projectScope = req.authContext?.projectId ?? null;\n      const repo = new PostgresObservationRepository(this.options.pool);\n      const assertProjectAllowed = (projectId: string): void => {\n        if (projectScope && projectScope !== projectId) {\n          throw new Error('API key is scoped to a different project');\n        }\n      };\n      const backend: RecallBackend = {\n        search: async ({ projectId, query, limit }) => {\n          assertProjectAllowed(projectId);\n          const rows = await repo.search({ projectId, teamId, query, limit });\n          // Audit the read, same as POST /v1/search — the MCP path is no exception.\n          await this.auditWrite(req, 'observation.read', null, projectId, {\n            mode: 'search', via: 'mcp', query, limit,\n            resultCount: rows.length, observationIds: rows.map(o => o.id),\n          });\n          return rows.map(serializeObservation);\n        },\n        context: async ({ projectId, query, limit }) => {\n          assertProjectAllowed(projectId);\n          const rows = await repo.search({ projectId, teamId, query, limit });\n          await this.auditWrite(req, 'observation.read', null, projectId, {\n            mode: 'context', via: 'mcp', query, limit,","sourceCodeStart":999,"sourceCodeEnd":1035,"githubUrl":"https://github.com/thedotmack/claude-mem/blob/d768ba364302d12b76e69e4f021f0bb1d2d50ed6/src/server/routes/v1/ServerV1PostgresRoutes.ts#L999-L1035","documentation":"Thrown by assertProjectAllowed() inside the /v1/mcp handler when the authenticated API key is scoped to a specific project (projectScope from the auth context) but the MCP tool call requests a different projectId. This enforces project-level authorization on the read-only MCP surface, identical to /v1/search scoping. The check only fires when the key has a non-null projectScope.","triggerScenarios":"An API key created with a project scope of projectA is used in an MCP tool call (search/context/recent) that passes projectId=projectB. The client hardcodes or caches a projectId that no longer matches the key's scope.","commonSituations":"A team-scoped key is reused across projects after reconfiguration. A client defaults to a project id that differs from the key's scope. A key was re-scoped but the client still sends the old projectId.","solutions":["Use a key scoped to the requested project, or an unscoped (team-level) key if you need cross-project access.","Pass the projectId that matches the key's scope in the tool arguments.","Re-issue the key with the correct project scope via `claude-mem server api-key`.","Audit the key's scope and align the client's projectId with it."],"exampleFix":"// before: key scoped to projectA, client requests projectB -> throws\nclient.callTool({ name: 'search', arguments: { projectId: 'projectB', query: 'x' } });\n// after: request the project the key is scoped to\nclient.callTool({ name: 'search', arguments: { projectId: 'projectA', query: 'x' } });\n// or: re-issue an unscoped key for cross-project reads","handlingStrategy":"validation","validationCode":"// Client-side: align projectId with the key's scope before calling the tool.\nfunction pickProjectId(keyScope: string | null, requested: string): string {\n  if (keyScope && keyScope !== requested) {\n    // either use the key's scope, or obtain an unscoped key\n    return keyScope;\n  }\n  return requested;\n}","typeGuard":"function projectAllowed(keyScope: string | null, projectId: string): boolean {\n  return keyScope === null || keyScope === projectId;\n}","tryCatchPattern":"try {\n  await backend.search({ projectId, query, limit });\n} catch (error) {\n  if (/scoped to a different project/i.test((error as Error).message)) {\n    // re-issue with the projectId matching the key's scope,\n    // or re-authenticate with an unscoped key\n    return makeMcpError('API key project scope mismatch');\n  }\n  throw error;\n}","preventionTips":["Know the scope of each API key at creation time; document it alongside the key.","For cross-project reads, issue an unscoped (team-level) key.","Have clients prefer the key's resolved scope over a hardcoded projectId."],"tags":["auth","authorization","mcp","multi-tenant","api-key"],"backgroundTag":null,"analyzedSha":"d768ba364302d12b76e69e4f021f0bb1d2d50ed6","analyzedAt":"2026-08-12T23:52:55.241Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}