Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
66bee8e
fix(mcp): send mirrored headers from the stdio-to-HTTP bridge
luckyPipewrench Oct 26, 2026
ac4bf2c
fix(mcp): keep header-contract receipts verifiable
luckyPipewrench Oct 26, 2026
b2fe980
fix(mcp): don't release holds on a structural-only inventory
luckyPipewrench Oct 26, 2026
49e1361
docs(mcp): note bridge list exclusion without tool scanning
luckyPipewrench Oct 26, 2026
e8ea51a
fix(mcp): keep bridge header metadata on modern sessions
luckyPipewrench Oct 26, 2026
748f124
fix(mcp): preserve session state during private discovery
luckyPipewrench Oct 26, 2026
e5bc2e6
fix(mcp): refuse routed notifications on modern bridge sessions
luckyPipewrench Oct 26, 2026
49d38b4
docs(mcp): note the bridge refuses routed methods sent as notifications
luckyPipewrench Oct 26, 2026
dbae903
fix(mcp): preserve initialized bridge header contracts
luckyPipewrench Oct 26, 2026
e00821f
fix(mcp): stop held releases before ending the bridge session
luckyPipewrench Oct 26, 2026
6793648
fix(mcp): keep the bridge's negotiated modern version
luckyPipewrench Oct 27, 2026
3a5e3ab
docs(mcp): note the bridge keeps its negotiated revision
luckyPipewrench Oct 27, 2026
001e730
test(mcp): assert shutdown sends no held call
luckyPipewrench Oct 27, 2026
1969a71
test(mcp): check the bridge result after cancellation
luckyPipewrench Oct 27, 2026
da8e69d
test(mcp): cover bridge list timeout, empty tool names and spent budgets
luckyPipewrench Oct 27, 2026
87f88c6
fix(mcp): refuse malformed modern protocol versions on the bridge
luckyPipewrench Oct 27, 2026
bdc2edd
fix(mcp): refuse any malformed per-request protocol version on the br…
luckyPipewrench Oct 27, 2026
aa7677c
Merge remote-tracking branch 'origin/main' into fix/mcp-bridge-param-…
luckyPipewrench Oct 27, 2026
13f9c5f
fix(mcp): close a waiting tools/list and shut down cleanly on cancell…
luckyPipewrench Oct 27, 2026
e61d69b
test(mcp): close stdin with cancellation in the list-lock test
luckyPipewrench Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1977,6 +1977,10 @@ Protocol revision `2026-07-28` does not use `initialize` or protocol sessions. E

Anonymous current-protocol listener calls are request-local only. Cross-request taint protection is **not active**, even when taint is enabled and a session store exists. A later call cannot use exposure history from an earlier anonymous call. Use verified caller identity and successful cross-request recorder admission for workflows that need that history. The opt-in identity requirement refuses anonymous calls before forwarding; a taint trust override cannot lift that identity refusal. A shared listener bearer gives all its holders one principal, not separate identities. The verifier-backed resolver is an integration interface; this does not imply a built-in OAuth or mTLS verifier.

The stdio-to-HTTP bridge (`pipelock mcp proxy --upstream URL`) constructs `Mcp-Method`, the applicable `Mcp-Name`, and schema-selected `Mcp-Param-*` headers for protocol revision `2026-07-28` and later. It uses the final forwarded arguments, including any redaction, and the same property paths and value encoding as the HTTP listener. Null or absent arguments omit their mirrors. Body-derived headers replace operator-configured routing and parameter headers, so static values can't override them. This synthesis applies to JSON-RPC requests, including deferred calls at release; notifications and responses don't receive routing or parameter headers. MCP defines `tools/call`, `resources/read` and `prompts/get` only as requests, so the bridge refuses one sent without an `id` instead of forwarding it without its headers. Once the bridge negotiates `2026-07-28` or later through `initialize`, it keeps that revision for the life of the process and refuses a second `initialize` with the same JSON-RPC error. Earlier negotiated protocol revisions keep their existing behavior.

The bridge uses its per-process tool-contract registry and privately discovers unknown contracts through its own upstream HTTP client, preserving the upstream credentials and session context. It shares the listener's pagination, combined 10 MiB response budget, 120-second discovery deadline, response inspection, and concurrent-discovery coordination. This private discovery doesn't mark tools client-seen, adopt definition drift, approve held actions, or ask for operator approval. An invalid contract, discovery failure, or argument that can't be mirrored refuses the call locally with a JSON-RPC error; no upstream tool call is sent. With `require_receipts` enabled, a blocked outcome receipt at `mcp_header_contract` closes the call's allow intent, as on the HTTP listener. A deferred call refused at release is closed by its resolution receipt, with `resolution_source: "upstream_contract"` and pattern `tool_header_contract_unavailable`. When the operator configured no tool scanning, the bridge still collects current-protocol `tools/list` structure for header synthesis, so it excludes definitions with invalid `x-mcp-header` contracts from those lists as described below. That structural inventory is never the confirmed inventory that `allow_on.tool_inventory_baseline` waits for. No unchecked fallback or automatic tool-call replay occurs. See [MCP Request Metadata](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#request-metadata) for the routing, encoding, and null-omission rules.

Current HTTP requests carry `Mcp-Method` and, where applicable, `Mcp-Name` plus schema-selected `Mcp-Param-*` mirrors. Pipelock checks the standard routing headers against the body before forwarding. When tool scanning accepts a `tools/list` response for an authenticated principal, Pipelock records valid `x-mcp-header` contracts and checks each recognized parameter mirror against the exact body argument path. Missing, duplicate, malformed, or mismatched recognized mirrors fail locally.

When MCP tool scanning or session binding is configured, current-protocol HTTP lists exclude definitions with invalid `x-mcp-header` contracts and keep valid siblings and pagination metadata. A schema with no `x-mcp-header` annotation is never excluded for this reason. Pipelock reads an annotated parameter's type from the property itself: a `type` (optionally with `null`), an `enum` or `const`, or an `anyOf`/`oneOf` of one type plus `null`. It doesn't resolve `$ref`, so a tool whose annotated parameter gets its type only through a reference, such as an enum stored under `$defs`, is excluded; declaring the type on the property keeps it. Pipelock remembers each rejection for the authenticated principal whose list carried it, so that principal's calls naming the rejected tool fail locally until a later list carries a corrected definition. Anonymous current-protocol requests are request-local, so an anonymous call naming a rejected tool is handled like a call for a tool this proxy hasn't seen. A structurally valid definition forwarded under warn policy supplies its exact current bindings, so a scan warning doesn't switch off its header check; drift adoption follows the ordinary warn-policy rules. An excluded definition never becomes the drift baseline, because the agent never receives it.
Expand Down
133 changes: 133 additions & 0 deletions internal/mcp/mcp_http_bridge_evidence_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Copyright 2026 Pipelock contributors
// SPDX-License-Identifier: Apache-2.0

package mcp

import (
"encoding/hex"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"sync/atomic"
"testing"
"time"

"github.com/luckyPipewrench/pipelock/internal/config"
"github.com/luckyPipewrench/pipelock/internal/evidence/completeness"
"github.com/luckyPipewrench/pipelock/internal/receipt"
"github.com/luckyPipewrench/pipelock/internal/testwait"
)

// openReceiptHarness starts a lifecycle-complete chain, as a running proxy
// does, so completeness analysis applies its intent/outcome pairing rules.
func openReceiptHarness(t *testing.T, opts *MCPProxyOpts, required bool) *mcpDecisionReceiptHarness {
t.Helper()
h := newMCPDecisionReceiptHarness(t)
if err := h.v1.EmitSessionOpen(); err != nil {
t.Fatal(err)
}
opts.ReceiptEmitter, opts.V2ReceiptEmitter, opts.PolicyHash, opts.RequireReceipts = h.v1, h.v2, mcpTestPolicyHash, required
return h
}

func requireEvidenceNotBroken(t *testing.T, label string, h *mcpDecisionReceiptHarness) {
t.Helper()
if err := h.v1.EmitSessionClose("test"); err != nil {
t.Fatal(err)
}
if err := h.rec.Close(); err != nil {
t.Fatal(err)
}
receipts := readActionReceipts(t, h.dir)
chain := receipt.VerifyChain(receipts, hex.EncodeToString(h.pub))
if !chain.Valid {
t.Fatalf("%s chain invalid: %s", label, chain.Error)
}
report := completeness.Analyze(receipts, chain)
if report.Status == completeness.StatusBroken {
for _, r := range receipts {
a := r.ActionRecord
t.Logf("%s seq=%d action=%s phase=%q verdict=%s layer=%s", label, a.ChainSeq, a.ActionID, a.DecisionPhase, a.Verdict, a.Layer)
}
t.Fatalf("%s evidence reported broken: %s", label, report.Error)
}
}

func TestHTTPBridgeHeaderEvidenceCompleteness(t *testing.T) {
for _, required := range []bool{false, true} {
t.Run(fmt.Sprintf("forwarded/required=%t", required), func(t *testing.T) {
var calls atomic.Int32
up := bridgeHeaderUpstream(t, func(w http.ResponseWriter, _ *http.Request, f MCPFrame) {
discoveryList(w, f, headerStateDefs(bridgeHeaderSchema, "Echo input"), "")
}, func(w http.ResponseWriter, _ *http.Request, f MCPFrame) {
calls.Add(1)
_, _ = fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%s,"result":{"content":[]}}`, f.ID)
})
listenerOpts := paramContractOpts(t)
listener := openReceiptHarness(t, &listenerOpts, required)
listenerURL, _ := startListenerProxyWithOpts(t, up.URL, listenerOpts)
bridgeOpts := bridgeHeaderOpts(t)
bridge := openReceiptHarness(t, &bridgeOpts, required)
headers := http.Header{"Proxy-Authorization": {"Bearer current-client-token"}}
out, logs := runBridgeHeaderInput(t, listenerURL, bridgeHeaderCall(`{"region":"body"}`), headers, bridgeOpts)
if calls.Load() != 1 || strings.Contains(out, `"error"`) {
t.Fatalf("calls=%d out=%s logs=%s", calls.Load(), out, logs)
}
requireEvidenceNotBroken(t, "bridge", bridge)
requireEvidenceNotBroken(t, "listener", listener)
})
t.Run(fmt.Sprintf("refused/required=%t", required), func(t *testing.T) {
up := bridgeHeaderUpstream(t, func(w http.ResponseWriter, _ *http.Request, f MCPFrame) {
discoveryList(w, f, `[]`, "")
}, func(http.ResponseWriter, *http.Request, MCPFrame) { t.Error("refused call reached upstream") })
opts := bridgeHeaderOpts(t)
h := openReceiptHarness(t, &opts, required)
out, logs := runBridgeHeaderInput(t, up.URL, bridgeHeaderCall(`{"region":"body"}`), nil, opts)
if !strings.Contains(out, `"code":-32020`) {
t.Fatalf("out=%s logs=%s", out, logs)
}
requireEvidenceNotBroken(t, "bridge", h)
})
t.Run(fmt.Sprintf("deferred refused/required=%t", required), func(t *testing.T) {
up := bridgeHeaderUpstream(t, func(w http.ResponseWriter, _ *http.Request, f MCPFrame) {
discoveryList(w, f, `[]`, "")
}, func(http.ResponseWriter, *http.Request, MCPFrame) { t.Error("refused release reached upstream") })
opts := bridgeHeaderOpts(t)
h := openReceiptHarness(t, &opts, required)
manager := newDeferKillManager(t)
opts.DeferManager = manager
policy := deferApprovalPolicy(config.DeferResolverProfile{})
policy.Rules[0].ToolPattern = regexp.MustCompile(`^echo$`)
policy.Rules[0].ResolutionPolicy.ResolverProfile = ""
opts.PolicyCfg = policy
inputR, inputW := io.Pipe()
var out, logs syncBuffer
done := make(chan error, 1)
go func() { done <- RunHTTPProxy(t.Context(), inputR, &out, &logs, up.URL, nil, opts) }()
if _, err := io.WriteString(inputW, bridgeHeaderCall(`{"region":"body"}`)+"\n"); err != nil {
t.Fatal(err)
}
testwait.For(t, 5*time.Second, func() bool { return len(manager.Snapshot()) == 1 }, "held call")
if err := manager.Resolve(manager.Snapshot()[0].DeferID, config.ActionAllow, "approval"); err != nil {
t.Fatal(err)
}
if err := inputW.Close(); err != nil {
t.Fatal(err)
}
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(testwait.Deadline(10 * time.Second)):
t.Fatal("bridge did not finish")
}
if !strings.Contains(out.String(), `"code":-32020`) {
t.Fatalf("out=%s logs=%s", out.String(), logs.String())
}
requireEvidenceNotBroken(t, "bridge", h)
})
}
}
109 changes: 109 additions & 0 deletions internal/mcp/mcp_http_bridge_headers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright 2026 Pipelock contributors
// SPDX-License-Identifier: Apache-2.0

package mcp

import (
"context"
"fmt"
"io"
"net/http"
"sync"

"github.com/luckyPipewrench/pipelock/internal/mcp/transport"
)

// mcpBridgeHeaders joins the listener's contract acquisition/serialization and
// the bridge's per-process tool baseline. Only the contract portion of state is
// used: this bridge's fixed upstream credentials scope its lifetime.
type mcpBridgeHeaders struct {
client *transport.HTTPClient
state *mcpListenerClientState
opts MCPProxyOpts
log io.Writer

mu sync.Mutex
// negotiated keeps the first modern version the transport reports. The
// transport clears its copy on a new handshake and when the session ends,
// and an empty version would read as legacy and send no request metadata.
// Once modern, this bridge stays modern for its lifetime.
negotiated string
}

func (b *mcpBridgeHeaders) version(msg []byte) string {
// A modern per-request version takes precedence over a legacy handshake,
// matching the listener's request metadata contract. Otherwise preserve the
// transport's initialize negotiation, including its legacy fallback. A
// legacy per-request version can't downgrade a modern negotiation, which
// would otherwise drop the request's routing and parameter headers.
if version := ParseMCPFrame(msg).ProtocolVersion; version >= currentMCPVersion {
return version
}
return b.negotiatedVersion()
}

func (b *mcpBridgeHeaders) negotiatedVersion() string {
b.mu.Lock()
defer b.mu.Unlock()
version := b.client.ProtocolVersion()
if version >= currentMCPVersion {
b.negotiated = version
}
if b.negotiated != "" {
return b.negotiated
}
return version
}

func (b *mcpBridgeHeaders) prepare(ctx context.Context, msg []byte) (http.Header, error) {
version := b.version(msg)
if version < currentMCPVersion {
return nil, nil
}
if !validMCPProtocolVersion([]string{version}) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Lexical comparison admits malformed dates such as 2026-99-99; the
// listener accepts only canonical YYYY-MM-DD, so refuse them here
// rather than forward an unusable protocol header.
return nil, fmt.Errorf("invalid MCP protocol version")
}
frame := ParseMCPFrame(msg)
if frame.Method == "initialize" && b.negotiatedVersion() >= currentMCPVersion {
// This process already established its modern header contract. A
// second handshake would clear the transport's negotiated version,
// including while deferred calls can be released. Refuse it before
// that reset rather than let those calls take the legacy path.
return nil, fmt.Errorf("modern bridge is already initialized")
}
if isRPCNotification(frame.ID) {
switch frame.Method {
case methodToolsCall, methodResourcesRead, methodPromptsGet:
// MCP defines these methods only as requests, which must carry
// Mcp-Name and parameter mirrors. A notification form would leave
// unmirrored, so it is refused like an unavailable contract.
return nil, fmt.Errorf("%s cannot be sent as a notification", frame.Method)
}
}
if frame.Method == methodToolsCall {
if frame.RoutingNameErr != nil || frame.RoutingName == "" {
return nil, fmt.Errorf("invalid tool routing name")
}
req := &http.Request{Header: make(http.Header)}
req = req.WithContext(ctx)
req.Header.Set(listenerProtocolVersion, version)
release, err := b.state.acquireToolContract(ctx, frame.RoutingName, func() error {
return discoverMCPToolContract(mcpContractDiscoveryRequest{
request: req, state: b.state, bridge: b.client, opts: b.opts, log: b.log, tool: frame.RoutingName,
})
})
if err != nil {
return nil, fmt.Errorf("cannot establish tool header contract: %w", err)
}
defer release()
}
headers, err := synthesizeMCPRequestHeaders(frame, b.state.baseline)
if err != nil {
return nil, err
}
headers.Set(listenerProtocolVersion, version)
return headers, nil
}
Loading
Loading