Structured output for code-mode harnesses
Some MCP harnesses let the model write a program that calls tools and reads
their results as data (“code mode”), instead of reading each result as text.
Such a program needs a stable, machine-readable result: MCP’s
outputSchema on the tool and structuredContent on the result. This
server provides both for the tools listed below, behind one setting. The
design is recorded in the decision record.
Turning it on
Section titled “Turning it on”| Setting | Default | Meaning |
|---|---|---|
MCP_STRUCTURED_OUTPUT |
false |
When true, the tools below advertise outputSchema in tools/list and every successful result carries structuredContent. |
MCP_STRUCTURED_MAX_BYTES |
262144; clamped to 16384–16777216 |
Upper bound on one serialized structuredContent value. A larger result is saved as an artifact (see Oversized results). |
Both settings use the usual configuration cascade
(configuration). With the flag off, tools/list and
every result are byte-identical to a server without this feature. The flag is
server-wide rather than per call because once a tool advertises
outputSchema, the MCP specification requires every successful result to
carry conforming structuredContent, and the official client SDKs check it.
Tools that advertise a schema
Section titled “Tools that advertise a schema”| Tool | Contract |
|---|---|
github_list_workflow_runs |
Paged |
github_list_workflow_jobs |
Paged |
circleci_get |
CircleCI read |
jira_get |
Atlassian read |
conf_get |
Atlassian read |
bb_get |
Atlassian read |
Every other tool is unchanged: it returns text only.
What a result contains
Section titled “What a result contains”Structured output is added to the text block, never substituted for it:
content[0].textis the same TOON (or JSON, withoutputFormat: "json") text the tool returned before, truncated for the model exactly as before. A model that readscontentsees no difference.structuredContentis the JSON value that text was rendered from, after the optionaljqprojection. WithoutputFormat: "json"and no truncation,JSON.parse(content[0].text)equalsstructuredContent, except for the Atlassian reads, whose text is the bare body and equalsstructuredContent.data.- Text truncation and the structured bound are independent. A result can have truncated text and complete structured content.
The top level of structuredContent is always an object. data holds the
upstream body after jq, and has no declared type because jq may project
it to any JSON value. Tool-specific metadata sits beside data, and
incomplete appears only when data is not the whole result.
Paged tools
Section titled “Paged tools”{"data": <any>, "nextPage": <integer >= 1 | null>, "incomplete"?: {...}}nextPage is the page to request next (pass it as page), or null when
GitHub advertised no next page. It is the same value the text block shows.
An empty page (complete; incomplete absent):
{"data": {"total_count": 0, "workflow_runs": []}, "nextPage": null}A one-item page:
{"data": {"total_count": 1, "workflow_runs": [{"id": 30433642, "conclusion": "failure"}]}, "nextPage": null}A projected page (jq: "workflow_runs[*].id"):
{"data": [30433642], "nextPage": null}A page with more to come (call again with page: 2):
{"data": {"total_count": 61, "workflow_runs": [ ... 30 runs ... ]}, "nextPage": 2}An oversized page:
{ "data": null, "nextPage": 2, "incomplete": {"reason": "size", "bytes": 403117, "limitBytes": 262144, "artifactId": "6f1c..."}}circleci_get
Section titled “circleci_get”{"data": <any>, "cache": {"hit": <bool>, "ageMs": <integer >= 0>, "fetchedAt": <string>}, "incomplete"?: {...}}circleci_get is a passthrough: the shape of data is CircleCI’s, not this
server’s, and only the envelope is this server’s contract. cache says
whether the response came from the server’s HTTP cache (hit), how old it
was (ageMs), and when it was fetched upstream (fetchedAt). Continuation
is CircleCI’s own: read next_page_token inside data and pass it back as
the page-token query parameter.
{ "data": {"items": [{"id": "5034460f-...", "state": "created", "number": 42}], "next_page_token": "AARLwwV..."}, "cache": {"hit": false, "ageMs": 0, "fetchedAt": "2026-10-07T16:20:11.402Z"}}Projected with jq: "items[*].id":
{"data": ["5034460f-..."], "cache": {"hit": true, "ageMs": 2140, "fetchedAt": "2026-10-07T16:20:11.402Z"}}jira_get, conf_get, bb_get
Section titled “jira_get, conf_get, bb_get”{"data": <any>, "incomplete"?: {...}}These are passthroughs whose text matches the reference Atlassian servers
byte for byte, so the text is the bare upstream body and the structured value
only wraps it: data is that body after jq, with no metadata the text
lacks. The shape of data is Jira’s, Confluence’s, or Bitbucket’s, not this
server’s. Continuation is the vendor’s own, inside data: Jira’s startAt
or nextPageToken, Confluence’s _links.next, Bitbucket’s next URL.
{"data": {"issues": [{"key": "PLAT-1"}, {"key": "PLAT-2"}], "isLast": true}}Projected with jq: "issues[*].key":
{"data": ["PLAT-1", "PLAT-2"]}An endpoint that returns text (a Bitbucket diff, a raw file under /src)
gives a JSON string, identical to the text block:
{"data": "diff --git a/README.md b/README.md\n..."}An empty response (204) gives {"data": {}}.
Jira issues with their default fields are large (rich-text descriptions and
custom fields), so an unprojected /search/jql page of 50 issues can exceed
the default MCP_STRUCTURED_MAX_BYTES. Request only the fields you need
(fields query parameter) and project with jq to keep results inline; an
over-bound page still comes back whole through incomplete.artifactId.
jira_post, jira_put, jira_patch, jira_delete, and their Confluence and
Bitbucket counterparts advertise no schema and return text only.
Oversized results
Section titled “Oversized results”When the serialized value exceeds MCP_STRUCTURED_MAX_BYTES, it is never
clipped. Instead:
databecomesnulland the tool metadata (nextPage,cache), if any, is kept;incompletesays why:reason(size),bytes(the full serialized length),limitBytes(the bound in force), andartifactId;- the full value, exactly as it would have appeared in
structuredContent, is saved as anapplication/jsonartifact. Read it withartifact_readusingartifactId, continuing withnextOffsetuntileof, then base64-decode and concatenate the chunks.
A program therefore tells “complete and empty” (incomplete absent) from
“incomplete” (incomplete present) without parsing prose. artifactId is
null only if saving the artifact failed; the result is then incomplete
with no way to recover it, and the program should narrow the request (a
jq projection or a smaller perPage).
The artifact belongs to the principal that made the call: another principal
gets “Artifact not found or expired”. It stays readable for
STREAMING_ARTIFACT_RETENTION_SECONDS. structuredContent never contains a
filesystem path. Truncated text may name a server-local file; that path is
not reachable from a remote harness, so programs must use artifactId.
incomplete.reason can also be unavailable (with bytes and limitBytes
of 0). It marks a server defect: a tool that advertised a schema had no
value to report. It should never occur; please report it if it does.
Errors
Section titled “Errors”An error result has isError: true and no structuredContent; its text is
the existing error envelope, unchanged. Check isError before reading
structuredContent. The machine-readable channel for errors is _meta:
_meta.codeand_meta.decisionon a policy refusal, wherecodeispolicy_deniedorapproval_requiredanddecisionis the policy decision object;_meta["mcp-devtools/upstream"]on every call made under an enterprise policy, naming the upstream identity that acted.
These keys are the same with the flag on or off.
Harness example
Section titled “Harness example”Pending: a worked harness example and its measurement against direct tool calls are recorded here once run (structured-output plan, PR 3).