Skip to content

feat(extract): capture MCP tool_response so ctx_search finds the data the MCP returned#330

Open
halindrome wants to merge 15 commits intomksglu:nextfrom
halindrome:feat/capture-mcp-response
Open

feat(extract): capture MCP tool_response so ctx_search finds the data the MCP returned#330
halindrome wants to merge 15 commits intomksglu:nextfrom
halindrome:feat/capture-mcp-response

Conversation

@halindrome
Copy link
Copy Markdown

Closes #329.

Problem

extractMcp (src/session/extract.ts) destructures only tool_name and tool_input, so for tools like mcp__jira__jira_get, mcp__grafana__query_loki_logs, mcp__sentry__list_issues, etc. the captured event records the call shape (jira_get: CVX-5909) but never the body the server returned. ctx_search therefore can't surface the ticket text, log lines, or issue details — defeating the cache for exactly the external MCPs whose responses are most expensive to re-fetch.

The PostToolUse hook (hooks/posttooluse.mjs:41-43) already passes tool_response to extractEvents. The data is arriving — it's just being dropped in extractMcp.

Fix

Destructure tool_response and append it to the event data, gated by the same length > 0 guard used by the rule_content precedent at src/session/extract.ts:85-91:

```ts
const responseStr = tool_response && tool_response.length > 0
? `\nresponse: ${safeString(tool_response)}`
: "";
```

  • No truncation. SQLite TEXT is unbounded, FTS5 indexes arbitrary text, and large responses are exactly the ones the cache most wants to preserve. Same stance as rule_content.
  • Backward-compatible. When tool_response is absent the emitted data is byte-identical to before (`"ctx_stats"`, not `"ctx_stats\nresponse: "`). Existing consumers reading the prefix continue to work.
  • No new event type, no schema change, no new imports.

Tests added

Four cases appended to tests/session/session-extract.test.ts (new `MCP Events` describe block):

  1. Captures tool_response body — asserts a mcp__jira__jira_get event includes \"MQTT reconnect storm\" from the response, not just \"jira_get\" and the ticket key.
  2. Preserves large payloads — 45 KB Loki-export-style payload round-trips through data with no loss.
  3. Missing tool_response regressiondata === \"ctx_stats\" (no `\nresponse:` suffix) when the field is omitted.
  4. Empty tool_response regression — same shape when the field is \"\".

tests/session/session-extract.test.ts → 101/101 pass. Full suite: 1540 pass / 18 skipped / 0 failures attributable to this change. tsc --noEmit clean. Bundle (hooks/session-extract.bundle.mjs) regenerated via npm run build.

Open questions for maintainers

These are flagged for direction — happy to adjust the PR either way:

  1. Size cap? Current: none, matching rule_content. If you'd prefer a defensive ceiling for pathological cases (e.g. a 50 MB grafana export that would stall a synchronous PostToolUse hook), a high cap like 256 KB or 1 MB is a one-line addition: `safeString(tool_response).slice(0, MAX)`.
  2. Redaction? Current: none, matching rule_content. extractEnv already redacts export VAR=*** patterns, so there's precedent in the file for active redaction if you want a pass over tool_response before storage (API keys, OAuth/JWT bearers, customer PII in jira tickets, etc.). I haven't added one because the existing rule_content path also stores raw — but happy to add a redaction step if you prefer.
  3. Opt-in gate? Current: default-on, since the PostToolUse matcher already includes mcp__ and capture is the expected behavior. If you want an env override, the natural shape is:
    ```ts
    const CAPTURE_MCP_RESPONSES = process.env.CONTEXT_MODE_CAPTURE_MCP_RESPONSES !== "0";
    ```
    (default on, disable with =0).

Let me know which direction you want and I'll iterate.

github-actions Bot and others added 15 commits April 15, 2026 18:22
… the MCP returned

extractMcp previously emitted only the call shape (tool name + first arg)
while the PostToolUse hook passed all four fields. For mcp__jira__jira_get,
mcp__grafana__query_loki_logs, mcp__sentry__list_issues, etc. the response
body was in tool_response but never stored, so ctx_search never found it.

Match the rule_content precedent at extract.ts:85-91 — destructure
tool_response and append it to the event data. No truncation: SQLite TEXT
is unbounded, FTS5 indexes arbitrary text, and large responses are exactly
what the cache most wants to preserve.

Backward-compatible: events.data becomes a superset of what it was.
Existing mcp event consumers read the same prefix.

Closes mksglu#329

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@halindrome
Copy link
Copy Markdown
Author

QA Round 1 — local verification by author

Posting the QA pass I ran against this PR before requesting review, so reviewers can see what's already been checked and what's left for them.

Contract verification (against #329)

Criterion (from #329) Status Evidence
ctx_search finds the response body (jira ticket, grafana lines, sentry issue) — not just the call shape satisfied Smoke test on the shipped bundle hooks/session-extract.bundle.mjs with mcp__sentry__list_issues input emits "data": "list_issues: p1\nresponse: issue body content" — response body is in the indexed text
Pattern matches the rule_content precedent (src/session/extract.ts:85-91) satisfied Same destructure, same tool_response && tool_response.length > 0 guard, same safeString(tool_response) call
No truncation — large payloads round-trip intact satisfied Test "preserves full response even for large payloads (no truncation)" round-trips a ~45 KB body through data
Backward-compatible: pre-existing mcp event consumers see the byte-identical prefix when no response is present satisfied Tests "gracefully handles missing tool_response (regression)" and "gracefully handles empty tool_response" both assert data === "ctx_stats" (no \nresponse: suffix)
posttooluse.mjs already plumbs tool_response — no upstream hook change needed satisfied hooks/posttooluse.mjs:41-43 coerces non-string tool_response via JSON.stringify before forwarding
Bundle (hooks/session-extract.bundle.mjs) regenerated from updated source satisfied npm run build produces zero working-tree diff against the committed bundle

Verification details

Check Result
npx tsc --noEmit clean
npm test -- tests/session/session-extract.test.ts 101 / 101 pass (113ms)
npm test (full suite) 1540 pass / 18 skipped / 0 failures attributable to this change
Bundle smoke test (load hooks/session-extract.bundle.mjs, call extractEvents with an mcp__* input + tool_response) response body present in data, prefix unchanged

Findings

Contract findings: none.
Regression findings: none.

Non-blocking observations (already surfaced as the three open questions in the PR description — flagging here so they aren't lost):

  1. No size cap. A pathological multi-MB response (e.g. a huge Loki export) would write a single huge SQLite row inside a synchronous PostToolUse hook. SQLite + FTS5 handle it but the JSON.stringify + sync write could perceptibly stall a tool call. Easy one-line addition (safeString(tool_response).slice(0, MAX)) if you want a safety net — happy to add a high ceiling (256 KB / 1 MB) on request.
  2. No redaction. safeString(tool_response) stores raw text. If an MCP server returns API keys, OAuth/JWT bearers, or PII, those land in the FTS5 index. Matches existing rule_content behavior — but extractEnv (line 377+) does have a redaction precedent in this file, so adding a pass over tool_response is straightforward if you'd prefer that stance.
  3. Default-on capture. Currently always-on, since the mcp__ matcher already implies capture. Easy env gate (CONTEXT_MODE_CAPTURE_MCP_RESPONSES !== "0") if you want an opt-out.

Note for CI

gh pr checks 330 reports no checks reported on the 'feat/capture-mcp-response' branch — I don't see CI workflow runs against this PR. If the repo runs CI on a different trigger, please let me know and I'll re-verify against the CI environment.


Verdict: clean. Ready for maintainer review. No blocking findings; three open stance questions to confirm direction.

@mksglu mksglu changed the base branch from main to next April 23, 2026 04:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants