Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/model-capture-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@posthog/mcp': minor
---

Add opt-in `captureModel` option: injects a required `llm_model` parameter into every tool — including the `get_more_tools` virtual tool — so the calling agent self-reports the model it runs as, captured as `$mcp_llm_model` with `$mcp_llm_model_source = "self_reported"`. Stripping the argument before the handler runs and capturing the property both require confirmed SDK ownership of the parameter: a customer-declared `llm_model` is never stolen or captured, and a low-level `Server` that builds a fresh instance per request records nothing (see the README). An honest `"unknown"` from the agent is dropped rather than recorded.
29 changes: 29 additions & 0 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,35 @@ instrument(server, posthog, { // keep it, dr

`intentFallback` is the third option: supply the intent yourself when the agent did not send one.

### What `$mcp_llm_model` records, and when it stays empty

`captureModel` is **off** by default. Turn it on and the SDK adds a required `llm_model` parameter to
every tool it advertises — including the `get_more_tools` virtual tool — asks the agent which model
it runs as, and records the answer as `$mcp_llm_model` with `$mcp_llm_model_source = "self_reported"`.

The value is self-reported and unverified, exactly like `clientInfo` in the MCP spec. Use it to spot
degradation across models ("does our MCP get worse on model X?"), never for billing or access
control. An agent that answers `unknown` is recorded as nothing rather than as a model called
"unknown".

Unlike `context`, this option degrades to **silence** rather than to a kept argument. Both the strip
and the capture require the SDK to have confirmed the parameter is its own:

- `instrument(server)` on a high-level `McpServer` resolves ownership for your registered tools per
request from the live tool registry, so those work even on a fresh instance.
- The `get_more_tools` virtual tool works on any instance and on either server type: the SDK writes
that descriptor itself, so what it declares is known without a listing.
- Instrumenting a low-level `Server` learns ownership of **your** tools while serving `tools/list`.
On a server that builds a fresh instance per HTTP request — `createMcpHandler`, or
`@rekog/mcp-nest` in its stateless mode — the instance handling a `tools/call` never served one,
so for those tools it neither strips `llm_model` nor records `$mcp_llm_model`. Nothing breaks and
no wrong value is stored; the property is simply absent while agents still pay a token for the
extra field.

As with `context`, what matters is instance lifetime rather than statelessness: a transport-stateless
server (`sessionIdGenerator: undefined`) that keeps one long-lived server object learns ownership
from the first `tools/list` and keeps it.

### If you switched to `instrument(server.server)`

Before v2 support landed, the compatibility gate rejected high-level v2 servers, and the usual
Expand Down
5 changes: 4 additions & 1 deletion packages/mcp/docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ All events are emitted by `buildPostHogCaptureEvents`. The main event name is co

| PostHog event | When | Notable extras |
| ------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `$mcp_tool_call` | Every tool invocation | `$mcp_tool_name`, `$mcp_tool_description`, `$mcp_tool_category`, `$mcp_parameters`, `$mcp_response`, `$mcp_duration_ms`, `$mcp_is_error`, optionally `$mcp_intent` / `$mcp_intent_source` |
| `$mcp_tool_call` | Every tool invocation | `$mcp_tool_name`, `$mcp_tool_description`, `$mcp_tool_category`, `$mcp_parameters`, `$mcp_response`, `$mcp_duration_ms`, `$mcp_is_error`, optionally `$mcp_intent` / `$mcp_intent_source`, `$mcp_llm_model` / `$mcp_llm_model_source` |
| `$mcp_tools_list` | Client lists tools | `$mcp_listed_tool_names` (array of tool names advertised); useful for "did this client discover us?" and "which advertised tools never get called?" |
| `$mcp_initialize` | Client/server handshake | `$mcp_client_name`, `$mcp_client_version`, `$mcp_server_name`, `$mcp_server_version`, `$mcp_protocol_version` (negotiated MCP spec version — for tracking spec-revision adoption) |
| `$mcp_missing_capability` | Agent calls the `get_more_tools` virtual tool | A capability gap, **not** a tool invocation. The `context` arg is captured as `$mcp_intent` with `$mcp_intent_source = "context_parameter"` |
Expand Down Expand Up @@ -141,6 +141,8 @@ All wire keys live in `PostHogMCPAnalyticsProperty` (`src/extensions/constants.t
| `Intent` | `$mcp_intent` | string | `context` argument when present, else `intentFallback()` return |
| `IntentSource` | `$mcp_intent_source` | `"context_parameter" \| "inferred"` | Where the intent came from |
| `ConversationId` | `$mcp_conversation_id` | string | Optional; set when `enableConversationId: true` and the SDK owns the tool's injected `conversation_id` parameter |
| `LlmModel` | `$mcp_llm_model` | string | Optional; set when `captureModel` is enabled and the SDK owns the tool's injected `llm_model` parameter. The calling agent's **self-reported, unverified** model id — right for degradation analytics ("does our MCP get worse on model X?"), never for billing or security. A blank or `"unknown"` answer is dropped rather than recorded |
| `LlmModelSource` | `$mcp_llm_model_source` | `"self_reported"` | How the model id was obtained. Always `self_reported` today — it exists so a future verified source stays distinguishable |
| `Parameters` | `$mcp_parameters` | object | Sanitized MCP request payload (see §3) |
| `Response` | `$mcp_response` | object | Sanitized tool result |

Expand Down Expand Up @@ -171,6 +173,7 @@ The `posthog-node` client is **not** an option — it is the required positional
| `enableConversationId` | `false` | Inject `conversation_id` into eligible tools that don't already declare it and stamp `$mcp_conversation_id` on their events. |
| `reportMissing` | `false` | Register the `get_more_tools` virtual tool. |
| `context` | `true` (object form: `{ description }`) | Inject required `context` arg into every tool schema. |
| `captureModel` | `false` (object form: `{ description }`) | Inject a required `llm_model` arg into every tool schema (including the `get_more_tools` virtual tool) so the agent self-reports the model it runs as → `$mcp_llm_model`. Capture and stripping both require confirmed SDK ownership, so a fresh-instance-per-request server that never served the `tools/list` records nothing (see README). |
| `intentFallback` | — | Consumer-supplied callback returning a `$mcp_intent` string when the client didn't pass a `context` argument. SDK does no inference. |
| `identify` | — | Per-request callback returning `{ distinctId, properties?, groups? } \| null` — posthog-node's `identify` shape. `properties` → `$set`, `groups` → `$groups`. |
| `beforeSend` | — | `(event) => event \| null \| undefined` (sync or async), matching posthog-node. Runs on each fully-built payload right before `posthog.capture()` — once per emitted event, including the `$exception` sibling. Return nullish (or throw) to drop that event. |
Expand Down
34 changes: 34 additions & 0 deletions packages/mcp/src/__tests__/instrument-lowlevel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,40 @@ describe('Low-level Server reportMissing ownership (e2e)', () => {
}
})

it('captures llm_model on a virtual call handled by a pod that never advertised it', async () => {
const podA = await setupLowLevelServer()
const podB = await setupLowLevelServer()
try {
instrument(podA.server, fakePostHog(), { reportMissing: true, captureModel: true })
instrument(podB.server, fakePostHog(), { reportMissing: true, captureModel: true })
await Promise.all([podA.connect(), podB.connect()])

const { tools } = await podA.client.request({ method: 'tools/list', params: {} }, ListToolsResultSchema)
const advertised = tools.find((tool) => tool.name === 'get_more_tools')
expect(advertised?.inputSchema?.properties?.llm_model).toBeDefined()

await podB.client.request(
{
method: 'tools/call',
params: {
name: 'get_more_tools',
arguments: { context: 'Need a database tool', llm_model: 'claude-opus-4-8' },
},
},
CallToolResultSchema
)

await new Promise((resolve) => setTimeout(resolve, 50))
const captures = eventCapture.findCapturesByEvent('$mcp_missing_capability')
expect(captures).toHaveLength(1)
expect(captures[0].properties.$mcp_llm_model).toBe('claude-opus-4-8')
expect(captures[0].properties.$mcp_llm_model_source).toBe('self_reported')
expect((captures[0].properties.$mcp_parameters as any)?.llm_model).toBeUndefined()
} finally {
await Promise.all([podA.cleanup(), podB.cleanup()])
}
})

it('handles a custom-named virtual tool on a fresh instance', async () => {
const customName = 'posthog_find_tools'
const { server, client, connect, cleanup } = await setupLowLevelServer()
Expand Down
Loading