Gateway configuration
The loopback gateway exposes health checks and an authenticated MCP tool server.
Start it with the installed executable:
ptc gateway /absolute/path/ptc-gateway.json --env-file /absolute/path/credentials.envA source checkout runs the equivalent mix ptc.gateway command from
ptc_gateway/.
Remote clients need an authenticated tunnel to loopback. Native TLS, proxy trust, direct private-network binding, and container port publishing are not supported. Changing configuration, credentials, templates, or pins requires a whole-domain restart.
Version 1 contract
priv/schemas/ptc-gateway-config.schema.json is generated from
PtcRunner.Kernel.GatewayConfig.schema/0 by mix ptc.gen_docs. The root
staleness gate checks it. The schema and this semantic table are normative.
There is no implicit configuration search or implicit environment-file search.
| Surface | Rule |
|---|---|
| JSON | UTF-8, at most 1,000,000 bytes; duplicate and unknown keys rejected at every depth. Strings exclude NUL, CR and LF. Schema string maxima are also byte maxima. |
| Required fields | version, listen, authentication, host, admission, tools; nested requirements are specified by the schema. |
| Version | Exactly 1. Optional $schema has the exact published gateway schema URL. |
| Listener | Literal 127.0.0.1 or ::1; port 1–65535, fixed path /mcp. |
| Origins | listen.allowed_origins defaults to []; at most 128 unique HTTP(S) serialized origins, each at most 2048 bytes. No userinfo, path, query, fragment, whitespace, escapes, uppercase host, or explicit default port. |
| Authority | Every request requires exactly the configured literal listener authority, including its port except port 80; IPv6 uses brackets. No proxy headers are trusted. |
| Bearer | authentication.bearer.binding names a host credential bound to an environment variable or a file. A literal host credential is refused, so neither document carries the bearer value. Capture enforces 32–4096 ASCII bytes in HTTP bearer-token grammar. |
| Admission | max_inflight_requests, max_concurrent_runs, and max_active_provider_calls each require 1–65535; max_waiting_provider_calls requires 0–65535. Each authenticated request reserves one in-flight slot before body reading and holds it through response completion, so a peer that stops sending its body holds a slot until the two-second body-read budget expires. |
| Tools | 1–128 entries, unique names matching [a-zA-Z0-9_.-]{1,128}, validated in UTF-8 name-byte order. Titles require 1–256 bytes; descriptions 1–4096. Each normalized schema is at most 64 KiB and the encoded static catalog at most 4 MiB. |
| Application | application.manifest names one manifest file, at most 1024 bytes. The serving constructor requires object input/output contracts and a validated read/write effect. |
| Event policy | events.policy must be normal, which is what an omitted events section or field already means. A private policy refuses the constructor ahead of the content-digest and write-permission checks and is reported as template_invalid. Gateway artifacts configuration may authorize normal trace and inspection destinations; holding the endpoint or setting allow_write never grants a private-result override. To serve such an application, set its manifest policy to normal and record the new content pin. |
| Artifacts | Optional artifacts has root and optional trace and inspection booleans (both default false), and an optional events object. The root resolves from the gateway document directory and must pass the project artifact root's private-directory checks at startup. Enabled traces go to traces/<run_ref>.jsonl and inspection records to inspection/<run_ref>.ptcins under that root. Reservation failure refuses the call before execution. Omit the section to record no artifacts. Point a ptc-project.json artifacts.root at the same directory to browse served runs with ptc viewer or ptc repl. |
| Write permission | allow_write defaults to false. A compiled write effect requires true; permission never changes the compiled effect. |
| Content pin | Required expected_application_content_digest, exactly sha256: plus 64 lowercase hex characters; effective application identity is not interchangeable. |
| Installation pins | Required exact map installation_config_pins, keyed by installation name. Missing, extra, stale or mismatched values refuse startup. |
| Snapshot pins | Required exact map provider_snapshot_pins, keyed workflow/name or mission/name. Values pin acquisition identity. Normal trace snapshot sites are omitted because their content changes per call. Tools without providers require both maps to be empty. |
| Paths | Host, application manifests, audit directory and artifact root resolve from the gateway document directory. Nested host-owned paths retain host-document-relative semantics. |
| Environment file | Anchored once from the invocation directory. Warm startup captures the bearer and selected provider credentials in one scope, restoring environment and lock before listener binding, including on failure. |
| Private audit | Required iff any tool sets allow_write: true; forbidden otherwise. Required fields: directory, max_file_bytes (1024–67108864), max_retained_files (2–128). |
Pins
The listener derives one acceptor and max_inflight_requests + 8 connections.
The connection limit is per acceptor, so this is also the total handler ceiling.
The eight extra handlers provide shared headroom for idle keep-alive and health
sockets; they do not reserve health access. Idle sockets and incomplete headers
consume transport capacity without consuming request admission. Once all handlers
are occupied, new TCP connections can succeed while their HTTP exchanges wait;
transport saturation does not produce an application admission refusal. Closing
idle sockets lets pending exchanges proceed. Request and run limits still apply
independently to admitted work.
After validating tool templates and before starting audit or providers, startup
checks the inherited process
soft file-descriptor limit. The required budget is the handler ceiling, one
additional socket held by the acceptor, and a reserve of 128 descriptors for the
runtime, listener, audit/event files and transient operations, plus
max_active_provider_calls for each host installation (including unused
installations), plus four descriptors for each compiled provider declaration
across all tools, plus two descriptors per concurrent run when artifacts are
configured. Declarations are counted separately even when they select the same
installation: each tool acquires its own transports. The provider allowance
includes retained pools, transports and stdio pipes; the artifact allowance
covers trace and inspection files.
A budget above the soft limit refuses startup with listener_capacity_exceeded;
an unreadable limit refuses with listener_capacity_unavailable. An unlimited
soft limit passes. This check budgets gateway resources; unrelated application
activity in the same process can still consume descriptors.
listener_policy.transport reports num_acceptors, num_connections,
handler_ceiling, idle_headroom, descriptor_reserve, and
soft_descriptor_limit. There are no additional listener configuration keys.
Print every tool's startup pins from the gateway document:
ptc gateway /absolute/path/ptc-gateway.json --print-pins --env-file credentials.envThe command prints one JSON object keyed by tool name. Each value contains
expected_application_content_digest, installation_config_pins keyed by
installation name, and provider_snapshot_pins keyed by workflow/name or
mission/name. Digests are qualified SHA-256 values. Tools without providers have
empty pin maps. Copy all three fields into each tool's configuration.
Discovery accepts missing or stale pin fields; supplied fields still follow the schema. Serving requires all pins and checks exact matches. Discovery validates every template, including selected OAuth and workflow catalog refusals, before acquiring tools in name order. Selected command-VM applications are admitted once for all tools and reused within discovery. It never executes a workflow. Only selected provider credentials are captured once before acquisition; empty values are refused. The inbound bearer binding is neither required in the host nor read. The optional environment file resolves from the invocation directory, is read once, and its temporary values are restored before exit, including on failure.
Discovery creates no listener, audit directory, or artifact root and does not check their filesystem paths. Every acquisition closes before output. Success exits 0; failure prints one closed JSON error on stderr, nothing on stdout, and exits 78. Credentials and volatile provider content are absent from output. Update pins and restart after changing applications, installations, or acquisition identities; a fenced serving domain never re-pins automatically.
Cold trace snapshot tools
A tool selecting only ptc_trace_snapshot installations captures the directory
anew for every call. Concurrent calls own separate captures and cleanup. Startup
checks the exact installation configuration pins and validates the trace directory
before binding the listener; it retains no snapshot. provider_snapshot_pins
omits snapshot sites in both discovery and startup, so later trace content needs
no re-pin. Startup requires the empty snapshot pin map for such a tool.
Mixing a snapshot with a model or MCP provider, or selecting
ptc_private_trace_snapshot or ptc_inspection_snapshot, is refused with
provider_source_unsupported. See serving debug tools
for the analysis workflow and artifact-root boundary.
For a runnable composition over two stdio servers, see the gateway MCP example.
Shared MCP providers
A served tool can select non-OAuth mcp installations as mission tool grants.
Stdio and HTTP with static credential headers are supported. Each served tool
acquires its own transports once at startup and shares them across all calls
of that tool. Two tools selecting the same installation acquire separate
transports; there is no gateway-wide sharing. Pin discovery uses the same
acquisition path.
Use trusted upstream servers: every call of one tool shares the server's
in-memory state, so the server must not retain caller-sensitive state that can
leak between calls. The server must honor cancellation notifications; remote
work that ignores cancellation may outlive an abandoned call. Each acquisition
has a fixed bound of 128 in-flight requests, including detached requests still
settling. The bound is not configurable. Capacity refusal is
mcp_transport_busy, occurs before dispatch, and is retryable.
With inspection enabled, shared stdio calls capture request and response bodies independently by request id, with each call's trace context and byte ceiling. Inspection uses the same in-flight cap, caller monitoring, and original deadline as ordinary requests, including time spent queued. Shared server stderr is retained in a bounded per-transport buffer and never enters a call's inspection record. Per-run stdio acquisitions retain their serialized stderr attribution.
Selected OAuth installations and workflow catalog: true selections are
refused with provider_source_unsupported during tool construction, before
credential capture, OAuth store access, or upstream acquisition. Unselected
OAuth installations in the host document are ignored.
Transport-owner loss permanently fences readiness for the entire warm gateway domain, and new calls receive the existing unavailable response. A failed HTTP request alone does not fence readiness. Borrow cleanup seals further requests and waits for detached transport work; an execution owner's death does not release its borrow before settlement. Settlement expiry is a provider cleanup failure and also fences readiness. Recovery requires a gateway restart. Drain waits for outstanding borrows until its cutoff, then closes acquisitions even when detached work remains.
Private audit directory
Startup resolves the audit ancestry, and every resolved ancestor must be a
directory owned by you or root and not group- or world-writable unless
sticky. This makes /tmp and the default TMPDIR usable locations. The audit
directory itself must not be a symbolic link. Startup creates missing
directories as 0700 and files as 0600 before writing any content. The private
directory is exclusively locked for the owner's lifetime. After an unclean
owner death, the retained lock requires you to stop the old process before
removing it.
Each startup durably opens a new numbered file and separately appends and
flushes a temporary probe before readiness. The probe is then removed. Only
after the replacement is durable may retention remove closed files, oldest
first, so narrowing max_retained_files prunes down to the new bound on the
next startup. A startup that fails after opening its replacement removes it
again, so a failed start leaves no empty file counting against that bound.
Active files are never truncated. Unexpected files, hard links, oversized files
or wrong permissions refuse startup. There
is no HTTP audit-reading endpoint. Every dispatched write durably appends one
bounded record before run admission is released. It contains only the call ID,
tool name, start/end times, closed outcome and dispatch state, write uncertainty,
disconnect flag, and cleanup status.
Pending records use bounded group commit: flush after at most 2 ms of batching,
64 records, or 64 KiB of encoded JSONL, whichever comes first. The byte limit
also cannot exceed max_file_bytes. A single larger record that fits
max_file_bytes is flushed alone. These are batching bounds; disk sync and
scheduler delays can extend the time a call waits. Light traffic flushes without
waiting for a full batch. Records retain arrival order across batches and file
rotation. Each call retains run admission until its batch is durable; a failed
write or sync never acknowledges any affected record as durable and makes the
gateway unavailable. Orderly shutdown drains admitted calls through the same
durability barrier. Admission bounds the callers waiting for the audit.
Health
| Request | Response |
|---|---|
GET /health/live | 200 {"status":"live"} while listener and gateway owner remain alive. |
GET /health/ready | 200 {"status":"ready"} when the warm domain is ready; otherwise 503 {"status":"not_ready"}. |
| Other method on either health path | 405. OPTIONS is unsupported. |
| Other path | 404. |
| Invalid authority, on any path | 400 before routing. |
Health needs no bearer and returns no CORS headers. Responses use
application/json and Cache-Control: no-store. Readiness uses the warm
runtime's bounded snapshot, including captured credentials, pinned providers,
run admission and provider-call admission. Request and run saturation stay
ready, including when clients withhold bodies and occupy every in-flight slot. Readiness describes the ability to serve work
as capacity frees; use the MCP refusal reason below to identify overload. Required
runtime loss or fencing makes readiness permanently false while liveness
continues. Invalid static startup configuration prevents listener binding.
MCP discovery, listing, and calls
POST /mcp accepts only MCP revision 2026-07-28 methods
server/discover, tools/list, and tools/call. It is stateless: Mcp-Session-Id and
Last-Event-ID are ignored and no session header is returned. Other HTTP
methods return 405, and unsupported MCP methods return HTTP 404 with JSON-RPC
-32601.
Requests require the exact listener authority, an allowed or absent Origin,
one bearer authorization, Content-Type: application/json (optionally with a
UTF-8 charset), and an Accept value that admits both application/json and
text/event-stream at nonzero quality. Critical headers may occur only once.
Authorization permits outer HTTP OWS but requires one or more SP bytes between
the case-insensitive Bearer scheme and token. Authentication precedes content
and body parsing. Limits are an 8 KiB HTTP/1 request line, 64 headers, 32 KiB
of decoded header-name plus header-value bytes, 8 KiB per complete HTTP/1 header field line, 2 MiB body,
64 KiB decoded _meta, JSON depth 64 and 100,000 nodes. String IDs contain
1–256 UTF-8 bytes; integer IDs are in the interoperable safe range. A body read
waits at most two seconds for the next bytes: a peer that stops sending gets
HTTP 408 {"error":"request_timeout"} and an unreadable body HTTP 400
{"error":"request_invalid"}. Neither is reported as an internal error, and
both close the connection rather than leave an unread body to drain. A peer
that closes its own write side mid-body receives no reply at all: the read
fails immediately and the connection ends.
Every request has object params._meta fields
io.modelcontextprotocol/protocolVersion and
io.modelcontextprotocol/clientCapabilities, with optional schema-shaped
io.modelcontextprotocol/clientInfo. MCP-Protocol-Version and Mcp-Method
must match the body. Mcp-Name is forbidden for these methods. Responses are
deterministic JSON, at most 4 MiB, and use Cache-Control: no-store.
Missing or malformed required metadata returns HTTP 400 JSON-RPC -32602.
Admission saturation returns HTTP 429 JSON-RPC -31999 (Server busy).
The optional JSON-RPC error.data object carries a stable reason:
request_capacity_exhausted: the in-flight request bound is full; the refusal occurs before body parsing and has no request ID.run_capacity_exhausted: execution capacity is full; the refusal preserves the validated call ID and occurs before committing the event stream. There is no waiting queue for execution capacity.
You can attribute refusals using error.data.reason while
readiness remains green. Neither reason guarantees a recovery time, and no
Retry-After duration is supplied. Ordinary error envelopes are unchanged; an
unavailable or fenced runtime returns HTTP 503 -31998.
Discovery advertises the fixed revision and static tool capability. Listing
returns every configured tool in UTF-8 name-byte order with its configured
name, title and description, exact normalized input/output schemas, and only
the compiler-derived annotations.readOnlyHint. A cursor, unknown parameter,
or malformed method parameters returns HTTP 200 JSON-RPC -32602.
A call accepts only name, optional object arguments (default {}), and the
required _meta. Exactly one Mcp-Name must decode to the body name. Schema
leaves annotated with x-mcp-header require exactly matching Mcp-Param-*
headers; duplicate, missing, unexpected, malformed, and mismatched headers return
-32020. Unknown call fields return -32602.
After validating a known call, the gateway atomically reserves run admission,
commits HTTP 200 text/event-stream, and only then activates execution. It emits
one final message event and closes. While silent it writes an SSE comment at
most every five seconds to detect a disconnected client. Success returns
deterministic JSON in both structuredContent and its exact text encoding.
Execution-domain failures return a complete tool error. A write failure with
possible effects uses exactly Operation may have changed data; do not retry automatically; the gateway makes no retry or transaction claim.
SIGINT and SIGTERM stop the gateway owner and exit zero only after its listener,
provider runtime, audit owner, and admissions have stopped cleanly. From a
source checkout, use scripts/run_gateway_source.sh CONFIG [--env-file FILE];
the wrapper forwards both signals into the same staged shutdown path, including
during application and gateway startup.
Event log
Set artifacts.events to enable a private, bounded JSON Lines log independently
of traces, inspection, and private_audit. Read-only tools can log without an
audit directory:
{"artifacts":{"root":"artifacts","events":{"max_file_bytes":1048576,"max_retained_files":8,"stderr":false}}}max_file_bytes is required (1024–67108864 bytes), as is
max_retained_files (2–128, including the active file). stderr defaults to
false. The log directory is owner-only (0700), files are owner-only (0600), and
linked directories, linked files, foreign owners, and unexpected filenames are
refused. Retained files exceeding the current max_file_bytes are also refused
at startup, including after reducing the limit. The directory is exclusively locked for the gateway's lifetime.
Files are events/<gateway_start_ref>-<sequence>.jsonl under the validated
artifact root. The start reference is 32 lowercase hexadecimal characters;
the sequence is a 20-digit increasing number. Rotation opens a new file and
removes the oldest closed files to enforce retention, including across restarts.
A record never crosses files or exceeds the configured file bound. Transport
faults retain recorded cleanup failures even when the transport process exits
normally; an unclassified failed cleanup is transport_error. Cleanup diagnostics
do not shorten the captured per-transport stderr buffer.
Each line is an object with kind and an ISO 8601 UTC timestamp.
The following fields complete each record:
kind | Fields |
|---|---|
startup_stage | stage: artifact_root, templates, audit, run_admission, warm_providers, or listener; outcome: started or ready. |
startup_failed | code: the public startup catalog code; reason_class: the closed internal failure class, before public normalization. Unknown classes use other. |
readiness | transition: ready or not_ready; tool, provider; cause: startup, provider_runtime_lost, or provider_cleanup_failed. Startup has null tool and provider. |
transport | tool, provider; fault: close, server_exit, owner_eof, launcher_signal, protocol_error, close_timeout, killed, normal, shutdown, or transport_error; exit_status: an integer when reported by the transport, otherwise null. |
busy, detached, settlement_timeout, dropped_events | tool, provider, positive count, and interval_ms (1000). Gateway-wide drops have null tool and provider. Only nonzero counters are written. |
stderr_tail | tool, provider, text, and boolean truncated. Only emitted when stderr is true. |
Internal failure classes include the public startup codes, provider acquisition,
protocol, policy, runtime-validation and cleanup classes, and MCP transport,
timeout, authentication and launcher classes. They contain no exception text.
All records except stderr_tail contain only configured tool/provider names,
closed classes, timestamps, and numeric or boolean metadata. Arguments,
results, credentials, endpoints, and paths are excluded.
With stderr: true, transport loss and shutdown additionally write the bounded
per-transport stderr tail. This is raw private upstream evidence: it may
contain secrets or paths printed by the server. The tail is shortened further
when necessary to fit the file bound. It is never part of a served tool result
or a call's inspection record. HTTP transports have no stderr tail.
Events begin after artifact-root validation. Failure to create or validate the
event directory reports artifact_root_unavailable. Earlier failures produce
only the existing startup diagnostic. Logging uses a queue capped at 256
records; counters use fixed registered tool/provider slots. A full queue or an
open/write failure drops records and reports them in the next writable
dropped_events interval. Failed counter reports retain their counts for a
later interval. Logging failures never change serving readiness. Health
bodies, startup stderr, and MCP error envelopes retain their existing formats.
Read the files directly; no served tool, REPL profile, or Viewer page reads this
log.
Startup failures
Only the first error is returned. Precedence is document read/JSON, structural schema and byte bounds, origins, duplicate tool names, audit presence, host, artifact-root validation, then each tool's constructor/pin and write permission in name order, audit filesystem probe, run admission, warm credential capture/provider pins, and listener binding. Listener capacity is checked after tool validation and before audit or provider startup. A stage must succeed before the next stage runs.
The CLI writes one JSON object {"error":"<code>"} and newline to stderr,
nothing to stdout, and exits 78. Successful startup is silent. No names, paths,
credentials, causes or stack traces belong in startup diagnostics.
The finite catalog is config_unavailable, duplicate_json_key,
config_invalid, origin_invalid, tool_name_duplicate, audit_invalid,
host_invalid, template_invalid, catalog_too_large, application_content_digest_mismatch,
write_forbidden, audit_unavailable, artifact_root_unavailable, run_admission_unavailable,
credential_unavailable, installation_pin_mismatch, provider_pin_mismatch,
provider_pin_unavailable, provider_admission_unavailable,
provider_runtime_unavailable, provider_source_unsupported, listener_capacity_exceeded,
listener_capacity_unavailable, listener_unavailable, and internal_error.
Unrecognized internal failures map to internal_error.