From 05641d4076596dc8d9bf2146e2ae51d8e6dc6ac5 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Wed, 2 Sep 2026 03:30:50 +0200 Subject: [PATCH] feat(mcp): per-client pretty-printed results via the X-MCP-Pretty header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compact JSON stays the platform default: measured with an OpenAI-family tokenizer on realistic payloads, 2-space indentation costs 14-46 % more tokens (2-2.7 tokens per line for newline, indent and colon spacing) — gzip removes it from the wire, not from a model's context. A human reading results in an interactive client opts in per client with `X-MCP-Pretty: 1`; the access guard stores the preference on the caller context and stringifyResult honours it. This repo's .mcp.json sets the header so operator sessions in Claude Code get readable output. The MCP_PRETTY_JSON environment switch remains the global escape hatch. Co-Authored-By: Claude Fable 5.1 --- .mcp.json | 5 +++- docs | 2 +- .../src/__tests__/lean-defaults.test.ts | 23 +++++++++++++++++-- .../autopilot-monitor-mcp/src/access-guard.ts | 5 +++- .../autopilot-monitor-mcp/src/client.ts | 20 ++++++++++++++++ .../autopilot-monitor-mcp/src/config.ts | 8 ++++--- .../autopilot-monitor-mcp/src/tools/shared.ts | 8 ++++--- 7 files changed, 60 insertions(+), 11 deletions(-) diff --git a/.mcp.json b/.mcp.json index ff376bbf..7c10d950 100644 --- a/.mcp.json +++ b/.mcp.json @@ -2,7 +2,10 @@ "mcpServers": { "autopilot-monitor": { "type": "http", - "url": "https://mcp.autopilotmonitor.com/mcp" + "url": "https://mcp.autopilotmonitor.com/mcp", + "headers": { + "X-MCP-Pretty": "1" + } }, "playwright": { "command": "cmd", diff --git a/docs b/docs index 08572486..755cc01c 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 085724868216f964b297c9950116302bfccbd44a +Subproject commit 755cc01cf77e9a0894ce03b55e521b8578c73239 diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/lean-defaults.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/lean-defaults.test.ts index d92fcd4a..0ae79e63 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/lean-defaults.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/lean-defaults.test.ts @@ -11,6 +11,7 @@ import { McpServer } from '@modelcontextprotocol/server'; import { registerTools } from '../tools.js'; import { runWithCaller, + wantsPrettyJson, DEFAULT_FIRST_PAGE_SIZE, DEFAULT_SCAN_BUDGET, DEFAULT_SCAN_ROW_BUDGET, @@ -167,13 +168,31 @@ describe('first-page defaults', () => { }); describe('result serialization', () => { - it('tool results are compact JSON (no indentation whitespace)', () => { - const payload = { sessions: [{ id: 1, tags: ['a', 'b'] }], nested: { deep: { x: null } } }; + const payload = { sessions: [{ id: 1, tags: ['a', 'b'] }], nested: { deep: { x: null } } }; + + it('tool results are compact JSON (no indentation whitespace) by default', () => { const text = toolResultText(payload, 1000).content[0].text; expect(text).toBe(JSON.stringify(payload)); expect(text).not.toContain('\n'); expect(stringifyResult(payload)).toBe(text); }); + + it('a caller that opted in via X-MCP-Pretty gets indented JSON for the same payload', () => { + const text = runWithCaller({ ...GA, prettyJson: true }, () => toolResultText(payload, 1000).content[0].text); + expect(text).toBe(JSON.stringify(payload, null, 2)); + // The opt-in is scoped to that caller's async context — the next caller is compact again. + expect(runWithCaller(GA, () => stringifyResult(payload))).toBe(JSON.stringify(payload)); + }); + + it('accepts "1" and "true" (any case) as the header opt-in and nothing else', () => { + expect(wantsPrettyJson('1')).toBe(true); + expect(wantsPrettyJson('true')).toBe(true); + expect(wantsPrettyJson(' TRUE ')).toBe(true); + expect(wantsPrettyJson(['1'])).toBe(true); + expect(wantsPrettyJson('0')).toBe(false); + expect(wantsPrettyJson('yes')).toBe(false); + expect(wantsPrettyJson(undefined)).toBe(false); + }); }); describe('scan budget', () => { diff --git a/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts b/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts index bd04faa4..820ebd9c 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts @@ -10,7 +10,7 @@ import type { Request, Response, NextFunction } from 'express'; import crypto from 'node:crypto'; import { extractTokenClaims, isTokenExpired } from './auth.js'; -import { runWithCaller } from './client.js'; +import { runWithCaller, wantsPrettyJson } from './client.js'; import { API_BASE_URL, getPublicBaseUrl, parsePositiveInt } from './config.js'; import type { CheckMcpAccessResponse } from './generated/wire-types.generated.js'; @@ -490,6 +490,9 @@ export function accessGuard(req: Request, res: Response, next: NextFunction): vo // UPN domain labels the synthesized home-tenant entry in list_tenants for a delegated // caller (the home tenant is never in the backend-bounded config/all subset). upn, + // Per-client readability opt-in (see CallerContext.prettyJson): compact JSON stays the + // default for every AI agent; a human-facing client sends the header. + prettyJson: wantsPrettyJson(req.headers['x-mcp-pretty']), }, () => next(), ); diff --git a/src/McpServer/autopilot-monitor-mcp/src/client.ts b/src/McpServer/autopilot-monitor-mcp/src/client.ts index 650135b5..2b45cdf6 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/client.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/client.ts @@ -77,10 +77,30 @@ interface CallerContext { * in utils/homeTenantScope.ts). */ upn?: string; + /** + * The caller asked for indented (pretty-printed) tool results via the `X-MCP-Pretty: 1` request + * header — a per-CLIENT preference for humans reading results in an interactive client. The + * platform default is compact JSON: measured with an OpenAI-family tokenizer, indentation costs + * 14-46 % more tokens (2-2.7 tokens per line for newline + indent + colon spacing), which the + * wire's gzip does not remove from a model's context. + */ + prettyJson?: boolean; } const callerStore = new AsyncLocalStorage(); +/** True for the accepted `X-MCP-Pretty` header values ("1" / "true", case-insensitive). */ +export function wantsPrettyJson(headerValue: string | string[] | undefined): boolean { + const raw = Array.isArray(headerValue) ? headerValue[0] : headerValue; + const v = raw?.trim().toLowerCase(); + return v === '1' || v === 'true'; +} + +/** True when the current caller opted into pretty-printed results (see CallerContext.prettyJson). */ +export function isPrettyJsonRequested(): boolean { + return callerStore.getStore()?.prettyJson === true; +} + /** * Run a callback within an async context that carries the given caller info. * All calls to `apiFetch` and routing helpers inside the callback (and its diff --git a/src/McpServer/autopilot-monitor-mcp/src/config.ts b/src/McpServer/autopilot-monitor-mcp/src/config.ts index dc56654b..bf0b3ebc 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/config.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/config.ts @@ -35,10 +35,12 @@ export const ENTRA_LOGIN_BASE_URL = 'https://login.microsoftonline.com'; export const DOCS_BASE_URL = 'https://docs.autopilotmonitor.com'; /** - * Pretty-print tool results (2-space indentation) instead of compact JSON. Off by default: + * Pretty-print EVERY tool result (2-space indentation) instead of compact JSON. Off by default: * the consumer of a tool result is a language model whose context window is not gzipped — - * measured on the static catalogs, indentation added 18–55 % characters per response for - * zero information. Set MCP_PRETTY_JSON=true for a human eyeballing results in a debugger. + * measured with an OpenAI-family tokenizer, indentation adds 14–46 % tokens per response for + * zero information. Prefer the per-client `X-MCP-Pretty: 1` request header (client.ts + * wantsPrettyJson) so only the humans who read results pay for them; this global switch is + * the debugger escape hatch. */ export const PRETTY_JSON = process.env.MCP_PRETTY_JSON?.trim().toLowerCase() === 'true'; diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/shared.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/shared.ts index 17f2aaf0..ffc7c3da 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/shared.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/shared.ts @@ -1,6 +1,7 @@ import type { ToolAnnotations } from '@modelcontextprotocol/server'; import { z } from 'zod'; import { PRETTY_JSON } from '../config.js'; +import { isPrettyJsonRequested } from '../client.js'; /** * Projection sent by get_session_events when the caller omits `fields`: everything an event @@ -196,11 +197,12 @@ export const MAX_RESULT_SIZE_CHARS = { /** * Serializes a tool result or resource body. Compact by default: the text lands verbatim in * a language model's context, which — unlike the gzipped wire — pays for every indentation - * space (measured +18–55 % characters on the static catalogs). MCP_PRETTY_JSON=true restores - * the indented form for humans reading results in a debugger. + * character (measured with an OpenAI-family tokenizer: +14–46 % tokens, 2–2.7 tokens per line). + * Indented output is available per client via the `X-MCP-Pretty: 1` request header (humans + * reading results in an interactive client) or globally via MCP_PRETTY_JSON=true. */ export function stringifyResult(data: unknown): string { - return PRETTY_JSON ? JSON.stringify(data, null, 2) : JSON.stringify(data); + return PRETTY_JSON || isPrettyJsonRequested() ? JSON.stringify(data, null, 2) : JSON.stringify(data); } /**