Skip to content

JumpMasters/portcullis

Repository files navigation

Portcullis

ci codeql codecov Go Report Card

Portcullis is a security gateway for the Model Context Protocol (MCP). It presents itself to clients as a single MCP server and proxies their tool calls to the downstream MCP servers they are allowed to use. Clients authenticate to the gateway and never hold downstream credentials; every call passes through a deny-by-default policy, gateway-held secret injection, and outbound redaction before it reaches a downstream or the audit log.

Status

The V1 feature set is implemented: client authentication, deny-by-default policy, downstream proxying over stdio and HTTP, connection-level secret injection, outbound redaction, circuit breaking and timeouts, structured audit, Prometheus metrics, and configuration hot-reload. The repository builds and is tested under CI. It is young software — interfaces may still change, and it has not yet seen production use. See Scope for the precise boundaries of what V1 does and does not cover.

Why a gateway

Connecting an MCP client directly to many servers means distributing credentials to each client and trusting every client to call only what it should. Portcullis centralises that trust:

  • Clients never hold downstream secrets. The gateway resolves each downstream credential from an environment variable and applies it at the connection — as an environment variable in the stdio subprocess, or as a request header to a remote server. The client authenticates only to the gateway and never sees the credential. (Connection-level injection is a deliberate V1 scope; see ADR-0012.)
  • Deny by default. A client can call a tool only when an explicit rule allows it. Tools a client may not call are hidden from its catalog entirely — including their schemas and descriptions.
  • Edge hardening. The client-facing endpoint validates the request Origin to guard against DNS rebinding, is configured to bind to localhost, and authenticates API keys with a constant-time comparison.
  • Redaction. Injected secret values are scrubbed from results, errors, and notifications before they leave the gateway or are written to the audit log.
  • Audit. Every call produces a structured, post-redaction record.

Architecture

flowchart LR
    client["MCP client<br/>(Claude, IDE)"] -->|Streamable HTTP| edge

    subgraph portcullis [Portcullis]
        direction TB
        edge["edge<br/>origin check · auth · session"]
        agg["aggregate<br/>namespaced catalog"]
        pipe["pipeline<br/>policy · secrets · resilience · redact · audit"]
        reg["registry<br/>downstream sessions · stdio pool"]
        edge --> agg --> pipe --> reg
    end

    reg -->|stdio| d1["stdio server<br/>(subprocess pool)"]
    reg -->|Streamable HTTP| d2["remote MCP server"]
Loading

Each client session gets its own sessions to the downstreams it is allowed to use, so notifications, cancellation, and per-session state never cross between clients (see ADR-0002). The internal packages form an acyclic dependency graph: shared types and port interfaces live in internal/domain, concrete packages depend on domain but never on each other, and only the composition root (internal/app) wires concrete implementations together. The graph is enforced mechanically (see Development and checks).

Security model

Portcullis is deny-by-default and treats catalog visibility as security-load-bearing.

  • Deny-by-default policy. A call is allowed only when an explicit rule permits it. Conflicts resolve most-restrictively. A wildcard rule (github__*) admits only the tools observed at the last allowlist sync, so a downstream cannot widen a client's access by exposing a new tool later (ADR-0009).
  • Hidden catalog entries. A client never receives the schema or description of a tool it may not call; disallowed tools are filtered out of tools/list.
  • Origin / DNS-rebinding guard. Every request's Origin is checked against an allowlist before authentication. An absent Origin (non-browser clients) is permitted; the configured localhost bind is the primary defense against the browser-only rebinding vector.
  • Constant-time API-key authentication. Presented keys are compared in constant time over fixed-width SHA-256 digests, so the comparison time does not vary with the key's length or content (ADR-0003).
  • Connection-level secret injection. Gateway-held credentials are applied at the transport (stdio environment or HTTP header), never exposed to clients, and registered with the outbound redactor by value.
  • Outbound redaction. Injected secret values are removed by exact match. Configurable PII patterns add literal-gated regular expressions on top. Pattern matching is best-effort: it operates on the literal bytes of a payload and does not decode base64, percent-encoding, or other transforms, so a value present only in an encoded form is not matched. Each result is buffered and scanned in full before it is emitted, bounded by a configurable size cap (ADR-0011).
  • Structured audit. Every call produces a JSON record after redaction, so secret values never reach the audit log. Security records (denials, redactions) take a durability path that fails the request closed rather than proceed un-audited (ADR-0010).

Threat model

The boundaries Portcullis defends, and the assumptions behind them, are stated explicitly so operators can judge what it does and does not protect.

Trust boundaries.

  • Client → gateway. Clients are authenticated but otherwise untrusted: a client may attempt any tool call, including ones it is not permitted to make. The gateway is the policy decision and enforcement point.
  • Gateway → downstream. Downstream MCP servers are semi-trusted. They receive only the calls policy allows and the credentials the gateway injects, but their responses are treated as untrusted input and pass through redaction before reaching a client or the audit log.
  • Operator → gateway. The configuration and the environment holding the downstream secrets are trusted and assumed to be under the operator's control.

What each position can and cannot do.

  • A malicious or compromised client cannot call tools outside its allowlist, cannot enumerate tools it may not call (their schemas and descriptions are withheld), and never receives downstream credentials.
  • A malicious downstream cannot widen a client's access by advertising new tools after the last allowlist sync, and cannot exfiltrate injected secret values that the redactor matches in results, errors, or notifications.
  • A browser-based network attacker is countered by the localhost bind and the Origin / DNS-rebinding check.

Assumptions.

  • The gateway binds to localhost (or another trusted network); it is not hardened as an internet-facing edge.
  • The operator controls the configuration and the secret-bearing environment.
  • Redaction is exact-match on injected values plus configurable literal-gated patterns. It operates on the literal bytes of a payload and does not decode base64, percent-encoding, or other transforms, so a secret present only in an encoded form may pass.

Security non-goals (V1). Argument-level secret injection, client-schema stripping, true streaming-window redaction, per-tenant downstream isolation, and OAuth client authentication are out of scope. See Scope for the full list and the ADRs behind each boundary.

Getting started

Requires Go 1.26 or newer.

# Build and run the full set of checks that gate every change.
make verify

# Or individually:
make build      # compile all packages
make vet        # go vet
make fmt-check  # fail if any file is not gofmt -s clean
make test       # unit tests
make race       # unit tests with the race detector
make cover      # tests + coverage threshold
make lint       # golangci-lint
make vuln       # govulncheck

To run the gateway, copy the example configuration, export the referenced environment variables, and point the binary at the file:

cp config/portcullis.example.yaml config/portcullis.yaml
# edit config/portcullis.yaml and export the referenced *_KEY / *_TOKEN env vars
go run ./cmd/portcullis --config config/portcullis.yaml

Flags:

  • --config — path to the configuration file (default portcullis.yaml).
  • --version — print the gateway version and exit.
  • --watch — reload automatically when the config file changes.
  • --watch-interval — how often to poll the file under --watch (default 1s).

The gateway hot-reloads its client-auth and policy configuration without dropping connections. Send SIGHUP to reload on demand, or start with --watch to reload automatically:

go run ./cmd/portcullis --config config/portcullis.yaml --watch

Both signals feed a single serialized reload consumer, so reloads never overlap, and a reload that fails to load or validate leaves the running configuration in place. Downstream topology, redaction, and audit changes are restart-required; when a reload detects one it applies the client and policy parts and logs that a restart is needed for the rest.

Configuration

Configuration is a single declarative YAML file, validated at startup with fail-fast errors: a malformed file, an unknown field, a missing referenced secret, or a rule naming an unknown downstream or client is an error before the gateway serves. Secrets are referenced by environment-variable name and never stored in the file. See config/portcullis.example.yaml for a fully commented example.

Observability

A Prometheus endpoint is served at /metrics on the configured listen address. The registered metrics are:

  • portcullis_calls_total — tool calls, labelled by client, downstream, and policy decision.
  • portcullis_call_latency_seconds — downstream call latency, by downstream.
  • portcullis_downstream_up — downstream health, by downstream.
  • portcullis_breaker_state — circuit-breaker state by downstream (0 closed, 1 half-open, 2 open).
  • portcullis_pool_in_use — stdio subprocesses currently checked out, by downstream.
  • portcullis_pool_exhausted_total — stdio pool exhaustion fast-fails, by downstream.
  • portcullis_audit_unrecordable_total — security records that could not be audited (the gateway fails closed when this happens, so a rising value is security-relevant).

Project layout

cmd/portcullis        command entrypoint, signal handling, reload wiring
internal/domain       shared value types and port interfaces (leaf package)
internal/edge         client-facing Streamable HTTP endpoint, origin/auth guard
internal/aggregate    namespaced, paginated tool catalog and call routing
internal/registry     downstream sessions and the bounded stdio subprocess pool
internal/pipeline     per-call security chain composition
internal/policy       deny-by-default authorisation and catalog filtering
internal/secrets      connection-level secret-injection contract (ADR-0012)
internal/redact       outbound secret and PII redaction
internal/resilience   failure-class-aware circuit breaking and timeouts
internal/audit        asynchronous structured audit pipeline
internal/metrics      Prometheus instrumentation
internal/config       configuration loading, defaulting, and validation
internal/watch        config file-change polling for --watch
internal/guard        import-DAG enforcement test
internal/app          composition root
docs/adr              architecture decision records

Design decisions

The significant, hard-to-reverse choices are recorded as architecture decision records in Michael Nygard's format. Among them: per-client-session downstream sessions (ADR-0002), failure-class-aware circuit breaking (ADR-0005), wildcard rules pinned to the synced tool set (ADR-0009), the async audit pipeline's overflow and durability behaviour (ADR-0010), and connection-level secret injection (ADR-0012).

Development and checks

make verify runs the same gates CI enforces:

  • go build, go vet, and a gofmt -s cleanliness check.
  • Unit tests under the race detector, with a coverage threshold of at least 80% across internal/....
  • golangci-lint, including a depguard rule that restricts internal imports to the allowed edges of the dependency graph.
  • An AST-based import-guard test (internal/guard) that parses the source tree and fails the build on a forbidden internal import.
  • govulncheck for known vulnerabilities.

CI additionally runs CodeQL static analysis. The toolchain is Go 1.26 and golangci-lint v2.12.2.

Scope

In scope for V1 (implemented):

  • Proxying tools/list and tools/call across downstream MCP servers over stdio and Streamable HTTP.
  • Per-client-session downstream sessions and a bounded, supervised stdio subprocess pool with idle reaping and fail-fast exhaustion.
  • Deny-by-default policy with security-load-bearing catalog filtering.
  • Connection-level secret injection (stdio environment or HTTP header).
  • Outbound redaction of injected secret values and configurable PII patterns.
  • Failure-class-aware circuit breaking and per-call timeouts.
  • Asynchronous structured audit with tiered overflow and a fail-closed path for security records.
  • A Prometheus /metrics endpoint.
  • SIGHUP and --watch hot-reload of client-auth and policy configuration.

Not in scope yet — deliberate, documented boundaries:

  • Argument-level secret injection and client-schema stripping (ADR-0012).
  • True streaming-window redaction; results are buffered and scanned in full (ADR-0011).
  • MCP resources and prompts capabilities; only the tools capability is served.
  • OAuth-based client authentication; V1 uses static API keys (ADR-0003).
  • Per-tenant container or process isolation of downstreams.

Contributing

See CONTRIBUTING.md for the development workflow and the checks every pull request must pass. Security issues should be reported privately as described in SECURITY.md.

License

Licensed under the Apache License 2.0.

About

Security gateway for the Model Context Protocol (MCP)

Topics

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages