All files / scripts/mcp-client/transport response-parser.ts

18.6% Statements 8/43
28.12% Branches 9/32
60% Functions 3/5
19.44% Lines 7/36

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132                                        104x                           104x 104x       104x                                                                                                                                         101x 101x                     101x                      
/**
 * @module mcp-client/transport/response-parser
 * @description Pure helpers for parsing JSON-RPC 2.0 response payloads and
 * reading the MCP gateway side-channel payload file.
 *
 * Extracted from `jsonrpc.ts` (Hack23/riksdagsmonitor#2578 follow-up) so the
 * wire-level dispatcher stays focused on orchestration, and the path-
 * traversal defence on `payloadPath` is reviewable in one bounded file.
 *
 * @author Hack23 AB
 * @license Apache-2.0
 */
 
import type { JsonRpcResponse } from '../../types/mcp.js';
import { parseSSEResponse } from './session.js';
 
/** Inspect a fetch `Response`'s `content-type` header defensively. */
export function getResponseContentType(response: {
  headers?: { get?: (name: string) => string | null };
}): string {
  return response.headers && typeof response.headers.get === 'function'
    ? (response.headers.get('content-type') ?? '')
    : '';
}
 
/**
 * Parse a `Response` body into a {@link JsonRpcResponse} envelope.
 * Branches on `content-type: text/event-stream` (SSE-framed) vs. plain JSON.
 */
export async function parseJsonRpcEnvelope(response: {
  text(): Promise<string>;
  json(): Promise<unknown>;
  headers?: { get?: (name: string) => string | null };
}): Promise<JsonRpcResponse> {
  const contentType = getResponseContentType(response);
  Iif (contentType.includes('text/event-stream')) {
    const text = await response.text();
    return parseSSEResponse(text);
  }
  return (await response.json()) as JsonRpcResponse;
}
 
/**
 * Read a large MCP gateway response from a side-channel `payloadPath`.
 *
 * The MCP gateway returns oversized JSON-RPC results via a file path pointer
 * (`{ "payloadPath": "/tmp/.../mcp-payload-….json" }`) instead of inlining
 * megabytes of text in the JSON-RPC envelope. That path is controlled by the
 * MCP gateway, so a compromised or buggy gateway could direct us at arbitrary
 * local files (e.g. `/etc/passwd`, `~/.copilot/mcp-config.json`) and
 * exfiltrate their contents back into `RawDocument` records.
 *
 * Defence-in-depth — the path is accepted only when ALL of the following hold:
 *   - it is a non-empty string;
 *   - it ends in `.json`;
 *   - it contains no NUL byte;
 *   - it resolves (after `path.resolve`) inside an allowed temp root —
 *     either `os.tmpdir()` or `/tmp`.
 *
 * On any policy violation, `null` is returned and the original inline `parsed`
 * object is surfaced to the caller as a graceful degradation.
 */
export async function readGatewayPayload(
  rawPath: string,
): Promise<Record<string, unknown> | null> {
  if (typeof rawPath !== 'string' || rawPath.length === 0) return null;
  if (rawPath.includes('\0')) return null;
  if (!rawPath.toLowerCase().endsWith('.json')) return null;
 
  const [pathMod, fsMod, osMod] = await Promise.all([
    import('path'),
    import('fs'),
    import('os'),
  ]);
  const resolved = pathMod.resolve(rawPath);
  const allowedRoots = [pathMod.resolve(osMod.tmpdir()), pathMod.resolve('/tmp')];
  const inAllowedRoot = allowedRoots.some((root) => {
    const rootWithSep = root.endsWith(pathMod.sep) ? root : root + pathMod.sep;
    return resolved === root || resolved.startsWith(rootWithSep);
  });
  if (!inAllowedRoot) {
    console.warn(
      `⚠️ Refusing to read MCP gateway payload outside allowed temp roots: ${resolved}`,
    );
    return null;
  }
 
  try {
    return JSON.parse(fsMod.readFileSync(resolved, 'utf8')) as Record<string, unknown>;
  } catch (err) {
    console.warn(
      `⚠️ Could not read MCP gateway payload ${resolved}: ${(err as Error).message}`,
    );
    return null;
  }
}
 
/**
 * Resolve the `result.content[0].text` payload from a JSON-RPC envelope into
 * a plain object. When the content holds a `payloadPath` pointer, dereferences
 * it via {@link readGatewayPayload} subject to the temp-root allow-list.
 *
 * Falls back to `{ text: <raw> }` on JSON parse failure, mirroring the legacy
 * single-blob client behaviour relied on by older callers.
 */
export async function resolveResultContent(
  result: Record<string, unknown>,
): Promise<Record<string, unknown>> {
  const content = result['content'] as Array<{ text?: string }> | undefined;
  Eif (!Array.isArray(content) || !content[0]?.text) return result;
 
  try {
    const parsed = JSON.parse(content[0].text) as Record<string, unknown>;
    if (!parsed['payloadPath']) return parsed;
 
    const payloadRaw = await readGatewayPayload(parsed['payloadPath'] as string);
    if (!payloadRaw) return parsed;
 
    const payloadContent = payloadRaw['content'] as Array<{ text?: string }> | undefined;
    const payloadText = payloadContent?.[0]?.text;
    Iif (!payloadText) return payloadRaw;
 
    try {
      return JSON.parse(payloadText) as Record<string, unknown>;
    } catch {
      return { text: payloadText };
    }
  } catch {
    return { text: content[0].text };
  }
}