mcp-response-types-lab
# mcp-response-types-lab
A local MCP server (protocol `2025-06-18`) purpose-built to answer one question:
**when a tool result carries both `content` and `structuredContent`, which one
do different MCP clients actually use — and does that change if the tool
declares an `outputSchema`?**
## Background (2025-06-18 spec)
- `CallToolResult.content` — an array of content blocks (text/image/audio/
resource). Required, but MAY be empty (`[]`).
- `CallToolResult.structuredContent` — optional arbitrary JSON object.
- `Tool.outputSchema` — optional JSON Schema. If a tool declares one, the
spec says the server **MUST** return `structuredContent` conforming to it.
- For backwards compatibility, the spec **recommends** servers that return
`structuredContent` also return a serialized-JSON text block in `content`,
so pre-2025-06-18 clients still get something.
- The spec says clients **SHOULD** validate `structuredContent` against
`outputSchema` when present.
None of that says what a client does with the *model-facing* context when
both are present, what a *user-facing UI* renders, or what happens on a
schema violation — that's what this lab is for.
## The tool matrix
Every tool takes no arguments. Names encode the two variables under test:
`no_schema__*` (tool has no `outputSchema`) vs `with_schema__*` (it does),
followed by what `content`/`structuredContent` combination is returned.
| Tool | outputSchema | content | structuredContent |
|---|---|---|---|
| `no_schema__text_only` | no | text | — |
| `no_schema__structured_only` | no | `[]` | present |
| `no_schema__text_and_structured` | no | text | present, matches text |
| `with_schema__text_and_structured_matching` | yes | text | present, matches text and schema |
| `with_schema__structured_only_no_text` | yes | `[]` | present, matches schema |
| `with_schema__text_and_structured_mismatched` | yes | text says price $25, in stock | structuredContent says price $9.99, out of stock |
| `with_schema__structured_violates_schema` | yes | text | present but **violates** schema (missing required field, wrong types) |
| `with_schema__structured_missing` | yes | text | **absent**, despite schema being declared |
Source: `src/server.ts`. Add more variants there if you find a client-specific
edge case worth isolating — keep the naming convention so results stay
diffable.
## Baseline: raw server responses (via mcp-inspector CLI)
Before testing any chat client, every tool was called directly against the
server over stdio using `@modelcontextprotocol/inspector --cli` — this uses
the official TS SDK's `Client` class with no vendor UI logic on top, so it's
the ground truth for what actually goes over the wire. Command used for each:
```bash
npx @modelcontextprotocol/inspector --cli node dist/server.js \
--method tools/call --tool-name <tool_name>
```
**`no_schema__text_only`**
```json
{
"content": [
{ "type": "text", "text": "Widget costs $9.99 and is currently out of stock." }
]
}
```
**`no_schema__structured_only`**
```json
{
"content": [],
"structuredContent": { "name": "Widget", "price": 9.99, "in_stock": false }
}
```
**`no_schema__text_and_structured`**
```json
{
"content": [
{ "type": "text", "text": "Widget costs $9.99 and is currently out of stock." }
],
"structuredContent": { "name": "Widget", "price": 9.99, "in_stock": false }
}
```
**`with_schema__text_and_structured_matching`**
```json
{
"content": [
{ "type": "text", "text": "Widget costs $9.99 and is currently out of stock." }
],
"structuredContent": { "name": "Widget", "price": 9.99, "in_stock": false }
}
```
**`with_schema__structured_only_no_text`**
```json
{
"content": [],
"structuredContent": { "name": "Widget", "price": 9.99, "in_stock": false }
}
```
**`with_schema__text_and_structured_mismatched`** — text and structuredContent
deliberately disagree:
```json
{
"content": [
{ "type": "text", "text": "Widget costs $25.00 and is in stock." }
],
"structuredContent": { "name": "Widget", "price": 9.99, "in_stock": false }
}
```
**`with_schema__structured_violates_schema`** — server tried to return
`{"price":"twenty-five dollars","in_stock":"yes"}` (missing required `name`,
wrong types), but the call **never succeeds**. mcp-inspector rejects it
client-side before returning anything to the caller:
```
Failed to call tool with_schema__structured_violates_schema: MCP error -32602:
Structured content does not match the tool's output schema: data must have
required property 'name', data/price must be number, data/in_stock must be boolean
```
**`with_schema__structured_missing`** — server declared `outputSchema` but
returned only text, no `structuredContent`. Also **rejected client-side**:
```
Failed to call tool with_schema__structured_missing: MCP error -32600:
Tool with_schema__structured_missing has an output schema but did not return
structured content
```
So for any client built on the official SDK's `Client` class, `outputSchema`
isn't just documentation — the client actively enforces it and refuses to
hand a malformed or incomplete result to the caller at all. Whether Claude
Code / Desktop / claude.ai run that same validation path, or roll their own
handling, is what the client comparison below checks.
## Setup
```bash
npm install
npm run build
```
**Node version note:** this machine's default `node` (Homebrew 18.11.0) is
currently broken — `dyld: Library not loaded: libicui18n.71.dylib` — because
a later `brew upgrade` moved `icu4c` out from under it. It has nothing to do
with this project, but it will silently break any tool here that shells out
to `node`. A working Node 20 was installed alongside it (`brew install
node@20`, keg-only, does not touch the default `node` symlink). All commands
below use it explicitly:
```bash
export PATH="/opt/homebrew/opt/node@20/bin:$PATH"
export NODE_EXTRA_CA_CERTS=/etc/ssl/cert.pem # see note below
```
**Cert note:** Node 18.11's bundled CA store can't validate npm's current
TLS chain (`UNABLE_TO_GET_ISSUER_CERT_LOCALLY`) even though `curl` works
fine using the system store. Pointing `NODE_EXTRA_CA_CERTS` at the system
bundle (`/etc/ssl/cert.pem`) fixes `npm install`. Only needed for install /
running the inspector via npx; the built server itself makes no network
calls.
## Testing each client
### 1. mcp-inspector (reference client / GUI)
```bash
export PATH="/opt/homebrew/opt/node@20/bin:$PATH"
npx @modelcontextprotocol/inspector node dist/server.js
```
Opens a local web UI. Call each tool, note what the "Structured Content" and
"Content" panes show, and whether schema-violating calls are blocked in the
UI (they are, per the CLI results above) or just visually flagged.
CLI form (no UI, scriptable), one tool at a time:
```bash
npx @modelcontextprotocol/inspector --cli node dist/server.js \
--method tools/call --tool-name <tool_name>
```
### 2. Claude Code (this CLI)
Already registered at project scope — see `.mcp.json`. From an interactive
`claude` session in this repo:
```
/mcp
```
to confirm `response-types-lab` is connected, then ask Claude to call each
of the 8 tools by name and report back verbatim what it received in
`content` vs `structuredContent`, and whether any call errored. Because I
(this background session) can't hot-reload newly-registered MCP servers
into my own running process, this step needs a fresh interactive session —
I can't self-report Claude Code's live behavior from here.
### 3. Claude Desktop
Installed on this Mac but not something I can drive (no GUI access from a
background job). Add to Desktop's config —
`~/Library/Application Support/Claude/claude_desktop_config.json` — merging
in:
```json
{
"mcpServers": {
"response-types-lab": {
"command": "/opt/homebrew/opt/node@20/bin/node",
"args": ["/Users/chiragchadha/Code/mcp-response-types-lab/dist/server.js"]
}
}
}
```
Fully restart Claude Desktop (quit, not just close the window), then in a
chat ask it to call each tool and report what it saw. Also worth eyeballing
the UI directly — does it render a distinct "structured data" view, a raw
JSON block, or nothing when `content` is empty?
### 4. claude.ai / Claude Mobile
Both only connect to **remote** MCP servers (HTTP transport with OAuth), not
local stdio processes. The server already supports Streamable HTTP:
```bash
export PATH="/opt/homebrew/opt/node@20/bin:$PATH"
MCP_TRANSPORT=http npm start # listens on http://localhost:3939/mcp
```
To reach it from claude.ai or mobile you'd need to expose that port
publicly (e.g. `ngrok http 3939` or `cloudflared tunnel`). **I haven't done
this** — it means opening this machine's server to the internet, even if
briefly, so it's your call whether/how to do it. Say the word and I'll wire
up a tunnel and walk through adding it as a custom connector in
claude.ai's settings; otherwise this is a manual step for you.
## Results (captured 2026-08-11)
Tested against the deployed `MCP_TRANSPORT=http` server via a Cloudflare
quick tunnel, added as a custom connector on claude.ai (name
`response-types-lab`). Claude Desktop's and Claude Code's results below are
both reached through that same connector — Desktop natively, Claude Code via
the `claude.ai` account-connector bridge (shows up prefixed `claude.ai
response-types-lab` in its tool-call log) — so this is one server, three
different client-side handling layers.
The two possible outcomes below, spelled out so the table cells are
unambiguous:
- **text** = the model was shown the human-readable sentence from `content`
(e.g. `"Widget costs $9.99 and is currently out of stock."`)
- **JSON** = the model was shown the raw `structuredContent` object (e.g.
`{"name":"Widget","price":9.99,"in_stock":false}`), not a sentence
The mcp-inspector column shows the actual raw wire response instead (it's
not a chat client, so there's no "which one wins" decision to observe — see
the full payloads above).
| Tool | mcp-inspector (raw wire) | Claude Desktop showed | claude.ai showed | Claude Code showed |
|---|---|---|---|---|
| `no_schema__text_only` | `content` text only | text: `"Widget costs $9.99 and is currently out of stock."` | text: `"Widget costs $9.99 and is currently out of stock."` | text: `"Widget costs $9.99 and is currently out of stock."` |
| `no_schema__structured_only` | `structuredContent` only, `content: []` | JSON: `{"name":"Widget","price":9.99,"in_stock":false}` | JSON: `{"name":"Widget","price":9.99,"in_stock":false}` | JSON: `{"name":"Widget","price":9.99,"in_stock":false}` |
| `no_schema__text_and_structured` | both present in envelope | **text**: `"Widget costs $9.99 and is currently out of stock."` (JSON not shown) | **text**: `"Widget costs $9.99 and is currently out of stock."` (JSON not shown) | **JSON**: `{"name":"Widget","price":9.99,"in_stock":false}` (text not shown) |
| `with_schema__text_and_structured_matching` | both present in envelope | text: `"Widget costs $9.99 and is currently out of stock."` | text: `"Widget costs $9.99 and is currently out of stock."` | JSON: `{"name":"Widget","price":9.99,"in_stock":false}` |
| `with_schema__structured_only_no_text` | `structuredContent` only, `content: []` | JSON: `{"name":"Widget","price":9.99,"in_stock":false}` | JSON: `{"name":"Widget","price":9.99,"in_stock":false}` | JSON: `{"name":"Widget","price":9.99,"in_stock":false}` |
| `with_schema__text_and_structured_mismatched` | both present, unreconciled (text says $25/in stock, structuredContent says $9.99/out of stock) | **text**: `"Widget costs $25.00 and is in stock."` | **text**: `"Widget costs $25.00 and is in stock."` | **JSON**: `{"name":"Widget","price":9.99,"in_stock":false}` |
| `with_schema__structured_violates_schema` | **call rejected client-side**, JSON-RPC `-32602` | text: `"Widget costs twenty-five dollars, in stock: yes"` (server's bad text, but no error) | text: `"Widget costs twenty-five dollars, in stock: yes"` (server's bad text, but no error) | **malformed JSON passed through**: `{"price":"twenty-five dollars","in_stock":"yes"}` (no error) |
| `with_schema__structured_missing` | **call rejected client-side**, JSON-RPC `-32600` | text: `"Widget costs $9.99 and is currently out of stock."` (no error) | text: `"Widget costs $9.99 and is currently out of stock."` (no error) | text: `"Widget costs $9.99 and is currently out of stock."` (no error) |
### Key findings
1. **Text-vs-structured preference is inconsistent even within Anthropic's
own surfaces.** Claude Desktop and claude.ai's native chat both prefer
`content` text when both fields are present — they never showed
`structuredContent` unless `content` was empty. Claude Code, hitting the
*same connector*, does the opposite: it prefers `structuredContent`
whenever present, even over conflicting text. On the mismatched-values
tool the two groups showed genuinely different numbers to the model/user
for an identical server response.
2. **`outputSchema` is not enforced by any of the three real clients.** The
deliberately-invalid `structuredContent` (string where a number was
required, missing required field) passed through untouched on Desktop,
claude.ai, and Claude Code — no validation error, no rejection. Only the
bare reference `Client()` class (as used by mcp-inspector) validates
client-side and refuses the call outright (`-32602` for a schema
violation, `-32600` when `outputSchema` is declared but
`structuredContent` is missing).
3. **Practical takeaway:** write `content` text as a complete, standalone
answer — don't assume a client will read `structuredContent` instead of
or in addition to it. And don't rely on `outputSchema` to catch a server
bug before it reaches a client; validate `structuredContent` yourself
before returning it.
Not yet tested: Claude Mobile (should inherit claude.ai's account
connectors — worth a quick spot check to confirm it matches claude.ai's
native behavior rather than Claude Code's).
TDQS
Scored across 8 tools
Each tool targets a distinct response-type scenario (schema presence × content composition), with descriptions that clearly differentiate matching, mismatched, missing, and violating structured content. No two tools are functionally interchangeable.
All tool names follow a uniform pattern: [with_schema|no_schema]__[content description]. The consistent use of double underscores and ordered descriptors makes the naming scheme predictable and scannable.
8 tools is well-scoped for a response-types lab, covering the combinatorial space of schema declarations and text/structured content variations without redundancy or bloat.
The tool set covers the major response-type permutations: no schema (text-only, structured-only, both), with schema (matching, mismatched, structured-only, schema violation, missing structured content). This is a thorough and coherent test surface for the lab's purpose.