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); } /**