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.
|
|
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.
--> {"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 (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: |
An option ( |
The long name ( |
A flag ( |
The flag’s name, typed as a |
An unrolled record ( |
One property per field, keyed by the field name. |
A record passed whole ( |
A single |
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 is the JSON-RPC input stream; the two cannot share it. |
streams to |
stdout is the JSON-RPC output stream. |
has an Arrow |
a |
has a stream-handle argument or return ( |
a live handle cannot be marshalled as a JSON value. |
|
|
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 |
Unknown tool, or missing / unexpected / wrong-typed arguments |
JSON-RPC error |
The function raises ( |
A result with |
--> {"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:
{
"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 (install → expose → start) are covered in the
Building APIs and mim (Morloc Installation Manager) chapters.
7.5.9. Summary
| Aspect | Detail |
|---|---|
Invocation (local) |
|
Invocation (networked) |
|
Transport |
JSON-RPC 2.0 over stdio, or over HTTP (protocol version |
Tools |
One per exported function; |
Positional keys |
|
Option / flag keys |
the |
Return |
|
Static preview |
|