Skip to content

fix(mcp): keep switch-organization available when only a project is pinned#73023

Open
posthog[bot] wants to merge 1 commit into
masterfrom
posthog-code/mcp-cross-org-switch-on-project-pin
Open

fix(mcp): keep switch-organization available when only a project is pinned#73023
posthog[bot] wants to merge 1 commit into
masterfrom
posthog-code/mcp-cross-org-switch-on-project-pin

Conversation

@posthog

@posthog posthog Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Problem

Multi-org users can't reach a project in another organization over MCP. The catalog is supposed to support this: switch-project's own description documents the recovery flow - if a project isn't found, "call organizations-get and switch-organization first, then retry projects-get."

But the exposure logic contradicts that. When a request pins a projectId, the resolver excluded both switch-organization and switch-project:

if (projectId) {
    excludeTools.push('switch-organization', 'switch-project')
} else if (organizationId) {
    excludeTools.push('switch-organization')
}

Nearly every MCP connection pins a project, so the switch tools were almost never exposed, and the documented cross-org flow was impossible to execute from an active project.

For context on how this surfaced: a Signals scout searched a project-scoped catalog and found no org tools at all. That part is correct and expected - a project-scoped key (scoped_teams) can't act across orgs, and getToolsForFeatures already strips every organization:* tool the backend would 403 for such a token. So this change does not alter what a project-scoped connection sees. It fixes the real blocker for the affected population: org-capable keys (personal API keys / OAuth with organization:read) that pin a project and were silently denied switch-organization.

Changes

Pinning now fixes the default active context without disabling navigation. Only an explicitly pinned organization is a hard lock (drop switch-organization, keep project switching). A pinned project excludes nothing, so the full discover-then-switch flow works from an active project.

The derivation moved into a small pure function so the intent is explicit and testable:

export function switchToolsToExclude(pinned: { organizationId?: string | undefined }): string[] {
    return pinned.organizationId ? ['switch-organization'] : []
}
Pinned context Before After
nothing exclude nothing exclude nothing
org only exclude switch-organization exclude switch-organization
project only exclude both switch tools exclude nothing
org + project exclude both switch tools exclude switch-organization

Note

This does not weaken project-scoped-key isolation. switch-organization requires organization:read, which getToolsForFeatures already strips for scoped_teams keys, independent of this logic.

How did you test this code?

Automated only - I (Claude) did not run a live multi-org MCP session.

  • New tests/unit/switch-tools-to-exclude.test.ts - covers the derivation the resolver had no unit coverage for. The regression it catches: if a pinned project ever re-excludes switch-organization/switch-project, the documented cross-org flow silently breaks. Existing tool-filtering tests only exercise the filter given an explicit excludeTools array; they never tested how that array is built from the pinned context.
  • Ran the new test plus tests/unit/tool-filtering.test.ts and tests/integration/feature-routing.test.ts: 91 passed. feature-routing already asserts the workspace feature exposes organizations-get / switch-organization / projects-get / switch-project, so tool registration stays verified.

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

Docs update

No docs changes - the MCP tool catalog is generated from tool definitions and the behavior lives in the resolver.

🤖 Agent context

Autonomy: Fully autonomous

Investigated and implemented by Claude (Opus 4.8) via PostHog Code, from an inbox report that org discovery/switch tools were still missing after #71976. I reproduced the symptom against the live catalog and traced it to two independent filters: the scopedTeams strip (correct - project-scoped key) and the excludeTools project-pin logic (the real bug). I confirmed the reporting session used a project-scoped key, so #71976 was never the relevant lever there; the fix targets org-capable keys instead, matching the report's stated user population.

Considered but rejected: relaxing the scopedTeams strip (would advertise tools the backend 403s), and adding a discoverability hint for project-scoped keys (useful, but a separate concern - kept this PR focused on the navigation bug). Skill invoked: /writing-tests.


Created with PostHog Code from this inbox report.

…inned

A pinned projectId excluded both switch-organization and switch-project, which
made the documented cross-org navigation flow impossible: from an active project
an agent is supposed to resolve an org via organizations-get, call
switch-organization, then switch-project to reach a project in another
organization. Nearly every MCP connection pins a project, so the switch tools
were almost never exposed.

Only an explicitly pinned organization is now treated as a hard lock (drop
switch-organization, keep project switching). A pinned project excludes nothing.
Project-scoped keys (scoped_teams) are unaffected — getToolsForFeatures already
strips organization:* tools the backend would 403 for such tokens.

Generated-By: PostHog Code
Task-Id: 5d4d5022-89c9-4f80-930c-63af7f569714
@trunk-io

trunk-io Bot commented Jul 22, 2026

Copy link
Copy Markdown

Merging to master in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@andrewm4894
andrewm4894 marked this pull request as ready for review July 23, 2026 11:52
@andrewm4894 andrewm4894 self-assigned this Jul 23, 2026
@andrewm4894 andrewm4894 added the reviewhog ($$$) Reviews pull requests before humans do label Jul 23, 2026
@posthog

posthog Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

🦔 ReviewHog reviewed this pull request

Nothing worth raising this time, so here's a calming picture instead:

A panda relaxing and waving

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bc1fe22d93

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

} else if (organizationId) {
excludeTools.push('switch-organization')
}
const excludeTools = switchToolsToExclude({ organizationId })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep project switches from being overwritten

When a connection sends project_id/x-posthog-project-id on every MCP request, this exposes switch-project even though the resolver still writes props.projectId back into the token cache at the start of each request (reqCtx.tokenCache.setMany above). In the documented flow, switch-project can set the new project during its call, but the next tool call resolves state again and immediately restores the originally pinned project, so subsequent project-scoped tools run against the old project. Either make the project pin a one-time/default value before advertising navigation, or keep hiding switch-project for truly hard-pinned project sessions.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — this is a real gap, thanks for catching it.

switch-project writes projectId into the same token cache key that resolve() re-pins from props.projectId via setMany at the top of every request, and props.projectId is derived per-request from x-posthog-project-id / project_id (request-properties.ts:75, index.ts:229). So for a connection that keeps sending the project pin — the common case this PR is aimed at — a switch-project call is reverted on the very next request, and later project-scoped tools silently run against the originally pinned project. That's arguably worse than hiding the tool, since it now looks usable but no-ops across requests.

Worth noting switch-organization is not affected in the target scenario: the org is usually not pinned, so setMany never re-asserts orgId and the org switch persists. Only the project step gets clobbered — which means exposing switch-project on a project pin isn't sufficient on its own.

Fix direction: treat the project pin as a one-time default rather than a hard re-pin — only seed projectId from props when nothing is already cached (gate the setMany project write on the absence of a cached projectId), so a subsequent switch-project survives later requests. Will address before merge.

@posthog

posthog Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Thanks ReviewHog — noted, nothing to action from this pass. 🐼

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

reviewhog ($$$) Reviews pull requests before humans do

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant