Transforms
A transform is a small program that rewrites the JSON a tool test sends or receives. It runs in one of three phases:
- The request transform rewrites the outbound
tools/callparams before they are sent. - The response transform rewrites the response before assertions run.
- An assertion transform rewrites one extracted value before a single matcher compares it. See Per-assertion transform.
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:
- A subprocess command in any language:
jqfor a one-liner,node script.jsfor JavaScript,python normalize.pyfor Python. There is no framing protocol to implement. - An inline jq program, written as
{ jq: "<program>" }. It runs in-process through an embedded jq engine, so the common reshape, rename, and unwrap cases need no helper on yourPATH. See Inline jq.
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.
- Request phase. Stdin is the request params,
{ "name": <tool>, "arguments": <args> }. Stdout is the replacement params in the same shape. - Response phase. Stdin is the response envelope,
{ "result": <raw> }. Stdout is the replacement envelope. The transform usually mutates.result.
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:
MCPTEST_TRANSFORM_PHASE:request,response, orassertion.MCPTEST_TEST_NAME: the test name.MCPTEST_SERVER: the server key the test targets.MCPTEST_TOOL: the tool name the test invokes.MCPTEST_VAR_<NAME>: one variable per resolved suite variable.
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