@@ -8,7 +8,49 @@ import {
88} from "../plugins/result-truncation-plugin.js" ;
99import { toolOutputAbsolutePath } from "../plugins/tool-result-materialize.js" ;
1010import { CREDENTIAL_REDACTION } from "../plugins/tool-result-secret-scrub.js" ;
11- import type { MCPClient } from "./client.js" ;
11+ import type { MCPClient , MCPContentBlock } from "./client.js" ;
12+
13+ interface ScriptedMcpEnvelope {
14+ blocks : MCPContentBlock [ ] ;
15+ isError ?: boolean ;
16+ structuredContent ?: Record < string , unknown > ;
17+ }
18+
19+ function fakeEnvelopeClient ( envelope : ScriptedMcpEnvelope ) : MCPClient {
20+ const client : MCPClient = {
21+ serverName : "acme" ,
22+ tools : [
23+ {
24+ name : "fetch_secret" ,
25+ description : "returns a value" ,
26+ inputSchema : { type : "object" , properties : { } } ,
27+ } ,
28+ ] ,
29+ call : async ( ) => "" ,
30+ callBlocks : async ( ) => envelope . blocks ,
31+ close : async ( ) => undefined ,
32+ } ;
33+ // callResult is the new envelope channel (GREEN); absent on RED code.
34+ Object . assign ( client , {
35+ callResult : async ( ) => envelope ,
36+ } ) ;
37+ return client ;
38+ }
39+
40+ async function runEnvelopeTool (
41+ envelope : ScriptedMcpEnvelope ,
42+ callId : string ,
43+ spillOptions ?: Parameters < typeof mcpClientToAgentTools > [ 2 ] ,
44+ ) {
45+ const gate = skipGate ( ) ;
46+ const client = fakeEnvelopeClient ( envelope ) ;
47+ const [ tool ] = mcpClientToAgentTools ( client , gate , spillOptions ) ;
48+ if ( tool ?. kind !== "full" ) throw new Error ( "expected full tool" ) ;
49+ return tool . handler (
50+ { id : callId , name : "mcp__acme__fetch_secret" , arguments : { } } ,
51+ new AbortController ( ) . signal ,
52+ ) ;
53+ }
1254
1355function fakeClient ( reply : string ) : MCPClient {
1456 return {
@@ -179,4 +221,105 @@ describe("mcpClientToAgentTools", () => {
179221 toolOutputAbsolutePath ( contextDir , key , "text/plain" ) ,
180222 ) ;
181223 } ) ;
224+
225+ test ( "tool-level failure surfaces as an error result with text preserved" , async ( ) => {
226+ const result = await runEnvelopeTool (
227+ {
228+ blocks : [ { type : "text" , text : "tool failed: bad input" } ] ,
229+ isError : true ,
230+ } ,
231+ "c-mcp-iserror-text" ,
232+ ) ;
233+
234+ expect ( result . isError ) . toBe ( true ) ;
235+ expect ( result . content ) . toContain ( "tool failed: bad input" ) ;
236+ } ) ;
237+
238+ test ( "tool-level failure with empty content still yields a failure message" , async ( ) => {
239+ const result = await runEnvelopeTool (
240+ { blocks : [ ] , isError : true } ,
241+ "c-mcp-iserror-empty" ,
242+ ) ;
243+
244+ expect ( result . isError ) . toBe ( true ) ;
245+ expect ( typeof result . content ) . toBe ( "string" ) ;
246+ expect ( ( result . content as string ) . length ) . toBeGreaterThan ( 0 ) ;
247+ } ) ;
248+
249+ test ( "structured-only result surfaces a scrubbed JSON string, not empty text" , async ( ) => {
250+ const result = await runEnvelopeTool (
251+ { blocks : [ ] , structuredContent : { answer : 42 } } ,
252+ "c-mcp-structured-only" ,
253+ ) ;
254+
255+ expect ( result . isError ) . toBeUndefined ( ) ;
256+ expect ( typeof result . content ) . toBe ( "string" ) ;
257+ expect ( result . content ) . toContain ( "42" ) ;
258+ expect ( result . detail ) . toEqual ( { answer : 42 } ) ;
259+ } ) ;
260+
261+ test ( "text plus structured content keeps the text and preserves structured detail" , async ( ) => {
262+ const result = await runEnvelopeTool (
263+ {
264+ blocks : [ { type : "text" , text : "hello from tool" } ] ,
265+ structuredContent : { answer : 42 } ,
266+ } ,
267+ "c-mcp-text-plus-structured" ,
268+ ) ;
269+
270+ expect ( result . isError ) . toBeUndefined ( ) ;
271+ expect ( result . content ) . toContain ( "hello from tool" ) ;
272+ expect ( result . detail ) . toEqual ( { answer : 42 } ) ;
273+ } ) ;
274+
275+ test ( "credential-shaped values inside structured content are redacted" , async ( ) => {
276+ const secret = "sk-live-abcdefghij1234567890" ;
277+ const result = await runEnvelopeTool (
278+ {
279+ blocks : [ ] ,
280+ structuredContent : { token : secret } ,
281+ } ,
282+ "c-mcp-structured-secret" ,
283+ ) ;
284+
285+ expect ( result . content ) . toContain ( CREDENTIAL_REDACTION ) ;
286+ expect ( result . content ) . not . toContain ( secret ) ;
287+ expect ( JSON . stringify ( result . detail ) ) . not . toContain ( secret ) ;
288+ } ) ;
289+
290+ test ( "thrown transport failures still surface as error results" , async ( ) => {
291+ const gate = skipGate ( ) ;
292+ const client : MCPClient = {
293+ serverName : "acme" ,
294+ tools : [
295+ {
296+ name : "fetch_secret" ,
297+ description : "returns a value" ,
298+ inputSchema : { type : "object" , properties : { } } ,
299+ } ,
300+ ] ,
301+ call : async ( ) => {
302+ throw new Error ( "transport exploded" ) ;
303+ } ,
304+ callBlocks : async ( ) => {
305+ throw new Error ( "transport exploded" ) ;
306+ } ,
307+ close : async ( ) => undefined ,
308+ } ;
309+ Object . assign ( client , {
310+ callResult : async ( ) => {
311+ throw new Error ( "transport exploded" ) ;
312+ } ,
313+ } ) ;
314+ const [ tool ] = mcpClientToAgentTools ( client , gate ) ;
315+ if ( tool ?. kind !== "full" ) throw new Error ( "expected full tool" ) ;
316+
317+ const result = await tool . handler (
318+ { id : "c-mcp-throw" , name : "mcp__acme__fetch_secret" , arguments : { } } ,
319+ new AbortController ( ) . signal ,
320+ ) ;
321+
322+ expect ( result . isError ) . toBe ( true ) ;
323+ expect ( result . content ) . toContain ( "transport exploded" ) ;
324+ } ) ;
182325} ) ;
0 commit comments