skills/testing-mcp-tools-locally/SKILL.md
Set up the local dev environment, seed data, and API keys to test the staff-only managed migrations MCP tools (managed-migrations-support-list, managed-migrations-support-get) end to end. Use when testing batch import support tooling, debugging MCP tool responses or discovery (tools not appearing), or verifying the support API before deploying. Covers the discovery gate: hidden scope, is_staff, user:read, and why wildcard keys and OAuth never work.
npx skillsauth add posthog/ai-plugin testing-mcp-tools-locallyInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
The dev environment must be running with Docker services healthy. The batch import support API and MCP tools require:
is_staff = True)batch_import_support:read and user:read, explicitlyWhy both scopes: the backend accepts batch_import_support:read alone,
but MCP tool discovery verifies staffness via /api/users/@me/ and hides the tools (fail-closed) when the key cannot make that call.
A * wildcard does not substitute for either — the discovery gate requires the hidden scope explicitly, and the backend's INTERNAL scope handling rejects wildcard keys outright.
For the production setup flow, see docs/support-mcp-tools.md.
hogli start -d
hogli wait
If hogli wait fails on migrate-persons-db or migrate-behavioral-cohorts,
those are optional separate databases — ignore them.
If it fails on migrate-postgres, check Docker port forwarding (see troubleshooting below).
hogli migrations:run
ClickHouse migration failures are fine — batch imports only need Postgres.
hogli dev:shell-plus -y -- -c "
from posthog.models import Team, User
print(Team.objects.first(), User.objects.first())
"
If this fails with connection refused on port 5432, see troubleshooting below.
Use hogli dev:shell-plus to create BatchImport records in various states.
The secrets field is an EncryptedJSONStringField — empty {} serializes to null
and violates the NOT NULL constraint; always pass a non-empty dict.
from products.managed_migrations.backend.models.batch_imports import BatchImport
BatchImport.objects.create(
team=team,
created_by_id=user.id,
status=BatchImport.Status.PAUSED,
import_config={
'source': {'type': 's3', 'bucket': 'test', 'region': 'us-east-1', 'prefix': 'data/'},
'data_format': {'type': 'json_lines', 'skip_blanks': True, 'content': {'type': 'mixpanel'}},
'sink': {'type': 'capture'},
},
secrets={'access_key': 'test', 'secret_key': 'test'},
state={'parts': [
{'key': 'part-1', 'current_offset': 50000, 'total_size': 50000},
{'key': 'part-2', 'current_offset': 10000, 'total_size': 50000},
{'key': 'part-3'},
]},
)
See references/seed-data.md for a full seeding script covering all statuses.
Important: the local batch-import-worker process will pick up RUNNING records
and may modify their status (e.g. pausing them due to config validation errors).
To keep records stable for testing, either stop the worker or use COMPLETED/FAILED/PAUSED statuses.
Mint fresh keys rather than editing scopes on an existing one — the MCP server caches a key's scopes per token, so edited scopes can serve stale results.
from posthog.models import User
from posthog.models.personal_api_key import PersonalAPIKey
from posthog.models.utils import generate_random_token_personal, hash_key_value
me = User.objects.first()
me.is_staff = True; me.save()
def mint(user, scopes):
token = generate_random_token_personal()
PersonalAPIKey.objects.create(user=user, label=str(scopes)[:40], secure_value=hash_key_value(token), scopes=scopes)
return token
print(mint(me, ["batch_import_support:read", "user:read"]))
To test the negative cases of the discovery gate, also mint:
a ["*"] key (tools must NOT appear),
a ["batch_import_support:read"] key without user:read (tools must NOT appear — staff lookup fails closed),
and the full pair on a non-staff user (tools must NOT appear).
# List all batch imports
curl -H "Authorization: Bearer <token>" \
http://localhost:8010/api/managed_migrations_support/ | jq
# Get detail for a specific import
curl -H "Authorization: Bearer <token>" \
http://localhost:8010/api/managed_migrations_support/<uuid>/ | jq
Run the Hono server, not pnpm run dev.
The wrangler worker (pnpm run dev, port 8787) proxies /mcp to production mcp.us.posthog.com unless MCP_HONO_URL is set,
so local keys get 401 Invalid API key.
The Hono server serves MCP directly against the local API:
cd services/mcp
cp .dev.vars.example .dev.vars # POSTHOG_API_BASE_URL=http://localhost:8010
pnpm run dev:hono # serves http://localhost:3001/mcp
Authenticate with the PAT as a Bearer header, never the OAuth flow. The hidden scope is structurally absent from OAuth — signing in through the inspector's OAuth login can never surface these tools.
The Hono server runs exec mode: tools/list returns a single exec tool,
and real tools are discovered and invoked through it.
Test with the MCP Inspector CLI:
# Discovery — should list both support tools for the staff key, none for the others
npx @modelcontextprotocol/inspector --cli http://localhost:3001/mcp \
--header "Authorization: Bearer <token>" \
--method tools/call --tool-name exec --tool-arg "command=search managed-migrations-support"
# Invocation — end-to-end through Django
npx @modelcontextprotocol/inspector --cli http://localhost:3001/mcp \
--header "Authorization: Bearer <token>" \
--method tools/call --tool-name exec --tool-arg "command=call managed-migrations-support-list {}"
Expected discovery matrix:
| key | tools visible |
| ----------------------------------------------------------- | -------------------------------- |
| staff user, batch_import_support:read + user:read | both |
| staff user, * only | none |
| staff user, batch_import_support:read without user:read | none (staff lookup fails closed) |
| non-staff user, both scopes | none (and direct API calls 403) |
The interactive Inspector UI (http://localhost:6274) also works —
paste the PAT as the Bearer token in connection settings instead of using its OAuth login.
You're talking to the wrangler worker, which proxies /mcp to production — your local key is invalid there.
Use the Hono server on port 3001 (see step 7), or set MCP_HONO_URL=http://localhost:3001 in .dev.vars.
Check, in order:
batch_import_support:read explicitly — * does not match hidden scopes.user:read (or *) — the discovery staff check reads /api/users/@me/ and fails closed.is_staff = True.The posthog-db-1 Docker container may have stale port mappings
(container created days ago without the current port binding config).
Fix by force-recreating:
docker compose -f docker-compose.dev.yml -f docker-compose.profiles.yml \
up -d --force-recreate db
Verify: nc -z 127.0.0.1 5432 should succeed.
secrets={} causes NOT NULL violationEncryptedJSONStringField encrypts the value — an empty dict serializes to null.
Always pass a non-empty dict: secrets={'placeholder': 'true'}.
The local batch-import-worker process automatically claims RUNNING records.
If it encounters a config validation error (e.g. missing skip_blanks),
it will pause the import with a detailed Rust backtrace in status_message.
Stop the worker or seed with non-RUNNING statuses to prevent this.
A request passes through two independent layers:
/api/users/@me/ confirms is_staff — otherwise it is hidden, fail-closed (services/mcp/src/lib/staff-only-tools.ts).IsAuthenticated + IsStaffUser + APIScopePermission with scope_object = "INTERNAL" and batch_import_support:read. Sessions need staffness only; PATs need staffness plus the explicit scope; *-only keys always 403.data-ai
Signals scout for PostHog Tasks, the agent work items a project runs. Two lenses: delivery health (runs failing, clustered by repository and error class, and retry storms) every run, and on a slower rotation demand (recurring asks across human-authored tasks that point at a product gap). Skips the scout fleet's own run rows.
devops
Signals scout for the PostHog Conversations (support inbox) product. Watches the `$conversation_*` ticket-lifecycle events for support-delivery regressions — SLA breach-rate steps, first-response latency blowouts, backlog inflow-vs-resolution imbalance, and channel / assignment concentration — and files each dated regression as a report. Complements the per-ticket product-feedback signals the emission pipeline already fires; does not re-surface individual ticket content.
development
Populates and maintains a project's data catalog (semantic layer): canonical metrics, trust marks (certifications) on warehouse tables/views, and reviewed table relationships. Use when asked to set up / seed / bootstrap the data catalog or semantic layer, to catalog a project's metrics, to certify or deprecate data sources, to propose or review table joins, or to work through the proposal review queue. To *use* an existing catalog to answer a business-number question, see querying-posthog-data instead. Trigger terms: data catalog, semantic layer, canonical metric, certify table, deprecate source, relationship proposal, metric drift, review queue.
tools
Investigate logs in a PostHog project: verify a service or deployment is healthy, explain an error spike, triage an incident, or understand what a log stream is saying. Use when the user asks to "check the logs", asks whether a service, deploy, release, or change is working or broke anything, asks why errors are up or what changed, or wants the root cause of failures visible in logs. Routes the logs MCP tools (services overview, pattern mining, before/after pattern diffing, bucketed counts, facets, raw rows) so investigations start from summaries instead of raw rows or hand-written SQL over the logs table.