Skip to content

extractMcp captures call shape but drops tool_response — external MCP outputs (jira, grafana, sentry) not searchable via ctx_search #329

@halindrome

Description

@halindrome

Summary

extractMcp in src/session/extract.ts fires correctly on mcp__* tool invocations and emits an mcp event containing <toolShort>: <firstStringArg>. But it doesn't touch tool_response — so the contents the MCP server returned (jira ticket body, grafana loki lines, sentry issue details, context7 doc fetch) never reach the session DB, and ctx_search can't find them. For external-data MCPs this is the exact workflow where caching matters most: the calls are slow and expensive, so re-running them when a previous answer is already in the transcript is wasteful.

Evidence

Field-validated on 2026-04-22 during a real research session:

Tool usage over 185 transcript messages, 9 external-data MCP calls:

MCP server calls current capture
mcp__jira__jira_get / jira_post 4 call shape only (tool name + ticket ID)
mcp__sentry__list_issues 3 call shape only (tool name + first string arg)
mcp__grafana__list_datasources / query_loki_logs 2 call shape only

Session DB contents after the run (ctx_stats output):

19 events preserved (11 file reads, 3 prompts, 2 rules, 1 subagent, 1 intent, 1 decision)

Zero mcp events with response content. The matcher fires; the extractor runs; the response is discarded.

Practical consequence: the agent re-fetched the same jira ticket later in the session to confirm a detail it had already seen — ctx_search("CVX-5909") returned no hits because the ticket body was never indexed, only the call that retrieved it.

Root cause

extractMcp only reads tool_name and tool_input:

```ts
function extractMcp(input: HookInput): SessionEvent[] {
const { tool_name, tool_input } = input;
if (!tool_name.startsWith("mcp__")) return [];
// ... derives toolShort from tool_name split
// ... extracts firstArg from tool_input
return [{ type: "mcp", category: "mcp", data: `${toolShort}${argStr}`, priority: 3 }];
}
```

HookInput already declares tool_response?: string, and posttooluse.mjs already JSON-stringifies objects before passing — the data is sitting right there, just unused.

Proposed fix

Extend extractMcp to append tool_response to the event's data field, following the same pattern extractFileAndRule already uses for rule_content events on CLAUDE.md / .claude/ reads (line ~85):

```ts
if (tool_response && tool_response.length > 0) {
events.push({ type: "rule_content", category: "rule",
data: safeString(tool_response), priority: 1 });
}
```

No truncation there — and that's the right call for the same reason it would be right here: the responses we most want the cache to preserve are exactly the large, expensive ones. Capping at a small byte limit would throw away precisely the content that's most valuable to avoid re-fetching. SessionDB's data column is TEXT NOT NULL (SQLite text, effectively unbounded), so there's no storage-layer reason to cap.

Sketch of the change

```ts
function extractMcp(input: HookInput): SessionEvent[] {
const { tool_name, tool_input, tool_response } = input; // ← also read tool_response
if (!tool_name.startsWith("mcp__")) return [];

const parts = tool_name.split("__");
const toolShort = parts[parts.length - 1] || tool_name;

const firstArg = Object.values(tool_input).find((v): v is string => typeof v === "string");
const argStr = firstArg ? `: ${safeString(String(firstArg))}` : "";

// Append response so ctx_search can find details, not just call shape.
// No truncation — matches rule_content precedent; SQLite TEXT is unbounded.
const responseStr = tool_response && tool_response.length > 0
? `\nresponse: ${safeString(tool_response)}` : "";

return [{
type: "mcp",
category: "mcp",
data: safeString(`${toolShort}${argStr}${responseStr}`),
priority: 3,
}];
}
```

No schema change, no new event type. Backward-compatible for existing consumers of mcp events — data is a superset of what it was before.

Open questions for maintainers

  1. Cap or no cap? I'm arguing for no cap (matching rule_content) based on the "cache the expensive stuff first" principle. If you'd prefer an upper bound for defensive reasons, a very high ceiling (say 256 KB or 1 MB) would still capture >99% of real responses and protect against pathological cases.
  2. Redaction? Some MCP responses may contain tokens (Bearer headers returned in a debug response, for instance). Do you want a redaction pass, or trust users not to log secrets into tool responses? (The current rule_content path also has this concern and doesn't redact.)
  3. Opt-in? The matcher already includes mcp__ so capture is expected behavior. My preference is this becomes the default; but an env var like CONTEXT_MODE_CAPTURE_MCP_RESPONSES=0 to disable would be trivial to add if you'd rather gate it.

Happy to send a PR once the direction is settled. Willing to own tests, bundle regeneration, and any doc updates.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions