← All documentation

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.env

A 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.

SurfaceRule
JSONUTF-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 fieldsversion, listen, authentication, host, admission, tools; nested requirements are specified by the schema.
VersionExactly 1. Optional $schema has the exact published gateway schema URL.
ListenerLiteral 127.0.0.1 or ::1; port 1–65535, fixed path /mcp.
Originslisten.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.
AuthorityEvery request requires exactly the configured literal listener authority, including its port except port 80; IPv6 uses brackets. No proxy headers are trusted.
Bearerauthentication.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.
Admissionmax_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.
Tools1–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.
Applicationapplication.manifest names one manifest file, at most 1024 bytes. The serving constructor requires object input/output contracts and a validated read/write effect.
Event policyevents.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.
ArtifactsOptional 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 permissionallow_write defaults to false. A compiled write effect requires true; permission never changes the compiled effect.
Content pinRequired expected_application_content_digest, exactly sha256: plus 64 lowercase hex characters; effective application identity is not interchangeable.
Installation pinsRequired exact map installation_config_pins, keyed by installation name. Missing, extra, stale or mismatched values refuse startup.
Snapshot pinsRequired 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.
PathsHost, application manifests, audit directory and artifact root resolve from the gateway document directory. Nested host-owned paths retain host-document-relative semantics.
Environment fileAnchored 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 auditRequired 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.env

The 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

RequestResponse
GET /health/live200 {"status":"live"} while listener and gateway owner remain alive.
GET /health/ready200 {"status":"ready"} when the warm domain is ready; otherwise 503 {"status":"not_ready"}.
Other method on either health path405. OPTIONS is unsupported.
Other path404.
Invalid authority, on any path400 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:

kindFields
startup_stagestage: artifact_root, templates, audit, run_admission, warm_providers, or listener; outcome: started or ready.
startup_failedcode: the public startup catalog code; reason_class: the closed internal failure class, before public normalization. Unknown classes use other.
readinesstransition: ready or not_ready; tool, provider; cause: startup, provider_runtime_lost, or provider_cleanup_failed. Startup has null tool and provider.
transporttool, 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_eventstool, provider, positive count, and interval_ms (1000). Gateway-wide drops have null tool and provider. Only nonzero counters are written.
stderr_tailtool, 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.