Integrations
MCP servers
Transports, remote sample delivery, and the conventions that make a tool server a good citizen of a run.
Maljan is an MCP client. Every analysis capability its pipeline uses is also a tool on an MCP server, and any MCP server an operator adds is called the same way. This page is for someone connecting or writing a server: the transports, how the sample reaches a server on another host, and the conventions that make a server a better citizen of a run. Adding a server from the console is described in Generic MCP servers.
Transports
| Transport | Reached through | The sample is handed over as |
|---|---|---|
stdio | a child process of the worker (command, args, env, cwd, env_allow) | a path: the child reads the worker's filesystem |
http, streamable-http, sse | url, with auth_token stored encrypted | an upload, when the server advertises put_sample; otherwise a path, which needs a shared volume |
The transport decides, not the manifest: a stdio sidecar is never uploaded to, because it can already open the file.
A stdio server's stdout carries protocol messages only; its logs, banners and
progress output belong on stderr. Maljan's own sidecars route the root
handler and the maljan logger to stderr first of all (core.stdio_safe), so
none of their log lines reaches stdout. A UTF-8 text line on stdout that is not a
protocol message (a log line, a JSON log record, a dependency's download
progress bar) does not break the connection: the client leaves it out, warns at
the first such line of each connection and states the count when the
connection closes. During setup only, until the client has the server's tool
list, a progress bar that ends without a newline can leave the server's answer
after its last carriage return, and the client reads the answer out from there
(or from after a byte-order mark): the answers to initialize and tools/list
carry nothing from a sample, and no tool has been called yet. The client sets a
connection up once and refuses a second setup while it is connected or still
connecting, so this window never reopens. After setup every
stray line is skipped, so an answer glued to other text is lost and its call
fails, as before: a tool's answer can echo text a sample's author wrote, and an
absent answer beats a forged one. A line that claims to be a protocol message
(an object with a jsonrpc member) and is not a valid one is stated when it
comes, with what failed its validation, since it is an answer the server got
wrong. Bytes on stdout that are not UTF-8 still end the connection, as before:
the SDK decodes stdout strictly before any line reaches the client.
A server on another host
Every path-taking tool assumes the server can open the path it is handed. For a server elsewhere there are two ways to make that hold.
A server reached over HTTP advertises put_sample on its manifest, and
Maljan uploads the sample to it before the agent's first tool call:
| Tool | Arguments | Returns |
|---|---|---|
put_sample | filename, content_b64, sha256 | {"path": ...} |
put_sample_begin | filename, sha256, size | {"upload_id": ...} |
put_sample_chunk | upload_id, seq, content_b64 | {"seq": ...} |
put_sample_finish | upload_id | {"path": ...} |
Samples over 8 MiB go through the three chunked calls when the manifest carries all of them. The returned path is what that server's tools are then called with, and uploads are cached per server and sha256 for half an hour.
Staging never fails a run: an upload that goes wrong is recorded as a degradation reason, and the server is called with the local path. These primitives are never shown to the model. See Tool servers on another host.
Files a server wrote
A tool that writes a file (a carved payload, an unpacked member, a decoded
script) names it in its answer, and a triage pack step that declares
outputs has the platform take each such file into the job's artefact store,
from which a later step's tool on any other server is handed it (see Files
passed between servers in the pack's steps).
A server on the worker's filesystem (stdio, no sample_mount) is read inside
its own job staging directory and needs nothing more. Any other server, or one
that would rather serve its files itself, offers one tool:
| Tool | Arguments | Returns |
|---|---|---|
get_artefact | path (the name its answer gave the file), offset, length (bytes) | {"content_b64": ..., "size": <the whole file's bytes>, "sha256": <the whole file's, where known>} |
The platform asks for 4 MiB at a time until size is read, checks the
sha256 it was told against the bytes it got, and refuses a file whose digest
disagrees. There is no size limit of the platform's own: the read ends where
the server said the file ends, and the server is held to it. Every answer
states size; the first fixes it, and an answer with none, an answer that
states another, a chunk that would pass it, an empty chunk before it and a
sha256 that changes are each refused. A file handed to a server over
put_sample is streamed from disk over the three chunked calls wherever the
server offers them, its digest checked as it is read before the upload is
finished; a server offering only put_sample is sent the file in that one
call, whatever its size, as the sample is, and refuses it itself if it will.
Each such upload is listed in the run's disclosure block with what it sent
(a file a tool wrote, the capture or the sandbox report). A file the server
will not serve is answered {"error": "..."},
and the step's entry names the file as not taken with those words. Serve only
files the server wrote for the job it was started for, as put_sample stores
only into that job's directory. Like put_sample, the tool is never shown to
the model.
The capability manifest
A tool named capabilities, taking no argument, answers what the server can do
on the host it runs on:
{"server": "analysis", "version": "1.0.0",
"tools": [{"name": "document_info", "optional_dependency": "olefile",
"available": false, "reason": "olefile is not installed",
"timeout_s": null,
"remediation": "install the optional tool libraries on the host that runs this server: uv sync --extra tools",
"without": "the PDF and OOXML halves"}]}Compute it when the server starts, by probing — an import, a which, an
environment variable — never by asserting. The registry reads it once per job,
the connection test returns it, and the console's server card lists the
unavailable tools with their reason before any run. timeout_s is the tool's
real timeout, and the platform waits that long for it.
Errors that name their remedy
A tool that cannot answer returns, never raises:
{"error": {"code": "missing_dependency",
"message": "olefile is not installed",
"remediation": "install the optional tool libraries: uv sync --extra tools"},
"tool": "document_info"}The codes are missing_dependency, timeout, bad_argument, no_such_file,
unsupported_format, not_configured and tool_failed; a code of your own is
kept as written. A returned error is a failed ledger entry with its remedy, and
the report header lists each distinct failure once. See Writing a tool
server.
What the platform does around every call
- Every call is ledgered, with its arguments as the model wrote them, its result, whether it succeeded and how long it took.
- Quoted search arguments are read unquoted on the built-in sidecars, and the answer says what each argument was read as.
- A server that keeps failing is rested. After three unanswered calls in a
row, calls are answered with a
server_restingerror for 60 s. See A tool server that keeps failing is rested. - Calls are capped per server at four at once by default
(
core.mcp.breaker.max_concurrent_calls).