A remote MCP server with a credential
An agent may start any MCP server it likes inside the sandbox, but it has
no credential to give one. [mcp.NAME] names a remote MCP server whose
credential the caller holds as a secret. run serves the server to the
agent through a proxy that holds the credential, and the agent never does.
This is the first part of
#120: one
server, reached over MCP's streamable HTTP, with one secret.
The reusable workflow refuses an [mcp.*] server for now, and passes
MCP_TOKEN to no job: its agent job runs the agent as the runner's
user, which could read the credential from run, until the host keeps
the runner's processes from reading each other
(workflow.md). What follows is what the binary does for
a caller that composes its own agent job with a sandbox user.
Configuration
In the caller's configuration file (the config input of the reusable
workflow):
[mcp.tracker]url = "https://mcp.example.com/mcp"secret = "MCP_TOKEN"allowed = ["search_issues", "get_issue"]NAMEis a lower-case letter, then up to 31 lower-case letters, digits,_and-. A run takes one[mcp.*]table; a second is refused.urlis https, even to this machine's loopback, which the sandbox shares: over plain http the credential would go to whatever process holds the port. It has no user, password or fragment, and is at most 2048 bytes.secretis the name of the workflow's secret that holds the credential. The reusable workflow declares each secret it takes and cannot look one up by name, so the only name it takes isMCP_TOKEN.allowednames, exactly, each tool the agent may call: 1 to 64 names of letters, digits and_.-, without wildcards.
The caller's own agent job gives the secret only to its run step, as
run's input mcp-token and the variable AGENTIC_JOB_MCP_TOKEN of
agentic-job run. The sandbox
user's commands are started without that variable. A run with [mcp.*]
and no credential does not start, and says which variable it wanted.
What run does
Before the agent starts, run checks the credential with the server, as
it checks a provider's key: it initializes a session, lists the tools, and
ends that session. The run does not start, and nothing is spent, if the
server refuses the credential, cannot be reached after a few tries, or
lists no tool of a name that allowed names. The error quotes the
server's refusal with the credential cut out.
Then run serves a proxy on a port of the loopback address, as the
runner's user, with a token made for the run. The agent gets the proxy's
address and that token in ACP's session/new, as an HTTP MCP server
named NAME. Its brief names the server and the allowed tools. An agent
that does not take HTTP MCP servers (mcpCapabilities.http) fails before
its first prompt. run does not fall back to anything else.
Before the agent starts, probes check two things. No process of the sandbox user's holds the credential. The run's token for the proxy is in no file or process of that user's.
The proxy takes only the run's token, as Authorization: Bearer, and
only while the run lasts. It answers only POST and DELETE on its one
path. GET, which would open a stream from the server, gets 405, as
streamable HTTP allows. It forwards one JSON-RPC message at a time, and
refuses a batch. The message must be one of these:
initialize,notifications/initialized,notifications/cancelledorping;tools/list;tools/callof a toolallowednames;- the answer to a ping the server sent.
Anything else is refused with HTTP 403, and with a JSON-RPC error where it is one message, and never reaches the server. That includes resources, prompts, completions, logging, and answers to the server's other requests, such as sampling. A body that is not JSON, or JSON that is neither a JSON-RPC 2.0 message nor a batch, is refused with 400.
Upstream, the proxy sends the credential as Authorization: Bearer, and
passes on Mcp-Session-Id and MCP-Protocol-Version and no other header
of the agent's. It does not go through a proxy of the job's environment,
and it does not follow a redirect. It reads each answer whole, up to
8 MiB, and refuses a larger one. It replaces the credential where the
answer quotes it as it is or escaped in a JSON string. In an answer to
tools/list it removes every tool allowed does not name. It passes back
only the answer's content type and session id. It logs the name and HTTP status of each
tool call, not the arguments or the result. The run's logs, transcripts
and hand-back are redacted of the credential and of the proxy's token.
What it does not give
- The server is reached from the runner's side, outside the egress filter,
as the inference provider is. Whatever the agent puts in an allowed
tool's arguments reaches the server, whatever the filter allows the
sandbox. So
allowedshould hold only tools the caller would let any run use, with anything the agent read. - Answers from the server, tool results included, go to the agent unchecked but for the tool list. A server's text can be a prompt injection like any other content the agent reads.
- The credential's authority is the server's to bound. If the credential can write, then an allowed tool that writes is a write the safe outputs never see. Give it read access only where the server allows that.
- Detection does not see tool traffic.
- One server and one secret. Servers started from a command (stdio) by a confined runner, more than one secret, and Playwright with its browsers remain under #120.