Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Development

- `locksmith mcp run --url` now triggers templated-header resolution
not only on HTTP `401/403` but also when the remote MCP server
returns `200 OK` with a JSON-RPC `error` field or a tool-level
`result.isError: true`. Resolution and retry happen exactly once
per session; if the retry also fails, the error is forwarded to the
AI client unchanged. Detection looks only at structural fields - no
keyword matching against `result.content[].text`.
- docs(cli): drop Touch ID wording from CLI help, README, configuration
and architecture docs, and plugin READMEs; keychain authorization is
described as OS-delegated without naming a specific auth method
Expand Down
14 changes: 14 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,20 @@ sends its first MCP request:
the MCP `initialize` handshake without auth therefore never
trigger a vault prompt for that connection.

The same `resolveOnce` lifecycle also triggers on **body-level errors**:
while the resolver has not yet been invoked, the proxy run loop
inspects each server response. If the response is a JSON-RPC envelope
with a non-empty top-level `error` field, or a `result.isError: true`
tool error, the proxy treats it as an auth-failure signal: it calls
`resolveOnce`, re-sends the original request bytes (keyed by JSON-RPC
`id`), and waits for the response with the same id before forwarding
to the client. Any other responses that arrive during the retry wait
are forwarded as-is. After the first attempt (success or failure), no
further body inspection happens; subsequent errors propagate verbatim.
Free-form text inside `result.content[].text` is never matched against
keywords - only structural signals (`error`, `isError`) trigger the
retry.

Each mode resolves its secrets exactly once - lazily, but not
repeatedly. Subsequent client requests reuse the env vars or HTTP
headers established on the first message.
Expand Down
8 changes: 8 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,14 @@ first request. The auth-deferral is automatic and has no config
knob; servers that do not require auth on the MCP handshake therefore
never trigger a vault prompt for that connection.

The same one-shot resolution also fires on **JSON-RPC body errors**:
if the remote server returns HTTP `200 OK` but the response carries a
JSON-RPC `error` field or a tool-level `result.isError: true`,
locksmith treats it as an auth-failure signal, resolves the templated
headers, and retries the failing request once. If the retry also
fails, the response is forwarded to the AI client unchanged. No
configuration knob - the behaviour is automatic.

### mcp.servers.\<name\>.command

**Required (local mode).** List of strings: executable followed by
Expand Down
66 changes: 66 additions & 0 deletions internal/mcp/auth_state.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package mcp

import (
"context"
"net/http"
"sync"
)

// authState carries the shared auth-header resolution lifecycle between
// the proxy run loop (body-level error trigger) and the transports
// (HTTP 401/403 trigger). Resolution happens at most once per session;
// after the first attempt succeeds or fails, the result is cached and
// the resolver is never invoked again.
type authState struct {
mu sync.Mutex
attempted bool
headers http.Header
resolver HeaderResolver
}

// newAuthState constructs an authState whose resolveOnce invokes
// resolver. A nil resolver makes resolveOnce a no-op that still marks
// the state as attempted (so callers do not loop).
func newAuthState(resolver HeaderResolver) *authState {
return &authState{resolver: resolver}
}

// resolveOnce invokes the resolver under the mutex if it has not been
// invoked yet. After return, Attempted() reports true regardless of
// outcome. If the resolver returns an error, headers remain nil and
// the error is propagated; subsequent calls short-circuit without
// invoking resolver again.
func (s *authState) resolveOnce(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.attempted {
return nil
}
s.attempted = true
if s.resolver == nil {
return nil
}
h, err := s.resolver(ctx)
if err != nil {
return err
}
s.headers = h
return nil
}

// Headers returns the cached headers (may be nil before resolve or
// after a failed resolve). The returned http.Header must not be
// mutated by callers; it is shared across all subsequent requests.
func (s *authState) Headers() http.Header {
s.mu.Lock()
defer s.mu.Unlock()
return s.headers
}

// Attempted reports whether resolveOnce has been called (success or
// failure). After it returns true, no further resolves will occur.
func (s *authState) Attempted() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.attempted
}
105 changes: 105 additions & 0 deletions internal/mcp/auth_state_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package mcp

import (
"context"
"errors"
"net/http"
"sync"
"sync/atomic"
"testing"
)

func TestAuthState_ResolveOnce_Single(t *testing.T) {
var calls atomic.Int32
resolver := func(ctx context.Context) (http.Header, error) {
calls.Add(1)
h := http.Header{}
h.Set("Authorization", "Bearer x")
return h, nil
}
s := newAuthState(resolver)

if s.Attempted() {
t.Fatal("Attempted() = true before first call")
}
if err := s.resolveOnce(context.Background()); err != nil {
t.Fatalf("resolveOnce: %v", err)
}
if calls.Load() != 1 {
t.Errorf("resolver calls = %d, want 1", calls.Load())
}
if !s.Attempted() {
t.Errorf("Attempted() = false after first call")
}
if got := s.Headers().Get("Authorization"); got != "Bearer x" {
t.Errorf("Headers().Get(Authorization) = %q, want %q", got, "Bearer x")
}
}

func TestAuthState_ResolveOnce_Concurrent(t *testing.T) {
var calls atomic.Int32
resolver := func(ctx context.Context) (http.Header, error) {
calls.Add(1)
h := http.Header{}
h.Set("X", "y")
return h, nil
}
s := newAuthState(resolver)

const numGoroutines = 16
var wg sync.WaitGroup
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func() {
defer wg.Done()
_ = s.resolveOnce(context.Background())
}()
}
wg.Wait()

if got := calls.Load(); got != 1 {
t.Errorf("resolver calls = %d, want 1", got)
}
if got := s.Headers().Get("X"); got != "y" {
t.Errorf("Headers().Get(X) = %q, want %q", got, "y")
}
}

func TestAuthState_ResolveOnce_ResolverFails(t *testing.T) {
var calls atomic.Int32
want := errors.New("boom")
resolver := func(ctx context.Context) (http.Header, error) {
calls.Add(1)
return nil, want
}
s := newAuthState(resolver)

if err := s.resolveOnce(context.Background()); !errors.Is(err, want) {
t.Errorf("resolveOnce err = %v, want errors.Is(_, boom)", err)
}
if !s.Attempted() {
t.Errorf("Attempted() = false after failed call")
}
if s.Headers() != nil {
t.Errorf("Headers() != nil after failed call")
}
if err := s.resolveOnce(context.Background()); err != nil {
t.Errorf("second resolveOnce err = %v, want nil", err)
}
if got := calls.Load(); got != 1 {
t.Errorf("resolver calls = %d, want 1", got)
}
}

func TestAuthState_NilResolver(t *testing.T) {
s := newAuthState(nil)
if err := s.resolveOnce(context.Background()); err != nil {
t.Errorf("resolveOnce with nil resolver: %v", err)
}
if !s.Attempted() {
t.Errorf("Attempted() = false")
}
if s.Headers() != nil {
t.Errorf("Headers() != nil")
}
}
56 changes: 56 additions & 0 deletions internal/mcp/jsonrpc_error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package mcp

import (
"bytes"
"encoding/json"
)

// rpcEnvelope is the minimal JSON-RPC 2.0 response shape needed to
// classify whether a response carries an error worth one retry.
type rpcEnvelope struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Error json.RawMessage `json:"error,omitempty"`
Result *struct {
IsError bool `json:"isError,omitempty"`
} `json:"result,omitempty"`
}

// inspectResponse reports whether raw looks like a JSON-RPC response that
// indicates an error worth retrying once with resolved auth headers.
// Returns false for parse failures, notifications, id:null responses,
// non-JSON-RPC payloads, and any response without an error indicator.
func inspectResponse(raw []byte) bool {
var env rpcEnvelope
if err := json.Unmarshal(raw, &env); err != nil {
return false
}
if env.JSONRPC != "2.0" {
return false
}
if len(env.ID) == 0 || bytes.Equal(env.ID, []byte("null")) {
return false
}
if len(env.Error) > 0 {
return true
}
if env.Result != nil && env.Result.IsError {
return true
}
return false
}

// extractID returns the raw JSON bytes of the response's id field as a
// string, or "" if the id is missing or literal null. The string is
// suitable as a map key: two responses share the same logical id iff
// their extractID values are equal.
func extractID(raw []byte) string {
var env rpcEnvelope
if err := json.Unmarshal(raw, &env); err != nil {
return ""
}
if len(env.ID) == 0 || bytes.Equal(env.ID, []byte("null")) {
return ""
}
return string(env.ID)
}
Loading
Loading