# Tools and MCP servers

Give Nur tools: functions your app runs, and remote MCP servers EESI connects to and runs for you during the conversation.

Nur can use two kinds of tools in a [live conversation](/realtime/live):

| | Who runs it | You declare it as |
| --- | --- | --- |
| **Function tools** | Your app. You get the call, run it, and send the result back. | `{"type": "function", ...}` |
| **Remote MCP servers** | EESI. It connects to the server, calls its tools and gives Nur the results. Your app only watches. | `{"type": "mcp", ...}` |

Both go in the session's `tools`, and you can mix them.

## Function tools

A function tool is the OpenAI Realtime format: a name, a description and a JSON
schema for the arguments.

```json
{
  "type": "function",
  "name": "check_order",
  "description": "Look up an order by its number.",
  "parameters": {
    "type": "object",
    "properties": { "order": { "type": "string" } },
    "required": ["order"]
  }
}
```

When Nur calls it you get `response.function_call_arguments.done` with a
`call_id`, `name` and `arguments`. Run it, then send the result and ask Nur to
go on:

```json
{ "type": "conversation.item.create", "item": { "type": "function_call_output", "call_id": "call_123", "output": "Shipped on Monday." } }
{ "type": "response.create" }
```

Answer every call, even with an error: Nur waits for the result.

## Remote MCP servers

Point the session at an [MCP](https://modelcontextprotocol.io) server and Nur
can use its tools while it talks. EESI lists the server's tools, offers them to
Nur, runs the calls she makes and puts the results back into the conversation.
Your app does not handle the calls.

```json
{
  "type": "session.update",
  "session": {
    "type": "realtime",
    "instructions": "You answer questions about open source repositories.",
    "tools": [
      {
        "type": "mcp",
        "server_label": "deepwiki",
        "server_url": "https://mcp.deepwiki.com/mcp",
        "server_description": "Answers questions about public GitHub repositories.",
        "allowed_tools": ["ask_wiki_question", "read_wiki_structure"]
      }
    ]
  }
}
```

| Field | What it does |
| --- | --- |
| `server_label` | Required. A short name: letters, digits, `_` and `-`, up to 32 characters, unique in the session. |
| `server_url` | Required. The server's address. Streamable HTTP and the older HTTP with SSE both work. It must be `https` and a public address. |
| `server_description` | Optional. What the server is for. Nur reads it next to each tool. |
| `allowed_tools` | Optional. The tool names to offer. Leave it out, or send `[]`, to offer all of them. `{"read_only": true}` offers only the tools the server marks read-only. |
| `authorization` | Optional. A token sent as `Authorization: Bearer <token>`. |
| `headers` | Optional. Other headers for the server, up to 16. |
| `require_approval` | Optional. `"always"`, or `{"always": {"tool_names": [...]}}`, to make Nur ask the person before those tools run. See [Asking first](#asking-first). |

### What happens

1. Your `session.update` takes effect at once. The server's tools are listed
   in the background, so nothing waits on a slow server.
2. You get `mcp_list_tools.in_progress`, then `mcp_list_tools.completed` (or
   `mcp_list_tools.failed`). A `conversation.item.done` with an
   `mcp_list_tools` item lists the tools Nur was given. If your first question
   needs the tools, wait for `completed` before you ask it.
3. Nur sees each tool as a function named `<server_label>__<tool>`, for example
   `deepwiki__ask_wiki_question`.
4. When Nur calls one, you see an `mcp_call` item (in
   `response.output_item.added` and `response.done`), then
   `response.mcp_call.in_progress`, then `response.mcp_call.completed` or
   `response.mcp_call.failed`. A `conversation.item.done` carries the `mcp_call`
   item with its `output`, or its `error`.
5. Nur reads the result and keeps talking. You don't send anything back.

If the person interrupts the reply that asked for a tool, the call is stopped
and its result is dropped, so Nur doesn't come back later with an answer to
something the person moved past. She can call it again if they still want it.

### Asking first

A tool the server marks as destructive (`destructiveHint` in its MCP
annotations) never runs on Nur's word alone. Neither does a tool you list in
`require_approval`. The first time Nur calls it, she asks the person instead,
and you get an `mcp_approval_request` item:

```json
{ "id": "item_7", "type": "mcp_approval_request", "server_label": "crm", "name": "delete_contact", "arguments": "{\"id\": \"c_42\"}" }
```

The person can say yes out loud, or your app can show a button and send their
answer:

```json
{ "type": "conversation.item.create", "item": { "type": "mcp_approval_response", "approval_request_id": "item_7", "approve": true } }
```

Either way the tool runs once, with the arguments Nur asked about. A yes is
good for one run and for one minute. `require_approval: "never"` cannot switch
this off for a tool the server marks as destructive.

When others can be heard, on a call or a speakerphone, a yes said in the room
may not be the person's. Set `eesi_spoken_approvals` to `false` and only your
app's `mcp_approval_response` answers: nothing said or typed in the
conversation does, and Nur asks the person to answer on their screen. It can
change in any `session.update`; `true`, the default, lets their words answer
again.

```json
{ "type": "session.update", "session": { "eesi_spoken_approvals": false } }
```

Any other value is refused with an `invalid_value` error, and until you send
`true` or `false` only your app's answer counts.

### Per reply

`response.create` may carry its own `tools`. An MCP entry there names one of
the session's servers by its `server_label` and stands for that server's tools;
declare servers on the session. A `tool_choice` of
`{"type": "mcp", "server_label": "deepwiki", "name": "ask_wiki_question"}`
names that tool, the way a function `tool_choice` names a function.

### Limits

| | |
| --- | --- |
| Servers per session | 4 |
| Tools offered | 32, split evenly between the servers (16 each for two). Use `allowed_tools` to choose. |
| Listing a server | 10 seconds |
| One call | 60 seconds. After that Nur is told it didn't answer. |
| Calls per session | 64, and 4 at a time |
| One result | The first 16,000 characters. Images and files are noted, not passed on. |

### Security

- The server must be on the public internet over `https`. Addresses that point
  to private networks or cloud metadata services are refused, and so are
  redirects.
- `authorization` and `headers` are sent to that server and nowhere else. They
  are not logged and not echoed back to you.
- Results are third-party content, and Nur is told so: they reach her as the
  server's words, not hers and not yours.

### With client secrets

A server can be part of a [client secret](/realtime/client-secrets). Minting
checks it, and the response shows `authorization` and `headers` as
`[redacted]`. With `tools` locked (the default), the device cannot add, change
or remove servers, in `session.update` or in a reply's own tools. The person
can still approve or decline a tool, since that is their answer to Nur.

## Example

```python
import asyncio, json, os

import websockets

URL = "wss://api.eesi.ai/v1/realtime?model=nur-v1"
DEEPWIKI = {
    "type": "mcp",
    "server_label": "deepwiki",
    "server_url": "https://mcp.deepwiki.com/mcp",
}


async def main():
    async with websockets.connect(
        URL, additional_headers={"Authorization": f"Bearer {os.environ['EESI_API_KEY']}"}
    ) as ws:
        await ws.send(json.dumps({
            "type": "session.update",
            "session": {
                "type": "realtime",
                "instructions": "Answer questions about code repositories.",
                "tools": [DEEPWIKI],
            },
        }))
        async for raw in ws:
            event = json.loads(raw)
            kind = event["type"]
            if kind == "mcp_list_tools.completed":
                await ws.send(json.dumps({
                    "type": "conversation.item.create",
                    "item": {"type": "message", "role": "user", "content": [
                        {"type": "input_text", "text": "What is the modelcontextprotocol/python-sdk repo for?"},
                    ]},
                }))
                await ws.send(json.dumps({"type": "response.create"}))
            elif kind.startswith("response.mcp_call."):
                print(kind)
            elif kind == "response.output_audio_transcript.done":
                print("Nur:", event["transcript"])


asyncio.run(main())
```
