mcptest docs GitHub

Transforms

A transform is a small program that rewrites the JSON a tool test sends or receives. It runs in one of three phases:

Every phase is optional, and you can mix them. The request and response transforms are tool-test-only today; the per-assertion transform also works on agent tests.

Use a transform to wrap or unwrap a protocol envelope, inject a computed argument, or normalize a response (strip volatile ids or timestamps) before assertions or a snapshot see it.

The request and response transforms take one of two forms:

Run the example. examples/transform.yml uses a jq request transform and a small shell response transform.

mcptest run --config examples/transform.yml

The contract

A transform command is spawned once per invocation. The host writes one JSON value to its stdin, closes stdin, reads stdout to end of file, and parses it as a single JSON value. That value replaces the original.

Stdin carries only the JSON value, nothing wrapping it, so a jq filter operates on the request or response directly.

Inline jq

When all you need is a one-liner, write the jq program inline as { jq: "<program>" } instead of shelling out to jq or a script. It runs in-process through an embedded jq engine, so there is no subprocess to spawn and nothing extra on your PATH.

transform:
  request:
    jq: '.arguments.query |= ascii_downcase'
  response:
    jq: '.result.content[0].text'

The contract is the same as the subprocess form: the program receives the request params or the response envelope as its single input and must produce exactly one output value, which replaces the input. The request example above lowercases an argument; the response example unwraps the nested content text so a matcher can assert on the bare string.

A jq syntax error is caught when the suite loads, with a message pointing at the test, so a typo fails fast rather than at run time. A jq run-time error, or a program that yields zero or more than one output (for example empty or .[]), fails the transform with a message naming the phase.

The inline jq form does not read the MCPTEST_* environment variables; those serve the subprocess form, which cannot see the suite context any other way. Reach for the subprocess form when you need that context, a real scripting language, or a tool other than jq.

The host does not pollute stdin with metadata. Context arrives on environment variables instead, so the JSON on stdin stays clean:

The transform also inherits the parent environment, the same passthrough the suite hooks rely on, so anything you export before running mcptest is visible to the command.

Failure handling

A spawn failure, a non-zero exit, a timeout, or unparseable stdout is a transform error. It fails the test with a message naming the phase and the command, so a broken transform never silently passes a test on stale data. The budget for one invocation is 30 seconds.

Transform payloads may carry secrets, so the host never logs the stdin or stdout values. Only the phase and the command name appear in logs.

Defaults and overrides

Set a transform once for every test with defaultTest: and override it on the tests that need something different. A per-test transform: replaces the default wholesale; it is not merged field by field.

defaultTest:
  transform:
    response: ./transforms/strip-ids.sh
tools:
  - name: inherits the default response transform
    server: api
    tool: search
    args: { query: weather }
    expect:
      - target: result.status
        matcher: { exact: ok }
  - name: uses its own transform instead
    server: api
    tool: create
    args: { title: hello }
    transform:
      request: node ./transforms/wrap-envelope.js
    expect:
      - target: result.id
        matcher: { regex: "^[a-f0-9-]+$" }

Per-assertion transform

A transform: on an assertion rewrites only the value at that assertion's target, after extraction and before the matcher runs. The request and response transforms reshape the whole envelope; this one normalizes a single value so a matcher judges the clean form. Use it to lowercase a string, strip a volatile id, or reshape one field without touching the rest of the response.

The value at target is written to the command's stdin as JSON, and the parsed stdout replaces it. Context arrives on the same MCPTEST_* environment variables, with MCPTEST_TRANSFORM_PHASE set to assertion. A spawn failure, a non-zero exit, a timeout, or unparseable stdout fails that assertion with a message naming the command, the same as the other phases.

The value at target may be any JSON, not just an object. When the matcher compares a string, stdin and stdout are a JSON string.

tools:
  - name: name matches case-insensitively
    server: api
    tool: lookup
    args: { id: 42 }
    expect:
      - target: result.name
        transform: jq ascii_downcase
        matcher: { exact: echo }

Unlike the request and response transforms, the per-assertion transform works on agent tests too, where it sees the test name and the assertion phase marker but no single server or tool.

Caching and cassettes

A test that declares a transform is user logic, so it opts out of result caching, the same rule as hooks.

Tool tests do not record cassettes today. When a tool cassette layer lands, the request transform must run before the request is recorded and the response transform must run after replay, so the recording stays faithful and replay matches the transformed value.

Worked examples

jq request transform

Lowercase the query argument before sending it. The request params arrive on stdin, so the filter targets .arguments directly.

transform:
  request: jq '.arguments.query |= ascii_downcase'

Node response transform

Strip a volatile id from the result before assertions run. Read all of stdin, parse, mutate, print.

// transforms/strip-id.js
let input = "";
process.stdin.on("data", (chunk) => (input += chunk));
process.stdin.on("end", () => {
  const envelope = JSON.parse(input);
  if (envelope.result && typeof envelope.result === "object") {
    delete envelope.result.id;
  }
  process.stdout.write(JSON.stringify(envelope));
});
transform:
  response: node ./transforms/strip-id.js

Python request transform

Inject a computed argument and read context from the environment.

# transforms/inject-region.py
import json
import os
import sys

params = json.load(sys.stdin)
params.setdefault("arguments", {})["region"] = os.environ.get("MCPTEST_VAR_REGION", "us-east")
json.dump(params, sys.stdout)
transform:
  request: python ./transforms/inject-region.py