Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/olive-cameras-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"@cosyte/cli": patch
---

Publish an output schema for every MCP tool, and make every tool result conform to it.

An agent calling a cosyte tool previously received `structuredContent: { exit, ok }` with no schema to
check it against, and a text content block holding the command's stdout, which was a different value
from the structured one. Deciding whether a result held data or a diagnostic meant pattern-matching a
text blob.

Each of the four tools now advertises an `outputSchema` (and a `title`) on `tools/list`, and every
dispatch path returns structured content conforming to the schema its own tool declared. The result
carries `ok`, a `status` of `success` / `verdict` / `failed`, the `exit` code from the documented
exit-code contract, a stable `code` on a failed call, and the tool's own payload under `data`: the
parsed model and warnings, the validation verdict and findings, the structural summary, or the
converted Bundle. `status` is the property that separates a negative verdict about the message (the
tool ran; the payload is present) from a call that produced nothing, which no text blob could
distinguish reliably.

The text content block is now the serialized JSON of that same structured result, so a client that
reads only text sees exactly the value a schema-aware client validates.

On a failed call the structured result is value-free by construction: every property is drawn from a
fixed set (the outcome vocabulary, the exit-code contract, the diagnostic-code registry), so no part
of the caller's input can appear in it. The tool name of an unknown tool is no longer echoed back, for
the same reason. Tool names, tool count, input schemas, the exit-code contract and the rule that a
parsed-but-invalid `validate` is a successful call are all unchanged, and no dependency was added.
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,29 @@ still do. Each entry was assigned to the release whose tag first contains it, re

### Added

- **Every MCP tool now publishes an `outputSchema`, and every tool result conforms to it.** An agent
calling a cosyte tool used to receive `structuredContent: { exit, ok }` with no schema to check it
against, so deciding whether a result held data or a diagnostic meant pattern-matching a text blob.
Each of the four tools now advertises an output schema and a `title` on `tools/list`, and every
dispatch path (success, negative verdict, hard failure, usage error, internal error) returns
structured content conforming to the schema its own tool declared.
- **The result carries `ok`, `status`, `exit`, `code` and `data`.** `status` is `success`,
`verdict` or `failed`, and it is the property that separates a negative verdict about the message
(the tool ran, the payload is present, the call is not an error) from a call that produced
nothing: the distinction no text blob could make reliably. `code` is the stable diagnostic code on
a failed call. `data` is the tool's own payload: the parsed model and warnings (or a record stream
for a multi-record input), the validation verdict and findings, the value-free structural summary,
or the converted `Bundle`.
- **On a failed call the structured result is value-free by construction**, not by review: every
property is drawn from a fixed set (the outcome vocabulary, the exit-code contract, the diagnostic
code registry), so no part of the caller's input can appear in it. One consequence is deliberate:
an unknown tool name is no longer echoed back, because a tool name is caller-supplied text like
any other argument.
- Tool names, tool count, `tools/list` ordering, input schemas, the exit-code contract, and the rule
that a parsed-but-invalid `validate` is a successful call are all unchanged, and no dependency was
added. The suite validates each emitted result against its own tool's declared schema with a
dependency-free checker that refuses any schema keyword it does not implement.

- **`redact` / `deid` produces a real de-identified copy, delegated whole to `@cosyte/deid`.** The
command that shipped as an honest `CLI_NOT_IMPLEMENTED` is wired. For `ccda`, `fhir`, `hl7` and
`x12` stdout carries the de-identified document (serialized exactly as `cosyte fmt` serializes that
Expand Down Expand Up @@ -127,6 +150,11 @@ still do. Each entry was assigned to the release whose tag first contains it, re

### Changed

- **The MCP text content block is now the serialized JSON of the structured result**, replacing the
command's raw stdout (on a success) or its stderr diagnostic (on a failure). A client that reads
only text now sees exactly the value a schema-aware client validates: there is one value, serialized
once, so the two channels cannot disagree. A client that read the text block expecting the bare
command output will find the same payload one level down, under `data`.
- **Multi-record `parse` output (`--ndjson` and MLLP) is now emitted record by record, as each record
is parsed**, instead of being accumulated and written once at the end. The first line reaches stdout
before the rest of the input has been read, so a bulk batch pipes into the next process instead of
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,12 @@ and the agent get identical results. The PHI posture is inherited and hardened:
tool _error_ carries only value-free diagnostics (a stable code + position, never an input value). A
parsed-but-invalid `validate` is a **successful** call reporting the verdict, not a tool error.

**Every tool publishes an `outputSchema`**, so a client validates a result against a declared contract
rather than pattern-matching prose. Each result carries `ok`, a `status` of `success` / `verdict` /
`failed`, the `exit` code, a stable `code` on a failure, and the tool's own payload under `data`; the
text content block carries the serialized JSON of that same structured result. See the docs for the
per-tool payload shapes.

The MCP SDK (`@modelcontextprotocol/sdk`) is the CLI's only third-party runtime dependency; it is
declared **optional** and loaded only on the MCP path, so a `cosyte parse` invocation never pulls it and
the core works with the SDK absent. The server surface is importable via the `@cosyte/cli/mcp` subpath
Expand Down
44 changes: 44 additions & 0 deletions docs-content/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,50 @@ cosyte mcp # start the server on stdio (also: cosyte-mcp)
Every tool takes a `content` string (the raw message); `parse`/`validate`/`inspect` accept an optional
`format` override.

## The result contract

**Every tool publishes an `outputSchema`**, so a client validates a result against a declared contract
instead of pattern-matching a text blob to work out what it is holding. `tools/list` carries that
schema next to each tool's input schema and title; every reply from that tool conforms to it.

The structured result has the same three outcome properties for every tool, plus that tool's own
payload:

| Property | Type | What it says |
| -------- | --------- | --------------------------------------------------------------------------------- |
| `ok` | boolean | Whether the call produced data. A negative verdict about the message is still `true`. |
| `status` | string | `success`, `verdict`, or `failed`: the one property to branch on. |
| `exit` | integer | The exit code from the documented [exit-code contract](./reference-commands#exit-codes). |
| `code` | string | On a failed call, the stable diagnostic code. Absent when data was produced. |
| `data` | object | The tool's own payload. Absent on a failed call, which produced none. |

`status` is the distinction a text blob could never make reliably:

- **`success`** the operation completed cleanly.
- **`verdict`** the tool ran and reports a negative finding _about the message_: a resource that
parsed but is not conformant, or a conversion with an error-severity issue. The payload is present
and the call is not an error.
- **`failed`** the call produced no data: a usage mistake, unparseable input, an unavailable parser,
or an internal error. `code` says which, `data` is absent.

`data` is the payload the terminal command puts on stdout: `parse` gives `format` + `model` +
`warnings` (or `records`, one entry per record, for a multi-record input), `validate` gives `format` +
`valid` + `findings`, `inspect` gives the value-free structural summary, and `convert` gives `format` +
`bundle` + `findings`.

```json
{
"ok": true,
"status": "verdict",
"exit": 1,
"data": { "format": "fhir", "valid": false, "findings": [{ "code": "value-not-in-set", "severity": "error", "location": "Patient.gender" }] }
}
```

**The text content block carries the serialized JSON of that same structured result**, so a client that
reads only text sees exactly the value a schema-aware client validates. There is one value, serialized
once: the two channels cannot disagree.

## PHI posture on the agent surface

The value-free discipline is **hardened** for agents: there is **no `--unsafe-show-values` door** over
Expand Down
3 changes: 3 additions & 0 deletions src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ export {
TOOL_DEFS,
type McpToolDef,
type McpToolInputSchema,
type McpToolOutputSchema,
type McpToolResult,
type McpToolMeta,
type McpStructuredResult,
type McpToolStatus,
type McpTextContent,
} from "./tools.js";
17 changes: 16 additions & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export const SERVER_INFO = { name: "cosyte", version: VERSION } as const;
* the caller connects it to stdio (in production) or to an in-memory transport (in tests), so the
* handler wiring is drivable without a subprocess.
*
* Both handlers are **explicit about what reaches the wire**: `tools/list` copies each tool's title,
* description, input schema and **output schema**, and `tools/call` passes the whole structured result
* through, so what a client validates is exactly what the tool declared and produced.
*
* @returns A configured, unconnected {@link Server}.
* @example
* ```ts
Expand All @@ -45,6 +49,7 @@ export function createMcpServer(): Server {
server.setRequestHandler(ListToolsRequestSchema, () => ({
tools: TOOL_DEFS.map((t) => ({
name: t.name,
title: t.title,
description: t.description,
inputSchema: {
type: t.inputSchema.type,
Expand All @@ -54,6 +59,14 @@ export function createMcpServer(): Server {
? { additionalProperties: t.inputSchema.additionalProperties }
: {}),
},
// Every advertised tool publishes its result schema; a client validates a reply against it
// instead of pattern-matching the text block. Declared, so it is copied unconditionally.
outputSchema: {
type: t.outputSchema.type,
properties: { ...t.outputSchema.properties },
required: [...t.outputSchema.required],
additionalProperties: t.outputSchema.additionalProperties,
},
})),
}));

Expand All @@ -63,7 +76,9 @@ export function createMcpServer(): Server {
return {
content: result.content.map((c) => ({ type: c.type, text: c.text })),
isError: result.isError,
structuredContent: { exit: result.structuredContent.exit, ok: result.structuredContent.ok },
// The whole structured value reaches the wire. A field-by-field copy here would silently
// truncate the payload the called tool's own schema promises.
structuredContent: { ...result.structuredContent },
};
});

Expand Down
Loading
Loading