Skip to content

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.

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.

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.

Structured output is added to the text block, never substituted for it:

  • content[0].text is the same TOON (or JSON, with outputFormat: "json") text the tool returned before, truncated for the model exactly as before. A model that reads content sees no difference.
  • structuredContent is the JSON value that text was rendered from, after the optional jq projection. With outputFormat: "json" and no truncation, JSON.parse(content[0].text) equals structuredContent, except for the Atlassian reads, whose text is the bare body and equals structuredContent.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.

{"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..."}
}
{"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"}}
{"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.

When the serialized value exceeds MCP_STRUCTURED_MAX_BYTES, it is never clipped. Instead:

  • data becomes null and the tool metadata (nextPage, cache), if any, is kept;
  • incomplete says why: reason (size), bytes (the full serialized length), limitBytes (the bound in force), and artifactId;
  • the full value, exactly as it would have appeared in structuredContent, is saved as an application/json artifact. Read it with artifact_read using artifactId, continuing with nextOffset until eof, 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.

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.code and _meta.decision on a policy refusal, where code is policy_denied or approval_required and decision is 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.

Pending: a worked harness example and its measurement against direct tool calls are recorded here once run (structured-output plan, PR 3).