fix(mcp): hide org-only tools for project-scoped tokens#59957
Conversation
Tools that need an org-level scope (organization:*, organization_member:*, organization_integration:*) can't be exercised with a project-scoped personal API key or OAuth token. posthog/permissions.py:559 raises 403 because view.team isn't resolvable for org-nested viewsets. ~446 such 403s have hit 200+ distinct customer orgs in the last 30 days across real clients (claude-code, cursor-vscode, codex-mcp-client, replit, etc.). This hides those tools from tools/list when the session's token carries scoped_teams, so the agent doesn't waste calls on guaranteed failures. ToolFilterOptions gains a scopedTeams field that getToolsForFeatures uses to drop tools whose required_scopes contain any scope starting with organization — by convention in posthog/scopes.py every org-level scope object uses that prefix. request-state-resolver pulls scoped_teams off the API key and passes it into the existing catalog filter. Five new unit tests in tool-filtering.test.ts cover the filter, including the empty and undefined cases that must not gate any tool. Three tools 403 today on project-scoped tokens but won't be hidden by this filter because their required_scopes don't include any organization scope: feature-flags-copy-flags-create (feature_flag:write), project-get (project:read), project-settings-update (project:write). Those will be addressed separately by adding project-nested API endpoints on the backend, not by widening the MCP filter.
Prompt To Fix All With AIFix the following 1 code review issue. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 1
services/mcp/tests/unit/tool-filtering.test.ts:544-554
The two tests for `scopedTeams: []` and `scopedTeams: undefined` are identical in their assertions and differ only in the input value — a clear parameterization candidate per the project's preference. Using `it.each` here removes the duplication and makes extending the boundary cases cheaper.
```suggestion
it.each([
{ label: 'empty array (unscoped token)', scopedTeams: [] as number[] },
{ label: 'undefined', scopedTeams: undefined },
])('keeps org-scope tools visible when scopedTeams is $label', ({ scopedTeams }) => {
const tools = getToolsForFeatures({ scopedTeams })
expect(tools).toContain('roles-list')
expect(tools).toContain('organization-get')
})
```
Reviews (1): Last reviewed commit: "fix(mcp): hide org-only tools from tools..." | Re-trigger Greptile |
| it('keeps org-scope tools visible when scopedTeams is empty (unscoped token)', () => { | ||
| const tools = getToolsForFeatures({ scopedTeams: [] }) | ||
| expect(tools).toContain('roles-list') | ||
| expect(tools).toContain('organization-get') | ||
| }) | ||
|
|
||
| it('keeps org-scope tools visible when scopedTeams is undefined', () => { | ||
| const tools = getToolsForFeatures({ scopedTeams: undefined }) | ||
| expect(tools).toContain('roles-list') | ||
| expect(tools).toContain('organization-get') | ||
| }) |
There was a problem hiding this comment.
The two tests for
scopedTeams: [] and scopedTeams: undefined are identical in their assertions and differ only in the input value — a clear parameterization candidate per the project's preference. Using it.each here removes the duplication and makes extending the boundary cases cheaper.
| it('keeps org-scope tools visible when scopedTeams is empty (unscoped token)', () => { | |
| const tools = getToolsForFeatures({ scopedTeams: [] }) | |
| expect(tools).toContain('roles-list') | |
| expect(tools).toContain('organization-get') | |
| }) | |
| it('keeps org-scope tools visible when scopedTeams is undefined', () => { | |
| const tools = getToolsForFeatures({ scopedTeams: undefined }) | |
| expect(tools).toContain('roles-list') | |
| expect(tools).toContain('organization-get') | |
| }) | |
| it.each([ | |
| { label: 'empty array (unscoped token)', scopedTeams: [] as number[] }, | |
| { label: 'undefined', scopedTeams: undefined }, | |
| ])('keeps org-scope tools visible when scopedTeams is $label', ({ scopedTeams }) => { | |
| const tools = getToolsForFeatures({ scopedTeams }) | |
| expect(tools).toContain('roles-list') | |
| expect(tools).toContain('organization-get') | |
| }) |
Prompt To Fix With AI
This is a comment left during a code review.
Path: services/mcp/tests/unit/tool-filtering.test.ts
Line: 544-554
Comment:
The two tests for `scopedTeams: []` and `scopedTeams: undefined` are identical in their assertions and differ only in the input value — a clear parameterization candidate per the project's preference. Using `it.each` here removes the duplication and makes extending the boundary cases cheaper.
```suggestion
it.each([
{ label: 'empty array (unscoped token)', scopedTeams: [] as number[] },
{ label: 'undefined', scopedTeams: undefined },
])('keeps org-scope tools visible when scopedTeams is $label', ({ scopedTeams }) => {
const tools = getToolsForFeatures({ scopedTeams })
expect(tools).toContain('roles-list')
expect(tools).toContain('organization-get')
})
```
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
|
Size Change: 0 B Total Size: 80 MB ℹ️ View Unchanged
|
|
|
||
| // Hide tools that need org-level access when the session's token is | ||
| // project-scoped - the backend would 403 them | ||
| if (scopedTeams && scopedTeams.length > 0) { |
There was a problem hiding this comment.
Should it check if the key has access_type=all? Ref: https://posthog.slack.com/archives/C06NZEZ7V3Q/p1779801478143529?thread_ts=1779800677.932059&cid=C06NZEZ7V3Q
There was a problem hiding this comment.
Not really, access_type is a UI-only field in personalAPIKeysLogic.tsx
The serializer only exposes scoped_teams / scoped_organizations, so it would be a bigger refactor

Problem
Agents that authenticate to PostHog's MCP server with a project-scoped personal API key or OAuth token still see org-level tools in
tools/list— tools likeroles-list,org-members-list,organization-get,organizations-list,projects-get, and theproxy-*family. They call them, the backend 403s the request becauseview.teamisn't resolvable for org-nested viewsets, and the agent gets a confusing "API keys with scoped projects are only supported on project-based endpoints" error.In the last 30 days, ~446 such 403s have hit 200+ distinct customer orgs across real clients (claude-code, cursor-vscode, codex-mcp-client, Replit, CodeRabbit, Anthropic/ClaudeAI, etc.). Per-tool error rates: MCP — Platform Features tools usage.
Changes
ToolFilterOptionsgains ascopedTeamsfield — the token'sscoped_teamsarray from the API key.request-state-resolver.tsreadsscoped_teamsoff the API key and passes it intocatalog.getFilteredToolsalongside the existingscopesgetToolsForFeatures()drops tools whoserequired_scopesinclude any scope starting withorganization. Every org-level scope object uses that prefix (organization,organization_integration,organization_member).Known gap — three tools still 403, not covered by this filter
Three tools still 403 on project-scoped tokens but won't be hidden by this filter because their
required_scopesdon't include anyorganization*scope:required_scopesfeature-flags-copy-flags-createfeature_flag:writePOST /api/organizations/{org_id}/feature_flags/copy_flags/project-getproject:readGET /api/organizations/{org_id}/projects/{id}/project-settings-updateproject:writePATCH /api/organizations/{org_id}/projects/{id}/settings/For these, the scope describes the data being read/written, but the URL is org-nested because the API exposes no project-nested alternative. The right fix is on the backend — add
GET /api/projects/{id}/for project metadata, similar for settings, and similarly relaxfeature-flags-copy-flags-create. We'll tackle that separately rather than widening the MCP filter to compensate.How did you test this code?
Ran the existing unit suite:
All 1,300 unit tests pass, including 5 new ones in
tool-filtering.test.tscovering: non-emptyscopedTeamshides org-scoped tools, non-emptyscopedTeamskeeps project-scoped tools visible, empty/undefinedscopedTeamskeeps everything visible, and the filter composes with the existing feature/tools allowlist.Publish to changelog?
no