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
- 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.
- 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.)
- 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.
Summary
extractMcpinsrc/session/extract.tsfires correctly onmcp__*tool invocations and emits anmcpevent containing<toolShort>: <firstStringArg>. But it doesn't touchtool_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, andctx_searchcan'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__jira__jira_get/jira_postmcp__sentry__list_issuesmcp__grafana__list_datasources/query_loki_logsSession DB contents after the run (
ctx_statsoutput):Zero
mcpevents 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
extractMcponly readstool_nameandtool_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 }];
}
```
HookInputalready declarestool_response?: string, andposttooluse.mjsalready JSON-stringifies objects before passing — the data is sitting right there, just unused.Proposed fix
Extend
extractMcpto appendtool_responseto the event'sdatafield, following the same patternextractFileAndRulealready uses forrule_contentevents 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
datacolumn isTEXT 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
mcpevents —datais a superset of what it was before.Open questions for maintainers
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.rule_contentpath also has this concern and doesn't redact.)mcp__so capture is expected behavior. My preference is this becomes the default; but an env var likeCONTEXT_MODE_CAPTURE_MCP_RESPONSES=0to 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.