7.5. Model Context Protocol (MCP)

The same compiled program that runs as a CLI tool or a daemon can also serve as an MCP 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:

$ 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); 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:

$ ./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 (Intinteger, Strstring, [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.

$ ./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:

$ 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 (installexposestart) are covered in the Building APIs and mim (Morloc Installation Manager) 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