11import { defined } from "../../tests/helpers/defined.js" ;
22import { describe , test , expect } from "bun:test" ;
3+ import { mkdtempSync } from "node:fs" ;
4+ import { tmpdir } from "node:os" ;
5+ import { join } from "node:path" ;
6+ import type { ToolResult } from "@intx/types/runtime" ;
37import { mcpClientToAgentTools } from "./plugin.js" ;
48import { createPermissionGate } from "../permission/gate.js" ;
59import {
@@ -8,6 +12,7 @@ import {
812} from "../plugins/result-truncation-plugin.js" ;
913import { toolOutputAbsolutePath } from "../plugins/tool-result-materialize.js" ;
1014import { CREDENTIAL_REDACTION } from "../plugins/tool-result-secret-scrub.js" ;
15+ import { createCompactionArchive } from "../session/compaction-archive.js" ;
1116import type { MCPClient , MCPContentBlock } from "./client.js" ;
1217
1318interface ScriptedMcpEnvelope {
@@ -78,6 +83,39 @@ function fakeBlobStore() {
7883 } ;
7984}
8085
86+ function memoryEvidenceArchive ( ) {
87+ const blobs = new Map < string , Uint8Array > ( ) ;
88+ const archive = createCompactionArchive ( {
89+ sessionId : "mcp-plugin-test" ,
90+ contextDir : mkdtempSync ( join ( tmpdir ( ) , "mcp-plugin-archive-" ) ) ,
91+ writeBlob : async ( key , bytes ) => {
92+ blobs . set ( key , bytes ) ;
93+ } ,
94+ readBlob : async ( key ) => {
95+ const bytes = blobs . get ( key ) ;
96+ if ( bytes === undefined ) throw new Error ( `missing archive blob ${ key } ` ) ;
97+ return bytes ;
98+ } ,
99+ } ) ;
100+ return { archive, blobs } ;
101+ }
102+
103+ function serializePersistedToolResultTurn ( result : ToolResult ) : string {
104+ return JSON . stringify ( {
105+ role : "user" ,
106+ content : [
107+ {
108+ type : "tool_result" ,
109+ callId : result . callId ,
110+ content : [ { type : "text" , text : String ( result . content ) } ] ,
111+ ...( result . detail !== undefined ? { detail : result . detail } : { } ) ,
112+ ...( result . isError !== undefined ? { isError : result . isError } : { } ) ,
113+ } ,
114+ ] ,
115+ timestamp : 0 ,
116+ } ) ;
117+ }
118+
81119function skipGate ( ) {
82120 return createPermissionGate ( {
83121 approvals : [ ] ,
@@ -292,6 +330,151 @@ describe("mcpClientToAgentTools", () => {
292330 expect ( detailJson ) . not . toContain ( "sk-live-" ) ;
293331 } ) ;
294332
333+ test ( "scrubs structured keys from detail, archive bytes, and model content" , async ( ) => {
334+ const topLevelKey = [ "sk-" , "live-" , "a" . repeat ( 24 ) ] . join ( "" ) ;
335+ const nestedKey = [ "sk-" , "live-" , "b" . repeat ( 24 ) ] . join ( "" ) ;
336+ const { archive, blobs } = memoryEvidenceArchive ( ) ;
337+ const result = await runEnvelopeTool (
338+ {
339+ blocks : [ { type : "resource" , [ topLevelKey ] : "block-value" } ] ,
340+ isError : false ,
341+ structuredContent : {
342+ [ topLevelKey ] : "top-level" ,
343+ nested : { [ nestedKey ] : "nested" } ,
344+ } ,
345+ } ,
346+ "c-mcp-structured-key-secret" ,
347+ { getEvidenceArchive : ( ) => archive } ,
348+ ) ;
349+
350+ const detail = JSON . stringify ( result . detail ) ;
351+ const modelTurn = serializePersistedToolResultTurn ( result ) ;
352+ const archiveBytes = [ ...blobs . values ( ) ] . map ( ( bytes ) =>
353+ new TextDecoder ( ) . decode ( bytes ) ,
354+ ) ;
355+ const surfaces = [
356+ detail ,
357+ String ( result . content ) ,
358+ modelTurn ,
359+ ...archiveBytes ,
360+ ] ;
361+ for ( const surface of surfaces ) {
362+ expect ( surface ) . not . toContain ( topLevelKey ) ;
363+ expect ( surface ) . not . toContain ( nestedKey ) ;
364+ }
365+ expect ( detail ) . toContain ( CREDENTIAL_REDACTION ) ;
366+ expect ( String ( result . content ) ) . toContain ( CREDENTIAL_REDACTION ) ;
367+ expect ( modelTurn ) . toContain ( CREDENTIAL_REDACTION ) ;
368+ expect ( archiveBytes . join ( "\n" ) ) . toContain ( CREDENTIAL_REDACTION ) ;
369+ } ) ;
370+
371+ test ( "keeps oversized structured content full only in the evidence archive" , async ( ) => {
372+ const { archive } = memoryEvidenceArchive ( ) ;
373+ const hugeValue = "x" . repeat ( MAX_RESULT_CHARS * 4 ) ;
374+ const result = await runEnvelopeTool (
375+ {
376+ blocks : [ ] ,
377+ isError : false ,
378+ structuredContent : { hugeValue } ,
379+ } ,
380+ "c-mcp-oversized-detail" ,
381+ { getEvidenceArchive : ( ) => archive } ,
382+ ) ;
383+
384+ expect ( result . detail ) . toBeUndefined ( ) ;
385+ expect ( JSON . stringify ( result ) . length ) . toBeLessThanOrEqual (
386+ MAX_RESULT_CHARS + 256 ,
387+ ) ;
388+ const serializedTurn = serializePersistedToolResultTurn ( result ) ;
389+ expect ( serializedTurn . length ) . toBeLessThanOrEqual ( MAX_RESULT_CHARS + 512 ) ;
390+ expect ( serializedTurn ) . not . toContain ( hugeValue ) ;
391+
392+ const [ occurrence ] = await archive . listOccurrences ( ) ;
393+ if ( occurrence === undefined ) throw new Error ( "missing archive occurrence" ) ;
394+ const archived = JSON . parse (
395+ await archive . readAuthorizedPayload ( occurrence . occurrenceId ) ,
396+ ) as { structuredContent : { hugeValue : string } } ;
397+ expect ( archived . structuredContent . hugeValue ) . toBe ( hugeValue ) ;
398+ } ) ;
399+
400+ test ( "omits unserializable structured detail without failing text content" , async ( ) => {
401+ const result = await runEnvelopeTool (
402+ {
403+ blocks : [ { type : "text" , text : "usable text" } ] ,
404+ isError : false ,
405+ structuredContent : { unsupported : 1n } ,
406+ } ,
407+ "c-mcp-unserializable-detail" ,
408+ ) ;
409+
410+ expect ( result . isError ) . toBeUndefined ( ) ;
411+ expect ( result . content ) . toBe ( "usable text" ) ;
412+ expect ( result . detail ) . toBeUndefined ( ) ;
413+ expect ( JSON . stringify ( result ) . length ) . toBeLessThan ( MAX_RESULT_CHARS ) ;
414+ } ) ;
415+
416+ test ( "archives identical success and failure payloads with distinct isError" , async ( ) => {
417+ const { archive } = memoryEvidenceArchive ( ) ;
418+ const envelope = {
419+ blocks : [ { type : "text" , text : "same payload" } ] ,
420+ structuredContent : { answer : 42 } ,
421+ } ;
422+
423+ await runEnvelopeTool (
424+ { ...envelope , isError : false } ,
425+ "c-mcp-archive-success" ,
426+ { getEvidenceArchive : ( ) => archive } ,
427+ ) ;
428+ await runEnvelopeTool (
429+ { ...envelope , isError : true } ,
430+ "c-mcp-archive-failure" ,
431+ { getEvidenceArchive : ( ) => archive } ,
432+ ) ;
433+
434+ const occurrences = await archive . listOccurrences ( ) ;
435+ expect ( occurrences ) . toHaveLength ( 2 ) ;
436+ const payloads = await Promise . all (
437+ occurrences . map ( async ( occurrence ) =>
438+ JSON . parse (
439+ await archive . readAuthorizedPayload ( occurrence . occurrenceId ) ,
440+ ) ,
441+ ) ,
442+ ) ;
443+ expect ( payloads . map ( ( payload ) => payload . isError ) ) . toEqual ( [ false , true ] ) ;
444+ } ) ;
445+
446+ test ( "falls back to legacy call when block and envelope methods are absent" , async ( ) => {
447+ let calls = 0 ;
448+ const client : MCPClient = {
449+ serverName : "legacy" ,
450+ tools : [
451+ {
452+ name : "echo" ,
453+ description : "returns legacy text" ,
454+ inputSchema : { type : "object" , properties : { } } ,
455+ } ,
456+ ] ,
457+ call : async ( ) => {
458+ calls ++ ;
459+ return "legacy response" ;
460+ } ,
461+ close : async ( ) => undefined ,
462+ } ;
463+ const [ tool ] = mcpClientToAgentTools ( client , skipGate ( ) ) ;
464+ if ( tool ?. kind !== "full" ) throw new Error ( "expected full tool" ) ;
465+
466+ const result = await tool . handler (
467+ { id : "c-mcp-legacy" , name : "mcp__legacy__echo" , arguments : { } } ,
468+ new AbortController ( ) . signal ,
469+ ) ;
470+
471+ expect ( calls ) . toBe ( 1 ) ;
472+ expect ( result ) . toEqual ( {
473+ callId : "c-mcp-legacy" ,
474+ content : "legacy response" ,
475+ } ) ;
476+ } ) ;
477+
295478 test ( "thrown transport failures still surface as error results" , async ( ) => {
296479 const gate = skipGate ( ) ;
297480 const client : MCPClient = {
0 commit comments