@@ -15,8 +15,8 @@ function envInt(name: string, fallback: number): number {
1515 return Number . isFinite ( parsed ) && parsed > 0 ? parsed : fallback ;
1616}
1717
18- // Measured against the 2-space-indented text server.ts emits, not compact JSON.
1918export const INLINE_RESULT_BYTES = envInt ( 'MCP_RESPONSE_INLINE_BYTES' , 32 * 1024 ) ;
19+ export const PREVIEW_RESULT_BYTES = envInt ( 'MCP_RESPONSE_PREVIEW_BYTES' , 8 * 1024 ) ;
2020export const HARD_RESULT_BYTES = envInt ( 'MCP_RESPONSE_HARD_BYTES' , 128 * 1024 ) ;
2121export const ARTIFACT_PAGE_BYTES = envInt ( 'MCP_RESPONSE_ARTIFACT_PAGE_BYTES' , 24 * 1024 ) ;
2222export const DEFAULT_PAGE_SIZE = 20 ;
@@ -50,6 +50,7 @@ export interface FilterStat {
5050}
5151
5252export interface ResponseMeta {
53+ contract_version : 2 ;
5354 truncated : boolean ;
5455 complete : boolean ;
5556 truncation_reason : string | null ;
@@ -58,7 +59,7 @@ export interface ResponseMeta {
5859 remaining_count : number | null ;
5960 next_cursor : string | null ;
6061 serialized_bytes : number ;
61- artifact : ArtifactReference | null ;
62+ artifact ? : ArtifactReference | null ;
6263 warning : string | null ;
6364 /** What returned_count/total_count count: array elements, object entries, or bytes. */
6465 page_unit ?: 'items' | 'entries' | 'bytes' ;
@@ -133,7 +134,9 @@ export const queryResponseArtifactTool = {
133134 'preview is a reshaped summary, so pointers copied from it may not exist in the artifact ' +
134135 '(response_meta.artifact.primary_paths lists pointers that do). responsePath uses RFC 6901. ' +
135136 'fields accepts root names such as id or pointers such as /status/name. Arrays page by element and ' +
136- 'objects page by entry; follow next_cursor until it is null. Artifact handles are valid until the ' +
137+ 'objects page by entry. For keyed maps such as n8n connections, set objectMode="entries" and filter ' +
138+ 'on /key rather than guessing nested array paths. Shape descriptions are pageable. Follow next_cursor ' +
139+ 'until it is null. Artifact handles are valid until the ' +
137140 'MCP server restarts, and at most 24 hours; an unknown handle means you should re-run the originating tool.' ,
138141 inputSchema : {
139142 type : 'object' ,
@@ -145,6 +148,11 @@ export const queryResponseArtifactTool = {
145148 description : 'Return the shape at responsePath (types, key names, array lengths) instead of values. Use this first when you do not know the structure.' ,
146149 default : false ,
147150 } ,
151+ objectMode : {
152+ type : 'string' ,
153+ enum : [ 'entries' ] ,
154+ description : 'For an object selection, expose [{key,value}] rows so filters and fields can be applied generically.' ,
155+ } ,
148156 fields : {
149157 type : 'array' ,
150158 maxItems : 50 ,
@@ -180,11 +188,11 @@ function encode(value: unknown): Buffer {
180188}
181189
182190/**
183- * The single definition of how a tool result becomes text. Budgets here are only correct
184- * while server.ts emits results through this, so both go through one function .
191+ * The single definition of how a tool result becomes text. Compact JSON avoids spending
192+ * the response budget on indentation and matches the bytes persisted in an artifact .
185193 */
186194export function serializeToolText ( value : unknown ) : string {
187- return JSON . stringify ( value , null , 2 ) ?? '' ;
195+ return JSON . stringify ( value ) ?? '' ;
188196}
189197
190198function emittedBytes ( value : unknown ) : number {
@@ -201,7 +209,7 @@ function safeOwner(owner: string): string {
201209}
202210
203211function encodeCursor ( state : Record < string , unknown > ) : string {
204- const payload = Buffer . from ( JSON . stringify ( state ) ) ;
212+ const payload = Buffer . from ( JSON . stringify ( { v : 2 , ... state } ) ) ;
205213 const signature = createHmac ( 'sha256' , cursorKey ) . update ( payload ) . digest ( ) ;
206214 return Buffer . concat ( [ payload , signature ] ) . toString ( 'base64url' ) ;
207215}
@@ -219,7 +227,11 @@ function decodeCursor(cursor: string): Record<string, any> {
219227 'issued them; if the server restarted, restart the query without a cursor.' ,
220228 ) ;
221229 }
222- return JSON . parse ( payload . toString ( 'utf8' ) ) as Record < string , any > ;
230+ const decoded = JSON . parse ( payload . toString ( 'utf8' ) ) as Record < string , any > ;
231+ if ( decoded . v !== 2 ) {
232+ throw new ArtifactHandleError ( 'invalid_cursor' , 'Artifact cursor uses an unsupported response contract version' ) ;
233+ }
234+ return decoded ;
223235}
224236
225237function escapeToken ( token : string ) : string {
@@ -502,37 +514,49 @@ function shapeOf(container: unknown, keyLimit: number): KeyShape[] {
502514}
503515
504516/** Shape at a path without values; array item keys are merged across a sample. */
505- function describeSelection ( selected : unknown , keyLimit = 60 , sampleSize = 20 ) : Record < string , unknown > {
517+ function describeSelection (
518+ selected : unknown ,
519+ responsePath : string ,
520+ offset : number ,
521+ pageSize : number ,
522+ sampleSize = 20 ,
523+ ) : { shape : Record < string , unknown > ; total : number ; returned : number } {
506524 const type = jsonType ( selected ) ;
507525 if ( Array . isArray ( selected ) ) {
508526 const merged = new Map < string , KeyShape > ( ) ;
509527 for ( const item of selected . slice ( 0 , sampleSize ) ) {
510- for ( const shape of shapeOf ( item , keyLimit ) ) {
528+ for ( const shape of shapeOf ( item , Number . MAX_SAFE_INTEGER ) ) {
511529 if ( ! merged . has ( shape . name ) ) merged . set ( shape . name , shape ) ;
512530 }
513531 }
514- return {
532+ const keys = Array . from ( merged . values ( ) ) ;
533+ const page = keys . slice ( offset , offset + pageSize ) ;
534+ return { shape : {
515535 type,
516536 length : selected . length ,
517537 sampled_items : Math . min ( selected . length , sampleSize ) ,
518538 item_type : selected . length ? jsonType ( selected [ 0 ] ) : null ,
519- item_keys : Array . from ( merged . values ( ) ) ,
539+ item_keys : page ,
520540 note : 'item_keys pointers are relative to each item — use them for filters[].path and fields.' ,
521- } ;
541+ } , total : keys . length , returned : page . length } ;
522542 }
523543 if ( selected && typeof selected === 'object' ) {
524- const keys = Object . keys ( selected as Record < string , unknown > ) ;
525- return {
544+ const entries = Object . entries ( selected as Record < string , unknown > ) ;
545+ const page = shapeOf ( Object . fromEntries ( entries . slice ( offset , offset + pageSize ) ) , pageSize )
546+ . map ( key => ( {
547+ ...key ,
548+ pointer : `${ responsePath } /${ escapeToken ( key . name ) } ` ,
549+ } ) ) ;
550+ return { shape : {
526551 type,
527- entry_count : keys . length ,
528- keys : shapeOf ( selected , keyLimit ) ,
529- truncated_keys : keys . length > keyLimit ? keys . length - keyLimit : 0 ,
530- note : 'keys pointers are relative to responsePath — append them to reach a value.' ,
531- } ;
552+ entry_count : entries . length ,
553+ keys : page ,
554+ note : 'keys pointers are absolute RFC 6901 pointers from the artifact root.' ,
555+ } , total : entries . length , returned : page . length } ;
532556 }
533557 const shape : Record < string , unknown > = { type } ;
534558 if ( typeof selected === 'string' ) shape . length = selected . length ;
535- return shape ;
559+ return { shape, total : 0 , returned : 0 } ;
536560}
537561
538562/** Pointers to the main collections. Bounded walk: arrays sampled at [0], budget capped. */
@@ -920,7 +944,6 @@ export function readResponseArtifact(artifactId: string, cursor: string | undefi
920944 throw new ArtifactHandleError ( 'invalid_cursor' , 'Artifact cursor is past the end of the artifact' ) ;
921945 }
922946
923- const reference = artifactReference ( artifactId , metadata ) ;
924947 const build = ( chunk : Buffer , nextOffset : number ) => {
925948 const nextCursor = nextOffset < total
926949 ? encodeCursor ( { artifactId, offset : nextOffset , owner : safeOwner ( owner ) } )
@@ -931,13 +954,13 @@ export function readResponseArtifact(artifactId: string, cursor: string | undefi
931954 offset,
932955 text : chunk . toString ( 'utf8' ) ,
933956 response_meta : {
957+ contract_version : 2 ,
934958 truncated : nextCursor !== null ,
935959 truncation_reason : nextCursor ? 'artifact_page' : null ,
936960 returned_count : chunk . length ,
937961 total_count : total ,
938962 next_cursor : nextCursor ,
939963 serialized_bytes : 0 ,
940- artifact : reference ,
941964 page_unit : 'bytes' as const ,
942965 source_truncated : metadata . source_truncated ?? false ,
943966 ...completionMetadata ( nextCursor !== null , chunk . length , total , offset ) ,
@@ -990,6 +1013,7 @@ export function queryResponseArtifact(
9901013 cursor : string | undefined ,
9911014 owner : string ,
9921015 describe = false ,
1016+ objectMode ?: 'entries' ,
9931017) : unknown {
9941018 if ( ! Number . isInteger ( pageSize ) || pageSize < 1 || pageSize > MAX_PAGE_SIZE ) {
9951019 throw new Error ( `pageSize must be an integer between 1 and ${ MAX_PAGE_SIZE } ` ) ;
@@ -1007,33 +1031,69 @@ export function queryResponseArtifact(
10071031
10081032 const { dataPath, metadata } = loadMetadata ( artifactId , owner ) ;
10091033 const document = loadDocument ( artifactId , dataPath ) ;
1010- const reference = artifactReference ( artifactId , metadata ) ;
10111034 const sourceTruncated = metadata . source_truncated ?? false ;
10121035
10131036 let selected = pointer ( document , responsePath ) ;
10141037
10151038 if ( describe ) {
1039+ if ( fields ?. length || filters ?. length ) {
1040+ throw new Error ( 'describe cannot be combined with fields or filters' ) ;
1041+ }
1042+ if ( objectMode !== undefined ) {
1043+ throw new Error ( 'describe cannot be combined with objectMode' ) ;
1044+ }
1045+ const viewHash = createHash ( 'sha256' ) . update ( encode ( {
1046+ contractVersion : 2 ,
1047+ artifactId,
1048+ responsePath,
1049+ describe : true ,
1050+ pageSize,
1051+ } ) ) . digest ( 'hex' ) ;
1052+ let offset = 0 ;
1053+ if ( cursor ) {
1054+ const decoded = decodeCursor ( cursor ) ;
1055+ if ( decoded . owner !== safeOwner ( owner ) || decoded . artifactId !== artifactId || decoded . viewHash !== viewHash ) {
1056+ throw new ArtifactHandleError ( 'invalid_cursor' , 'Artifact shape cursor does not match this artifact, scope, path, or page size' ) ;
1057+ }
1058+ offset = decoded . offset ;
1059+ }
1060+ const described = describeSelection ( selected , responsePath , offset , pageSize ) ;
1061+ if ( offset > described . total ) {
1062+ throw new ArtifactHandleError ( 'invalid_cursor' , 'Artifact shape cursor is past the end of the selection' ) ;
1063+ }
1064+ const nextOffset = offset + described . returned ;
1065+ const nextCursor = nextOffset < described . total
1066+ ? encodeCursor ( { artifactId, owner : safeOwner ( owner ) , viewHash, offset : nextOffset } )
1067+ : null ;
10161068 const result = {
10171069 artifact_id : artifactId ,
10181070 response_path : responsePath ,
1019- describe : true ,
1020- response : describeSelection ( selected ) ,
1071+ shape : described . shape ,
10211072 response_meta : {
1022- truncated : false ,
1023- truncation_reason : null ,
1024- returned_count : null ,
1025- total_count : null ,
1026- next_cursor : null ,
1073+ contract_version : 2 ,
1074+ truncated : nextCursor !== null ,
1075+ truncation_reason : nextCursor ? 'page_limit' : null ,
1076+ returned_count : described . returned ,
1077+ total_count : described . total ,
1078+ next_cursor : nextCursor ,
10271079 serialized_bytes : 0 ,
1028- artifact : reference ,
1080+ page_unit : 'entries' as const ,
10291081 source_truncated : sourceTruncated ,
1030- ...completionMetadata ( false , null , null ) ,
1082+ ...completionMetadata ( nextCursor !== null , described . returned , described . total , offset ) ,
10311083 } satisfies ResponseMeta ,
10321084 } ;
10331085 result . response_meta . serialized_bytes = emittedBytes ( result ) ;
10341086 return result ;
10351087 }
10361088
1089+ if ( objectMode !== undefined ) {
1090+ if ( objectMode !== 'entries' ) throw new Error ( `Unsupported objectMode: ${ objectMode } ` ) ;
1091+ if ( ! selected || typeof selected !== 'object' || Array . isArray ( selected ) ) {
1092+ throw new Error ( `objectMode=entries requires responsePath to select a JSON object, but it selects ${ jsonType ( selected ) } ` ) ;
1093+ }
1094+ selected = Object . entries ( selected as Record < string , unknown > ) . map ( ( [ key , value ] ) => ( { key, value } ) ) ;
1095+ }
1096+
10371097 let filterStats : FilterStat [ ] | undefined ;
10381098 if ( filters ?. length ) {
10391099 if ( ! Array . isArray ( selected ) ) {
@@ -1055,8 +1115,10 @@ export function queryResponseArtifact(
10551115 }
10561116
10571117 const viewHash = createHash ( 'sha256' ) . update ( encode ( {
1118+ contractVersion : 2 ,
10581119 artifactId,
10591120 responsePath,
1121+ objectMode : objectMode ?? null ,
10601122 fields : fields ?? null ,
10611123 filters : filters ?? null ,
10621124 pageSize,
@@ -1110,13 +1172,13 @@ export function queryResponseArtifact(
11101172 if ( selection . kind === 'scalar' ) {
11111173 const build = ( response : unknown , truncated : boolean ) => {
11121174 const meta : ResponseMeta = {
1175+ contract_version : 2 ,
11131176 truncated,
11141177 truncation_reason : truncated ? 'scalar_size_limit' : null ,
11151178 returned_count : null ,
11161179 total_count : null ,
11171180 next_cursor : null ,
11181181 serialized_bytes : 0 ,
1119- artifact : reference ,
11201182 source_truncated : sourceTruncated ,
11211183 ...completionMetadata ( truncated , null , null ) ,
11221184 } ;
@@ -1148,13 +1210,13 @@ export function queryResponseArtifact(
11481210 const build = ( response : unknown , returnedCount : number , nextCursor : string | null , truncationReason : string | null ) => {
11491211 const truncated = nextCursor !== null || truncationReason !== null ;
11501212 const meta : ResponseMeta = {
1213+ contract_version : 2 ,
11511214 truncated,
11521215 truncation_reason : truncationReason ,
11531216 returned_count : returnedCount ,
11541217 total_count : totalCount ,
11551218 next_cursor : nextCursor ,
11561219 serialized_bytes : 0 ,
1157- artifact : reference ,
11581220 page_unit : pageUnit ,
11591221 source_truncated : sourceTruncated ,
11601222 ...completionMetadata ( truncated , returnedCount , totalCount , offset ) ,
@@ -1265,6 +1327,7 @@ export function boundToolResult(toolName: string, value: unknown, owner: string)
12651327 : true ,
12661328 data : compactToolValue ( toolName , value ) as unknown ,
12671329 response_meta : {
1330+ contract_version : 2 ,
12681331 truncated : true ,
12691332 truncation_reason : 'size_limit' ,
12701333 returned_count : null ,
@@ -1282,8 +1345,8 @@ export function boundToolResult(toolName: string, value: unknown, owner: string)
12821345 `read_response_artifact only as a raw byte fallback.` ,
12831346 } ;
12841347
1285- if ( emittedBytes ( bounded ) > INLINE_RESULT_BYTES ) {
1286- bounded . data = compactToBudget ( bounded . data , INLINE_RESULT_BYTES , compacted => ( { ...bounded , data : compacted } ) ) ;
1348+ if ( emittedBytes ( bounded ) > PREVIEW_RESULT_BYTES ) {
1349+ bounded . data = compactToBudget ( bounded . data , PREVIEW_RESULT_BYTES , compacted => ( { ...bounded , data : compacted } ) ) ;
12871350 }
12881351 bounded . response_meta . serialized_bytes = emittedBytes ( bounded ) ;
12891352 if ( emittedBytes ( bounded ) > HARD_RESULT_BYTES ) {
0 commit comments