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
4 changes: 4 additions & 0 deletions docs/EXTENDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,10 @@ attribution headers `X-AIMLAPI-Source: agent/zero` and `X-AIMLAPI-Partner-ID`,
and gets the key under the rules above. Other MCP servers never receive these
headers or the key.

If the server echoes back the key Zero sent it, Zero replaces the key with
`[REDACTED]` in that server's error messages and tool results before anything
reads them, and reports an HTTP error status without the response body.

### As a server — expose Zero's tools to another agent

```bash
Expand Down
178 changes: 173 additions & 5 deletions internal/mcp/aimlapi.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
package mcp

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"sync"

"github.com/Twigpine/zero/internal/aimlapi"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/redaction"
)

// The AI/ML API MCP server authenticates with the user's AI/ML API key.
Expand All @@ -20,11 +26,17 @@ import (
// config.LoadAIMLAPIServiceKey, which in the same call decides whether it may
// be sent (no proxy profile, a single token), so a provider change applies to
// the next request and a withheld or invalid key means no Authorization
// header. It lives only in that request's headers: never in Server, its
// Identity (OAuth token binding), config.json, list output or errors (an
// error response to such a request is reported without its body). A
// user-configured Authorization header, OAuth, or a project-touched server
// always goes without it.
// header. It lives only in that request's headers, and for the length of the
// call in the collector its context carries (aimlapiSentKeys), so the reply can
// be scrubbed: never in Server, its Identity (OAuth token binding), config.json,
// list output, errors or tool output. Every key the call sent, redirect hops
// included, and on the SSE transport the keys the stream request sent, is
// scrubbed from the response's error text, method and result before anything
// reads them (scrubAIMLAPIKeys) and from any error the call returns, a
// transport error quoting a URL the server chose among them
// (scrubAIMLAPIKeyError). An error status to such a request is also reported
// without its body. A user-configured Authorization header, OAuth, or a
// project-touched server always goes without it.
var loadAIMLAPIServiceKey = config.LoadAIMLAPIServiceKey

var aimlapiMCPHost = func() string {
Expand Down Expand Up @@ -56,6 +68,9 @@ func applyAIMLAPIRequestHeaders(server Server, request *http.Request) {
return
}
request.Header.Set("Authorization", "Bearer "+key)
if sent, ok := request.Context().Value(aimlapiSentKeysContextKey{}).(*aimlapiSentKeys); ok {
sent.add(key)
}
}

// reapplyAIMLAPIRedirectHeaders gives a redirect hop the automatic headers its
Expand Down Expand Up @@ -101,6 +116,159 @@ func hasUserAuthorizationHeader(server Server) bool {
return false
}

// aimlapiSentKeys collects every key applyAIMLAPIRequestHeaders puts on the
// requests of one call. The first request's context carries it, and net/http
// gives each redirect hop that same context, so a key resolved again for a later
// hop is collected too. The final hop's key alone is not enough: a same-origin
// server answering the last hop has seen every earlier one.
type aimlapiSentKeys struct {
mu sync.Mutex
keys []string
}

type aimlapiSentKeysContextKey struct{}

// withAIMLAPISentKeys returns ctx carrying a fresh collector for one call.
func withAIMLAPISentKeys(ctx context.Context) (context.Context, *aimlapiSentKeys) {
sent := &aimlapiSentKeys{}
return context.WithValue(ctx, aimlapiSentKeysContextKey{}, sent), sent
}

func (sent *aimlapiSentKeys) add(key string) {
sent.mu.Lock()
defer sent.mu.Unlock()
for _, existing := range sent.keys {
if existing == key {
return
}
}
sent.keys = append(sent.keys, key)
}

// list returns a copy of the keys collected so far.
func (sent *aimlapiSentKeys) list() []string {
sent.mu.Lock()
defer sent.mu.Unlock()
return append([]string(nil), sent.keys...)
}

// scrubAIMLAPIKeys removes the injected keys from a response before any of it
// is read: the error text, the method name and every string in the result,
// object member names included. A server can echo the credential it was sent,
// and MCP reports a failed tool call as a result rather than an error, so the
// result needs it as much as the error text. Only the known values go, split
// forms included (redaction.RedactSecretValues); a result that holds none of
// them keeps its original bytes.
func scrubAIMLAPIKeys(message rpcMessage, keys ...string) rpcMessage {
var sent []string
for _, key := range keys {
if strings.TrimSpace(key) != "" {
sent = append(sent, key)
}
}
if len(sent) == 0 {
return message
}
if message.Error != nil {
scrubbed := *message.Error
scrubbed.Message = redaction.RedactSecretValues(scrubbed.Message, sent...)
message.Error = &scrubbed
}
message.Method = redaction.RedactSecretValues(message.Method, sent...)
message.Result = scrubAIMLAPIKeysFromJSON(message.Result, sent)
return message
}

func scrubAIMLAPIKeysFromJSON(raw json.RawMessage, keys []string) json.RawMessage {
if len(raw) == 0 {
return raw
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
// Decoding it into the caller's type fails the same way, and that
// error does not quote the text.
return raw
}
scrubbed, changed := scrubAIMLAPIKeysFromValue(value, keys)
if !changed {
return raw
}
encoded, err := json.Marshal(scrubbed)
if err != nil {
// Never hand back the unscrubbed bytes: an empty result reads as
// nothing rather than as the key.
return nil
}
return encoded
}

func scrubAIMLAPIKeysFromValue(value any, keys []string) (any, bool) {
switch typed := value.(type) {
case string:
scrubbed := redaction.RedactSecretValues(typed, keys...)
return scrubbed, scrubbed != typed
case []any:
changed := false
for index, item := range typed {
if scrubbed, itemChanged := scrubAIMLAPIKeysFromValue(item, keys); itemChanged {
typed[index] = scrubbed
changed = true
}
}
return typed, changed
case map[string]any:
changed := false
scrubbed := make(map[string]any, len(typed))
for name, item := range typed {
scrubbedName := redaction.RedactSecretValues(name, keys...)
scrubbedItem, itemChanged := scrubAIMLAPIKeysFromValue(item, keys)
if itemChanged || scrubbedName != name {
changed = true
}
scrubbed[scrubbedName] = scrubbedItem
}
return scrubbed, changed
}
return value, false
}

// scrubAIMLAPIKeyError returns err with the injected keys removed from its text.
// An error the keys do not appear in keeps its chain. One they do appear in is
// replaced by its scrubbed text, because its chain still holds the key (a
// *url.Error keeps the URL the server chose); the replacement unwraps only to
// the context sentinel the original matched, so a cancellation still reads as
// one.
func scrubAIMLAPIKeyError(err error, keys ...string) error {
if err == nil || len(keys) == 0 {
return err
}
text := err.Error()
scrubbed := redaction.RedactSecretValues(text, keys...)
if scrubbed == text {
return err
}
replacement := &aimlapiScrubbedError{text: scrubbed}
for _, sentinel := range []error{context.Canceled, context.DeadlineExceeded} {
if errors.Is(err, sentinel) {
replacement.cause = sentinel
break
}
}
return replacement
}

// aimlapiScrubbedError is an error whose text had an injected key removed.
type aimlapiScrubbedError struct {
text string
cause error
}

func (scrubbed *aimlapiScrubbedError) Error() string { return scrubbed.text }

func (scrubbed *aimlapiScrubbedError) Unwrap() error { return scrubbed.cause }

// aimlapiUnauthorizedHint explains how to authenticate when the AI/ML API MCP
// server rejects a request without OAuth. It names no key material.
func aimlapiUnauthorizedHint(server Server, status int) string {
Expand Down
Loading
Loading