mcpgw

package
v0.5.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 7, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

Documentation

Overview

Package mcpgw — gateway-level audit + SSE wiring stubs.

This file is the documented-but-deferred home of the gateway-level audit and SSE wiring. v0.5 ships the bridge's `AuditEmit` and `SSEEmit` hooks (see bridge.go) but leaves them nil-by-default.

Why deferred: the v0.5 value proposition for the MCP Gateway is wire-level enforcement of tool calls. Every `tools/call` already produces a tool-call-level audit entry + SSE event via the central server's `/v1/check` flow (the gate stamps `meta["transport"] = "mcp_gateway"`, A19's transport tag plumbs it to disk and the SSE bus). That covers the operator-visible behaviour the dashboard needs.

The remaining gap — gateway-level events that AREN'T tool calls (upstream subprocess crashed, malformed JSON-RPC frame from host, gateway startup failure) — is a SEPARATE operator-monitoring concern. Implementing it cleanly needs a new central-server endpoint (see options below) and that's a v0.6 surface-area decision, not a v0.5 ship-blocker.

Three options were considered for v0.5:

(a) New endpoint /v1/audit/append on the central server that
    accepts a pre-built audit.Entry. Auth-gated. Bumps API
    surface area; needs a separate rate-limit posture; arguably
    a "write port" the dashboard token shouldn't carry.

(b) Gateway POSTs a synthetic /v1/check with scope:
    "mcp_gateway:event" and meta.event_type set. Reuses
    existing audit infrastructure. Adds a synthetic policy
    decision (always ALLOW) that's noisy in /metrics —
    operator alerting on agentguard_denied_total would silently
    sample over real denies + gateway events.

(c) Defer to v0.6.

Decision: (c). Rationale captured in .audit/v05_decisions.md ("MCP gateway-level audit emission").

What v0.5 ships in this file: nothing functional. Defining the types here would imply a stable API the v0.6 endpoint must conform to; better to design endpoint + wiring together when we know what shape operators need (e.g. is "upstream crashed" a deny-class alerting signal or a separate gauge?).

What an operator sees today for gateway-level health:

  • The central server's /v1/health returns warnings populated by the existing traffic / policy-load probes (see Phase 2 A10).
  • The gateway's stderr log carries upstream-crash / fail-mode decisions; operators with a log aggregator can alert on those.
  • /v1/audit + the dashboard show every tool-call-level event, including synthetic deny:gateway:fail_closed entries when the gate falls back due to /v1/check unreachable.

TODO(v0.6, #mcp-gateway-events): operator-grade gateway-level audit endpoint. Owner: orchestrator-decided; current best guess is a small `/v1/operator/event` endpoint scoped to `notify`-class events (degraded upstream, frame error, startup failure) with its own retention + auth posture distinct from the policy-decision audit log.

Package mcpgw implements the AgentGuard MCP Gateway: a stdio JSON-RPC bridge that sits between an MCP host (Claude Desktop, Cursor, IDE plugins) and one or more downstream MCP servers, gating every tools/call through the AgentGuard policy engine.

This file owns the wire-format types: JSON-RPC 2.0 envelopes, the MCP-specific request/result shapes for the methods the gateway dispatches (initialize, tools/list, tools/call, ping, notifications), and the protocol-version negotiation helper.

The types are intentionally minimal — only the fields the gateway actually inspects are typed. Everything else is preserved as json.RawMessage so the gateway can forward upstream payloads (descriptions, input schemas, content blocks) verbatim without shape-coupling to every downstream MCP server's quirks.

Index

Constants

View Source
const (
	FailModeRuleClosed      = "deny:gateway:fail_closed"
	FailModeRuleClosedAudit = "deny:gateway:fail_closed_audit"
	FailModeRuleOpen        = "allow:gateway:fail_open"
)

FailModeRuleClosed and FailModeRuleClosedAudit are the synthetic Rule strings the gate stamps on fail-closed denials so operators can alert on them without confusing them with a real policy DENY. Stable string contracts — referenced from dashboard + tests.

FailModeRuleClosedAudit fires on `--fail-mode fail-closed-with-audit` so dashboards can break out the two failure modes; v0.5 surfaces the failure via metrics + stderr only. v0.6 will emit a local audit entry from the gateway side. See TODO(v0.6, #fail-closed-with-audit-local-emit) in failModeDecision.

View Source
const (
	MethodInitialize        = "initialize"
	MethodToolsList         = "tools/list"
	MethodToolsCall         = "tools/call"
	MethodPing              = "ping"
	MethodLoggingSetLevel   = "logging/setLevel"
	NotificationInitialized = "notifications/initialized"
	NotificationCancelled   = "notifications/cancelled"
)

MCP method names the gateway routes. Anything not in this set is returned as method-not-found except notifications, which are broadcast to all upstreams (best-effort, fire-and-forget).

View Source
const (
	ErrCodeParseError     = -32700
	ErrCodeInvalidRequest = -32600
	ErrCodeMethodNotFound = -32601
	ErrCodeInvalidParams  = -32602
	ErrCodeInternalError  = -32603
)

JSON-RPC 2.0 reserved error codes (per the spec).

View Source
const (
	// ErrCodeUpstreamUnavail is returned when the namespace's upstream
	// is degraded (subprocess crashed, awaiting reconnect).
	ErrCodeUpstreamUnavail = -32002

	// ErrCodePolicyDeny / ErrCodePolicyApproval are reserved for the
	// JSON-RPC error path. The bridge prefers the tool-error path
	// (isError: true content block) for the actual deny/approval
	// responses; these codes exist so error.data is well-typed when
	// the bridge does need to surface a deny at the JSON-RPC layer
	// (e.g., a malformed tools/call that policy refuses before any
	// upstream sees it).
	ErrCodePolicyDeny     = -32000
	ErrCodePolicyApproval = -32001
)

AgentGuard server-defined error codes. JSON-RPC 2.0 reserves -32000..-32099 for server-defined errors. Per docs/MCP_GATEWAY.md § 11, denial and approval-required are surfaced as tool execution errors (`isError: true`) at the application layer, not as JSON-RPC protocol errors. These codes still exist for transport-level failures (upstream unavailable) and as a typed alternative the bridge can fall back to when a tool call cannot even reach the upstream.

View Source
const (
	StatusStarting = "starting"
	StatusOK       = "ok"
	StatusDegraded = "degraded"
	StatusStopped  = "stopped"
)

Upstream status strings. Returned by Upstream.Status() so the bridge / health endpoint can chip the namespace's state without caring about the implementation details.

View Source
const DefaultGuardHTTPTimeout = 5 * time.Second

DefaultGuardHTTPTimeout is the per-/v1/check-call timeout the gate applies when the operator does not pass a custom http.Client. Five seconds matches the SDKs and the value documented in docs/PROXY_ARCHITECTURE.md § 6.1.

View Source
const GatewayServerName = "agentguard-mcp-gateway"

GatewayServerName is the ServerInfo.name advertised on `initialize`. The gateway never impersonates a downstream — its identity is always agentguard-mcp-gateway.

View Source
const JSONRPCVersion = "2.0"

JSONRPCVersion is the only JSON-RPC version the gateway speaks. Every Request/Response/Notification must carry this exact string.

View Source
const MaxStdoutLineBytes = 4 * 1024 * 1024

MaxStdoutLineBytes bumps bufio.Scanner's per-line cap from the 64 KiB default to 4 MiB so tool argument JSON (legitimately large for some upstream responses, e.g., a filesystem read returning a big file) does not silently truncate.

View Source
const MetaApprovalIDKey = "dev.agentguard/approval_id"

MetaApprovalIDKey is the reserved `_meta` key MCP clients use to echo an AgentGuard approval id back to the gateway on retry. The reverse-DNS prefix `dev.agentguard/` follows the MCP `2025-11-25` `_meta` rules and avoids the reserved `io.modelcontextprotocol/` and `dev.mcp/` prefixes (per docs/MCP_GATEWAY.md § 6.2).

View Source
const MetaPrefixAgentGuard = "dev.agentguard/"

MetaPrefixAgentGuard is the namespace the bridge strips out of `_meta` before forwarding tools/call to the upstream. Downstream MCP servers should not see the gateway's internal protocol.

Variables

View Source
var DefaultBackoffSchedule = []time.Duration{
	1 * time.Second,
	2 * time.Second,
	5 * time.Second,
	30 * time.Second,
	60 * time.Second,
}

DefaultBackoffSchedule is the reconnect-backoff sequence the supervisor walks after an upstream subprocess exits unexpectedly. Steps progress through the slice; once we hit the last entry, we stay there (cap). Sourced from docs/MCP_GATEWAY.md § 7.

View Source
var DefaultSupportedProtocolVersions = []string{
	"2025-11-25",
	"2025-03-26",
	"2024-11-05",
}

DefaultSupportedProtocolVersions is the set of MCP protocol versions the gateway advertises. Order matters: index 0 is the gateway's preferred (newest) version. NegotiateProtocolVersion picks the highest version in this set that is ≤ the client's requested version, falling through to the gateway's preferred version when the client requested something newer than we know about.

Sourced from docs/MCP_GATEWAY.md § 3.1.

View Source
var ErrPolicyNotLoaded = errors.New("mcpgw: policy snapshot not loaded")

ErrPolicyNotLoaded is returned by gateway-side helpers when no policy snapshot has been wired into the gate yet. Currently unused inside gate.go (the dual-check fall-through handles nil policy gracefully) but exposed so downstream callers (cmd/agentguard-mcp-gateway/main.go) can sentinel-check.

View Source
var GatewayBuildVersion = "dev"

GatewayBuildVersion is overridden via -ldflags by the binary entry point. Default ("dev") is used when the package is built without -ldflags (e.g., go test).

Functions

func MergeCapabilities

func MergeCapabilities(upstreamCaps []map[string]interface{}) map[string]interface{}

MergeCapabilities returns the union of upstream capabilities for advertisement to the client during initialize. v0.5 rules (per docs/MCP_GATEWAY.md § 3.3):

  • `tools` is always advertised; listChanged: false (the gateway does not subscribe to upstream tools/list_changed in v0.5).
  • `logging` is always advertised; gateway forwards logging/setLevel to every upstream.
  • `completions` is not advertised (v0.6 follow-up).

v0.5 limitation: `resources` and `prompts` capabilities are intentionally masked OUT — even when an upstream advertises them, the gateway does NOT expose them to the client because resources/* and prompts/* method routing is deferred to v0.6 (TODO(v0.6, #mcp-resources): forward resources/* + prompts/* methods to the appropriate upstream). Advertising them in v0.5 would mislead the client into showing resources that every read would reject with MethodNotFound (see Bridge.handleNotImplemented).

TODO(v0.6, #mcp-list-changed): forward upstream notifications/tools/list_changed and flip our advertised tools capability to listChanged: true.

func NegotiateProtocolVersion

func NegotiateProtocolVersion(clientRequested string, supported []string) string

NegotiateProtocolVersion returns the protocolVersion to advertise to the client, given what the client requested and what the gateway supports.

Strategy (matches docs/MCP_GATEWAY.md § 3.2):

  • If `clientRequested` is in `supported`, echo it back exactly.
  • If `clientRequested` is newer than every entry in `supported` (i.e., the client knows a version the gateway has never heard of), return the gateway's highest supported version. The MCP lifecycle spec lets the client decide whether that's acceptable.
  • If `clientRequested` is older than every entry in `supported` (i.e., the client predates the gateway's lowest known version), return the empty string — the caller MUST treat that as "negotiation failed" and respond with -32602.
  • If `clientRequested` is between two entries in `supported` (e.g., gateway knows 2024-11-05 and 2025-11-25; client asks for 2025-03-26 but gateway doesn't list it), return the highest supported version that is ≤ clientRequested.

Versions are compared as opaque date strings ordered lexically. The MCP spec guarantees YYYY-MM-DD format which sorts correctly.

`supported` is treated read-only; pass DefaultSupportedProtocolVersions for the production set or a custom slice for tests.

func Run

func Run(ctx context.Context, cfg *Config, in io.Reader, out io.Writer, errLog io.Writer) error

Run is the package-level convenience entry point invoked by cmd/agentguard-mcp-gateway/main.go. Wires the bridge against the supplied stdio handles and returns when the bridge's main loop exits.

func SplitCommandLine

func SplitCommandLine(cmd string) ([]string, error)

SplitCommandLine performs a small shell-style tokenization of `cmd` suitable for exec.Command. It supports double-quoted segments (with `\"` escaping) and ignores tabs/spaces between tokens. It does NOT support backtick substitution, $VAR expansion, redirection, pipes, or single-quoted strings — operators who need those should pre-shell the command (e.g., wrap in `sh -c "..."`).

Returned slice is never empty unless input is empty.

Types

type AuditEntry

type AuditEntry struct {
	Timestamp  time.Time
	AgentID    string
	SessionID  string
	TenantID   string
	Scope      string
	Command    string
	Path       string
	Domain     string
	URL        string
	Decision   string
	Rule       string
	Reason     string
	DurationMs float64
	Meta       map[string]interface{}
}

AuditEntry is the bridge-internal shape passed to AuditEmit. A19 translates this into the canonical audit.Entry with Transport="mcp_gateway".

type Bridge

type Bridge struct {

	// PolicyCheck is the hook A18 wires. The default (nil) ALLOWs
	// every tool call — useful for early bring-up before the policy
	// engine is integrated. A18 sets this to a function that:
	//   1. Builds a policy.ActionRequest from the ToolsCallRequest
	//      (scope: "mcp_tool", command: "<ns>:<tool>", agent_id from
	//      clientInfo, etc.).
	//   2. Optionally also dispatches a second Engine.Check against
	//      the mapped scope per the dual-check pattern (governed by
	//      Config.PolicyMode == "strict").
	//   3. Returns a Decision struct.
	//
	// The bridge calls PolicyCheck with the bridge's context so a
	// cancellation propagates into the hook.
	PolicyCheck func(ctx context.Context, req *ToolsCallRequest) (Decision, error)

	// AuditEmit is the hook A19 wires. The default (nil) is a no-op.
	// A19 sets this to a function that writes one audit.Entry via
	// the shared BufferedAsyncLogger with Transport: "mcp_gateway".
	// The hook MUST NOT block the request hot path — A19 is expected
	// to fan out to its own goroutines internally.
	AuditEmit func(entry AuditEntry)

	// SSEEmit is the hook A19 wires. The default (nil) is a no-op.
	// A19 sets this to a function that pushes one SSE event into
	// the existing pkg/proxy ApprovalQueue's broadcast channel so
	// the dashboard sees MCP traffic with the `mcp_gateway` chip.
	SSEEmit func(event SSEEvent)
	// contains filtered or unexported fields
}

Bridge is the JSON-RPC orchestrator. It owns the set of upstreams, reads frames from the host's stdin, dispatches them per method, and writes responses to the host's stdout. Policy and audit are implemented as nil-safe hooks so A18 (policy) and A19 (audit/SSE) can wire real implementations without re-engineering the bridge.

func NewBridge

func NewBridge(cfg *Config, logger io.Writer, version string) *Bridge

NewBridge constructs a Bridge from a parsed Config and a logger writer (typically os.Stderr). Upstreams are NOT started until Run() is called.

func (*Bridge) Run

func (b *Bridge) Run(ctx context.Context, in io.Reader, out io.Writer, errLog io.Writer) error

Run is the bridge's main loop. Reads JSON-RPC frames from `in`, dispatches per method, writes responses to `out`. Returns when:

  • ctx is cancelled (graceful shutdown);
  • `in` EOFs (host disconnected);
  • a fatal error occurs (subprocess startup failure with fail-mode=deny).

Run is goroutine-safe at the entry-point level (one goroutine per host) but must not be called concurrently for the same Bridge.

func (*Bridge) SetUpstream

func (b *Bridge) SetUpstream(up Upstream)

SetUpstream wires a custom Upstream into the bridge. Used by tests to inject fakes. Must be called before Run.

type ClientInfo

type ClientInfo struct {
	Name    string `json:"name"`
	Version string `json:"version,omitempty"`
}

ClientInfo identifies the MCP host (Claude Desktop, Cursor, etc.).

type CommandFactory

type CommandFactory func(ctx context.Context, argv []string) (*exec.Cmd, error)

CommandFactory builds an *exec.Cmd from a parsed argv slice. The production factory is execCommand below; tests can swap in a no-network factory that runs a Go test binary with custom args.

type Config

type Config struct {
	// Upstreams is the ordered list of downstream MCP servers the
	// gateway brokers. Repeatable via --upstream. At least one entry
	// is required.
	Upstreams []UpstreamSpec

	// GuardURL is the central AgentGuard server's base URL (the host
	// of /v1/check). Default "http://127.0.0.1:8080".
	GuardURL string

	// APIKey is the bearer token sent to /v1/check. Falls back to the
	// AGENTGUARD_API_KEY env var when the flag is empty. May be empty
	// (the central server runs without auth in that case — a WARNING
	// is logged at startup).
	APIKey string

	// TenantID is the tenant header value. Default "local".
	TenantID string

	// FailMode controls behaviour when /v1/check is unreachable.
	// One of "deny", "allow", "fail-closed-with-audit". Default "deny".
	// Mirrors the SDK fail-mode contract documented in
	// docs/PROXY_ARCHITECTURE.md § 6.1.
	FailMode string

	// PolicyMode is "strict" (dual-check: mcp_tool + mapped scope) or
	// "fast" (single-check: mcp_tool only). Default "strict".
	// docs/MCP_GATEWAY.md § 4.4.3 — A18 wires the actual dual-check;
	// the bridge passes this value through to the policy hook.
	PolicyMode string

	// LogLevel controls stderr verbosity. "info" or "debug". Default
	// "info". A18/A19 may extend this set.
	LogLevel string

	// UpstreamTimeout caps how long the bridge waits for a response
	// from a single upstream Send. Default 30s.
	UpstreamTimeout time.Duration

	// ReconnectCap caps the upper bound on reconnect backoff between
	// upstream restart attempts. Default 60s.
	ReconnectCap time.Duration

	// SupportedProtocolVersions is the set of MCP protocol versions
	// the gateway will accept on `initialize`. Defaults to
	// DefaultSupportedProtocolVersions; populated by ParseConfig so
	// tests can pin a custom set.
	SupportedProtocolVersions []string

	// PolicyPath is the filesystem path to the same policy YAML the
	// central AgentGuard server loads. The gateway opens this file at
	// startup so it can resolve `tool_scope_map` locally for the
	// dual-check pattern (see docs/MCP_GATEWAY.md § 4.4 and gate.go).
	// Required when --policy-mode strict is in effect; tests may leave
	// it empty and supply a *policy.Policy directly via the gate's
	// constructor instead.
	PolicyPath string
}

Config is the parsed CLI/env configuration for one gateway invocation.

All fields are populated by ParseConfig (which honours both flags and env-var fallback). The bridge consumes this struct read-only; every value that influences a hot-path decision is plumbed through the bridge's hooks rather than re-read from the config at request time, so the bridge does not need to lock around config access.

func ParseConfig

func ParseConfig(args []string) (*Config, error)

ParseConfig parses CLI args (without the leading binary name) and returns a Config. Errors are returned for the caller to surface; usage text is written to `errOut` when non-nil and the args contain `--help`.

API-key resolution: the explicit --api-key flag wins over the AGENTGUARD_API_KEY env var (matching the agentguard core CLI).

func ParseConfigWithOutput

func ParseConfigWithOutput(args []string, errOut io.Writer) (*Config, error)

ParseConfigWithOutput is ParseConfig with the usage-output stream pluggable for tests.

type ContentBlock

type ContentBlock struct {
	Type string `json:"type"`
	Text string `json:"text,omitempty"`

	// Extra carries image/resource fields, annotations, etc., so a
	// content block from an upstream round-trips losslessly.
	Extra map[string]json.RawMessage `json:"-"`
}

ContentBlock is one entry in tools/call result.content. The MCP spec defines text/image/resource block types; the gateway only constructs text blocks itself (for policy refusals) but forwards arbitrary upstream blocks verbatim via Extra.

func (ContentBlock) MarshalJSON

func (c ContentBlock) MarshalJSON() ([]byte, error)

MarshalJSON / UnmarshalJSON keep Extra inlined on the wire.

func (*ContentBlock) UnmarshalJSON

func (c *ContentBlock) UnmarshalJSON(data []byte) error

UnmarshalJSON inverts MarshalJSON.

type Decision

type Decision struct {
	Allow       bool
	Reason      string
	Rule        string
	ApprovalID  string // set when REQUIRE_APPROVAL
	ApprovalURL string
	// RequiresApproval is set when the policy engine returned
	// REQUIRE_APPROVAL. The bridge surfaces it as an isError=true
	// content block (per docs/MCP_GATEWAY.md § 6.1) rather than a
	// JSON-RPC error.
	RequiresApproval bool
}

Decision is the verdict returned by PolicyCheck.

type Error

type Error struct {
	Code    int             `json:"code"`
	Message string          `json:"message"`
	Data    json.RawMessage `json:"data,omitempty"`
}

Error is the error object inside a JSON-RPC response.

type HTTPPolicyClient

type HTTPPolicyClient struct {
	GuardURL   string // e.g. "http://127.0.0.1:8080"
	APIKey     string // bearer token; empty if --api-key not set
	TenantID   string // "local" for v0.5
	PolicyMode string // "strict" | "fast"
	FailMode   string // "deny" | "allow" | "fail-closed-with-audit"

	// HTTPClient is reused across calls. Set to a custom client in
	// tests; otherwise the constructor defaults to a 5s-timeout client.
	HTTPClient *http.Client
	// contains filtered or unexported fields
}

HTTPPolicyClient calls the central AgentGuard server's /v1/check endpoint and orchestrates the dual-check pattern. One client per gateway process; the underlying http.Client (and its connection pool) is reused.

Policy is held atomically — the gateway's main.go subscribes to the PolicyProvider's Watch and calls SetPolicy on every reload so the tool_scope_map stays in sync with the central server's view. Reads are lock-free atomic loads on the hot path.

func NewHTTPPolicyClient

func NewHTTPPolicyClient(cfg *Config, pol *policy.Policy) *HTTPPolicyClient

NewHTTPPolicyClient constructs a gate against cfg + an initial policy snapshot. The caller is expected to subscribe to the policy provider's Watch and call SetPolicy on every reload.

func (*HTTPPolicyClient) Check

Check is the function wired into Bridge.PolicyCheck. Runs the dual-check pattern when policy mode is strict; falls through to a single check otherwise.

func (*HTTPPolicyClient) SetPolicy

func (c *HTTPPolicyClient) SetPolicy(pol *policy.Policy)

SetPolicy swaps the cached policy snapshot. Called from the policy provider's Watch callback in main.go. nil is accepted (resets to "no map known"); subsequent dual-check calls fall through to mcp_tool-only resolution until SetPolicy is called with a real policy again.

type InitializeParams

type InitializeParams struct {
	ProtocolVersion string                 `json:"protocolVersion"`
	Capabilities    map[string]interface{} `json:"capabilities,omitempty"`
	ClientInfo      ClientInfo             `json:"clientInfo"`
}

InitializeParams is the params object on `initialize`.

type InitializeResult

type InitializeResult struct {
	ProtocolVersion string                 `json:"protocolVersion"`
	Capabilities    map[string]interface{} `json:"capabilities"`
	ServerInfo      ServerInfo             `json:"serverInfo"`
}

InitializeResult is the result object on `initialize`.

type Notification

type Notification struct {
	JSONRPC string          `json:"jsonrpc"`
	Method  string          `json:"method"`
	Params  json.RawMessage `json:"params,omitempty"`
}

Notification is a JSON-RPC 2.0 notification (no id, no response expected). The MCP spec uses notifications for `initialized`, `cancelled`, log emissions, list-changed signals, etc.

type Request

type Request struct {
	JSONRPC string          `json:"jsonrpc"`
	ID      RequestID       `json:"id,omitempty"`
	Method  string          `json:"method"`
	Params  json.RawMessage `json:"params,omitempty"`
}

Request is a JSON-RPC 2.0 request envelope.

type RequestID

type RequestID = interface{}

RequestID is the JSON-RPC id field. Per the spec it MAY be a string, integer, or null. We carry it as an opaque interface{} so we can echo back exactly what the client sent without coercing types.

Note: notifications (no `id` field at all) are represented by the Notification struct, not by Request{ID: nil}.

type Response

type Response struct {
	JSONRPC string          `json:"jsonrpc"`
	ID      RequestID       `json:"id"`
	Result  json.RawMessage `json:"result,omitempty"`
	Error   *Error          `json:"error,omitempty"`
}

Response is a JSON-RPC 2.0 response envelope. Exactly one of Result or Error must be set; both are optional in JSON encoding so callers control which path is taken.

func NewResponseError

func NewResponseError(id RequestID, code int, message string, data json.RawMessage) *Response

NewResponseError builds an error response.

func NewResponseFrom

func NewResponseFrom(id RequestID, result interface{}) *Response

NewResponseFrom marshals the given Go value as the response result. Returns an error response if marshalling fails (which would indicate a programmer bug — every result we emit is composed of stdlib types and json.RawMessage forwarded from upstreams, so json.Marshal cannot fail in practice).

func NewResponseResult

func NewResponseResult(id RequestID, result json.RawMessage) *Response

NewResponseResult builds a successful response with the given result payload pre-marshalled into json.RawMessage. Callers that want to pass a typed Go struct should use NewResponseFrom.

type SSEEvent

type SSEEvent struct {
	Type      string // "check" | "denied" | "approval_required"
	Timestamp time.Time
	AgentID   string
	Decision  string
	Scope     string
	Command   string
	Meta      map[string]interface{}
}

SSEEvent is the bridge-internal shape passed to SSEEmit. A19 translates this into the broadcast channel the dashboard reads.

type ServerInfo

type ServerInfo struct {
	Name    string `json:"name"`
	Version string `json:"version"`
}

ServerInfo identifies the gateway (or, when received from an upstream, that upstream). The gateway's own ServerInfo.Name is always "agentguard-mcp-gateway" — we do not impersonate downstreams.

type StdioUpstream

type StdioUpstream struct {
	// contains filtered or unexported fields
}

StdioUpstream is the production Upstream impl: spawns the configured command, talks newline-delimited JSON-RPC over stdin/stdout, logs stderr to the gateway's logger, and reconnects on subprocess exit.

func NewStdioUpstream

func NewStdioUpstream(spec UpstreamSpec) *StdioUpstream

NewStdioUpstream constructs a StdioUpstream that runs the configured command. The subprocess is NOT started until Start() is called.

func NewStdioUpstreamWithOptions

func NewStdioUpstreamWithOptions(spec UpstreamSpec, opts StdioUpstreamOptions) *StdioUpstream

NewStdioUpstreamWithOptions is NewStdioUpstream with all knobs exposed. Pass a zero StdioUpstreamOptions to get the production defaults.

func (*StdioUpstream) Close

func (u *StdioUpstream) Close() error

Close terminates the upstream. Idempotent. After Close, Send / Notify return errors immediately.

Concurrency: Close MUST NOT call cmd.Wait() — the supervisor goroutine already owns that call. We close stdin (so a well-behaved MCP server exits cleanly), then wait on procExited (closed by the supervisor when its Wait() returns). On timeout we Kill() the process so the supervisor's Wait() returns and unblocks us.

func (*StdioUpstream) Initialize

func (u *StdioUpstream) Initialize(ctx context.Context, protocolVersion string, clientCaps map[string]interface{}, clientInfo ClientInfo) (*InitializeResult, error)

Initialize sends `initialize` to the upstream and caches the negotiated state for reconnect.

func (*StdioUpstream) Namespace

func (u *StdioUpstream) Namespace() string

Namespace returns the upstream's namespace label.

func (*StdioUpstream) Notify

func (u *StdioUpstream) Notify(ctx context.Context, n *Notification) error

Notify sends a one-way notification.

func (*StdioUpstream) Send

func (u *StdioUpstream) Send(ctx context.Context, req *Request) (*Response, error)

Send dispatches a JSON-RPC request and waits for the matching response. Replaces the caller's req.ID with a gateway-internal id to avoid collisions with the host's id space.

func (*StdioUpstream) Start

func (u *StdioUpstream) Start(ctx context.Context) error

Start spawns the subprocess for the first time and launches the reader/supervisor goroutines. Returns when the process is up and stdin/stdout are wired (BUT before Initialize runs — the bridge drives Initialize itself so it can capture the negotiated protocol version and forward it to the host).

func (*StdioUpstream) Status

func (u *StdioUpstream) Status() string

Status returns the current status string under a read lock.

type StdioUpstreamOptions

type StdioUpstreamOptions struct {
	Backoff        []time.Duration
	CommandFactory CommandFactory
	Logger         *transportLogger
}

StdioUpstreamOptions configures non-default StdioUpstream behaviour (mainly for tests).

type ToolDescriptor

type ToolDescriptor struct {
	Name        string                 `json:"name"`
	Description string                 `json:"description,omitempty"`
	InputSchema map[string]interface{} `json:"inputSchema,omitempty"`

	// Annotations / outputSchema / future fields are preserved so the
	// gateway forwards the descriptor verbatim. We marshal/unmarshal
	// through a custom helper to keep this clean — see toolDescriptorFromRaw.
	Extra map[string]json.RawMessage `json:"-"`
}

ToolDescriptor is one entry in `tools/list` result.tools. Fields outside Name/Description/InputSchema are passed through verbatim inside Extra so we don't shape-couple to downstream MCP server quirks (e.g., experimental schema annotations).

func (ToolDescriptor) MarshalJSON

func (t ToolDescriptor) MarshalJSON() ([]byte, error)

MarshalJSON re-merges Extra into the wire output. We can't use the default struct encoding because Extra needs to be inlined.

func (*ToolDescriptor) UnmarshalJSON

func (t *ToolDescriptor) UnmarshalJSON(data []byte) error

UnmarshalJSON inverts MarshalJSON. Stashes unknown keys in Extra so they round-trip when we re-emit the descriptor with a prefixed Name.

type ToolsCallParams

type ToolsCallParams struct {
	Name      string                 `json:"name"`
	Arguments map[string]interface{} `json:"arguments,omitempty"`
	Meta      map[string]interface{} `json:"_meta,omitempty"`
}

ToolsCallParams is the params object on `tools/call`.

type ToolsCallRequest

type ToolsCallRequest struct {
	Namespace  string                 // resolved from the prefixed name
	ToolName   string                 // un-prefixed name as the upstream sees it
	FullName   string                 // the prefixed name as the host sent it
	Arguments  map[string]interface{} // tool arguments verbatim
	Meta       map[string]interface{} // _meta with `dev.agentguard/*` keys preserved
	TenantID   string                 // from cfg.TenantID
	AgentID    string                 // synthesised from ClientInfo.Name
	SessionID  string                 // session-scoped key (clientInfo + pid hint)
	ApprovalID string                 // populated from _meta.dev.agentguard/approval_id if present
}

ToolsCallRequest is the bridge-internal shape passed to PolicyCheck. A18 reads this, calls Engine.Check, returns Decision.

type ToolsCallResult

type ToolsCallResult struct {
	Content []ContentBlock `json:"content"`
	IsError bool           `json:"isError,omitempty"`
}

ToolsCallResult is the result object on `tools/call`. Per the MCP spec, tool execution errors are reported with isError=true and a content block describing the failure (NOT a JSON-RPC error). The gateway uses this shape for policy denials and approval-required responses.

type ToolsListResult

type ToolsListResult struct {
	Tools      []ToolDescriptor       `json:"tools"`
	NextCursor string                 `json:"nextCursor,omitempty"`
	Meta       map[string]interface{} `json:"_meta,omitempty"`
}

ToolsListResult is the `result` of a tools/list response.

type Upstream

type Upstream interface {
	// Namespace returns the namespace label this upstream answers to
	// (e.g. "fs", "github").
	Namespace() string

	// Status returns one of the Status* constants.
	Status() string

	// Initialize handshakes the protocol version + capabilities with
	// the upstream. Called by the supervisor immediately after the
	// subprocess starts (and again on every reconnect). The
	// `clientCaps` arg is the host's capabilities, forwarded verbatim
	// per docs/MCP_GATEWAY.md § 3.2 step 3.
	Initialize(ctx context.Context, protocolVersion string, clientCaps map[string]interface{}, clientInfo ClientInfo) (*InitializeResult, error)

	// Send dispatches a request to the upstream and waits for its
	// matching response. The caller-supplied `req.ID` is replaced
	// with a gateway-internal id (the upstream's id space is per
	// connection); the original id is preserved so the bridge can
	// surface it back to the host. Honors ctx for cancellation.
	Send(ctx context.Context, req *Request) (*Response, error)

	// Notify dispatches a one-way notification (no response). The
	// upstream MUST NOT reply.
	Notify(ctx context.Context, n *Notification) error

	// Close terminates the upstream gracefully. Idempotent.
	Close() error
}

Upstream is the gateway-side handle to one downstream MCP server. Implementations own the subprocess lifetime, manage reconnect, and offer a request/notification API to the bridge. Only stdio is supported in v0.5; HTTP transport is reserved for v0.6.

type UpstreamSpec

type UpstreamSpec struct {
	Namespace string // e.g. "fs", "github"
	Command   string // raw command string, shell-tokenized at start time

	// Transport is reserved for future use. v0.5 always uses stdio.
	Transport string
}

UpstreamSpec describes one downstream MCP subprocess.

The Command field is the raw command string that the transport layer splits via a small shell-style tokenizer (see SplitCommandLine). For v0.5 only stdio is supported; the (currently unused) Transport field is reserved for v0.6 when Streamable-HTTP transport ships.

TODO(v0.6, #mcp-streamable-http): add Transport == "http" with a URL field, paired with a different Upstream impl in transport.go.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL