Malspark

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

TransportReached throughThe sample is handed over as
stdioa child process of the worker (command, args, env, cwd, env_allow)a path: the child reads the worker's filesystem
http, streamable-http, sseurl, with auth_token stored encryptedan 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.

Mount the worker's samples directory into the server, and declare where on the server's block: sample_mount is the path inside the server it is mounted at, and work_dir the directory under it the worker copies the sample into (a 0o700 directory, a 0o600 file, removed when the job ends). The server is handed <sample_mount>/<work_dir>/<file>. Nothing is uploaded; the mount and the declared sample_mount have to agree.

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:

ToolArgumentsReturns
put_samplefilename, content_b64, sha256{"path": ...}
put_sample_beginfilename, sha256, size{"upload_id": ...}
put_sample_chunkupload_id, seq, content_b64{"seq": ...}
put_sample_finishupload_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:

ToolArgumentsReturns
get_artefactpath (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_resting error 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).
Edit on GitHub

On this page