Skip to content
Merged
17 changes: 16 additions & 1 deletion .opencode/plugin/mcpx/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,21 @@ const withTimeout = (ms: number): RequestInit => {
}
}

/**
* #1975: attach the daemon Bearer [REDACTED] when one is configured
* (MCPX_TRANSPORT_BEARER_TOKEN, the transport.bearerToken setting
* env-sourced). Unset, the init passes through untouched, so plugin calls
* are byte-for-byte what they were before the setting existed. Read at
* call time so tests can set and clear the variable around a call.
*/
export const withDaemonAuth = (init: RequestInit): RequestInit => {
const tok = (globalThis as any).process?.env?.MCPX_TRANSPORT_BEARER_TOKEN ?? ""
if (!tok) return init
const headers = new Headers(init.headers as any)
headers.set("Authorization", `Bearer ${tok}`)
return { ...init, headers }
}

/**
* A client that reuses its connection.
*
Expand Down Expand Up @@ -414,7 +429,7 @@ export class DaemonClient {

/** The raw response, for the few callers that need the status code. */
async raw(path: string, init: RequestInit = {}): Promise<Response> {
return fetch(this.url(path), this.init(init))
return fetch(this.url(path), this.init(withDaemonAuth(init)))
}

async get<T>(path: string, timeoutMs?: number): Promise<T> {
Expand Down
1 change: 1 addition & 0 deletions pkgs/mcpx/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,7 @@ and a switch to turn the report off would have kept it quiet.
| setting | kind | default | scope | hot | flag | variable | governs |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `transport.allowedOrigins` | list | *(empty)* | daemon | no | `--transport-allowed-origins` | `MCPX_TRANSPORT_ALLOWED_ORIGINS` | browser Origins, beyond loopback, that may reach /mcp |
| `transport.bearerToken` | string | *(empty)* | daemon | no | `--transport-bearer-token` | `MCPX_TRANSPORT_BEARER_TOKEN` | when set, every /v1 and /mcp request must carry this token as a Bearer credential |
| `transport.sessionMetaKeys` | list | `dev.mcpx/session,ai.opencode/sessionID` | daemon | yes | `--transport-session-meta-keys` | `MCPX_TRANSPORT_SESSION_META_KEYS` | the _meta keys a client may name its session with, first present wins |
| `transport.sseKeepAlive` | duration | `15s` | daemon | no | `--transport-sse-keep-alive` | `MCPX_TRANSPORT_SSE_KEEP_ALIVE` | how often a quiet MCP event stream carries a comment line |
| `transport.stdioDrain` | duration | `30s` | client | no | `--transport-stdio-drain` | `MCPX_TRANSPORT_STDIO_DRAIN` | how long `mcpx serve` finishes in-flight requests after its input closes |
Expand Down
1 change: 1 addition & 0 deletions pkgs/mcpx/docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ and read by name for that reason.
| `MCPX_HOOK_EVENT_URI` | the declared-hook runner, for a hook it runs | empty when the event names no resource | the resource URI the event is about | the hook itself |
| `MCPX_ENDPOINT` | `mcpx exec` / `mcpx run`; the exec service (`/v1/exec`, `mcpx_exec`); an event consumer | always | the daemon's HTTP endpoint | the generated client; the plugin as a fallback for MCPX_DAEMON_ENDPOINT |
| `MCPX_SOCKET` | `mcpx exec` / `mcpx run`; the exec service (`/v1/exec`, `mcpx_exec`); the plugin loader, for a discovered plugin | empty when the daemon is remote, since its socket is not reachable from here | the daemon's unix socket, which is faster than the endpoint. Also where a daemon binds and a client dials, for anyone who sets it by hand | the generated client, `mcpx` run inside the script or shell, the plugin |
| `MCPX_TRANSPORT_BEARER_TOKEN` | `mcpx exec` / `mcpx run` | only when transport.bearerToken is configured | the daemon's bearer token, handed to the script so the generated client can authenticate to a daemon that requires it (#1975); the token itself stays out of the generated code | the generated client, `mcpx` run inside the script or shell, the plugin; also the setting `transport.bearerToken` |
| `MCPX_ARTIFACTS_LOCAL` | `mcpx exec` / `mcpx run`; the exec service (`/v1/exec`, `mcpx_exec`) | always | 1 when the daemon shares this filesystem, so artifact({path}) may hand over a path instead of the bytes | the generated client |
| `MCPX_CWD` | `mcpx exec` / `mcpx run`; the exec service (`/v1/exec`, `mcpx_exec`) | the exec service only when the caller sent its working directory | the directory the caller ran mcpx from, which a remote run cannot discover | the generated client (paths.cwd, and cwd scoping) |
| `MCPX_PID` | `mcpx exec` / `mcpx run`; the exec service (`/v1/exec`, `mcpx_exec`) | always | the pid of the mcpx process that started the script | the generated client, for pid scoping |
Expand Down
184 changes: 184 additions & 0 deletions pkgs/mcpx/internal/cli/bearer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package cli

import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"

"github.com/dezren39/mcpx/internal/daemon"
"github.com/dezren39/mcpx/internal/settings"
)

// #1975: the CLI attaches the same transport.bearerToken the daemon
// requires, from its own resolved settings; unset, no header is added.

func TestClientAuthorize(t *testing.T) {
const fixture = "fixture-token-0123456789abcdef01"
build := func(tok string) *Client {
sch, err := settings.New(settings.Registry())
if err != nil {
t.Fatal(err)
}
ov := map[string]string{}
if tok != "" {
ov[daemon.BearerTokenSetting] = tok
}
return &Client{set: settings.NewSet(sch).WithOverrides(ov, "test")}
}

req, err := http.NewRequest(http.MethodGet, "http://mcpx/v1/log", nil)
if err != nil {
t.Fatal(err)
}
build(fixture).authorize(req)
if got := req.Header.Get("Authorization"); got != "Bearer "+fixture {
t.Errorf("Authorization = %q, want the configured bearer token", got)
}

req2, err := http.NewRequest(http.MethodGet, "http://mcpx/v1/log", nil)
if err != nil {
t.Fatal(err)
}
build("").authorize(req2)
if got := req2.Header.Get("Authorization"); got != "" {
t.Errorf("Authorization = %q with no token configured, want no header", got)
}
}

// TestEveryDaemonRequestIsAuthorized pins the Codex P2 on PR #2711:
// authorizing open()/Stream() was not enough — Client.stream (exec.go),
// Client.streamOp (opcmd.go) and the direct requests in feedback.go and
// embeddings.go each built daemon requests that reached Do() without
// authorize(), so they 401'd whenever transport.bearerToken was set.
// Every request-construction site in this package must call authorize()
// before the request is sent; a new unauthenticated path fails here.
func TestEveryDaemonRequestIsAuthorized(t *testing.T) {
files, err := filepath.Glob("*.go")
if err != nil {
t.Fatal(err)
}
for _, file := range files {
if strings.HasSuffix(file, "_test.go") {
continue
}
data, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
lines := strings.Split(string(data), "\n")
for i, ln := range lines {
if !strings.Contains(ln, "http.NewRequestWithContext") {
continue
}
found := false
for j := i + 1; j < len(lines) && j <= i+20; j++ {
if strings.Contains(lines[j], ".Do(") {
break
}
if strings.Contains(lines[j], "authorize(") {
found = true
break
}
}
if !found {
t.Errorf("%s:%d: daemon request created without authorize() before it is sent", file, i+1)
}
}
}
}

// TestTargetBearerTokenResolvesTheTargetDaemon pins the Codex P2 on
// PR #2711 (round on 4b73ecbf): stop --all and daemons build clients
// from each daemon record's ConfigPath, but the client presented the
// invoking project's token, so a daemon configured with its own
// file token 401'd the probe and was skipped / reported stopped.
func TestTargetBearerTokenResolvesTheTargetDaemon(t *testing.T) {
const targetTok = "target-token-0123456789abcdef0123"
const invokerTok = "invoker-token-0123456789abcdef01"
write := func(name, tok string) string {
path := filepath.Join(t.TempDir(), name)
doc := map[string]any{"transport": map[string]any{"bearerToken": tok}}
b, err := json.Marshal(doc)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, b, 0o600); err != nil {
t.Fatal(err)
}
return path
}
target := write("target.json", targetTok)
invoker := write("invoker.json", invokerTok)

sch, err := settings.New(settings.Registry())
if err != nil {
t.Fatal(err)
}
own := settings.NewSet(sch)
doc, err := readJSONFile(invoker)
if err != nil {
t.Fatal(err)
}
if err := sch.ApplyFile(own, doc, invoker, 0); err != nil {
t.Fatal(err)
}
if got := daemon.BearerToken(own); got != invokerTok {
t.Fatalf("setup: invoker set token = %q, want the invoker fixture", got)
}

// The invoking project's file token must not be presented to the
// target daemon; the target's own file token is the credential.
tok, ok := targetBearerToken(target, own)
if !ok || tok != targetTok {
t.Errorf("targetBearerToken = %q, %v; want the target daemon's token", tok, ok)
}

// An invocation-scoped (runtime-layer) token still wins: it is
// the token this process was told to use.
scoped := settings.NewSet(sch).WithOverrides(
map[string]string{daemon.BearerTokenSetting: invokerTok}, "test")
tok, ok = targetBearerToken(target, scoped)
if !ok || tok != invokerTok {
t.Errorf("scoped targetBearerToken = %q, %v; want the invocation token", tok, ok)
}

// Inherited token: the recorded file carries no token, but a
// parent directory's .mcpx.json does -- the daemon found by
// discovery inherits it, and so must the client (Codex on
// d02a018d).
parent := t.TempDir()
sub := filepath.Join(parent, "sub")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}
docB, err := json.Marshal(map[string]any{"transport": map[string]any{"bearerToken": targetTok}})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(parent, ".mcpx.json"), docB, 0o600); err != nil {
t.Fatal(err)
}
near := filepath.Join(sub, ".mcpx.json")
if err := os.WriteFile(near, []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
empty := settings.NewSet(sch)
if tok, ok := targetBearerToken(near, empty); !ok || tok != targetTok {
t.Errorf("inherited targetBearerToken = %q, %v; want the parent config's token", tok, ok)
}

// And a client built for the recorded daemon authorizes with the
// target token end to end.
c := NewClientAt(daemon.Paths{}, target, "unix:///nonexistent")
req, err := http.NewRequest(http.MethodGet, "http://mcpx/v1/log", nil)
if err != nil {
t.Fatal(err)
}
c.authorize(req)
if got := req.Header.Get("Authorization"); got != "Bearer "+targetTok {
t.Errorf("record-targeted Authorization = %q, want the target token", got)
}
}
102 changes: 102 additions & 0 deletions pkgs/mcpx/internal/cli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -66,6 +67,13 @@ type Client struct {
resolveSocket bool
// set is this process's resolved configuration.
set *settings.Set
// authToken, when authResolved is true, is the bearer token of THIS
// client's target daemon, resolved from the target's own
// configuration (targetBearerToken). Record-targeted clients
// (stop --all, daemons) talk to daemons other projects configured;
// their token is that daemon's, not the invoking project's.
authToken string
authResolved bool
// callSettings is the JSON header carrying whatever call-scoped settings
// this invocation actually chose. Computed once: it cannot change within
// a run, and recomputing it per request would put a schema walk on the
Expand Down Expand Up @@ -119,6 +127,95 @@ func callHeader(set *settings.Set) string {
return string(b)
}

// authorize attaches the configured daemon bearer token to a request
// (#1975): the same transport.bearerToken setting the daemon checks,
// resolved from this client's own settings (environment or config), so
// a caller and its daemon agree by construction. Unset, no header --
// the request is byte-for-byte what it was before the setting existed.
func (c *Client) authorize(req *http.Request) {
tok := daemon.BearerToken(c.set)
if c.authResolved {
tok = c.authToken
}
if tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
Comment thread
dezren39 marked this conversation as resolved.
Resolved
}

// targetBearerToken resolves the bearer token of the daemon a client
// targets through configPath (#1975, Codex on PR #2711). Clients built
// for a recorded daemon -- stop --all, daemons -- construct through
// NewClientAt with that daemon's config path, but a token resolved
// from the invoking project's settings is the wrong credential for
// another project's daemon: the probe 401s, stop silently skips a
// live daemon, and daemons reports it stopped.
//
// Resolution mirrors the daemon's own: the target's configuration
// CHAIN, then the environment. The chain matters: a daemon started
// with ordinary discovery inherits the setting from a parent or user
// config when its nearest file does not carry it, so resolving only
// the recorded file would miss the inherited token and 401 the
// daemon's own ordinary clients (Codex on PR #2711, round on
// d02a018d). An invocation-scoped token in the client's own settings
// (environment, preset, flag or runtime layer -- anything but a
// file) still wins: it is the token this process was told to use. A
// token only the invoking project's file carries is NOT sent: the
// target daemon never saw it.
//
// Best effort by design: an unreadable target file resolves to no
// token rather than failing a fleet-wide listing or stop.
func targetBearerToken(configPath string, own *settings.Set) (string, bool) {
if v, ok := own.Value(daemon.BearerTokenSetting); ok &&
v.Origin.Layer != settings.LayerFile && v.Origin.Layer != settings.LayerDefault {
return v.Raw, true
}
sch, err := settings.New(settings.Registry())
if err != nil {
return "", false
}
set := settings.NewSet(sch)
files := targetConfigFiles(configPath)
for i, path := range files {
doc, rerr := readJSONFile(path)
if rerr != nil {
continue
}
if aerr := sch.ApplyFile(set, doc, path, i); aerr != nil {
return "", false
}
}
if eerr := sch.ApplyEnv(set, settings.Environ()); eerr != nil {
return "", false
}
return daemon.BearerToken(set), true
}

// targetConfigFiles is the farthest-first file list a target daemon's
// settings resolve from: the discovery chain of the recorded file's
// directory (the walk the daemon itself was found by), with the
// recorded file appended as the nearest layer when discovery cannot
// see it (an MCPX_CONFIG bypass names a file outside the walk).
func targetConfigFiles(configPath string) []string {
var files []string
if chain, _, err := config.ResolveChainFrom(filepath.Dir(configPath)); err == nil {
for i := len(chain) - 1; i >= 0; i-- {
if _, serr := os.Stat(chain[i]); serr == nil {
files = append(files, chain[i])
}
}
}
found := false
for _, f := range files {
if f == configPath {
found = true
}
}
if !found {
files = append(files, configPath)
}
return files
}

// NewClient builds a socket-backed API client.
func NewClient(paths daemon.Paths, configPath string) *Client {
return NewClientAt(paths, configPath, "")
Expand All @@ -139,6 +236,9 @@ func NewClientAt(paths daemon.Paths, configPath, endpoint string) *Client {
c := &Client{paths: paths, cfg: configPath, set: set,
callSettings: callHeader(set),
endpoint: strings.TrimRight(endpoint, "/")}
if configPath != "" {
c.authToken, c.authResolved = targetBearerToken(configPath, set)
Comment thread
dezren39 marked this conversation as resolved.
Resolved
}
timeout := set.Duration("http.requestTimeout")
idle := set.Duration("http.idleConnTimeout")

Expand Down Expand Up @@ -372,6 +472,7 @@ func (c *Client) open(ctx context.Context, method, path string, body []byte, con
if v := mcpclient.CallerVersionFrom(ctx); v != "" {
req.Header.Set(daemon.CallerVersionHeader, v)
}
c.authorize(req)
Comment thread
dezren39 marked this conversation as resolved.
Resolved
resp, err := c.hc.Do(req)
if err != nil {
if isDialErr(err) {
Expand Down Expand Up @@ -1117,6 +1218,7 @@ func (c *Client) Stream(ctx context.Context, f events.Filter, fn func(events.Eve
return err
}
req.Header.Set("Accept", "text/event-stream")
c.authorize(req)
// No timeout on a stream; the context bounds it instead. The
// client's ordinary timeout would cut a healthy stream off.
streamClient := &http.Client{Transport: c.hc.Transport}
Expand Down
9 changes: 9 additions & 0 deletions pkgs/mcpx/internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,15 @@ func (a *App) runScript(ctx context.Context, args []string, inline bool) error {
"MCPX_INPUT": inputReport(a.stdoutOverride == nil),
},
}
// #1975: propagate the resolved daemon Bearer [REDACTED] to scripts, so the
// generated client's TRANSPORT wrapper can authenticate. Resolved from
// the CLI's own settings rather than the environment alone, so a
// config-file token reaches scripts too. Unset, no variable is set:
// os.Environ() inheritance in the runner already covers an env-sourced
// token, and scripts stay byte-for-byte as before.
if tok := daemon.BearerToken(a.Settings()); tok != "" {
opts.Env["MCPX_TRANSPORT_BEARER_TOKEN"] = tok
}
opts.TypeCheck = shape.typecheck
opts.Imports = shape.imports
opts.ImportsOnDynamic = shape.importsOnDynamic
Expand Down
Loading
Loading