# ZuploMcpSdk

The `ZuploMcpSdk` class provides a helper API for custom MCP tool handlers to
interact with the MCP runtime. Use it to read metadata from the incoming
`tools/call` request and to override fields of the tool result the gateway sends
back to the AI client.

```ts
import { ZuploContext, ZuploMcpSdk, ZuploRequest } from "@zuplo/runtime";

export default async function (request: ZuploRequest, context: ZuploContext) {
  const sdk = new ZuploMcpSdk(context);

  // Read the incoming tool call request
  const mcpRequest = sdk.getRawCallToolRequest();
  context.log.info(`Tool called: ${mcpRequest?.params.name}`);

  // Invoke a route on your gateway
  const response = await context.invokeRoute("/todos");

  // Override the content the model sees, keeping auto-derived structuredContent
  sdk.setRawCallToolResult({
    content: [{ type: "text", text: "Fetched the todo list" }],
  });

  return response;
}
```

`ZuploMcpSdk` is available in custom tool handlers for both the
[MCP Server handler](../handlers/mcp-server.mdx) and the
[MCP Gateway](../mcp-gateway/overview.mdx). It is exported from
`@zuplo/runtime`.

## Methods

### `setRawCallToolResult(result)`

Overrides fields of the MCP tool result the gateway is about to send to the AI
client. Every field is independently optional — an absent field keeps the value
the gateway derives from the downstream response.

```ts
setRawCallToolResult(result: ZuploMcpToolResultOverride): void
```

#### Parameters

The `result` argument is a `ZuploMcpToolResultOverride` object with the
following optional fields:

| Field               | Type                        | Description                                                                                                                  |
| ------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `content`           | `CallToolResult["content"]` | Content blocks the model reads. Replaces the gateway's default (the serialized downstream body in a text block).             |
| `structuredContent` | `Record<string, unknown>`   | The structured form of the tool's output. Must be a JSON object — the MCP 2025-11-25 wire format rejects arrays and scalars. |
| `_meta`             | `Record<string, unknown>`   | Per-call metadata. Replaces the gateway's default of `{}`.                                                                   |

#### Compact summary example

The most useful combination is `content` alone: a human-readable summary
replaces the raw serialized body in the model's context, while
`structuredContent` is still auto-derived from the downstream response. This
reduces token consumption without losing data.

```ts
import { ZuploContext, ZuploMcpSdk, ZuploRequest } from "@zuplo/runtime";

export default async function (request: ZuploRequest, context: ZuploContext) {
  const response = await context.invokeRoute("/todos");
  new ZuploMcpSdk(context).setRawCallToolResult({
    content: [{ type: "text", text: "Fetched the todo list" }],
  });
  return response;
}
```

The model sees `"Fetched the todo list"` instead of the full JSON body, but the
`structuredContent` field still carries the complete payload for spec-compliant
clients that read it.

#### Full override example

Override all available fields at once:

```ts
import { ZuploContext, ZuploMcpSdk, ZuploRequest } from "@zuplo/runtime";

export default async function (request: ZuploRequest, context: ZuploContext) {
  const response = await context.invokeRoute("/orders");
  const orders = await response.json();

  new ZuploMcpSdk(context).setRawCallToolResult({
    content: [
      {
        type: "text",
        text: `Found ${orders.length} orders totaling $${orders.total}`,
      },
    ],
    structuredContent: { orders: orders.items, count: orders.length },
    _meta: { source: "order-service", version: "2.0" },
  });

  return response;
}
```

:::note

**`isError` is not overridable.** It follows the HTTP status of the response
your handler returns, so upstream failures cannot be masked. If the downstream
route returns a non-2xx status, the tool result carries `isError: true`
regardless of any override.

:::

:::caution{title="Single-use — consumed once"}

The override is **consumed** when the gateway assembles the tool result. It is
read and deleted from the context at that point, so calling
`setRawCallToolResult` twice in the same request replaces the first value before
the gateway reads it.

:::

#### How context lookup works

`setRawCallToolResult` writes the override to the context that carried the
matching `tools/call` request. The lookup travels exactly one
`context.invokeRoute` generation up from the context passed to the constructor.

This means:

- In a typical custom tool handler that calls `context.invokeRoute`, the
  override is written to the parent context (the one carrying the `tools/call`
  request), which is correct.
- If your handler calls `invokeRoute` to a route that **also** calls
  `invokeRoute`, and you construct `ZuploMcpSdk` in that grandchild invocation,
  the override is stored on the immediate parent — not the grandparent that
  carries the tool call. The gateway never reads it. Construct `ZuploMcpSdk` in
  the handler that is one level below the MCP route.

### `getRawCallToolRequest()`

Retrieves the original MCP `tools/call` request object from the context. Use
this to access metadata like the `_meta` field from the incoming tool call.

```ts
getRawCallToolRequest(): CallToolRequest | null
```

Returns the `CallToolRequest` object, or `null` if no MCP tool call is in flight
on the current or parent context.

```ts
import { ZuploContext, ZuploMcpSdk, ZuploRequest } from "@zuplo/runtime";

export default async function (request: ZuploRequest, context: ZuploContext) {
  const sdk = new ZuploMcpSdk(context);
  const mcpRequest = sdk.getRawCallToolRequest();

  if (mcpRequest) {
    const meta = mcpRequest.params._meta;
    context.log.info(`Incoming _meta: ${JSON.stringify(meta)}`);
  }

  // ... handle the tool call
}
```

Unlike `setRawCallToolResult`, this method can be called any number of times —
the request object is not consumed.

#### MCP metadata in runtime hooks

The MCP Server handler stores the original `tools/call` request when it starts
processing the request. Availability therefore depends on where the hook runs:

| Hook location                                     | `getRawCallToolRequest()` result                                            |
| ------------------------------------------------- | --------------------------------------------------------------------------- |
| Global request hook on the initial `/mcp` request | `null`: the MCP handler hasn't run yet                                      |
| Request or response hook on the tool's API route  | The original tool call, read from the parent context                        |
| Response hook on the outer `/mcp` request         | The original tool call, if the MCP handler processed a `tools/call` request |

Use `context.parentContext` to distinguish an internal invocation from the
initial client request. The presence of a tool call alone doesn't distinguish
them in a response hook. See
[Track tool calls with runtime hooks](../mcp-server/tools.mdx#track-tool-calls-with-runtime-hooks)
for an analytics example.

The helper checks only the current context and its immediate parent. If your
tool's API route invokes another route, the deeper invocation can't read the
original tool call through this helper unless your code passes the metadata
along. Calls such as `tools/list` don't populate tool-call metadata.

## Output schema enforcement

When a tool advertises an `outputSchema` (via `includeOutputSchema` on the
handler or route config), the gateway validates the tool result's
`structuredContent` against that schema before sending it to the client. A
result whose `structuredContent` does not conform to the advertised schema fails
the call with a diagnostic `isError` result instead of a raw protocol error.

This enforcement is skipped on the error path (`isError: true`), matching the
MCP SDK's own behavior.

### `includeOutputSchema` implies `includeStructuredContent`

Advertising an `outputSchema` while returning no `structuredContent` makes the
advertised schema inaccurate — spec-compliant clients reject the call. When
`includeOutputSchema` resolves to `true`, the gateway automatically forces
`includeStructuredContent` to `true` as well, and logs a warning naming the
route where the override kicked in.

You can set both explicitly to avoid the warning:

```json
{
  "x-zuplo-route": {
    "handler": {
      "export": "mcpServerHandler",
      "module": "$import(@zuplo/runtime)",
      "options": {
        "includeOutputSchema": true,
        "includeStructuredContent": true
      }
    }
  }
}
```

## Type reference

### `ZuploMcpToolResultOverride`

```ts
interface ZuploMcpToolResultOverride {
  content?: CallToolResult["content"];
  structuredContent?: Record<string, unknown>;
  _meta?: Record<string, unknown>;
}
```

The subset of a `tools/call` result that a module author may override via
`setRawCallToolResult`. Every field is independently optional.

## See also

- [MCP Server custom tools](../mcp-server/custom-tools.mdx) — how to build
  custom MCP tool handlers with TypeScript
- [MCP Server handler](../handlers/mcp-server.mdx) — handler configuration
  reference including `includeOutputSchema` and `includeStructuredContent`
- [MCP Gateway overview](../mcp-gateway/overview.mdx) — overview of the MCP
  Gateway product
- [MCP specification](https://modelcontextprotocol.io/specification/) — the
  canonical protocol reference
