> ## Documentation Index
> Fetch the complete documentation index at: https://www.plungeai.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Protocol & transport

> tools.ts, tool-exec.ts, extras.ts, orchestration/api-gateway/routes/mcp.ts, orchestration/api-gateway/lib/auth.ts, and the MCP TypeScript SDK 1.29.0 (node_modules/@modelcontextprotocol/sdk: server/index.js, server/mcp.js, server/webStandardStreamableHttp.js, shared/protocol.js, types.js).


Source: `orchestration/mcp-gateway/index.ts`, `server.ts`, `session-do.ts`, `shared.ts`,
`tool-outcome.ts`, `execution.ts`, `execution-do.ts`, `leg-state.ts`, `conversation.ts`, `chat.ts`,
`tools.ts`, `tool-exec.ts`, `extras.ts`, `orchestration/api-gateway/routes/mcp.ts`,
`orchestration/api-gateway/lib/auth.ts`, and the MCP TypeScript SDK 1.29.0
(`node_modules/@modelcontextprotocol/sdk`: `server/index.js`, `server/mcp.js`,
`server/webStandardStreamableHttp.js`, `shared/protocol.js`, `types.js`).

The PlungeAI MCP server speaks JSON-RPC 2.0 over **Streamable HTTP** at one endpoint:

    POST https://mcp.plungeai.com/v1

`https://api.plungeai.com/v1/mcp` is the same server, reached through the One API router. The
differences at the edge are listed in [4.11](#411-the-two-endpoints-at-the-transport-level). This
chapter is for people writing their own client or debugging one. If you use Claude Code, Cursor or
another MCP client, the client does all of this for you ([Connecting clients](/mcp/connecting-clients)).

## 4.1 How a request flows

The big picture:

```
you type a sentence
   └─ your client reads it and decides which tool to call
        └─ POST https://mcp.plungeai.com/v1   (one JSON-RPC message)
             └─ the platform: registry agents, the CNL engine, your saved workflows
```

Every `POST` passes the same gates, in this order (`index.ts`). The first gate that fails answers,
and nothing after it runs:

```
1. HTTP method  GET / DELETE without Mcp-Session-Id         → 405 -32000 "Method not allowed."
2. Host         not mcp.plungeai.com                          → 403 -32000 "Invalid Host header: …"
3. Size         Content-Length over 1 MiB                     → 413 -32600
4. Auth         X-API-Key, or Authorization: Bearer ozk_…     → 401 -32001 (failed auth is metered per IP)
5. Body         read with a hard 1 MiB cap                    → 413 -32600
   Rate limit   only when the body carries a tools/call       → 429 -32000 + Retry-After
6. Parse        body is not JSON                              → 400 -32700 "Parse error"
7. Pre-screen   single tools/call: unknown tool / fenced tool → 200 -32602
                single tools/call: arguments fail the schema  → 200 result, outcome needs_input
                single initialize: bad params                 → 200 -32602 "Invalid params: …"
8. Session      Mcp-Session-Id present, or a sessions key initializing → session Durable Object (4.5)
9. SDK          a fresh MCP server instance answers the message; tools/call streams as SSE
```

What happens after gate 9 depends on the tool. A tool that runs something (a workflow, an agent, a
mission) writes a row to your execution history with `source: mcp` and broadcasts it to your other
apps. That is the same account and the same history Studio shows. Every authenticated request,
including protocol traffic such as `initialize` and `resources/read`, also lands in your activity log
([13.10](/mcp/errors-limits#1310-the-activity-log)).

## 4.2 Endpoint, paths, headers and methods

| Item | Value |
|---|---|
| Endpoint | `POST https://mcp.plungeai.com/v1`. `/v1/` and the pre-2026-09-20 path `/mcp` answer identically. |
| Other hosts | Any other Host gets `403`. |
| `OPTIONS` | CORS preflight, answered for any origin: `204` with `Access-Control-Allow-Origin: *`, allowed methods `GET, POST, DELETE, OPTIONS`, allowed headers `Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version, X-API-Key`, exposed header `Mcp-Session-Id`, max-age 86400 (live-verified 2026-09-24). |
| `GET`, `DELETE` | `405` with `Allow: POST, OPTIONS`. They are only accepted with an `Mcp-Session-Id` (opt-in sessions, [4.5](#45-stateless-by-default-opt-in-sessions)). |
| Unknown path | `404 {"error":"Not found. MCP endpoint: POST /v1 (Streamable HTTP)."}` (not JSON-RPC). |
| No-auth info routes | `GET /v1/info` (server name, version, protocol version, capabilities), `GET /health` (dependency probes; `503` when one fails). |

Request headers:

| Header | Required | What the server does with it |
|---|---|---|
| `Authorization: Bearer $PLUNGE_API_KEY` or `X-API-Key: $PLUNGE_API_KEY` | yes | Your `ozk_` key ([Authentication](/mcp/authentication)). Bearer tokens that do not start with `ozk_` are not accepted. |
| `Content-Type: application/json` | yes, unless `Accept` is incomplete | When `Accept` lacks one of the two types (below), the server rewrites both headers for you. With a complete `Accept` and another `Content-Type`, the SDK answers `415 -32000 "Unsupported Media Type: Content-Type must be application/json"`. |
| `Accept: application/json, text/event-stream` | recommended | The spec requires both types. A request without them is **not** rejected: the server fills them in, so a bare `curl` works. |
| `MCP-Protocol-Version` | optional | On requests after `initialize`, an unsupported value gets `400` ([4.3](#43-protocol-version-and-initialize)). It is not checked on `initialize` itself, and a plain `tools/list` answered from the server's cache gets `200` whatever the value. Send a supported version, or leave the header out. |
| `Mcp-Session-Id` | only for opt-in sessions | Sending one routes the request to the session host. An unknown id gets `404` ([4.5](#45-stateless-by-default-opt-in-sessions)). |

Response types:

| Request | Response |
|---|---|
| `tools/call` (single, or any batch containing one) | `200`, `Content-Type: text/event-stream` ([4.6](#46-toolscall-over-sse-progress-and-keepalive)) |
| Every other request | `200`, `Content-Type: application/json` on the default stateless path. On an opt-in session ([4.5](#45-stateless-by-default-opt-in-sessions)) every request, `initialize` included, is answered as SSE. |
| Only notifications (no `id`), e.g. `notifications/initialized` | `202 Accepted`, empty body |
| An edge gate failed (401, 403, 405, 413, 429, 400) | that status, a JSON-RPC error body ([13.6](/mcp/errors-limits#136-json-rpc-and-http-errors)) |

Server info without a key:

```bash
curl -s https://mcp.plungeai.com/v1/info
```

A `GET` on the endpoint is refused because the server offers no standalone stream:

```bash
curl -s -i https://mcp.plungeai.com/v1
```

## 4.3 Protocol version and `initialize`

The server implements MCP revision **`2025-11-25`** (`PROTOCOL_VERSION` in `server.ts`). It
negotiates exactly as the SDK does (`_oninitialize`): if the `protocolVersion` your client sends is
one of `2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05`, `2024-10-07`, the server answers
with that version. Anything else gets `2025-11-25`, and your client decides whether it can speak
it. `/health` describes this as "negotiates down to 2024-11-05".

```bash
curl -s https://mcp.plungeai.com/v1 \
  -H "Authorization: Bearer $PLUNGE_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
```

Ask for an older revision and you get it back:

```bash
curl -s https://mcp.plungeai.com/v1 \
  -H "Authorization: Bearer $PLUNGE_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
```

The `initialize` result:

| Field | Value |
|---|---|
| `protocolVersion` | the negotiated version (above) |
| `serverInfo` | `{ "name": "plungeai.com", "version": "2.7.0" }` (`SERVER_NAME`, `SERVER_VERSION`; the version moves with deploys) |
| `capabilities` | `logging: {}`, `resources: {}`, `prompts: {}`, `tools: { listChanged: true }`. The `listChanged` flag comes from the SDK; the stateless server never actually sends a `list_changed` notification ([4.5](#45-stateless-by-default-opt-in-sessions)). |
| `instructions` | the server-level usage guide, injected into your client's context |

The `instructions` text is the one place that teaches every client how to use the platform. In
short, it tells the model to:

- relay every successful tool output **verbatim and in full**. The only parts meant for the model are
  the guidance tails: execution-id footers, poll hints and ⏸ blocks;
- treat "my agents" as the user's saved workflows (`plungeai_list_workflows`). `plungeai_list_agents`
  is the registry of building blocks;
- discover first: search the registry in natural language, fetch a full card before first use, and
  take agent ids only from live results (the catalog is active-only);
- run structured agents with the triad `plungeai_list_agents` → `plungeai_get_tool_contract` →
  `plungeai_execute_tool`, follow the outcome's remediation, never retry blind, and never approve a
  gated action on its own;
- use `mode:"async"` only for long runs and poll `plungeai_get_workflow_status`;
- stop at every ⏸ AWAITING USER / AWAITING USER APPROVAL block, relay it, then call
  `plungeai_continue`;
- pass `user_request`, the user's own words, on **every** tool call. Every tool accepts it, and the
  platform uses it for context, routing and your activity log ([13.10](/mcp/errors-limits#1310-the-activity-log)).

`initialize` params are checked before the SDK sees them. A malformed request (for example a
numeric `protocolVersion` and no `clientInfo`) gets `-32602` naming the bad fields:
`{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid params: protocolVersion, clientInfo"},"id":1}`
(HTTP 200). This check covers single-message requests only.

After `initialize`, a client sends `notifications/initialized`. It has no `id`, so the server
answers `202` with no body:

```bash
curl -s -o /dev/null -w '%{http_code}\n' https://mcp.plungeai.com/v1 \
  -H "Authorization: Bearer $PLUNGE_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
```

**The `MCP-Protocol-Version` header.** You may send it on requests after `initialize`. The SDK
accepts any of the five supported versions. An unsupported value gets
`400 -32000 "Bad Request: Unsupported protocol version: <v> (supported versions: 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07)"`.
Two exceptions: `initialize` itself is never checked, and a single uncursored `tools/list` that a
warm server instance answers from its cache (`index.ts`) returns `200` before the check runs, so
the same bad header can get `200` or `400` depending on cache state. Leaving it out is fine.

## 4.4 JSON-RPC methods

Every method the server answers for a normal key (`server.ts`, SDK `Protocol` and `McpServer`):

| Method | Supported | Returns |
|---|---|---|
| `initialize` | **supported** | [4.3](#43-protocol-version-and-initialize) |
| `notifications/initialized` | **supported** | `202`, no body |
| `ping` | **supported** | `{}` |
| `tools/list` | **supported** | the 22 `plungeai_*` tools in catalog order, or only the tools your key is fenced to. No pagination: there is no `nextCursor`, and a `cursor` param is **ignored** (full list again). Details in [4.10](#410-tool-annotations-read-only-hints-and-metadata) and the [Tool reference](/mcp/tool-reference). |
| `tools/call` | **supported** | streamed as SSE ([4.6](#46-toolscall-over-sse-progress-and-keepalive)) |
| `resources/list` | **supported** | 5 resources ([Prompts & resources](/mcp/prompts-resources)) |
| `resources/templates/list` | **supported** | 3 URI templates |
| `resources/read` | **supported** | the resource's text, or `-32002` for an unknown URI |
| `prompts/list` | **supported** | 1 prompt, `plungeai` |
| `prompts/get` | **supported** | the prompt's message, or `-32602` for an unknown name |
| `logging/setLevel` | **partial** | returns `{}`, but the stateless server keeps no state between requests, so the level is not applied to later calls. Progress fallback lines are always sent at level `info` ([4.6](#46-toolscall-over-sse-progress-and-keepalive)). |
| `notifications/cancelled` | **ignored** | `202`. The notification arrives in a new request with its own server instance, which cannot reach the call in flight. To stop a run, use `plungeai_executions` `{"action":"cancel"}` ([Runs, results & follow-ups](/mcp/runs-results)). |
| `resources/subscribe`, `resources/unsubscribe`, `completion/complete` | **rejected** | `-32601 "Method not found"`. They exist only on the conformance-test keys. |
| any other method | **rejected** | `-32601 "Method not found"` |

The server never sends requests to your client. Sampling, elicitation and roots are not used by
any production tool.

A quick liveness check:

```bash
curl -s https://mcp.plungeai.com/v1 \
  -H "Authorization: Bearer $PLUNGE_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":7,"method":"ping"}'
```

**Batches.** A JSON array of messages is accepted and answered as an array. The SDK answers each
message on its own:

```bash
curl -s https://mcp.plungeai.com/v1 \
  -H "Authorization: Bearer $PLUNGE_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '[{"jsonrpc":"2.0","id":1,"method":"ping"},{"jsonrpc":"2.0","id":2,"method":"prompts/list"}]'
```

Batch caveats (`index.ts`):

- The pre-screen in gate 7 runs for **single** messages only. In a batch, an unknown or fenced tool
  comes back as the SDK's own `isError` tool result (`MCP error -32602: Tool <name> not found`), and
  bad arguments come back as the SDK's `Input validation error: …` text. You get no `-32602`
  envelope and no `needs_input` outcome. Fenced tools are still never run: they are not registered
  on your server instance.
- A batch that contains `initialize` together with other messages gets
  `400 -32600 "Invalid Request: Only one initialization request is allowed"`.
- A batch containing a `tools/call` is answered as SSE.

## 4.5 Stateless by default, opt-in sessions

**Stateless is the default.** Each `POST` builds a fresh server instance, answers it, and forgets
it (`server.ts`: "Built fresh per request"). No `Mcp-Session-Id` is issued, and you do not need
one. Each request is self-contained: `tools/list` or `tools/call` work without an earlier
`initialize` in the same connection. Clients still send `initialize` once to learn the version,
capabilities and instructions.

What statelessness means in practice:

| Feature | On the stateless path |
|---|---|
| Server-initiated stream (`GET`) | not offered: `405` |
| `notifications/tools/list_changed` | never sent. If a tool seems missing after a platform update, reconnect your client so it re-reads `tools/list`. |
| `logging/setLevel` | accepted, not persisted |
| `notifications/cancelled` | cannot reach an in-flight call. Use `plungeai_executions` `cancel`. |
| Resumable SSE (`Last-Event-ID`) | not available. A dropped `tools/call` stream is not replayed. The run itself keeps going and can be read with `plungeai_get_result`. |
| Sampling, elicitation | not available (and no production tool uses them) |

**Opt-in sessions** (`session-do.ts`). A key minted with `sessions: true` gets a stateful session.
This is an operator setting: self-service keys from Dashboard → One API → Keys do not carry it.

- `initialize` returns an `Mcp-Session-Id` response header. Send it on every later request.
- On a session, every `POST` that carries a request is answered as `text/event-stream`, `initialize`
  and `tools/list` included. When the request speaks `2025-11-25` (the `initialize` param, or the
  `MCP-Protocol-Version` header after it), the stream opens with a priming event (`id:`,
  `retry: 1000`, an empty `data:`). Read the `Mcp-Session-Id` response header, then parse the
  `data:` lines as in [4.6](#46-toolscall-over-sse-progress-and-keepalive).
- `GET` with the header opens the standalone SSE stream. It is resumable with `Last-Event-ID`; the
  server sends a 1000 ms retry hint and stores up to 1,000 replay events per session.
- `DELETE` with the header ends the session.
- Sessions expire after **10 minutes** without a request. The window slides with each request.
- A session is bound to the key that created it. The same id presented by another key, an expired
  id, an id that never existed, or one longer than 128 characters all get
  `404 -32001 "Session not found"`. Per the spec, re-initialize to get a new one.
- A second `initialize` on a live session gets `400 -32600 "Invalid Request: Server already initialized"`.
- Sessions are available on `https://mcp.plungeai.com/v1` only ([4.11](#411-the-two-endpoints-at-the-transport-level)).

If your client sends an `Mcp-Session-Id` it picked up elsewhere, the request goes to the session
host and gets `404 Session not found`. Drop the header.

## 4.6 `tools/call` over SSE: progress and keepalive

A `tools/call` response is a Server-Sent Events stream. Each frame is `event: message`, then one
`data:` line holding a JSON-RPC message, then a blank line. Progress notifications (if any) come
first. The **last** frame is the JSON-RPC response, and then the stream closes. With curl, keep the
`data:` lines:

```bash
curl -s https://mcp.plungeai.com/v1 \
  -H "Authorization: Bearer $PLUNGE_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"plungeai_whoami","arguments":{"user_request":"who am I"}}}' \
  | sed -n 's/^data: //p'
```

Some `tools/call` answers are plain JSON instead of SSE: the gate 7 pre-screen answers (unknown
tool, fenced tool, arguments that fail the schema) and every edge error (401, 413, 429). A robust
client checks `Content-Type` before parsing.

### Live status: progress notifications

While a tool runs, the server streams what it is doing. There is one channel per call, never both
(`server.ts` `wrap`):

| Your `tools/call` | Channel | Params |
|---|---|---|
| carries `params._meta.progressToken` (Claude Code and Claude Desktop send one) | `notifications/progress` | `progressToken`, `progress` (strictly increasing), `total` (only when a raw done/total pair passes through unchanged), `message` (the human-readable line) |
| no progress token | `notifications/message` (logging) | `level: "info"`, `logger: "<tool name>"`, `data: "<the same line>"` |

`progress` is a sequence, not a percentage. The server keeps it strictly increasing as the spec
requires, and the readable "5/18" lives in `message`. Notifications stop the moment the call
settles.

A call that asks for progress (runs a model, costs credits, so it is not auto-verified):

```bash
curl -sN https://mcp.plungeai.com/v1 \
  -H "Authorization: Bearer $PLUNGE_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"_meta":{"progressToken":"p1"},"name":"plungeai_execute_agent","arguments":{"user_request":"one-sentence answer","agent":"one-agent","prompt":"In one sentence: what is a Durable Object?"}}}'
```

Frame shape while it runs (the shape `server.ts` builds; the numbers and text vary):

```
event: message
data: {"method":"notifications/progress","params":{"progressToken":"p1","progress":3,"message":"⚙️ Agents working — 0/1 agents complete · 1 active (one-agent)"},"jsonrpc":"2.0"}
```

The lines you will see, by tool (`execution.ts`, `conversation.ts`, `chat.ts`):

| Tool | Status lines |
|---|---|
| workflow, agent, tool and mission runs | `⚙️ Agents working — 5/18 agents complete · ✓ brave-agent · 13 active (tavily-agent, exa-agent…)` · `✍️ Generating answer` · `✍️ Writing (3,516 chars) — …<the last ~72 characters of the answer>` · `⚙️ one-agent still working — 45s elapsed…` · `✅ Complete — 18 agents in 24.6s` |
| `plungeai_followup` | `🔎 Looking up the run and checking for an open session…` · `💬 <agent> working — continuing the conversation…` or `📚 No open session — loading the run's stored result as context…` · `⏳ Collecting the answer…` · `💾 Saving this turn to the conversation thread…` · `✅ Complete — continued the existing session` / `✅ Complete` |
| `plungeai_continue` | `🔎 Loading the paused conversation…` · `🔓 Approval received` · `💾 Saving this turn…` · `✅ Complete` |
| `plungeai_build_workflow` | `📖 Loading the existing workflow…` · `🧠 Generating CNL YAML — the builder picks real agents, validates and self-repairs (30-60s)…` · `🔍 Validating the generated workflow (<n> chars)…` · `💾 Saving "<name>" and syncing to all apps…` · `✅ Complete` |
| `plungeai_chat` | `Thinking…` · `Streaming… <n> chars` |
| any tool, every 15 s | `⏳ Working — <n>s elapsed` |

The 15-second keepalive runs for every tool call. It keeps the SSE stream from going idle long
enough for an intermediary to cut it. The `✍️ Writing` lines carry only the tail of the answer as it
is produced. The full answer arrives in the final response.

Whether you **see** these lines depends on your client. Some render them under the running tool,
some ignore both channels. The work and the result are identical either way. `mode:"async"` runs
return at once, so they have no live feed ([4.7](#47-async-mode)).

## 4.7 Async mode

Four tools can run in the background. The call returns an **execution id** at once instead of
waiting for the result:

| Tool | `mode` | Default |
|---|---|---|
| `plungeai_execute_workflow` | `sync` \| `async` | `sync` |
| `plungeai_execute_tool` | `sync` \| `async` | `sync` |
| `plungeai_run_mission` | `sync` \| `async` | **`async`** (anything but `"sync"`) |
| `plungeai_learn` (`action: learn`) | `sync` \| `async` | **`async`** (anything but `"sync"`) |

`plungeai_execute_agent` has no `mode`.

The immediate answer is an `ok` outcome. Its `structuredContent` has `status: "ok"`,
`execution_id`, and a `summary` that says the run started in the background and tells the client
to poll `plungeai_get_workflow_status`. The wording differs per tool: "Started execution …"
(`plungeai_execute_workflow`), "Started `<name>` …" (`plungeai_execute_tool`), "Started mission …"
(`plungeai_run_mission`), "Started distilling …" (`plungeai_learn`). An `ok` outcome with
`execution_id` and no `result` means an async start, or a sync run whose output was not yet
available ("… completed (execution: …) but results not yet available"). Either way, poll
`plungeai_get_workflow_status`, then call `plungeai_get_result`. Don't branch on the summary text.
`plungeai_execute_tool` also sets `agent_id` and `operation`. The Markdown text ends with a footer
the client acts on:

```
---
execution: `<execution id>` — use this id for plungeai_get_result / plungeai_followup on this run.
```

Then:

1. Poll `plungeai_get_workflow_status` with the id. Its `structuredContent.status` reports the run,
   and `continuation` is non-null when the run paused for your approval or an answer.
2. When it reads `completed` (or `incomplete`), fetch the output with `plungeai_get_result`
   ([Runs, results & follow-ups](/mcp/runs-results)).

A background run (runs a workflow, costs credits, not auto-verified):

```bash
curl -s https://mcp.plungeai.com/v1 \
  -H "Authorization: Bearer $PLUNGE_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"plungeai_execute_workflow","arguments":{"user_request":"research in the background","mode":"async","format":"json","workflow_yaml":"name: bg-demo\ntasks:\n  - id: answer\n    agent: one-agent\n    prompt: |\n      In two sentences: what is edge computing?\n"}}}' \
  | sed -n 's/^data: //p'
```

How background runs are hosted (`execution-do.ts`, `leg-state.ts`, `index.ts`):

- Each run is held by its own Durable Object in **legs of up to 10 minutes** (the Ten-Minute
  Relay). A long run hands over at a turn boundary and resumes in the next leg from stored task
  results and checkpoints.
- A leg that is killed (deploy, host restart) is re-entered by a watchdog 12 minutes after it
  started. If it is killed **3 times in a row** without writing a checkpoint, the run fails with
  "Run was killed 3 times in a row without yielding a checkpoint…".
- A `running` row with no heartbeat for 16 minutes (960 s) is marked failed with
  `Interrupted (run did not complete)`. A cron does this every 15 minutes, and a status poll does
  it at once. Its cost is left pending, not `unpriced`: the cost worker then prices whatever the
  run's agents recorded before it stopped.

When to use it: the tool descriptions say async is for runs over about 3 minutes. Most workflows
finish in seconds in `sync`. Claude Desktop cuts a single tool call at about 4 minutes, so use
async there for anything long.

## 4.8 Output formats: Markdown by default, JSON on request

Results come back as **Markdown**, the format an AI reads best: tables, headings, links, step
indexes, conversation threads. The server's instructions tell clients to show it verbatim.

If your client needs machine-parseable output, add `"format": "json"` to a result-bearing tool.
The text becomes a JSON document, and the same object is attached as `structuredContent`
(`tool-outcome.ts` `renderAs`). Without `format` (or with `"markdown"`), nothing changes.
`isError` is the same in both forms.

| Tool | `format:"json"` returns |
|---|---|
| `plungeai_whoami` | `{ user, auth_type, tier, key_label, key_id, rate_window: { minute_used, day_used } \| null, server }` |
| `plungeai_list_workflows` | `{ workflows: [ { id, name, description, kind, folder } ] }`. Without filters this is the recent list; the folder summary line is Markdown-only. |
| `plungeai_executions` | `list` → `{ executions: [ { id, input, workflow_name, status, start_time, duration, source } ] }`; `conversation` → `{ execution_id, messages }`; `get` → the status object ([4.9](#49-outputschema-and-structuredcontent)); `output`, `cancel`, `delete` → `{ content: "<markdown>" }` |
| `plungeai_list_agents` | `{ content: "<registry markdown>" }` (typed rows are not available yet) |
| `plungeai_get_result` | `{ content: "<markdown>" }`, or the outcome object when the answer is guidance (e.g. an unknown id) |
| `plungeai_execute_workflow`, `plungeai_execute_agent`, `plungeai_execute_tool`, `plungeai_get_tool_contract` | the outcome object ([4.9](#49-outputschema-and-structuredcontent)) |

Tools without a `format` parameter **ignore** it. The server drops unknown arguments before the
tool runs.

```bash
curl -s https://mcp.plungeai.com/v1 \
  -H "Authorization: Bearer $PLUNGE_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"plungeai_whoami","arguments":{"user_request":"who am I, as JSON","format":"json"}}}' \
  | sed -n 's/^data: //p'
```

**Content annotations.** Successful Markdown results built as plain text carry
`annotations: { "audience": ["user","assistant"], "priority": 0.9 }` (`shared.ts`), a hint that the
text is for the user to read as-is. Outcome-envelope results and error text carry no annotations,
and neither does `format:"json"` output.

## 4.9 `outputSchema` and `structuredContent`

Seven tools declare an `outputSchema` in `tools/list`. For these, `structuredContent` is always
present on a non-error result, and it matches the schema:

| Tools | Schema |
|---|---|
| `plungeai_execute_workflow`, `plungeai_execute_agent`, `plungeai_get_tool_contract`, `plungeai_execute_tool`, `plungeai_run_mission`, `plungeai_learn` | the **outcome** envelope (below) |
| `plungeai_get_workflow_status` | the **status** object (below) |

The outcome envelope (`ToolOutcomeSchema`, deliberately loose so it can grow fields):

| Field | Type | Notes |
|---|---|---|
| `status` | `ok` \| `needs_input` \| `needs_connection` \| `needs_api_key` \| `needs_approval` \| `unavailable` \| `error` | the outcome ([Errors, feedback & limits](/mcp/errors-limits)) |
| `summary` | string | always present |
| `agent_id`, `operation` | string | when a specific agent answered |
| `result` | string | the run's output (on `ok`, or the partial output next to `needs_approval`) |
| `execution_id` | string | the run's id, for `plungeai_get_result` / `plungeai_followup` |
| `missing` | string[] | absent required fields (`needs_input`) |
| `warnings` | string[] | visible notes, e.g. "prose mapped: …" |
| `remediation` | `{ message, actions[] }` | what to do next ([13.2](/mcp/errors-limits#132-the-structured-feedback-contract)) |
| `contract` | object | the tool contract (from `plungeai_get_tool_contract`, and appended to `needs_input`) |

The status object (`plungeai_get_workflow_status`):

| Field | Type |
|---|---|
| `execution_id` | string |
| `status` | string (`running`, `completed`, `incomplete`, `failed`, `cancelled`, …) |
| `error_message` | string \| null (set when the run failed) |
| `workflow_name`, `started_at` (ISO), `final_task_id` | string \| null |
| `duration_ms` | number \| null |
| `continuation` | null, or `{ status, agent, question: string \| null, pending_action: { summary, price? } \| null }` when paused |

`isError` follows the spec: it is `true` only for a terminal `error` outcome and for plain tool
failures. The guidance outcomes (`needs_*`, `unavailable`) are answers your client acts on, so they
carry `isError: false`. The SDK skips `outputSchema` validation on `isError` results, which is why
an error answer from these tools may carry plain text only.

Tools without an `outputSchema` still attach `structuredContent` when you ask for `format:"json"`
([4.8](#48-output-formats-markdown-by-default-json-on-request)).

## 4.10 Tool annotations, read-only hints and metadata

Six tools carry `annotations: { "readOnlyHint": true }`. They only read. The hint is for your
client, for example to run them without a confirmation prompt:

`plungeai_get_tool_contract` · `plungeai_list_agents` · `plungeai_list_workflows` ·
`plungeai_get_result` · `plungeai_get_workflow_status` · `plungeai_whoami`

The other 14 tools carry **no** annotations: no `destructiveHint`, `idempotentHint` or
`openWorldHint`, so your client applies its own defaults. Some of them change state or spend:
the execute and mission tools run work, `plungeai_executions` can delete or cancel, and
`plungeai_schedule`, `plungeai_workflow`, `plungeai_memory remember` and `plungeai_templates use` write.

Other tool metadata in `tools/list`:

- No `title` is set. Clients display the `name`.
- The SDK marks every tool `execution: { taskSupport: "forbidden" }`: MCP task-augmented calls are
  not supported. Use [async mode](#47-async-mode) for long work.
- Every tool's `inputSchema` has an optional `user_request` string (up to 4,000 characters) for the
  user's own words.

```bash
curl -s https://mcp.plungeai.com/v1 \
  -H "Authorization: Bearer $PLUNGE_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
```

## 4.11 The two endpoints at the transport level

`https://api.plungeai.com/v1/mcp` forwards your JSON-RPC body (plus `Accept`, `Content-Type` and
`Mcp-Session-Id`) to this server with your own `ozk_` key, which this server validates again
(`orchestration/api-gateway/routes/mcp.ts`). The tools and their answers are identical. The edge
is not:

| | `https://mcp.plungeai.com/v1` | `https://api.plungeai.com/v1/mcp` |
|---|---|---|
| Methods | `POST`, `OPTIONS` (+ `GET`/`DELETE` with a session) | `POST` only |
| Auth failure | `401` JSON-RPC `-32001` + `WWW-Authenticate` | `401` One API envelope `{"error":{"code":"unauthorized",…}}` + `X-Error-Code: unauthorized` |
| Rate limit counts | only requests carrying a `tools/call` | only requests carrying a `tools/call`, counted by this server in the same per-account counter |
| Per-key restrictions: tool fence, per-key rate override, sessions | applied | applied. Your key is forwarded and this server validates it again. Sessions need `GET`/`DELETE`, which this route does not offer: open sessions on `mcp.plungeai.com`. |
| `plungeai_whoami` shows | `api-key`, your tier and key label | the same |
| Other routes | `/log`, `/v1/info`, `/health`, `/install`, `/docs` | `GET /v1/mcp/tools` (a JSON-RPC `tools/list` without writing JSON-RPC, [One API guide](https://api.plungeai.com/docs#11-mcp-plane)) |

Use `https://mcp.plungeai.com/v1` when you need sessions. The
unauthenticated difference, live-verified 2026-09-24:

```bash
curl -s -X POST https://mcp.plungeai.com/v1 -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
echo
curl -s -i -X POST https://api.plungeai.com/v1/mcp -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
