# 7.5. Model Context Protocol (MCP)

Morloc Manual > Building APIs | https://morloc-project.github.io/docs/apis/mcp.html | prev: https://morloc-project.github.io/docs/apis/api-interfaces.md | next: https://morloc-project.github.io/docs/runs/index.md

The same compiled program that runs as a CLI tool or a daemon can also serve as an [MCP](https://modelcontextprotocol.io) server, exposing its exported functions as tools that an AI agent (Claude Desktop, an IDE assistant, or any MCP client) can call. The MCP machinery is already part of the shared runtime; there is no separate build. Any compiled program is served over MCP by running the runtime in `mcp` mode against its manifest:

```console
$ morloc-nexus mcp ./combat            # or: morloc-nexus mcp combat-build/manifest.json
```

MCP is a JSON-RPC 2.0 protocol spoken over a program’s standard input and output: the server reads requests on stdin and writes responses on stdout; every other byte — pool output, log lines, diagnostics — is routed to stderr so the protocol stream stays clean.

This stdio server is the **local** transport: the client and morloc run on the same machine, and the client launches the server as a child process. For a **networked** deployment — morloc in a container, or the agent on another host — serve MCP over HTTP with `mim start` (see [Building APIs](https://morloc-project.github.io/docs/apis/index.md)); the same program then also answers a plain JSON API on the same port.

The command launches the language pools (Python and R for `combat`) and then blocks, waiting for JSON-RPC messages on stdin. It is normally started **by** an MCP client rather than typed at a shell, but because the transport is just line-delimited JSON you can drive it by hand to see how it works.

> **Note**
> Each message is one complete JSON object on a single line; the server reads one line at a time, so a pretty-printed object split across several lines is parsed as separate broken fragments (each answered with a `-32700 invalid JSON` error). The transcripts below are indented only for readability — on the wire every object is a single line.

## 7.5.1. The handshake

An MCP session opens with a three-message handshake: the client sends `initialize`, the server replies with its capabilities, and the client confirms with an `initialized` notification. Only then may tools be listed or called.

**client → server, then server → client**

```
--> {"jsonrpc":"2.0","id":1,"method":"initialize",
     "params":{"protocolVersion":"2025-06-18","capabilities":{},
               "clientInfo":{"name":"demo","version":"0"}}}
<-- {"jsonrpc":"2.0","id":1,"result":{
      "protocolVersion":"2025-06-18",
      "capabilities":{"tools":{"listChanged":false}},
      "serverInfo":{"name":"combat","version":"0.94.0"}}}

--> {"jsonrpc":"2.0","method":"notifications/initialized"}
```

The `notifications/initialized` message carries no `id` and receives no reply — that is what a JSON-RPC notification is. `ping` is answered at any point in the lifecycle; `tools/list` and `tools/call` are rejected until the handshake completes.

## 7.5.2. Inspecting the tool surface

Every exported function becomes one tool. You can dump the full tool list — the same payload `tools/list` returns — without starting a session, using the `--mcp-tools` flag. This is the MCP analogue of the daemon’s `/discover` endpoint:

```console
$ ./combat --mcp-tools | jq '.tools[] | {name, inputSchema}'
{
  "name": "rollAdv",
  "inputSchema": {
    "type": "object",
    "properties": {},
    "required": [],
    "additionalProperties": false
  }
}
{
  "name": "fighterDamage",
  "inputSchema": {
    "type": "object",
    "properties": {
      "_1": { "type": "integer", "description": "Enemy Armor Class" }
    },
    "required": [ "_1" ],
    "additionalProperties": false
  }
}
{
  "name": "intro",
  "inputSchema": {
    "type": "object",
    "properties": {
      "_1": { "type": "string", "description": "Monster name" }
    },
    "required": [ "_1" ],
    "additionalProperties": false
  }
}
```

Each tool’s `description` comes from the function’s docstring, and each argument’s morloc type is rendered as a JSON Schema type (`Int` → `integer`, `Str` → `string`, `[a]` → `array`, a record → `object`, `?a` → a nullable union). An argument’s own `--'` docstring becomes the property `description` — so ``fighterDamage’s `*1*`` *is documented as \_Enemy Armor Class* even though the key itself is a positional index.

## 7.5.3. How arguments map to properties

MCP delivers arguments as a **named** object, so every morloc argument needs a name. How that name is chosen depends on the kind of argument:

| Morloc argument | MCP property name |
| --- | --- |
| A positional argument | A reserved index: `_1` for the first positional, `_2` for the second, and so on. Metavars (`FILE`, `INT`, …​) are display placeholders that get reused and are deliberately **not** used as keys; the index is always unique. |
| An option (`--' arg: -f/--factor`) | The long name (`factor`), or the short character if there is no long form. |
| A flag (`--' true: --clean` / `--' false: --no-clean`) | The flag’s name, typed as a `boolean`. |
| An unrolled record (`--' unroll: true`) | One property per field, keyed by the field name. |
| A record passed whole (`--' arg: --config`) | A single `object` property named for the record type. |

Because option and flag names can never begin with an underscore (the compiler reserves that), a `_N` positional key can never collide with one. Two positionals that happen to share a metavar are still distinct tools arguments, where an earlier design would have had to drop the command.

## 7.5.4. Calling a tool

`tools/call` names the tool and supplies its arguments by key. The server inverts the named arguments back into a positional call, dispatches it through the same machinery the CLI and daemon use, and returns the result.

```
--> {"jsonrpc":"2.0","id":2,"method":"tools/call",
     "params":{"name":"fighterDamage","arguments":{"_1":15}}}
<-- {"jsonrpc":"2.0","id":2,"result":{
      "content":[{"type":"text","text":"12"}],
      "isError":false}}

--> {"jsonrpc":"2.0","id":3,"method":"tools/call",
     "params":{"name":"intro","arguments":{"_1":"Goblin"}}}
<-- {"jsonrpc":"2.0","id":3,"result":{
      "content":[{"type":"text","text":"\"A wild Goblin appears!\""}],
      "isError":false}}
```

A scalar or list return is placed in a single `text` content block. A record (Map) return additionally populates `structuredContent` and the tool’s `outputSchema`, so a client that understands structured tool output gets the typed object directly while still having the text mirror.

Omitted optional arguments fall back to their declared defaults, and an omitted record field is filled from its default — the client only has to supply what it wants to override.

## 7.5.5. What is not exposed

Some functions cannot be served correctly over a stdio JSON-RPC channel, so they are dropped from the tool surface (a note is written to stderr explaining why). The remaining tools are unaffected.

| Excluded when the function…​ | …​because |
| --- | --- |
| reads from `@stdin` (`--' stdin: true`) | stdin is the JSON-RPC input stream; the two cannot share it. |
| streams to `@stdout` | stdout is the JSON-RPC output stream. |
| has an Arrow `Table` argument or return | a `Table` has no JSON representation in either direction. |
| has a stream-handle argument or return (`IFile` / `IStream` / `OStream`) | a live handle cannot be marshalled as a JSON value. |

> **Note**
> Ordinary output from a function — a `print` in a Python pool, a `std::cout` in C++ — is **not** a problem. The server re-homes its own standard output before any pool starts, so stray writes land on stderr and can never corrupt the protocol stream.

## 7.5.6. Errors

The server distinguishes a malformed **request** from a failed **execution**. A bad request is a JSON-RPC error; a function that runs and fails is a normal result flagged with `isError`, so the agent can read the message and react rather than seeing the whole call rejected.

| Condition | Response |
| --- | --- |
| Unknown method | JSON-RPC error `-32601` (method not found) |
| Unknown tool, or missing / unexpected / wrong-typed arguments | JSON-RPC error `-32602` (invalid params) |
| The function raises (`@throw`), a pool errors, or a call fails | A result with `"isError": true` and the message in a `text` block |

```
--> {"jsonrpc":"2.0","id":4,"method":"tools/call",
     "params":{"name":"fireball","arguments":{}}}
<-- {"jsonrpc":"2.0","id":4,
     "error":{"code":-32602,"message":"unknown tool 'fireball'"}}
```

## 7.5.7. Connecting an MCP client

Point an MCP client at the compiled program. Most clients take a command and its arguments; give them the runtime in `mcp` mode against the program’s absolute manifest path. For a Claude Desktop-style configuration:

**mcp client configuration**

```
{
  "mcpServers": {
    "combat": {
      "command": "/absolute/path/to/morloc-nexus",
      "args": ["mcp", "/absolute/path/to/combat-build/manifest.json"]
    }
  }
}
```

You do not have to write this by hand: the launcher emits exactly this entry as pure JSON on stdout, ready to redirect into a client config file.

```console
$ ./combat --mcp-config > combat.mcp.json
```

MCP clients launch servers with a minimal `PATH`, so the `command` is an absolute path to `morloc-nexus`. For Claude Code, `claude mcp add combat — /absolute/path/to/morloc-nexus mcp /absolute/path/to/combat-build/manifest.json` registers the same entry.

The client launches the server, runs the handshake, calls `tools/list`, and surfaces `rollAdv`, `fighterDamage`, and `intro` to the model as callable tools. When the client disconnects (stdin closes) the server shuts its pools down and exits.

## 7.5.8. Inside versus outside the container

The configuration above assumes the client can **launch** the program — it runs wherever morloc is installed. For a containerized deployment that means the agent runs **inside** the same container as morloc: it points at the program’s absolute path and speaks stdio directly, with nothing to bridge. This is the simplest and most direct way to expose a module, and it is the baseline the networked cases build on.

When the agent runs **outside** the container it cannot launch the in-container program directly. Reach it over HTTP instead: `mim start` runs a serving front-end that answers MCP over HTTP at `POST /mcp` (the same handshake and `tools/list` / `tools/call` messages, carried in HTTP request bodies with a session header), and the client is registered with a URL rather than a command:

```console
$ mim start --mcp combat
$ claude mcp add --transport http combat http://127.0.0.1:9000/mcp
```

The same front-end also serves a plain JSON API (`POST /call/<module>/<command>` with positional arguments, discovered at `GET /discover`) for non-MCP HTTP clients on the same port. The HTTP transport, authentication, sessions, and the serving lifecycle (`install` → `expose` → `start`) are covered in the [Building APIs](https://morloc-project.github.io/docs/apis/index.md) and [`mim` (Morloc Installation Manager)](https://morloc-project.github.io/docs/utilities/mim.md) chapters.

## 7.5.9. Summary

| Aspect | Detail |
| --- | --- |
| Invocation (local) | `morloc-nexus mcp <manifest>`; config via `./prog --mcp-config` |
| Invocation (networked) | `mim start` (MCP over HTTP at `/mcp`, plus the JSON API) |
| Transport | JSON-RPC 2.0 over stdio, or over HTTP (protocol version `2025-06-18`) |
| Tools | One per exported function; `description` from the docstring |
| Positional keys | `_1`, `_2`, …​ (reserved, collision-free) |
| Option / flag keys | the `--long` / flag name |
| Return | `text` block; records also get `structuredContent` + `outputSchema` |
| Static preview | `./combat --mcp-tools` prints the `tools/list` payload |
