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
16 changes: 16 additions & 0 deletions .opencode/plugin/mcpx/ops.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,14 @@ export const OPS: Record<string, OpSpec> = {
},
response: "response",
},
writes_clear: {
method: "POST",
path: "/v1/writes/{server}/clear",
params: {
server: { in: "path", required: true },
},
response: "json",
},
}

/** A refusal from the daemon, with the status and the body kept. */
Expand Down Expand Up @@ -1496,4 +1504,12 @@ export class DaemonOps {
types(args: { ns?: string; instructions?: "0" | "1"; profile?: string; skipDefault?: "0" | "1"; allProfiles?: "0" | "1" } = {}, opts: CallOptions = {}): Promise<Response> {
return this.invoke("types", args, opts) as Promise<Response>
}

/**
* Clear a server's unknown-write block. `POST /v1/writes/{server}/clear`.
* Privileged: it changes the daemon rather than reading it.
*/
writesClear(args: { server: string }, opts: CallOptions = {}): Promise<unknown> {
return this.invoke("writes_clear", args, opts) as Promise<unknown>
}
}
6 changes: 4 additions & 2 deletions pkgs/mcpx/docs/parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ and costs an agent the ability to restart a server that has wedged. What they
carry instead is a description that says so and MCP annotations
(`readOnlyHint`, `destructiveHint`) for a client that wants to scope on them.

## /v1 operations (83)
## /v1 operations (84)

| Operation | Route | CLI | MCP tool | Plugin | Notes |
| --- | --- | --- | --- | --- | --- |
Expand Down Expand Up @@ -119,8 +119,9 @@ carry instead is a description that says so and MCP annotations
| `tools` | `GET /v1/tools` | `mcpx tools` *generated* | `mcpx_tools` | `ops.tools()` | |
| `tool_invoke` | `POST /v1/tools/{tool}` | `mcpx tool invoke` *generated* | `mcpx_tool_invoke` | `ops.toolInvoke()` | |
| `types` | `GET /v1/types` | `mcpx types` | `mcpx_types` | `ops.types()`, `types()` | |
| `writes_clear` | `POST /v1/writes/{server}/clear` | `mcpx writes clear` *generated* | `mcpx_writes_clear` | `ops.writesClear()` | Privileged. |

## CLI commands (69)
## CLI commands (70)

Every command the binary dispatches, with the operations it reaches. A command
that reaches none says why: those are decisions, not gaps.
Expand Down Expand Up @@ -196,6 +197,7 @@ that reaches none says why: those are decisions, not gaps.
| `tui` | – | An interactive interface over the reads above; it consumes the API rather than extending it. |
| `types` | `types` | |
| `upgrade` | – | Replaces the running daemon with another binary, a decision for whoever runs the CLI rather than a tool a model can call. |
| `writes` | `writes_clear` | Generated from the operation table. |

## Hand-written MCP tools (12)

Expand Down
1 change: 1 addition & 0 deletions pkgs/mcpx/internal/api/ops.go
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,7 @@ func Ops() []Op {
ops = append(ops, settingsOps()...)
ops = append(ops, resolveOps()...)
ops = append(ops, secretsOps()...)
ops = append(ops, writesOps()...)
sort.Slice(ops, func(i, j int) bool {
if ops[i].Path == ops[j].Path {
return ops[i].Method < ops[j].Method
Expand Down
23 changes: 23 additions & 0 deletions pkgs/mcpx/internal/api/ops_writes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package api

// writesOps exposes the explicit operator action that clears a server after
// an upstream write's outcome became unknown.
func writesOps() []Op {
return []Op{
{
Name: "writes_clear",
Method: "POST",
Path: "/v1/writes/{server}/clear",
Permission: PermissionAdmin,
Admin: true,
Mutating: true,
Summary: "Clear a server's unknown-write block",
Description: "Allows non-read-only calls to a server again after an operator has checked " +
"whether a write with an unknown outcome took effect.",
Params: []Param{
{Name: "server", In: InPath, Type: "string", Required: true,
Desc: "the configured server name shown in the refusal"},
},
},
}
}
67 changes: 46 additions & 21 deletions pkgs/mcpx/internal/daemon/consumer.go
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,8 @@ func (e ErrArgumentRuleDenied) Error() string {

// applyWritePolicy is the write policy gate (#1979, Phase 1): before a
// call, decide from its tool's annotations and the effective mode
// whether it proceeds, asks, or is refused.
// whether it proceeds, asks, or is refused. The ledger and argument-rule
// hooks run first, in the order fixed by #2126.
//
// The effective mode is the server's own writePolicy when it sets one,
// else the daemon's policy.write. An empty effective mode -- a zero
Expand All @@ -532,30 +533,54 @@ func (e ErrArgumentRuleDenied) Error() string {
// under allow confirmDestructive still runs, which is how the floor
// reaches a destructive call the policy let through.
func (r *Registry) applyWritePolicy(ctx context.Context, p *pool.Pool, server, tool string, cc config.CallContext, args any) (bool, error) {
// Argument rules (#1986) sit in the evaluator's fixed order
// (#2126) after the hidden-tool check -- which resolveAndGuard has
// already applied -- and the Phase 3 ledger hook (still a no-op),
// and BEFORE the write mode below: a call whose arguments break a
// rule is refused whatever the mode says, and a call that
// satisfies every rule has no opinion from this step and falls
// through to the mode. Rules are read from the requested server's
// VIEW for the same reason writePolicy is: aliases share a pool
// but not a configuration. The check runs even when no write
// policy is configured -- rules are their own opt-in -- and before
// the pool is touched, so a refused call starts nothing.
if view, ok := r.View(server); ok && !view.ArgumentRules.Empty() {
rules := view.ArgumentRules
var violation *config.ArgumentRuleViolation
evaluator := policy.Evaluator{ArgumentRules: func() (policy.Decision, bool) {
if v := rules.Check(tool, args); v != nil {
// A record only affects a server with an unknown write. Consult it
// before argument rules, the write mode, or the destructive floor.
// A read-only hint is exempt; if it cannot be refreshed, fail closed
// and keep the operator's clear path visible in the error.
var ledgerRefusal ErrUnknownOutcome
ledgerCheck := func() (policy.Decision, bool) {
if r.writeLedger == nil {
return policy.Decision{}, false
}
record, blocked := r.ledgerLookupFor(server, p)
if !blocked {
return policy.Decision{}, false
}
tools, _, at := p.CachedSchemas()
if at.IsZero() || p.SchemaExpired() {
fresh, _, err := p.RefreshSchemas(ctx)
if err != nil {
ledgerRefusal = ErrUnknownOutcome{Record: record,
Reason: "could not verify whether this tool is read-only: " + err.Error()}
return policy.Decision{Kind: policy.DecideDeny, Reason: ledgerRefusal.Error()}, true
}
tools = fresh
}
for _, candidate := range tools {
if candidate.Name == tool && policy.Classify(candidate.Annotations).ReadOnly {
return policy.Decision{}, false
}
}
ledgerRefusal = ErrUnknownOutcome{Record: record}
return policy.Decision{Kind: policy.DecideDeny, Reason: ledgerRefusal.Error()}, true
}

var violation *config.ArgumentRuleViolation
argumentRules := func() (policy.Decision, bool) {
if view, ok := r.View(server); ok && !view.ArgumentRules.Empty() {
if v := view.ArgumentRules.Check(tool, args); v != nil {
violation = v
return policy.Decision{Kind: policy.DecideDeny, Reason: v.Error()}, true
}
return policy.Decision{}, false
}}
if d := evaluator.Evaluate(policy.Input{Mode: policy.ModeAllow}); d.Kind == policy.DecideDeny {
return false, ErrArgumentRuleDenied{Tool: p.Namespace() + "." + tool, Violation: violation}
}
return policy.Decision{}, false
}
preflight := policy.Evaluator{LedgerCheck: ledgerCheck, ArgumentRules: argumentRules}
if d := preflight.Evaluate(policy.Input{Mode: policy.ModeAllow}); d.Kind == policy.DecideDeny {
if ledgerRefusal.Record.Server != "" {
return false, ledgerRefusal
}
return false, ErrArgumentRuleDenied{Tool: p.Namespace() + "." + tool, Violation: violation}
}

mode := r.consumer.WritePolicy
Expand Down
2 changes: 1 addition & 1 deletion pkgs/mcpx/internal/daemon/input_required.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ func (r *Registry) CallReporting(ctx context.Context, id, server, tool string, c
r.beginAsk(id, server, rs.Key, cc.SessionID)
defer r.endAsk(id)

res, err := p.Call(withRequestedCallTimeout(ctx), rs, effTool, effArgs)
res, err := r.callWithLedger(withRequestedCallTimeout(ctx), p, server, effTool, effArgs, rs, "reporting")

if err != nil {
return nil, r.explainCall(ctx, server, tool, args, err)
Expand Down
1 change: 1 addition & 0 deletions pkgs/mcpx/internal/daemon/ops.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func (s *Server) routesV1Ops(mux *http.ServeMux) {
s.route(mux, "GET /v1/tasks/{id}", s.handleTaskGet)
s.route(mux, "GET /v1/tasks/{id}/result", s.handleTaskResult)
s.route(mux, "POST /v1/tasks/{id}/cancel", s.handleTaskCancel)
s.route(mux, "POST /v1/writes/{server}/clear", s.handleWriteClear)
s.route(mux, "GET /v1/openapi.json", s.handleOpenAPI)
}

Expand Down
125 changes: 114 additions & 11 deletions pkgs/mcpx/internal/daemon/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"

"github.com/developing-today/code/pkgs/mcpx/internal/catalog"
Expand All @@ -27,6 +28,7 @@ import (
"github.com/developing-today/code/pkgs/mcpx/internal/events"
"github.com/developing-today/code/pkgs/mcpx/internal/logstore"
"github.com/developing-today/code/pkgs/mcpx/internal/mcpclient"
"github.com/developing-today/code/pkgs/mcpx/internal/policy"
"github.com/developing-today/code/pkgs/mcpx/internal/pool"
"github.com/developing-today/code/pkgs/mcpx/internal/secrets"
"github.com/developing-today/code/pkgs/mcpx/internal/settings"
Expand Down Expand Up @@ -107,6 +109,9 @@ type Registry struct {
// wait for them. Both are memory only; see internal/secrets.
secrets *secrets.Store
board *secrets.Board
// writeLedger persists post-dispatch calls whose outcome could not be
// established, so a restart cannot make a retry silently unsafe.
writeLedger *WriteLedger
}

// leaseState remembers which pool keys a caller created, so releasing a
Expand Down Expand Up @@ -199,18 +204,23 @@ func NewRegistry(cfg *config.Config, paths Paths, logf func(string, ...any)) (*R
return nil, err
}
raw, _ := json.Marshal(cfg.MCPServers)
writeLedger, err := OpenWriteLedger(paths.State, cfg.Sources...)
if err != nil {
return nil, err
}

r := &Registry{
cfg: cfg,
paths: paths,
hash: HashConfig(raw),
pools: make(map[string]*pool.Pool, len(servers)),
views: make(map[string]*config.Resolved, len(servers)),
leases: map[string]*leaseState{},
asks: newAskTable(),
logf: logf,
secrets: secrets.NewStore(),
board: secrets.NewBoard(defaults.SecretRequestRetention),
cfg: cfg,
paths: paths,
hash: HashConfig(raw),
pools: make(map[string]*pool.Pool, len(servers)),
views: make(map[string]*config.Resolved, len(servers)),
leases: map[string]*leaseState{},
asks: newAskTable(),
logf: logf,
secrets: secrets.NewStore(),
board: secrets.NewBoard(defaults.SecretRequestRetention),
writeLedger: writeLedger,
}
r.loadSessions()
if paths.State != "" {
Expand Down Expand Up @@ -2192,7 +2202,11 @@ func failureStatus(err error) int {
var secret SecretRequired
var denied ErrWriteDenied
var argDenied ErrArgumentRuleDenied
var unknownOutcome ErrUnknownOutcome
switch {
case errors.As(err, &unknownOutcome):
// The daemon has durable state that makes repeating this write unsafe.
return http.StatusConflict
case errors.As(err, &denied):
// The write policy refused the call: a permission answer, not
// a bad request and not an upstream failure.
Expand Down Expand Up @@ -2260,14 +2274,103 @@ func (r *Registry) Call(ctx context.Context, server, tool string, cc config.Call
return nil, err
}
defer r.joinAsk(ctx, server, rs.Key)()
res, err := p.Call(withRequestedCallTimeout(ctx), rs, effTool, effArgs)
res, err := r.callWithLedger(withRequestedCallTimeout(ctx), p, server, effTool, effArgs, rs, "call")

if err != nil {
return nil, r.explainCall(ctx, server, tool, args, err)
}
return res, nil
}

func toolReadOnly(p *pool.Pool, tool string) bool {
if p == nil {
return false
}
tools, _, at := p.CachedSchemas()
// A missing, invalidated, or expired catalogue cannot be trusted to
// exempt the call: Pool.Invalidate clears the timestamp but retains
// the old tools, and an expired deadline means the annotations may
// no longer hold. Conservatively treat all of those as a write.
if at.IsZero() || p.SchemaExpired() {
return false
}
for _, candidate := range tools {
if candidate.Name == tool {
return policy.Classify(candidate.Annotations).ReadOnly
}
}
// Missing annotations are conservatively treated as a write.
return false
}

func (r *Registry) callWithLedger(ctx context.Context, p *pool.Pool, server, tool string, args any, rs config.ResolvedScope, surface string) (json.RawMessage, error) {
var dispatched atomic.Bool
callCtx := mcpclient.WithToolCallDispatchHook(ctx, func() { dispatched.Store(true) })
result, err := p.Call(callCtx, rs, tool, args)
if err != nil && dispatched.Load() && !toolReadOnly(p, tool) && r.writeLedger != nil && !mcpclient.IsConclusiveUpstreamError(err) {
return nil, r.recordUnknownOutcome(p, server, tool, args, surface)
}
return result, err
}

// ledgerLookupFor finds an unknown-outcome block affecting server, across
// every name sharing its pool. Pools are shared by process identity, so an
// alias added later must not lose the block recorded under the earlier
// creating name: all names mapping to the same pool are checked, plus the
// requested and canonical spellings.
func (r *Registry) ledgerLookupFor(server string, p *pool.Pool) (unknownWriteRecord, bool) {
if r.writeLedger == nil {
return unknownWriteRecord{}, false
}
seen := map[string]bool{}
var candidates []string
add := func(name string) {
if name == "" || seen[name] {
return
}
seen[name] = true
candidates = append(candidates, name)
}
add(server)
add(r.CanonicalName(server))
if p != nil {
add(p.Name())
r.mu.RLock()
for name, other := range r.pools {
if other == p {
add(name)
}
}
r.mu.RUnlock()
}
for _, name := range candidates {
if record, blocked := r.writeLedger.Lookup(name); blocked {
return record, true
}
}
return unknownWriteRecord{}, false
}

func (r *Registry) recordUnknownOutcome(p *pool.Pool, fallbackServer, tool string, args any, surface string) error {
// Record under the requested server's canonical name so `mcpx writes
// clear <server>` names the same key the refusal names, and so the
// block survives a restart that changes which alias created the pool.
server := r.CanonicalName(fallbackServer)
if server == "" {
server = fallbackServer
}
if server == "" && p != nil {
server = p.Name()
}
digest := sha256.Sum256([]byte(policy.CanonicalArgs(args)))
record := unknownWriteRecord{
Server: server, Tool: tool,
ArgsSHA256: hex.EncodeToString(digest[:]),
At: time.Now().UTC(), Surface: surface,
}
return ErrUnknownOutcome{Record: record, PersistenceError: r.writeLedger.Record(record)}
}

// resolveAndGuard picks the instance key for a tool call and applies the
// consumer policies. Every path that calls a tool goes through it: the ask
// path once skipped both, so a destructive-tool guard was off for exactly the
Expand Down
2 changes: 1 addition & 1 deletion pkgs/mcpx/internal/daemon/routes_proto.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ func (r *Registry) CallAsk(ctx context.Context, id, server, tool string, cc conf
defer r.endAsk(id)
ctx = withRequestedCallTimeout(withAskCall(ctx, id))

return p.Call(ctx, rs, effTool, effArgs)
return r.callWithLedger(ctx, p, server, effTool, effArgs, rs, "ask")

}

Expand Down
Loading
Loading