Skip to content

Commit ee55532

Browse files
committed
feat(mcp): add response artifact contract v2
Conceived by Romuald Członkowski - www.aiadvisors.pl/en
1 parent ed15606 commit ee55532

4 files changed

Lines changed: 173 additions & 44 deletions

File tree

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,18 @@ Want to use n8n-MCP with your n8n instance? Check out our comprehensive [n8n Dep
6969

7070
### Bounded MCP responses
7171

72-
Tool results stay inline up to a conservative 32 KiB budget. Larger workflow,
72+
Compact JSON tool results stay inline up to a conservative 32 KiB budget. Larger workflow,
7373
execution, documentation, and host-injected tool results return a compact
74-
summary plus `response_meta.artifact`. Prefer `query_response_artifact` to
74+
preview capped at 8 KiB plus `response_meta.artifact`. Prefer `query_response_artifact` to
7575
select, filter, project, and paginate structured JSON without loading the full
7676
artifact into model context; `read_response_artifact` remains a raw 24 KiB page
7777
fallback. Artifact query paths use RFC 6901, while projected fields accept
78-
either root names such as `id` or pointers such as `/status/name`. The full serialized MCP result is capped at
78+
either root names such as `id` or pointers such as `/status/name`. Use
79+
`objectMode: "entries"` to query keyed objects (including native n8n connection
80+
maps) as `{key, value}` rows; filters can then select several keys in one call.
81+
`describe: true` pages shape metadata and returns absolute child pointers for
82+
objects. Query and read pages use response contract version 2 and do not repeat
83+
the full artifact descriptor minted by the originating tool. The full serialized MCP result is capped at
7984
128 KiB, artifacts are capped at 50 MiB, expire after 24 hours, and are pruned
8085
at a 1 GiB quota.
8186

src/mcp/server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1734,6 +1734,7 @@ export class N8NDocumentationMCPServer {
17341734
args.cursor,
17351735
owner,
17361736
args.describe === true,
1737+
args.objectMode,
17371738
);
17381739
}
17391740

src/services/mcp-response-bounding.ts

Lines changed: 100 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
1918
export const INLINE_RESULT_BYTES = envInt('MCP_RESPONSE_INLINE_BYTES', 32 * 1024);
19+
export const PREVIEW_RESULT_BYTES = envInt('MCP_RESPONSE_PREVIEW_BYTES', 8 * 1024);
2020
export const HARD_RESULT_BYTES = envInt('MCP_RESPONSE_HARD_BYTES', 128 * 1024);
2121
export const ARTIFACT_PAGE_BYTES = envInt('MCP_RESPONSE_ARTIFACT_PAGE_BYTES', 24 * 1024);
2222
export const DEFAULT_PAGE_SIZE = 20;
@@ -50,6 +50,7 @@ export interface FilterStat {
5050
}
5151

5252
export 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
*/
186194
export function serializeToolText(value: unknown): string {
187-
return JSON.stringify(value, null, 2) ?? '';
195+
return JSON.stringify(value) ?? '';
188196
}
189197

190198
function emittedBytes(value: unknown): number {
@@ -201,7 +209,7 @@ function safeOwner(owner: string): string {
201209
}
202210

203211
function 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

225237
function 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

Comments
 (0)