MCP: default project resolution stores "NaN" when the token's scoped org isn't the user's active org
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 39.9k
- Forks
- 3.4k
- Avg merge
- 6h 51m
- Merged PRs (30d)
- 232
Description
Summary
StateManager._getDefaultOrganizationAndProject calls Number() on a project object, producing NaN, which is then persisted as the string "NaN" and used as a project id for the life of the cache entry (7 days).
services/mcp/src/lib/StateManager.ts:170:
const projectsResult = await this._api.organizations().projects({ orgId: organizationId }).list()
if (projectsResult.success && projectsResult.data.length > 0) {
return { organizationId, projectId: Number(projectsResult.data[0]!) }
}
projects().list() is typed Promise<Result<Schemas.ProjectBackwardCompat[]>> and returns result.data.results (services/mcp/src/api/client.ts:536-545). ProjectBackwardCompatBasic is an object (services/mcp/src/api/generated.ts:44560), so Number(...) is NaN.
The intent is clearly .id — the sibling branch a few lines earlier in the same function returns projectId: activeTeam.id.
Impact
setDefaultOrganizationAndProject guards with if (projectId !== undefined), and NaN !== undefined is true, so "NaN" is written to the token-scoped cache. getProjectId() then returns a truthy "NaN" and every project-nested request becomes:
GET /api/projects/NaN/...POST /api/environments/NaN/mcp_tools/<tool>/
These 404. Because the value is cached (keyed on hash(bearer_token), 7-day TTL) and the default is only derived when no projectId is cached, it does not self-heal — every subsequent request on that token hits the same dead project id. The agent gets a bare 404 with NaN in the URL and no hint that switch-project would fix it, unlike the genuinely-missing-context path which returns the helpful MissingProjectContextError text.
When it fires
Only the fallback branch, i.e. when the token has scoped_organizations and the user's active organization is not among them (so the activeOrganization branch at StateManager.ts:148-155 is skipped). Concretely: an org-scoped OAuth token for a user who belongs to more than one organization and whose current organization isn't the scoped one.
Found while building an org-scoped OAuth integration against mcp.posthog.com.
Why CI doesn't catch it
services/mcp/tests/unit/StateManager.test.ts:200-206 mocks the list call as:
list: vi.fn().mockResolvedValue({ success: true, data: [789] }),
A bare number array, which the real client cannot return. With that mock Number(789) is 789 and the test passes.
Suggested fix
return { organizationId, projectId: Number(projectsResult.data[0]!.id) }
and update the mock to return project-shaped objects so the test exercises the real contract.
Notes
Introduced in #55953 (2026-04-23). list() already returned ProjectBackwardCompat[] at that commit, so this is not a regression from a later type change — the call has never matched its input type. Number() accepts any, so TypeScript doesn't flag it.
Verified by reading source at 34c5044330427. I have not run this against production.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in services/mcp/src/lib/StateManager.ts around line 170, then inspect the projects().list() contract in services/mcp/src/api/client.ts and the project shape in services/mcp/src/api/generated.ts. Update services/mcp/tests/unit/StateManager.test.ts around lines 200-206 so its mock matches the real response, run the StateManager tests, and verify the fallback never persists NaN as a project id.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 86/100