diff --git a/docs/tool-definition.md b/docs/tool-definition.md index 33bff100..6245bbe6 100644 --- a/docs/tool-definition.md +++ b/docs/tool-definition.md @@ -73,6 +73,43 @@ The bridge configuration that transforms MCP tool calls into API requests. The `$` prefix means "take the value from the tool input parameter with this name." +### Response headers and pagination (`exposeHeaders`) + +By default a tool receives the response **body** and nothing else. Some APIs put +the one thing a model needs to continue in a header instead: GitHub, GitLab, +Sentry and Shopify paginate with `Link: <...?cursor=xyz>; rel="next"`, and most +APIs report rate limits in `X-RateLimit-*`. Without those, every list tool is +exactly one page long. + +A REST tool can opt in per header name (case-insensitive): + +```json +{ + "method": "GET", + "path": "/organizations/{{SENTRY_ORG}}/issues/", + "queryParams": { "cursor": "$cursor", "query": "$query" }, + "exposeHeaders": ["link", "x-ratelimit-remaining"] +} +``` + +The selected headers are added to the tool result next to the body, and a +`Link` header with `rel="next"` is parsed for you: + +```json +{ + "...the body as before...": "", + "_headers": { "link": "; rel=\"next\"", "x-ratelimit-remaining": "39" }, + "_pagination": { "nextUrl": "https://sentry.io/api/0/...?cursor=1568:0:0", "nextCursor": "1568:0:0", "cursorParam": "cursor" } +} +``` + +- `_pagination` is **absent on the last page**; tell the model so in the tool description ("call again with `cursor` = `_pagination.nextCursor` until it is missing"). +- `nextCursor` is recognised for the usual parameter names (`cursor`, `page`, `offset`, `after`, `page_token`, `starting_after`, ...); otherwise only `nextUrl` is set. +- If the body is not a JSON object (an array, a string) it is wrapped as `data` so the extras have somewhere to live. +- A response transform (`responseMapping.transform`) runs on the body first; the extras are attached afterwards, so a `select` cannot drop them. +- The audit log keeps storing the bare body. Headers are cached together with it when `cacheTtl` is set. +- REST connectors only. Tools that did not set `exposeHeaders` behave exactly as before. + ### By Connector Type | Connector | method | path | queryParams | bodyMapping | headers | diff --git a/packages/backend/src/connectors/connectors.service.ts b/packages/backend/src/connectors/connectors.service.ts index 5f76617b..9d0b51bf 100644 --- a/packages/backend/src/connectors/connectors.service.ts +++ b/packages/backend/src/connectors/connectors.service.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../common/prisma.service'; import { Connector, ConnectorType, AuthType } from '../generated/prisma/client'; import { RestEngine } from './engines/rest.engine'; +import { attachResponseMeta } from './engines/response-headers.util'; import { SoapEngine } from './engines/soap.engine'; import { GraphqlEngine } from './engines/graphql.engine'; import { DatabaseEngine } from './engines/database.engine'; @@ -421,8 +422,24 @@ export class ConnectorsService { } switch (connector.type) { - case 'REST': + case 'REST': { + // The in-app "Run Test" must show what a model will see, so a tool + // that asked for response headers gets them here as well. + const wanted = (endpointMapping as { exposeHeaders?: string[] }).exposeHeaders; + if (Array.isArray(wanted) && wanted.length > 0) { + const out = await this.restEngine.executeWithMeta( + config, + endpointMapping, + mergedParams, + ); + return attachResponseMeta( + out.body, + { headers: out.headers }, + endpointMapping.queryParams, + ); + } return this.restEngine.execute(config, endpointMapping, mergedParams); + } case 'SOAP': return this.soapEngine.execute(config, endpointMapping, mergedParams); case 'GRAPHQL': diff --git a/packages/backend/src/connectors/engines/response-headers.util.spec.ts b/packages/backend/src/connectors/engines/response-headers.util.spec.ts new file mode 100644 index 00000000..1d85c1ea --- /dev/null +++ b/packages/backend/src/connectors/engines/response-headers.util.spec.ts @@ -0,0 +1,79 @@ +import { + describePagination, + parseLinkHeader, + pickExposedHeaders, +} from './response-headers.util'; + +describe('pickExposedHeaders', () => { + it('returns only the asked-for headers, lower-cased, regardless of the wire casing', () => { + const out = pickExposedHeaders( + { 'X-RateLimit-Remaining': '39', Link: '; rel="next"', 'set-cookie': 'nope' }, + ['link', 'x-ratelimit-remaining'], + ); + expect(out).toEqual({ link: '; rel="next"', 'x-ratelimit-remaining': '39' }); + }); + + it('joins multi-valued headers and returns nothing when no tool asked', () => { + expect(pickExposedHeaders({ link: ['a', 'b'] }, ['LINK'])).toEqual({ link: 'a, b' }); + expect(pickExposedHeaders({ link: 'x' }, undefined)).toEqual({}); + expect(pickExposedHeaders(undefined, ['link'])).toEqual({}); + }); +}); + +describe('parseLinkHeader', () => { + it('parses the GitHub / Sentry shape', () => { + const rels = parseLinkHeader( + '; rel="previous"; results="false", ' + + '; rel="next"; results="true"', + ); + expect(rels.next).toBe('https://api.example.com/issues/?cursor=100:0:1'); + expect(rels.previous).toBe('https://api.example.com/issues/?cursor=100:1:0'); + }); + + it('accepts unquoted rel and a rel listing several tokens', () => { + expect(parseLinkHeader('; rel=next last')).toEqual({ + next: 'https://x/a?page=3', + last: 'https://x/a?page=3', + }); + }); +}); + +describe('describePagination', () => { + it('lifts the cursor out of the next link', () => { + const p = describePagination({ + link: '; rel="next"', + }); + expect(p).toEqual({ + nextUrl: + 'https://sentry.io/api/0/organizations/o/issues/?cursor=1568%3A0%3A0&query=is%3Aunresolved', + nextCursor: '1568:0:0', + cursorParam: 'cursor', + }); + }); + + it('recognises page numbers and keeps the previous page when announced', () => { + const p = describePagination({ + link: '; rel="prev", ; rel="next"', + }); + expect(p?.nextCursor).toBe('3'); + expect(p?.cursorParam).toBe('page'); + expect(p?.prevUrl).toBe('https://x/repos?page=1'); + }); + + it('is absent on the last page, and absent without a Link header', () => { + expect(describePagination({ link: '; rel="first"' })).toBeUndefined(); + expect(describePagination({})).toBeUndefined(); + }); + + it('prefers the parameter the tool maps when the link carries several (GitHub: after + page)', () => { + const link = + '; rel="next"'; + expect(describePagination({ link }, ['page'])).toMatchObject({ nextCursor: '2', cursorParam: 'page' }); + expect(describePagination({ link })).toMatchObject({ nextCursor: 'Y3Vyc29y', cursorParam: 'after' }); + }); + + it('still returns nextUrl when the URL has no recognisable cursor', () => { + const p = describePagination({ link: '; rel="next"' }); + expect(p).toEqual({ nextUrl: 'https://x/feed/abc123' }); + }); +}); diff --git a/packages/backend/src/connectors/engines/response-headers.util.ts b/packages/backend/src/connectors/engines/response-headers.util.ts new file mode 100644 index 00000000..0a99ebb8 --- /dev/null +++ b/packages/backend/src/connectors/engines/response-headers.util.ts @@ -0,0 +1,144 @@ +/** + * The few response headers a tool may ask to see. + * + * A REST tool normally gets the body and nothing else, which is right for + * almost every call and wrong for exactly one kind: list endpoints that + * paginate through a `Link` header (GitHub, GitLab, Sentry, Shopify, ...). + * The model can pass a `cursor` in, but never learns the next one, so every + * such tool is one page long. Rate-limit headers are the other honest use. + * + * A tool opts in with `endpointMapping.exposeHeaders: ["link", ...]`. Nothing + * here runs for a tool that did not ask. + */ + +/** Header names an adapter may ask for, matched case-insensitively. */ +export function pickExposedHeaders( + headers: Record | undefined, + names: string[] | undefined, +): Record { + const picked: Record = {}; + if (!headers || !names?.length) return picked; + const wanted = new Set(names.map((n) => n.toLowerCase())); + for (const [key, value] of Object.entries(headers)) { + const name = key.toLowerCase(); + if (!wanted.has(name) || value === undefined || value === null) continue; + picked[name] = Array.isArray(value) + ? value.map(String).join(', ') + : String(value); + } + return picked; +} + +/** RFC 8288 `Link` header → { rel: url }. Tolerant of the usual sloppiness. */ +export function parseLinkHeader(value: string): Record { + const rels: Record = {}; + for (const part of value.split(',')) { + const m = part.match(/<\s*([^>]*)\s*>\s*;([^]*)/); + if (!m) continue; + const url = m[1].trim(); + const rel = m[2].match(/\brel\s*=\s*"?([^";]+)"?/i)?.[1]?.trim(); + if (!rel) continue; + // A single rel attribute may list several tokens: rel="next last". + for (const token of rel.split(/\s+/)) { + if (token && !(token in rels)) rels[token] = url; + } + } + return rels; +} + +/** Query parameters that, in practice, carry the "where to continue" value. */ +const CURSOR_PARAMS = [ + 'cursor', + 'page_token', + 'pageToken', + 'starting_after', + 'after', + 'offset', + 'page', + 'page_info', + 'continuation', +]; + +export interface Pagination { + /** The full URL of the next page, exactly as the API sent it. */ + nextUrl: string; + /** The value to pass back as the tool's cursor parameter, when recognisable. */ + nextCursor?: string; + /** Which query parameter that value belongs to (`cursor`, `page`, ...). */ + cursorParam?: string; + /** Present when the API also announced a previous page. */ + prevUrl?: string; +} + +/** + * What a model needs to fetch the next page, lifted out of `Link`. Returns + * undefined when there is no `next` relation — that is the "last page" + * signal, and it should read as absence, not as an empty object. + */ +export function describePagination( + headers: Record, + preferredParams: string[] = [], +): Pagination | undefined { + const link = headers['link']; + if (!link) return undefined; + const rels = parseLinkHeader(link); + if (!rels.next) return undefined; + + const page: Pagination = { nextUrl: rels.next }; + if (rels.prev) page.prevUrl = rels.prev; + try { + const params = new URL(rels.next).searchParams; + // A next link can carry more than one candidate (GitHub sends both + // `after=` and `page=`). The parameter the tool actually maps wins, so + // the model can feed the value straight back; the generic list is the + // fallback for tools that map none of them. + for (const name of [...preferredParams, ...CURSOR_PARAMS]) { + const v = params.get(name); + if (v !== null && v !== '') { + page.nextCursor = v; + page.cursorParam = name; + break; + } + } + } catch { + // Relative or malformed URL: nextUrl is still useful on its own. + } + return page; +} + +/** What the engine hands back besides the body, when a tool asked for it. */ +export interface ResponseMeta { + headers: Record; +} + +/** + * Puts the exposed headers, and the pagination read out of `Link`, next to + * the body the model already gets. An object body is extended in place; + * anything else (an array, a string) is wrapped as `data` so the extras have + * somewhere to live. `_pagination` is absent on the last page on purpose: + * absence is the signal. + */ +export function attachResponseMeta( + value: unknown, + meta: ResponseMeta, + queryParams?: Record, +): unknown { + const extras: { _headers: Record; _pagination?: Pagination } = { + _headers: meta.headers, + }; + const pagination = describePagination(meta.headers, mappedQueryParams(queryParams)); + if (pagination) extras._pagination = pagination; + + if (value && typeof value === 'object' && !Array.isArray(value)) { + return { ...(value as Record), ...extras }; + } + return { data: value, ...extras }; +} + +/** Query parameter names a tool feeds from its own inputs (`page: "$page"`). */ +function mappedQueryParams(queryParams?: Record): string[] { + if (!queryParams) return []; + return Object.entries(queryParams) + .filter(([, v]) => typeof v === 'string' && (v as string).startsWith('$')) + .map(([k]) => k); +} diff --git a/packages/backend/src/connectors/engines/rest.engine.spec.ts b/packages/backend/src/connectors/engines/rest.engine.spec.ts index d3f5fcdf..413acdea 100644 --- a/packages/backend/src/connectors/engines/rest.engine.spec.ts +++ b/packages/backend/src/connectors/engines/rest.engine.spec.ts @@ -59,6 +59,39 @@ describe('RestEngine', () => { ); }); + it('hands back only the response headers the mapping asked for, lower-cased', async () => { + mockedAxios.mockResolvedValue({ + data: [{ id: 1 }], + headers: { + Link: '; rel="next"', + 'X-RateLimit-Remaining': '9', + 'Set-Cookie': 'secret=1', + }, + }); + + const out = await engine.executeWithMeta( + { baseUrl: 'https://api.example.com', authType: 'NONE' }, + { method: 'GET', path: '/items', exposeHeaders: ['link', 'x-ratelimit-remaining'] }, + {}, + ); + + expect(out.body).toEqual([{ id: 1 }]); + expect(out.headers).toEqual({ + link: '; rel="next"', + 'x-ratelimit-remaining': '9', + }); + }); + + it('returns no headers at all when the mapping did not opt in', async () => { + mockedAxios.mockResolvedValue({ data: {}, headers: { Link: '; rel="next"' } }); + const out = await engine.executeWithMeta( + { baseUrl: 'https://api.example.com', authType: 'NONE' }, + { method: 'GET', path: '/items' }, + {}, + ); + expect(out.headers).toEqual({}); + }); + it('expands __rawquery into flat query params with dynamic keys (weclapp filter)', async () => { mockedAxios.mockResolvedValue({ data: {} }); diff --git a/packages/backend/src/connectors/engines/rest.engine.ts b/packages/backend/src/connectors/engines/rest.engine.ts index ce7ca038..e27fb29e 100644 --- a/packages/backend/src/connectors/engines/rest.engine.ts +++ b/packages/backend/src/connectors/engines/rest.engine.ts @@ -15,6 +15,7 @@ import { LoginTokenAuthConfig, } from './login-token.service'; import { assertSafeOutboundUrl } from '../../common/ssrf.util'; +import { pickExposedHeaders } from './response-headers.util'; /** * RestEngine — executes HTTP calls to REST APIs. @@ -31,7 +32,25 @@ export class RestEngine { private readonly loginTokenService: LoginTokenService, ) {} + /** + * The body alone. What every caller wanted until list endpoints that + * paginate through headers came along; see `executeWithMeta`. + */ async execute( + config: Parameters[0], + endpointMapping: Parameters[1], + params: Record, + ): Promise { + return (await this.executeWithMeta(config, endpointMapping, params)).body; + } + + /** + * The body plus the response headers the mapping asked to see + * (`exposeHeaders`, matched case-insensitively, lower-cased on the way out). + * `headers` is empty unless the tool opted in, so nothing leaks by default + * and the audit log never sees them. + */ + async executeWithMeta( config: { baseUrl: string; authType: string; @@ -55,9 +74,19 @@ export class RestEngine { bodyTemplate?: string; bodyEncoding?: string; headers?: Record; + // Response headers to hand back alongside the body, e.g. ["link"] for + // cursor pagination. Opt-in per tool; see response-headers.util.ts. + exposeHeaders?: string[]; }, params: Record, - ): Promise { + ): Promise<{ body: unknown; headers: Record }> { + const withMeta = (response: AxiosResponse) => ({ + body: response.data, + headers: pickExposedHeaders( + response.headers as Record, + endpointMapping.exposeHeaders, + ), + }); // Interpolate path parameters: /users/{id} → /users/123 // // `path` is optional on the stored mapping — tools saved as `method: @@ -223,7 +252,7 @@ export class RestEngine { try { const response = await this.requestWithRetry(axiosConfig); - return response.data; + return withMeta(response); } catch (error) { // OAuth2 auto-refresh: retry once on 401 if ( @@ -244,7 +273,7 @@ export class RestEngine { ...buildOauth2TokenHeader(config.authConfig, newToken), }; const retryResponse = await axios(axiosConfig); - return retryResponse.data; + return withMeta(retryResponse); } } // LOGIN_TOKEN auto-relogin: retry once on 401 when refreshOn401 is enabled @@ -262,7 +291,7 @@ export class RestEngine { ); injectLoginTokenHeaders(axiosConfig, authConfig, bundle.token, bundle.aud); const retryResponse = await axios(axiosConfig); - return retryResponse.data; + return withMeta(retryResponse); } throw error; } diff --git a/packages/backend/src/mcp-server/dynamic-mcp-tools.spec.ts b/packages/backend/src/mcp-server/dynamic-mcp-tools.spec.ts index 7763e841..d1c1d66d 100644 --- a/packages/backend/src/mcp-server/dynamic-mcp-tools.spec.ts +++ b/packages/backend/src/mcp-server/dynamic-mcp-tools.spec.ts @@ -322,3 +322,102 @@ describe('DynamicMcpTools — tool resolution is scoped to the caller', () => { expect(restEngine.execute).toHaveBeenCalled(); }); }); + +describe('DynamicMcpTools — exposed response headers (pagination)', () => { + const LINK = + '; rel="next", ; rel="prev"'; + const PAGE = { items: [{ id: 1 }] }; + + function pagedTool(exposeHeaders?: string[], responseMapping?: Record) { + const tool = makeTool(responseMapping); + tool.endpointMapping = { method: 'GET', path: '/devices', ...(exposeHeaders ? { exposeHeaders } : {}) }; + return tool; + } + + function buildPaged(tool: RegisteredTool, headers: Record, cached?: string) { + const built = build(tool, { engineResult: PAGE, cached }); + (built.restEngine as any).executeWithMeta = jest + .fn() + .mockResolvedValue({ body: PAGE, headers }); + return built; + } + + it('does not touch the engine\'s header path, nor the output, for a tool that did not opt in', async () => { + const { executor, restEngine } = buildPaged(pagedTool(), { link: LINK }); + const res = await executor.executeTool('list_devices', {}); + expect((restEngine as any).executeWithMeta).not.toHaveBeenCalled(); + expect(restEngine.execute).toHaveBeenCalled(); + expect(res.structured).toEqual(PAGE); + }); + + it('puts the asked-for headers and the next cursor next to the body', async () => { + const { executor } = buildPaged(pagedTool(['link']), { link: LINK }); + const res = await executor.executeTool('list_devices', {}); + expect(res.structured).toEqual({ + items: [{ id: 1 }], + _headers: { link: LINK }, + _pagination: { + nextUrl: 'https://api.example.com/devices?cursor=abc', + nextCursor: 'abc', + cursorParam: 'cursor', + prevUrl: 'https://api.example.com/devices?cursor=000', + }, + }); + expect(JSON.parse(res.content[0].text)._pagination.nextCursor).toBe('abc'); + }); + + it('omits _pagination on the last page, so its absence is the signal', async () => { + const { executor } = buildPaged(pagedTool(['link', 'x-ratelimit-remaining']), { + link: '; rel="prev"', + 'x-ratelimit-remaining': '41', + }); + const res = await executor.executeTool('list_devices', {}); + const out = res.structured as any; + expect(out._pagination).toBeUndefined(); + expect(out._headers['x-ratelimit-remaining']).toBe('41'); + }); + + it('wraps a non-object body as data instead of losing the extras', async () => { + const built = buildPaged(pagedTool(['link']), { link: LINK }); + (built.restEngine as any).executeWithMeta.mockResolvedValue({ body: [1, 2], headers: { link: LINK } }); + const res = await built.executor.executeTool('list_devices', {}); + expect((res.structured as any).data).toEqual([1, 2]); + expect((res.structured as any)._pagination.nextCursor).toBe('abc'); + }); + + it('applies the response transform to the body first, then attaches the extras', async () => { + const built = buildPaged( + pagedTool(['link'], { transform: { select: { first: '$.items[0].id' } } }), + { link: LINK }, + ); + const res = await built.executor.executeTool('list_devices', {}); + expect(res.structured).toMatchObject({ first: 1, _pagination: { nextCursor: 'abc' } }); + }); + + it('audits the bare body: headers never reach the log', async () => { + const { executor, audit } = buildPaged(pagedTool(['link']), { link: LINK }); + await executor.executeTool('list_devices', {}); + expect(audit.logInvocation).toHaveBeenCalledWith( + expect.objectContaining({ output: PAGE, status: 'SUCCESS' }), + ); + }); + + it('caches body and headers together, and a cache hit still carries the cursor', async () => { + const { executor, redis } = buildPaged(pagedTool(['link'], { cacheTtl: 60 }), { link: LINK }); + await executor.executeTool('list_devices', {}); + const stored = JSON.parse(redis.set.mock.calls[0][1]); + expect(stored).toEqual({ __amcpEnvelope: 1, body: PAGE, meta: { headers: { link: LINK } } }); + + const hit = buildPaged(pagedTool(['link'], { cacheTtl: 60 }), {}, redis.set.mock.calls[0][1]); + const res = await hit.executor.executeTool('list_devices', {}); + expect((hit.restEngine as any).executeWithMeta).not.toHaveBeenCalled(); + expect((res.structured as any)._pagination.nextCursor).toBe('abc'); + }); + + it('still reads a cache entry written before envelopes existed', async () => { + const hit = buildPaged(pagedTool(['link'], { cacheTtl: 60 }), {}, JSON.stringify(PAGE)); + const res = await hit.executor.executeTool('list_devices', {}); + expect(res.structured).toEqual(PAGE); + }); +}); + diff --git a/packages/backend/src/mcp-server/dynamic-mcp-tools.ts b/packages/backend/src/mcp-server/dynamic-mcp-tools.ts index 79fd2ecd..4270b250 100644 --- a/packages/backend/src/mcp-server/dynamic-mcp-tools.ts +++ b/packages/backend/src/mcp-server/dynamic-mcp-tools.ts @@ -19,6 +19,10 @@ import { } from '../common/caller-context.util'; import { resolveInternalDbRestUrl } from '../common/db-rest.util'; import { applyResponseTransform } from '../connectors/response-transform.util'; +import { + attachResponseMeta, + type ResponseMeta, +} from '../connectors/engines/response-headers.util'; import { KgService } from '../knowledge-graph/kg.service'; import type { ResponseMapping } from '../connectors/engines/engine-types'; import type { RegisteredTool } from './tool-registry'; @@ -218,6 +222,17 @@ export class DynamicMcpTools { try { const raw = JSON.parse(cached); this.logger.debug(`Cache hit for tool ${toolName}`); + // Entries written with response meta are wrapped; older ones are + // the bare body. Both must keep rendering. + if (raw && typeof raw === 'object' && raw.__amcpEnvelope === 1) { + return this.renderResult( + raw.body, + responseMapping, + toolName, + raw.meta, + tool.endpointMapping?.queryParams, + ); + } return this.renderResult(raw, responseMapping, toolName); } catch { // Unreadable entry — fall through and re-execute. @@ -277,7 +292,7 @@ export class DynamicMcpTools { // Apply JSON Schema defaults for missing params const mergedParams = this.applyDefaults(tool.parameters, paramsWithEnv); - const result = await this.executeWithEngine( + const { body: result, meta } = await this.executeWithEngine( tool.connectorType, engineConfig, interpolatedMapping, @@ -314,7 +329,11 @@ export class DynamicMcpTools { // Cache the raw response if cacheTtl is set (shaping happens on read). if (cacheTtl && cacheTtl > 0) { const cacheKey = this.buildCacheKey(toolName, params); - const serialized = JSON.stringify(result); + // The audit log above got the bare body; the cache needs the headers + // too, or a cached page would come back without its next cursor. + const serialized = JSON.stringify( + meta ? { __amcpEnvelope: 1, body: result, meta } : result, + ); if (serialized !== undefined) { await this.redisService.set(cacheKey, serialized, cacheTtl); this.logger.debug( @@ -323,7 +342,13 @@ export class DynamicMcpTools { } } - return this.renderResult(result, responseMapping, toolName); + return this.renderResult( + result, + responseMapping, + toolName, + meta, + tool.endpointMapping?.queryParams, + ); } catch (error: any) { const durationMs = Date.now() - startTime; const errorDetail = this.extractErrorDetail(error); @@ -385,6 +410,8 @@ export class DynamicMcpTools { raw: unknown, responseMapping: ResponseMapping | undefined, toolName: string, + meta?: ResponseMeta, + queryParams?: Record, ): { content: { type: 'text'; text: string }[]; isError?: boolean; @@ -413,14 +440,20 @@ export class DynamicMcpTools { ); } - let resultText = JSON.stringify(outcome.value, null, 2) ?? 'null'; + // Response headers the tool opted into ride along with the (shaped) + // body, after the transform so a `select` cannot drop them by accident. + const value = meta + ? attachResponseMeta(outcome.value, meta, queryParams) + : outcome.value; + + let resultText = JSON.stringify(value, null, 2) ?? 'null'; if (responseMapping?.followUp) { resultText += `\n\n---\nWORKFLOW HINT (guidance for the assistant, not part of the API response): ${responseMapping.followUp}`; } return { content: [{ type: 'text' as const, text: resultText }], - structured: outcome.value, + structured: value, }; } @@ -569,7 +602,7 @@ export class DynamicMcpTools { endpointMapping: any, params: Record, extra?: { connectorConfig?: Record }, - ): Promise { + ): Promise<{ body: unknown; meta?: ResponseMeta }> { // Static response tools — return text immediately without engine dispatch. // // The `method` alone decides this, not `method && staticResponse`. With the @@ -586,21 +619,45 @@ export class DynamicMcpTools { 'change the method to a real HTTP verb.', ); } - return { text: endpointMapping.staticResponse }; + return { body: { text: endpointMapping.staticResponse } }; } switch (connectorType) { - case 'REST': - return this.restEngine.execute(config, endpointMapping, params); + case 'REST': { + // Only a tool that asked for headers pays for them; every other REST + // call stays on the body-only path it always had. + const wanted = endpointMapping.exposeHeaders; + if (Array.isArray(wanted) && wanted.length > 0) { + const out = await this.restEngine.executeWithMeta( + config, + endpointMapping, + params, + ); + return { body: out.body, meta: { headers: out.headers } }; + } + return { + body: await this.restEngine.execute(config, endpointMapping, params), + }; + } case 'GRAPHQL': - return this.graphqlEngine.execute(config, endpointMapping, params); + return { + body: await this.graphqlEngine.execute(config, endpointMapping, params), + }; case 'SOAP': - return this.soapEngine.execute(config, endpointMapping, params); + return { + body: await this.soapEngine.execute(config, endpointMapping, params), + }; case 'MCP': - return this.mcpClientEngine.execute(config, endpointMapping, params); + return { + body: await this.mcpClientEngine.execute(config, endpointMapping, params), + }; case 'DATABASE': { const readOnly = (extra?.connectorConfig as any)?.readOnly !== false; - return this.databaseEngine.execute(config, endpointMapping, params, { readOnly }); + return { + body: await this.databaseEngine.execute(config, endpointMapping, params, { + readOnly, + }), + }; } default: throw new Error(`Unsupported connector type: ${connectorType}`); diff --git a/packages/backend/src/mcp-server/tool-registry.ts b/packages/backend/src/mcp-server/tool-registry.ts index 186692fc..1d653884 100644 --- a/packages/backend/src/mcp-server/tool-registry.ts +++ b/packages/backend/src/mcp-server/tool-registry.ts @@ -38,6 +38,10 @@ export interface RegisteredTool { queryParams?: Record; bodyMapping?: Record; headers?: Record; + // Response headers the tool wants back with the body (REST only). The + // usual reason is `["link"]`: cursor pagination lives in that header, + // and without it every list tool is exactly one page long. + exposeHeaders?: string[]; }; responseMapping?: Record; // JSON Schema of the response, served to clients as the tool's outputSchema.