mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Simplify MCP authorization for installed servers / 简化已安装 MCP 授权 (#6767)
* fix(mcp): trust installed readers in subagents Problem: User-installed MCP tools that declared readOnlyHint could be exposed as readers but then fail before dispatch in planners and read-only subagents. Users were told to configure a second per-tool reader policy after already installing or authorizing the server. Root cause: Strict reader trust did not treat explicit install or an exact project launch grant as authority for non-destructive server metadata. Direct, lazy, and on-demand paths also duplicated cached-to-live safety reconciliation, while trusted_read_only_tools remained wired through configuration and Desktop contracts. Fix: Authorize non-destructive readOnlyHint tools for installed or launch-authorized servers, carry reader intent only for trusted readers, and share one cached-to-live safety reconciliation boundary across lazy and on-demand adapters. Remove trusted_read_only_tools from runtime, Desktop, examples, and docs while accepting and dropping legacy input on the next save. Verification: - env -u DEEPSEEK_API_KEY go test -count=1 ./... - cd desktop && go test -count=1 ./... - cd desktop/frontend && pnpm test:typecheck - cd desktop/frontend && pnpm exec tsx src/__tests__/capabilities-panel-actions.test.ts - ./scripts/cache-guard.sh - ./scripts/check-cache-impact.sh - git diff --check * refactor(mcp): simplify installed server authorization Problem: User-installed MCP servers still depended on official-catalog and per-tool reader trust layers, so direct, lazy, and on-demand paths could disagree and strict child sessions could reject an already authorized reader. Root cause: Server authorization, signed package status, cached reader trust, and legacy plan-mode allowlists were modeled as separate overlapping decisions. Fix: Remove the official MCP catalog, signature/revocation plumbing, package verification, per-tool cached trust, and obsolete reader settings. Use one server-level authorization source for user installs, host-session installs, and exact project grants while retaining live read-only, destructive, and capability-fingerprint checks. Verification: - go test ./internal/plugin ./internal/agent ./internal/boot ./internal/config ./internal/installsource ./internal/pluginpkg ./internal/mcplaunch ./internal/planmode - go test ./internal/acp ./internal/agent ./internal/plugin ./internal/boot - cd desktop && go test ./... - ./scripts/cache-guard.sh - git diff --check * refactor(mcp): make install authorization final Problem: Installed MCP servers could still encounter server, per-tool, writer, or destructive approval branches, and users had to understand settings that duplicated the install decision. Root cause: MCP authorization, cached reader trust, approval policy, and process sandboxing evolved as separate paths across direct, lazy, and on-demand execution. Fix: Treat install or exact project identity confirmation as the server authorization boundary, keep only explicit global deny plus Plan/read-only and sandbox safety checks, remove catalog and approval configuration, and unify the persistent MCP process sandbox. Verification: env -u DEEPSEEK_API_KEY go test ./... go build ./... cd desktop && go test ./... cd desktop/frontend && pnpm typecheck cd desktop/frontend && pnpm exec tsx src/__tests__/capabilities-panel-actions.test.ts cd desktop/frontend && pnpm exec tsx src/__tests__/decision-surface.test.tsx * chore(mcp): remove retired cache helper Problem: CI lint found writeBoolKV left unused after per-tool reader trust was removed. Root cause: The cached reader-map fingerprint was deleted, but its serialization helper remained. Fix: Remove the dead helper. Verification: go test ./internal/plugin go vet ./internal/plugin go build ./... * refactor(mcp): stop blocking schema-only drift Problem: Authorized MCP calls could be rejected when a cached tool schema differed from the live server even though authorization and safety classification were unchanged. Root cause: A capability fingerprint mixed schema compatibility with read-only and destructive execution safety across direct, lazy, and on-demand paths. Fix: Remove the tool-level schema fingerprint chain, keep live server authorization and read-only/destructive reclassification checks, and refresh changed schemas for the next session without interrupting the current authorized call. Verification: - go test ./internal/plugin ./internal/agent ./internal/tool - env -u DEEPSEEK_API_KEY go test ./... - go vet ./... - go build ./... - ./scripts/cache-guard.sh - pnpm --dir site test - cd desktop && go test ./... * fix(mcp): preserve short Windows temp paths Problem: Windows stdio MCP servers inherited a deeply nested Reasonix private TEMP path. Servers such as MATLAB create watchdog Unix sockets below TEMP and exit before initialization when the path exceeds the platform limit. Root cause: MCP private-state preparation redirected TMP, TEMP, and TMPDIR below the workspace-scoped mcp-state directory on every platform even though Reasonix does not currently provide an OS sandbox on Windows. Fix: Keep the host temporary-directory environment for Windows stdio MCP processes while retaining private cache and state directories. Preserve the existing private temporary directory behavior on other platforms and add focused regression coverage for both branches. Verification: - go test ./internal/plugin -count=1 - GOOS=windows GOARCH=amd64 go test -c ./internal/plugin - go test ./... - go vet ./internal/plugin - git diff --check * Simplify MCP runtime authorization state Use Spec.Authorized as the sole resolved server authorization result so installed and host-session MCP servers no longer depend on launch-manager presence. Keep exact identity grants for project sources, align planner and strict read-only registries with non-destructive readOnlyHint metadata, and document the cooperative trust boundary. Add source and agent-surface matrix coverage while preserving the main provider-visible MCP tool surface. * Limit MCP identity digests to project servers Problem: User-installed and host-session MCP servers still resolved and hashed their executable identity during every startup even though installation was already the authorization decision. Root cause: Project launch authorization and schema-cache ownership shared generic fingerprint terminology, and start() computed the project identity before checking whether launch approval was required. Fix: Skip project identity resolution for installed servers, remove dead and non-participating identity state, and rename the remaining concepts to ProjectLaunchIdentityDigest and SchemaCacheKey while preserving existing JSON keys and digest bytes. Verification: go test ./internal/mcplaunch ./internal/plugin ./internal/capability ./internal/boot ./internal/control ./internal/agent; go test ./...; cd desktop && go test .; go vet ./...; ./scripts/cache-guard.sh; git diff --check. * Restore MCP 0.53 compatibility surfaces Problem: MCP authorization was simplified, but the current client still lacked four compatibility surfaces present in the 0.53 line: legacy SSE, workspace roots, progress notifications, and registry browsing/install. Root cause: the Go transport abstraction dropped inbound notifications and rejected legacy SSE; runtime specs did not carry a separate workspace root; the registry UI and CLI had no official MCP Registry client. Fix: add inbound request/progress routing across transports, implement legacy SSE with same-origin endpoint protection, advertise roots/list, and add an explicit cached official Registry marketplace to CLI and desktop. Remove stale approval documentation and the dead helper that broke lint. Verification: go test ./...; focused go test -race for plugin/registry/CLI/control; go vet in root and desktop; desktop go test ./...; frontend production build and marketplace UI test; Windows amd64 cross-build; official Registry browse smoke test. * Avoid MCP progress capacity overflow * Align MCP authorization documentation Problem: CLI help and Chinese documentation still contained ambiguous or obsolete wording that implied installed MCP writer or destructive calls could require per-call approval. Root cause: The server-level authorization simplification removed MCP approval modes, but not every user-facing explanation was updated consistently. Fix: State explicitly that install or exact project identity confirmation authorizes the server, that writer and destructive hints do not prompt per call, and that deny, Plan, and strict read-only filtering remain separate boundaries. Verification: go test ./internal/cli; go vet ./internal/cli; reasonix mcp help smoke test; stale-wording scan; git diff --check. * Harden MCP registry installs and SSE replies Problem: Registry cache freshness was shared across queries, cached entries could flow into install paths, and legacy SSE server requests spawned an unbounded reply goroutine per request. Root cause: The cache stored one global timestamp, Resolve reused Search's offline fallback, the desktop installed browse payloads directly, and the SSE reader delegated every reply to a new goroutine. Fix: Track freshness per Registry query with legacy-cache compatibility, require live resolution before CLI and desktop installs, and serialize SSE replies through a bounded single-worker queue with finite deadlines. Verification: - env -u DEEPSEEK_API_KEY go test -count=1 ./... - env -u DEEPSEEK_API_KEY go test -race -count=1 ./internal/mcpregistry ./internal/plugin - env -u DEEPSEEK_API_KEY go test -count=1 ./... (desktop) - env -u DEEPSEEK_API_KEY go vet ./... (root and desktop) - pnpm run test:all - pnpm run build - env -u DEEPSEEK_API_KEY ./scripts/cache-guard.sh - git diff --check * Reject unavailable live registry installs Problem: A Registry entry could become non-installable between browsing and the desktop's live re-resolution, leaving the generic add flow with an invalid empty connection. The SSE concurrency regression also used a fixed delay after routing the client response. Root cause: The desktop resolver verified freshness but not the resolved entry's installability, and the test inferred worker startup from elapsed time. Fix: Validate the live entry through PluginEntry before returning it to the installer, and synchronize the SSE test on the first reply POST instead of sleeping. Verification: - env -u DEEPSEEK_API_KEY go test -count=5 ./internal/plugin - env -u DEEPSEEK_API_KEY go test -race -count=3 ./internal/plugin - env -u DEEPSEEK_API_KEY go test -count=1 ./... - cd desktop && env -u DEEPSEEK_API_KEY go test -count=1 ./... - git diff --check
This commit is contained in:
1 parent
ef29d046fd
commit
7fe7d2b3f3
101 files changed
+3770
-5238
No files matched your search
@@ -1,104 +0,0 @@
|
||||
name: MCP catalog
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "cmd/mcp-catalog/**"
|
||||
- "internal/mcpcatalog/**"
|
||||
- "catalog/mcp/**"
|
||||
- ".github/workflows/mcp-catalog.yml"
|
||||
push:
|
||||
branches: [main-v2]
|
||||
paths:
|
||||
- "cmd/mcp-catalog/**"
|
||||
- "internal/mcpcatalog/**"
|
||||
- "catalog/mcp/**"
|
||||
- ".github/workflows/mcp-catalog.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- name: Validate schema, duplicate entries, reader declarations, and bundled signature
|
||||
run: |
|
||||
go test ./internal/mcpcatalog
|
||||
go run ./cmd/mcp-catalog validate \
|
||||
--index internal/mcpcatalog/catalog-v1.json \
|
||||
--signature internal/mcpcatalog/catalog-v1.json.minisig \
|
||||
--packages catalog/mcp/packages
|
||||
|
||||
sign:
|
||||
# A manually selected feature branch must never be able to publish a new
|
||||
# trust root payload. Catalog releases only run from the protected branch.
|
||||
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main-v2'
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- name: Sign catalog with the dedicated release secret
|
||||
env:
|
||||
MCP_CATALOG_MINISIGN_PRIVATE_KEY: ${{ secrets.MCP_CATALOG_MINISIGN_PRIVATE_KEY }}
|
||||
MCP_CATALOG_MINISIGN_PASSWORD: ${{ secrets.MCP_CATALOG_MINISIGN_PASSWORD }}
|
||||
run: |
|
||||
go run ./cmd/mcp-catalog sign \
|
||||
--index internal/mcpcatalog/catalog-v1.json \
|
||||
--output index.json.minisig
|
||||
cp internal/mcpcatalog/catalog-v1.json index.json
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mcp-catalog-v1-${{ github.run_id }}
|
||||
path: |
|
||||
index.json
|
||||
index.json.minisig
|
||||
if-no-files-found: error
|
||||
- name: Publish the signed catalog to the Reasonix R2 origin
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$AWS_ACCESS_KEY_ID"
|
||||
test -n "$AWS_SECRET_ACCESS_KEY"
|
||||
test -n "$R2_ACCOUNT_ID"
|
||||
test -n "$R2_BUCKET"
|
||||
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
sequence="$(jq -er '.sequence' index.json)"
|
||||
if aws s3 cp "s3://${R2_BUCKET}/plugins/catalog/v1/index.json" current-index.json --endpoint-url "$endpoint"; then
|
||||
current_sequence="$(jq -er '.sequence' current-index.json)"
|
||||
if [ "$sequence" -lt "$current_sequence" ]; then
|
||||
echo "catalog sequence rollback: new=$sequence current=$current_sequence" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$sequence" -eq "$current_sequence" ] && ! cmp -s index.json current-index.json; then
|
||||
echo "catalog content changed without increasing sequence $sequence" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
prefix="plugins/catalog/v1/history/${sequence}"
|
||||
aws s3 cp index.json "s3://${R2_BUCKET}/${prefix}/index.json" \
|
||||
--endpoint-url "$endpoint" --content-type application/json --cache-control "public, max-age=31536000, immutable"
|
||||
aws s3 cp index.json.minisig "s3://${R2_BUCKET}/${prefix}/index.json.minisig" \
|
||||
--endpoint-url "$endpoint" --content-type application/octet-stream --cache-control "public, max-age=31536000, immutable"
|
||||
# Readers retain their last-known-good copy across the brief pair
|
||||
# update; a mismatched pair therefore fails closed instead of running.
|
||||
aws s3 cp index.json "s3://${R2_BUCKET}/plugins/catalog/v1/index.json" \
|
||||
--endpoint-url "$endpoint" --content-type application/json --cache-control "public, max-age=300"
|
||||
aws s3 cp index.json.minisig "s3://${R2_BUCKET}/plugins/catalog/v1/index.json.minisig" \
|
||||
--endpoint-url "$endpoint" --content-type application/octet-stream --cache-control "public, max-age=300"
|
||||
@@ -1,9 +0,0 @@
|
||||
# MCP catalog package inputs
|
||||
|
||||
Place each release-ready plugin tree in a directory named after its catalog
|
||||
entry ID. Catalog CI recomputes the deterministic package tree and manifest
|
||||
SHA-256 values from these directories before a catalog can be signed.
|
||||
|
||||
These inputs are catalog-release artifacts, not runtime plugin installs. The
|
||||
private minisign key remains only in the `MCP_CATALOG_MINISIGN_PRIVATE_KEY`
|
||||
release secret.
|
||||
@@ -1,126 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"aead.dev/minisign"
|
||||
|
||||
"reasonix/internal/mcpcatalog"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fail("usage: mcp-catalog validate|sign [flags]")
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "validate":
|
||||
validate(os.Args[2:])
|
||||
case "sign":
|
||||
sign(os.Args[2:])
|
||||
default:
|
||||
fail("unknown command %q", os.Args[1])
|
||||
}
|
||||
}
|
||||
|
||||
func validate(args []string) {
|
||||
flags := flag.NewFlagSet("validate", flag.ExitOnError)
|
||||
indexPath := flags.String("index", "internal/mcpcatalog/catalog-v1.json", "catalog source JSON")
|
||||
packagesDir := flags.String("packages", "", "directory containing one package tree per catalog entry ID")
|
||||
signaturePath := flags.String("signature", "", "optional minisign signature to verify")
|
||||
_ = flags.Parse(args)
|
||||
data := mustRead(*indexPath)
|
||||
index, err := mcpcatalog.Parse(data)
|
||||
if err != nil {
|
||||
fail("validate catalog: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(*signaturePath) != "" {
|
||||
if _, err := mcpcatalog.Verify(data, mustRead(*signaturePath), mcpcatalog.PublicKeys); err != nil {
|
||||
fail("verify catalog signature: %v", err)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(*packagesDir) != "" {
|
||||
for _, entry := range index.Entries {
|
||||
root := filepath.Join(*packagesDir, entry.ID)
|
||||
digest, err := mcpcatalog.TreeSHA256(root)
|
||||
if err != nil {
|
||||
fail("hash package %s: %v", entry.ID, err)
|
||||
}
|
||||
if !strings.EqualFold(digest, entry.PackageSHA256) {
|
||||
fail("package %s digest mismatch: got %s want %s", entry.ID, digest, entry.PackageSHA256)
|
||||
}
|
||||
manifest, err := manifestDigest(root)
|
||||
if err != nil {
|
||||
fail("hash package %s manifest: %v", entry.ID, err)
|
||||
}
|
||||
if !strings.EqualFold(manifest, entry.ManifestSHA256) {
|
||||
fail("package %s manifest digest mismatch: got %s want %s", entry.ID, manifest, entry.ManifestSHA256)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf("validated MCP catalog sequence %d with %d entries\n", index.Sequence, len(index.Entries))
|
||||
}
|
||||
|
||||
func sign(args []string) {
|
||||
flags := flag.NewFlagSet("sign", flag.ExitOnError)
|
||||
indexPath := flags.String("index", "internal/mcpcatalog/catalog-v1.json", "catalog source JSON")
|
||||
outputPath := flags.String("output", "internal/mcpcatalog/catalog-v1.json.minisig", "signature output")
|
||||
_ = flags.Parse(args)
|
||||
data := mustRead(*indexPath)
|
||||
if _, err := mcpcatalog.Parse(data); err != nil {
|
||||
fail("validate catalog before signing: %v", err)
|
||||
}
|
||||
keyText := os.Getenv("MCP_CATALOG_MINISIGN_PRIVATE_KEY")
|
||||
if strings.TrimSpace(keyText) == "" {
|
||||
fail("MCP_CATALOG_MINISIGN_PRIVATE_KEY is required")
|
||||
}
|
||||
key, err := minisign.DecryptKey(os.Getenv("MCP_CATALOG_MINISIGN_PASSWORD"), []byte(keyText))
|
||||
if err != nil {
|
||||
var plain minisign.PrivateKey
|
||||
if plainErr := plain.UnmarshalText([]byte(keyText)); plainErr != nil {
|
||||
fail("parse catalog signing key: encrypted=%v plain=%v", err, plainErr)
|
||||
}
|
||||
key = plain
|
||||
}
|
||||
signature := minisign.Sign(key, data)
|
||||
if err := os.WriteFile(*outputPath, signature, 0o600); err != nil {
|
||||
fail("write signature: %v", err)
|
||||
}
|
||||
if _, err := mcpcatalog.Verify(data, signature, mcpcatalog.PublicKeys); err != nil {
|
||||
_ = os.Remove(*outputPath)
|
||||
fail("new signature does not match the app key ring: %v", err)
|
||||
}
|
||||
fmt.Printf("signed %s\n", *indexPath)
|
||||
}
|
||||
|
||||
func manifestDigest(root string) (string, error) {
|
||||
for _, rel := range []string{"reasonix-plugin.json", ".codex-plugin/plugin.json", ".claude-plugin/plugin.json"} {
|
||||
body, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel)))
|
||||
if err == nil {
|
||||
sum := sha256.Sum256(body)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("plugin manifest not found")
|
||||
}
|
||||
|
||||
func mustRead(path string) []byte {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
fail("read %s: %v", path, err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func fail(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
+133
-93
@@ -44,6 +44,7 @@ import (
|
||||
"reasonix/internal/jobs"
|
||||
"reasonix/internal/mcpdiag"
|
||||
"reasonix/internal/mcplaunch"
|
||||
"reasonix/internal/mcpregistry"
|
||||
"reasonix/internal/memory"
|
||||
"reasonix/internal/notify"
|
||||
"reasonix/internal/plugin"
|
||||
@@ -6139,37 +6140,33 @@ type SkillsSettingsView struct {
|
||||
// the connection error), "initializing" (background startup in progress), or
|
||||
// "disabled".
|
||||
type ServerView struct {
|
||||
Name string `json:"name"`
|
||||
Transport string `json:"transport"`
|
||||
Status string `json:"status"`
|
||||
StartIntent string `json:"startIntent,omitempty"`
|
||||
RuntimeState string `json:"runtimeState,omitempty"`
|
||||
BuiltIn bool `json:"builtIn,omitempty"`
|
||||
Configured bool `json:"configured,omitempty"`
|
||||
AutoStart bool `json:"autoStart"`
|
||||
Tier string `json:"tier,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
EnvKeys []string `json:"envKeys,omitempty"`
|
||||
HeaderKeys []string `json:"headerKeys,omitempty"`
|
||||
Tools int `json:"tools"`
|
||||
Prompts int `json:"prompts"`
|
||||
Resources int `json:"resources"`
|
||||
HasTools bool `json:"hasTools,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ToolList []ToolView `json:"toolList,omitempty"`
|
||||
TrustedReadOnlyTools []string `json:"trustedReadOnlyTools,omitempty"`
|
||||
CallTimeoutSeconds int `json:"callTimeoutSeconds,omitempty"`
|
||||
ToolTimeoutSeconds map[string]int `json:"toolTimeoutSeconds,omitempty"`
|
||||
DefaultToolsApprovalMode string `json:"defaultToolsApprovalMode,omitempty"`
|
||||
ToolPolicies map[string]config.MCPToolPolicy `json:"toolPolicies,omitempty"`
|
||||
ApprovalsReviewer string `json:"approvalsReviewer,omitempty"`
|
||||
RequiresLaunchApproval bool `json:"requiresLaunchApproval,omitempty"`
|
||||
AuthStatus string `json:"authStatus,omitempty"`
|
||||
AuthURL string `json:"authUrl,omitempty"`
|
||||
AuthConfigured bool `json:"authConfigured,omitempty"`
|
||||
ManagedByPlugin string `json:"managedByPlugin,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Transport string `json:"transport"`
|
||||
Status string `json:"status"`
|
||||
StartIntent string `json:"startIntent,omitempty"`
|
||||
RuntimeState string `json:"runtimeState,omitempty"`
|
||||
BuiltIn bool `json:"builtIn,omitempty"`
|
||||
Configured bool `json:"configured,omitempty"`
|
||||
AutoStart bool `json:"autoStart"`
|
||||
Tier string `json:"tier,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
EnvKeys []string `json:"envKeys,omitempty"`
|
||||
HeaderKeys []string `json:"headerKeys,omitempty"`
|
||||
Tools int `json:"tools"`
|
||||
Prompts int `json:"prompts"`
|
||||
Resources int `json:"resources"`
|
||||
HasTools bool `json:"hasTools,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ToolList []ToolView `json:"toolList,omitempty"`
|
||||
CallTimeoutSeconds int `json:"callTimeoutSeconds,omitempty"`
|
||||
ToolTimeoutSeconds map[string]int `json:"toolTimeoutSeconds,omitempty"`
|
||||
RequiresLaunchApproval bool `json:"requiresLaunchApproval,omitempty"`
|
||||
AuthStatus string `json:"authStatus,omitempty"`
|
||||
AuthURL string `json:"authUrl,omitempty"`
|
||||
AuthConfigured bool `json:"authConfigured,omitempty"`
|
||||
ManagedByPlugin string `json:"managedByPlugin,omitempty"`
|
||||
}
|
||||
|
||||
type ToolView struct {
|
||||
@@ -6257,6 +6254,89 @@ func (a *App) MCPServers() []ServerView {
|
||||
return a.mcpServersView()
|
||||
}
|
||||
|
||||
type MCPMarketplaceEntryView struct {
|
||||
Name string `json:"name"`
|
||||
SuggestedName string `json:"suggestedName"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
RepositoryURL string `json:"repositoryUrl,omitempty"`
|
||||
Installable bool `json:"installable"`
|
||||
UnavailableReason string `json:"unavailableReason,omitempty"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
type MCPMarketplaceView struct {
|
||||
Servers []MCPMarketplaceEntryView `json:"servers"`
|
||||
Cached bool `json:"cached"`
|
||||
Warning string `json:"warning,omitempty"`
|
||||
}
|
||||
|
||||
// MCPMarketplace explicitly queries the official MCP Registry. It is only
|
||||
// called from the settings marketplace; startup and tool discovery never touch
|
||||
// the network. A query-specific cache keeps the page useful during a registry
|
||||
// outage without treating cached entries as installed servers.
|
||||
func (a *App) MCPMarketplace(query string) (MCPMarketplaceView, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
result, err := mcpregistry.New(mcpRegistryCachePath()).Search(ctx, query, 50)
|
||||
if err != nil {
|
||||
return MCPMarketplaceView{Servers: []MCPMarketplaceEntryView{}}, err
|
||||
}
|
||||
view := MCPMarketplaceView{
|
||||
Servers: make([]MCPMarketplaceEntryView, 0, len(result.Entries)),
|
||||
Cached: result.Cached,
|
||||
Warning: result.Warning,
|
||||
}
|
||||
for _, entry := range result.Entries {
|
||||
view.Servers = append(view.Servers, mcpMarketplaceEntryView(entry))
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// MCPMarketplaceResolve re-fetches one Registry entry immediately before the
|
||||
// settings UI installs it. Offline cache remains useful for browsing, but it is
|
||||
// never accepted as installation metadata.
|
||||
func (a *App) MCPMarketplaceResolve(registryName string) (MCPMarketplaceEntryView, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
entry, _, err := mcpregistry.New(mcpRegistryCachePath()).Resolve(ctx, registryName)
|
||||
if err != nil {
|
||||
return MCPMarketplaceEntryView{}, err
|
||||
}
|
||||
if _, err := entry.PluginEntry(""); err != nil {
|
||||
return MCPMarketplaceEntryView{}, err
|
||||
}
|
||||
return mcpMarketplaceEntryView(entry), nil
|
||||
}
|
||||
|
||||
func mcpRegistryCachePath() string {
|
||||
if cacheDir := config.CacheDir(); cacheDir != "" {
|
||||
return filepath.Join(cacheDir, "mcp-registry-v0.1.json")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mcpMarketplaceEntryView(entry mcpregistry.Entry) MCPMarketplaceEntryView {
|
||||
return MCPMarketplaceEntryView{
|
||||
Name: entry.Name,
|
||||
SuggestedName: entry.SuggestedName,
|
||||
Title: entry.Title,
|
||||
Description: entry.Description,
|
||||
Version: entry.Version,
|
||||
RepositoryURL: entry.RepositoryURL,
|
||||
Installable: entry.Installable,
|
||||
UnavailableReason: entry.UnavailableReason,
|
||||
Transport: entry.Transport,
|
||||
Command: entry.Command,
|
||||
Args: append([]string{}, entry.Args...),
|
||||
URL: entry.URL,
|
||||
}
|
||||
}
|
||||
|
||||
// lockRuntimeMutation serializes controller rebuild/teardown operations and
|
||||
// freezes runtime admission so a captured controller or Host cannot be replaced
|
||||
// or closed in flight. The caller must not hold App.mu; the lock order is
|
||||
@@ -6559,8 +6639,7 @@ func (a *App) mcpLaunchSpec(root, name string) (plugin.Spec, error) {
|
||||
LaunchManager: mcplaunch.ForWorkspace(config.ReasonixHomeDir(), root),
|
||||
ConfigSource: "workspace_config", StateHome: config.ReasonixHomeDir(),
|
||||
WriterRoots: cfg.WriteRootsForRoot(root), ForbidReadRoots: boot.RuntimeForbidReadRoots(cfg, root),
|
||||
Network: cfg.Sandbox.Network,
|
||||
OfficialServers: boot.LoadVerifiedMCPPackages(context.Background(), cfg),
|
||||
Network: cfg.Sandbox.Network,
|
||||
})
|
||||
if len(specs) != 1 {
|
||||
return plugin.Spec{}, fmt.Errorf("failed to build MCP server %q", name)
|
||||
@@ -6836,12 +6915,8 @@ func withPluginConfig(v ServerView, p config.PluginEntry) ServerView {
|
||||
v.Command = p.Command
|
||||
v.Args = append([]string(nil), p.Args...)
|
||||
v.URL = p.URL
|
||||
v.TrustedReadOnlyTools = uniqueStrings(p.TrustedReadOnlyTools)
|
||||
v.CallTimeoutSeconds = p.CallTimeoutSeconds
|
||||
v.ToolTimeoutSeconds = cloneStringIntMap(p.ToolTimeoutSeconds)
|
||||
v.DefaultToolsApprovalMode = p.DefaultToolsApprovalMode
|
||||
v.ToolPolicies = cloneMCPToolPolicies(p.Tools)
|
||||
v.ApprovalsReviewer = p.ApprovalsReviewer
|
||||
// Only a current project launch-gate failure exposes the authorization action.
|
||||
v.RequiresLaunchApproval = p.Source.RequiresLaunchApproval() && v.RequiresLaunchApproval
|
||||
v.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)
|
||||
@@ -7267,20 +7342,16 @@ func skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {
|
||||
// MCPServerInput is the drawer's "add server" form. Transport is "stdio" (Command
|
||||
// + Args + Env) or "http"/"sse" (URL). Mirrors config.PluginEntry's writable shape.
|
||||
type MCPServerInput struct {
|
||||
Name string `json:"name"`
|
||||
Transport string `json:"transport"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
URL string `json:"url"`
|
||||
Env map[string]string `json:"env"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
AutoStart *bool `json:"autoStart"`
|
||||
CallTimeoutSeconds *int `json:"callTimeoutSeconds"`
|
||||
ToolTimeoutSeconds map[string]int `json:"toolTimeoutSeconds"`
|
||||
TrustedReadOnlyTools []string `json:"trustedReadOnlyTools"`
|
||||
DefaultToolsApprovalMode *string `json:"defaultToolsApprovalMode"`
|
||||
ToolPolicies map[string]config.MCPToolPolicy `json:"tools"`
|
||||
ApprovalsReviewer *string `json:"approvalsReviewer"`
|
||||
Name string `json:"name"`
|
||||
Transport string `json:"transport"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
URL string `json:"url"`
|
||||
Env map[string]string `json:"env"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
AutoStart *bool `json:"autoStart"`
|
||||
CallTimeoutSeconds *int `json:"callTimeoutSeconds"`
|
||||
ToolTimeoutSeconds map[string]int `json:"toolTimeoutSeconds"`
|
||||
}
|
||||
|
||||
// AddMCPServer connects a server live and persists it to config (Customize → MCP →
|
||||
@@ -7296,21 +7367,17 @@ func (a *App) AddMCPServer(in MCPServerInput) (int, error) {
|
||||
return 0, rebuildControllerActiveWorkError("MCP server")
|
||||
}
|
||||
entry := config.PluginEntry{
|
||||
Name: in.Name,
|
||||
Type: normalizeMCPTransport(in.Transport),
|
||||
Command: in.Command,
|
||||
Args: in.Args,
|
||||
URL: in.URL,
|
||||
Env: in.Env,
|
||||
Headers: in.Headers,
|
||||
TrustedReadOnlyTools: nil,
|
||||
AutoStart: in.AutoStart,
|
||||
CallTimeoutSeconds: mcpIntValue(in.CallTimeoutSeconds),
|
||||
ToolTimeoutSeconds: cloneStringIntMap(in.ToolTimeoutSeconds),
|
||||
DefaultToolsApprovalMode: mcpStringValue(in.DefaultToolsApprovalMode),
|
||||
Tools: cloneMCPToolPolicies(in.ToolPolicies),
|
||||
ApprovalsReviewer: mcpStringValue(in.ApprovalsReviewer),
|
||||
Source: config.MCPSourceUserConfig,
|
||||
Name: in.Name,
|
||||
Type: normalizeMCPTransport(in.Transport),
|
||||
Command: in.Command,
|
||||
Args: in.Args,
|
||||
URL: in.URL,
|
||||
Env: in.Env,
|
||||
Headers: in.Headers,
|
||||
AutoStart: in.AutoStart,
|
||||
CallTimeoutSeconds: mcpIntValue(in.CallTimeoutSeconds),
|
||||
ToolTimeoutSeconds: cloneStringIntMap(in.ToolTimeoutSeconds),
|
||||
Source: config.MCPSourceUserConfig,
|
||||
}
|
||||
entry, _ = config.NormalizePluginCommandLine(entry)
|
||||
if err := a.saveDesktopMCPServer(root, entry); err != nil {
|
||||
@@ -7365,15 +7432,6 @@ func (a *App) UpdateMCPServer(name string, in MCPServerInput) error {
|
||||
if in.ToolTimeoutSeconds != nil {
|
||||
updated.ToolTimeoutSeconds = cloneStringIntMap(in.ToolTimeoutSeconds)
|
||||
}
|
||||
if in.DefaultToolsApprovalMode != nil {
|
||||
updated.DefaultToolsApprovalMode = strings.TrimSpace(*in.DefaultToolsApprovalMode)
|
||||
}
|
||||
if in.ToolPolicies != nil {
|
||||
updated.Tools = cloneMCPToolPolicies(in.ToolPolicies)
|
||||
}
|
||||
if in.ApprovalsReviewer != nil {
|
||||
updated.ApprovalsReviewer = strings.TrimSpace(*in.ApprovalsReviewer)
|
||||
}
|
||||
updated, _ = config.NormalizePluginCommandLine(updated)
|
||||
if updated.Type == "stdio" {
|
||||
updated.URL = ""
|
||||
@@ -7806,13 +7864,6 @@ func mcpIntValue(value *int) int {
|
||||
return *value
|
||||
}
|
||||
|
||||
func mcpStringValue(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*value)
|
||||
}
|
||||
|
||||
func cloneStringIntMap(values map[string]int) map[string]int {
|
||||
if values == nil {
|
||||
return nil
|
||||
@@ -7824,17 +7875,6 @@ func cloneStringIntMap(values map[string]int) map[string]int {
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneMCPToolPolicies(values map[string]config.MCPToolPolicy) map[string]config.MCPToolPolicy {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]config.MCPToolPolicy, len(values))
|
||||
for key, value := range values {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mcpConnected(ctrl control.SessionAPI, name string) bool {
|
||||
if ctrl == nil || ctrl.Host() == nil {
|
||||
return false
|
||||
|
||||
+17
-47
@@ -7479,7 +7479,7 @@ url = %q
|
||||
// This test isolates effective-source selection from the project launch
|
||||
// approval flow, which has its own end-to-end coverage.
|
||||
spec.RequireLaunchApproval = false
|
||||
spec.ImplicitApproval = true
|
||||
spec.Authorized = true
|
||||
},
|
||||
})
|
||||
app := NewApp()
|
||||
@@ -8757,7 +8757,7 @@ func TestAddMCPServerPersistsRemoteHeaders(t *testing.T) {
|
||||
t.Fatalf("stripe MCP missing from view: %+v", view)
|
||||
}
|
||||
|
||||
func TestAddMCPServerPersistsCompleteAdvancedConfiguration(t *testing.T) {
|
||||
func TestAddMCPServerPersistsConnectionConfiguration(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
@@ -8769,8 +8769,6 @@ func TestAddMCPServerPersistsCompleteAdvancedConfiguration(t *testing.T) {
|
||||
defer app.activeCtrl().Close()
|
||||
autoStart := false
|
||||
callTimeout := 45
|
||||
defaultMode := "writes"
|
||||
reviewer := "auto_review"
|
||||
_, err := app.AddMCPServer(MCPServerInput{
|
||||
Name: "admin",
|
||||
Transport: "streamable-http",
|
||||
@@ -8780,12 +8778,6 @@ func TestAddMCPServerPersistsCompleteAdvancedConfiguration(t *testing.T) {
|
||||
ToolTimeoutSeconds: map[string]int{
|
||||
"wipe": 120,
|
||||
},
|
||||
TrustedReadOnlyTools: []string{"status"},
|
||||
DefaultToolsApprovalMode: &defaultMode,
|
||||
ToolPolicies: map[string]config.MCPToolPolicy{
|
||||
"wipe": {ApprovalMode: "prompt"},
|
||||
},
|
||||
ApprovalsReviewer: &reviewer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -8797,17 +8789,13 @@ func TestAddMCPServerPersistsCompleteAdvancedConfiguration(t *testing.T) {
|
||||
}
|
||||
entry, ok := findPluginEntry(cfg.Plugins, "admin")
|
||||
if !ok || entry.Type != "http" || entry.AutoStart == nil || *entry.AutoStart ||
|
||||
entry.CallTimeoutSeconds != 45 || entry.ToolTimeoutSeconds["wipe"] != 120 ||
|
||||
entry.DefaultToolsApprovalMode != "writes" || entry.Tools["wipe"].ApprovalMode != "prompt" ||
|
||||
entry.ApprovalsReviewer != "auto_review" || len(entry.TrustedReadOnlyTools) != 0 {
|
||||
entry.CallTimeoutSeconds != 45 || entry.ToolTimeoutSeconds["wipe"] != 120 {
|
||||
t.Fatalf("persisted advanced MCP entry = %+v, found=%v", entry, ok)
|
||||
}
|
||||
|
||||
views := app.MCPServers()
|
||||
if len(views) != 1 || views[0].Transport != "http" || views[0].AutoStart ||
|
||||
views[0].CallTimeoutSeconds != 45 || views[0].ToolTimeoutSeconds["wipe"] != 120 ||
|
||||
views[0].DefaultToolsApprovalMode != "writes" || views[0].ToolPolicies["wipe"].ApprovalMode != "prompt" ||
|
||||
views[0].ApprovalsReviewer != "auto_review" {
|
||||
views[0].CallTimeoutSeconds != 45 || views[0].ToolTimeoutSeconds["wipe"] != 120 {
|
||||
t.Fatalf("advanced MCP ServerView = %+v", views)
|
||||
}
|
||||
}
|
||||
@@ -8823,25 +8811,17 @@ func TestUpdateMCPServerPreservesAbsentFieldsAndClearsExplicitOnes(t *testing.T)
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer app.activeCtrl().Close()
|
||||
callTimeout := 45
|
||||
defaultMode := "writes"
|
||||
reviewer := "auto_review"
|
||||
if _, err := app.AddMCPServer(MCPServerInput{
|
||||
Name: "admin",
|
||||
Transport: "http",
|
||||
URL: srv.URL,
|
||||
CallTimeoutSeconds: &callTimeout,
|
||||
ToolTimeoutSeconds: map[string]int{"wipe": 120},
|
||||
TrustedReadOnlyTools: []string{"status"},
|
||||
DefaultToolsApprovalMode: &defaultMode,
|
||||
ToolPolicies: map[string]config.MCPToolPolicy{"wipe": {ApprovalMode: "prompt"}},
|
||||
ApprovalsReviewer: &reviewer,
|
||||
Name: "admin",
|
||||
Transport: "http",
|
||||
URL: srv.URL,
|
||||
CallTimeoutSeconds: &callTimeout,
|
||||
ToolTimeoutSeconds: map[string]int{"wipe": 120},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// An old frontend (or a partial payload) omits the new fields entirely:
|
||||
// approval settings survive, while legacy trusted_read_only_tools input is
|
||||
// intentionally not generated by new saves.
|
||||
// An old frontend (or a partial payload) omits optional timeout fields.
|
||||
if err := app.UpdateMCPServer("admin", MCPServerInput{Name: "admin", Transport: "http", URL: srv.URL}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -8850,26 +8830,18 @@ func TestUpdateMCPServerPreservesAbsentFieldsAndClearsExplicitOnes(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry, ok := findPluginEntry(cfg.Plugins, "admin")
|
||||
if !ok || entry.CallTimeoutSeconds != 45 || entry.ToolTimeoutSeconds["wipe"] != 120 ||
|
||||
entry.DefaultToolsApprovalMode != "writes" || entry.Tools["wipe"].ApprovalMode != "prompt" ||
|
||||
entry.ApprovalsReviewer != "auto_review" || len(entry.TrustedReadOnlyTools) != 0 {
|
||||
if !ok || entry.CallTimeoutSeconds != 45 || entry.ToolTimeoutSeconds["wipe"] != 120 {
|
||||
t.Fatalf("absent input fields must preserve persisted values, entry = %+v, found=%v", entry, ok)
|
||||
}
|
||||
|
||||
// Explicit zero values are the editor's clear semantics.
|
||||
cleared := 0
|
||||
emptyMode := ""
|
||||
emptyReviewer := ""
|
||||
if err := app.UpdateMCPServer("admin", MCPServerInput{
|
||||
Name: "admin",
|
||||
Transport: "http",
|
||||
URL: srv.URL,
|
||||
CallTimeoutSeconds: &cleared,
|
||||
ToolTimeoutSeconds: map[string]int{},
|
||||
TrustedReadOnlyTools: []string{},
|
||||
DefaultToolsApprovalMode: &emptyMode,
|
||||
ToolPolicies: map[string]config.MCPToolPolicy{},
|
||||
ApprovalsReviewer: &emptyReviewer,
|
||||
Name: "admin",
|
||||
Transport: "http",
|
||||
URL: srv.URL,
|
||||
CallTimeoutSeconds: &cleared,
|
||||
ToolTimeoutSeconds: map[string]int{},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -8878,9 +8850,7 @@ func TestUpdateMCPServerPreservesAbsentFieldsAndClearsExplicitOnes(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry, ok = findPluginEntry(cfg.Plugins, "admin")
|
||||
if !ok || entry.CallTimeoutSeconds != 0 || len(entry.ToolTimeoutSeconds) != 0 ||
|
||||
entry.DefaultToolsApprovalMode != "" || len(entry.Tools) != 0 ||
|
||||
entry.ApprovalsReviewer != "" || len(entry.TrustedReadOnlyTools) != 0 {
|
||||
if !ok || entry.CallTimeoutSeconds != 0 || len(entry.ToolTimeoutSeconds) != 0 {
|
||||
t.Fatalf("explicit empty fields must clear persisted values, entry = %+v, found=%v", entry, ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,116 @@ function ok(value: unknown, message: string) {
|
||||
if (!value) throw new Error(message);
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
const meta: Meta = { label: "test", ready: true, eventChannel: "mcp-registry-channel", cwd: "/tmp/reasonix-test", workspaceRoot: "/tmp/reasonix-test" };
|
||||
const tabs: TabMeta[] = [{
|
||||
id: "tab-mcp-registry",
|
||||
scope: "project",
|
||||
workspaceRoot: "/tmp/reasonix-test",
|
||||
workspaceName: "reasonix-test",
|
||||
topicId: "topic-mcp-registry",
|
||||
topicTitle: "Registry",
|
||||
label: "Registry",
|
||||
ready: true,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "auto",
|
||||
active: true,
|
||||
cwd: "/tmp/reasonix-test",
|
||||
}];
|
||||
let servers: ServerView[] = [];
|
||||
let installed: MCPServerInput | null = null;
|
||||
let registryCached = false;
|
||||
let resolvedRegistryName = "";
|
||||
const registryEntry = {
|
||||
name: "io.example/demo",
|
||||
suggestedName: "demo",
|
||||
title: "Demo MCP",
|
||||
description: "Registry demo server",
|
||||
version: "1.0.0",
|
||||
installable: true,
|
||||
transport: "http",
|
||||
args: [],
|
||||
url: "https://mcp.example.test/mcp",
|
||||
};
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
Meta: async () => meta,
|
||||
ListTabs: async () => tabs,
|
||||
MCPServers: async () => servers,
|
||||
MCPMarketplace: async () => ({
|
||||
cached: registryCached,
|
||||
warning: registryCached ? "offline" : undefined,
|
||||
servers: [registryEntry],
|
||||
}),
|
||||
MCPMarketplaceResolve: async (registryName) => {
|
||||
resolvedRegistryName = registryName;
|
||||
return registryEntry;
|
||||
},
|
||||
AddMCPServer: async (input) => {
|
||||
installed = input;
|
||||
servers = [{
|
||||
name: input.name,
|
||||
transport: input.transport,
|
||||
status: "connected",
|
||||
configured: true,
|
||||
autoStart: true,
|
||||
tools: 1,
|
||||
prompts: 0,
|
||||
resources: 0,
|
||||
url: input.url,
|
||||
}];
|
||||
return 1;
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(LocaleProvider, null, React.createElement(MCPServersSettingsPage)));
|
||||
await flush();
|
||||
});
|
||||
await waitFor("registry browse action", () => Boolean(findButton("Browse registry")));
|
||||
await act(async () => {
|
||||
findButton("Browse registry")?.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("registry result", () => document.body.textContent?.includes("Demo MCP") ?? false);
|
||||
await act(async () => {
|
||||
findButton("Install")?.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("registry install", () => installed !== null && document.body.textContent?.includes("demo") === true);
|
||||
const installedEntry = installed as MCPServerInput | null;
|
||||
ok(installedEntry?.name === "demo" && installedEntry.transport === "http" && installedEntry.url === "https://mcp.example.test/mcp", "registry install converts the selected entry into the normal add-and-connect input");
|
||||
ok(resolvedRegistryName === "io.example/demo", "registry install re-resolves current metadata by canonical name");
|
||||
|
||||
registryCached = true;
|
||||
installed = null;
|
||||
await act(async () => {
|
||||
findButton("Browse registry")?.click();
|
||||
await flush();
|
||||
findButton("Search")?.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("cached registry warning", () => document.body.textContent?.includes("Showing cached results") ?? false);
|
||||
const cachedInstall = findButton("Install");
|
||||
ok(cachedInstall?.disabled === true, "cached Registry results must remain browse-only");
|
||||
cachedInstall?.click();
|
||||
await flush();
|
||||
ok(installed === null, "cached Registry result must not be installed");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
const completeMCPJSON = JSON.stringify({
|
||||
admin: {
|
||||
type: "streamable-http",
|
||||
@@ -32,10 +142,11 @@ const completeMCP = parseMCPServerJSON(completeMCPJSON);
|
||||
ok(completeMCP.input.transport === "http", "streamable-http should normalize to http");
|
||||
ok(completeMCP.input.autoStart === false, "advanced JSON should preserve auto_start=false");
|
||||
ok(completeMCP.input.callTimeoutSeconds === 45 && completeMCP.input.toolTimeoutSeconds?.wipe === 120, "advanced JSON should preserve timeouts");
|
||||
ok(completeMCP.input.defaultToolsApprovalMode === "writes" && completeMCP.input.tools?.wipe.approval_mode === "prompt", "advanced JSON should preserve approval modes");
|
||||
ok(completeMCP.input.approvalsReviewer === "auto_review", "advanced JSON should preserve the reviewer");
|
||||
const completeMCPRoundTrip = parseMCPServerJSON(mcpServerDraftJSON(completeMCP.draft));
|
||||
ok(completeMCPRoundTrip.input.transport === "http" && completeMCPRoundTrip.input.tools?.wipe.approval_mode === "prompt", "Form/JSON switching should preserve advanced fields");
|
||||
ok(completeMCPRoundTrip.input.transport === "http" && completeMCPRoundTrip.input.toolTimeoutSeconds?.wipe === 120, "Form/JSON switching should preserve connection fields");
|
||||
const normalizedMCPJSON = mcpServerDraftJSON(completeMCP.draft);
|
||||
ok(!normalizedMCPJSON.includes("trusted_read_only_tools"), "Form/JSON switching should drop the removed reader setting");
|
||||
ok(!normalizedMCPJSON.includes("approval_mode") && !normalizedMCPJSON.includes("approvals_reviewer"), "Form/JSON switching should drop retired MCP approval settings");
|
||||
let unsupportedMCPFieldRejected = false;
|
||||
try {
|
||||
parseMCPServerJSON(JSON.stringify({ admin: { command: "admin-mcp", unsupported: true } }));
|
||||
@@ -53,8 +164,7 @@ try {
|
||||
ok(incompleteMCPRejected, "submitting incomplete MCP JSON must still require a command or URL");
|
||||
const incompleteMCPDraft = parseMCPServerJSON(incompleteMCPJSON, undefined, { allowIncomplete: true });
|
||||
ok(incompleteMCPDraft.draft.name === "admin" && incompleteMCPDraft.draft.command === "", "mode switching may recover an incomplete MCP draft for form editing");
|
||||
const clearedMCPPolicy = parseMCPServerJSON(JSON.stringify({ admin: { command: "admin-mcp", default_tools_approval_mode: "", approvals_reviewer: "" } }));
|
||||
ok(clearedMCPPolicy.input.defaultToolsApprovalMode === "" && clearedMCPPolicy.input.approvalsReviewer === "", "empty advanced policy values should clear saved overrides");
|
||||
parseMCPServerJSON(JSON.stringify({ admin: { command: "admin-mcp", default_tools_approval_mode: "", approvals_reviewer: "" } }));
|
||||
let nullToolTimeoutRejected = false;
|
||||
try {
|
||||
parseMCPServerJSON(JSON.stringify({ admin: { command: "admin-mcp", tool_timeout_seconds: { wipe: null } } }));
|
||||
@@ -63,8 +173,8 @@ try {
|
||||
}
|
||||
ok(nullToolTimeoutRejected, "a null per-tool timeout must be rejected instead of silently clearing all timeouts");
|
||||
const sparseEdit = withExplicitMCPClears(parseMCPServerJSON(JSON.stringify({ admin: { command: "admin-mcp" } })).input);
|
||||
ok(sparseEdit.callTimeoutSeconds === 0 && sparseEdit.defaultToolsApprovalMode === "" && sparseEdit.approvalsReviewer === "", "editing an existing server with fields removed must clear those settings");
|
||||
ok(sparseEdit.autoStart === true && Object.keys(sparseEdit.toolTimeoutSeconds ?? { x: 1 }).length === 0 && sparseEdit.trustedReadOnlyTools === undefined && Object.keys(sparseEdit.tools ?? { x: 1 }).length === 0, "removed collection fields must clear while legacy trust stays absent");
|
||||
ok(sparseEdit.callTimeoutSeconds === 0, "editing an existing server with fields removed must clear the timeout");
|
||||
ok(sparseEdit.autoStart === true && Object.keys(sparseEdit.toolTimeoutSeconds ?? { x: 1 }).length === 0, "removed timeout fields must clear");
|
||||
ok(sparseEdit.env === null && sparseEdit.headers === null, "absent env/headers must stay preserve-on-absent because their values are never seeded into the editor");
|
||||
|
||||
const refusedRegistryError = [
|
||||
@@ -300,7 +410,6 @@ console.log("capabilities panel MCP actions");
|
||||
{ name: "issue_write", description: "Write issues." },
|
||||
{ name: "broken_read", description: "Broken tool.", readOnlyHint: true, schemaError: "invalid input schema: bad nested type" },
|
||||
],
|
||||
trustedReadOnlyTools: [],
|
||||
}];
|
||||
window.go = {
|
||||
main: {
|
||||
|
||||
@@ -122,16 +122,16 @@ console.log("\ndecision surface");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Destructive MCP approval is one-shot even though the tool name is dynamic.
|
||||
// A fresh decision for a dynamic tool is one-shot.
|
||||
{
|
||||
const dom = installDom();
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
const answers: Array<[boolean, boolean, boolean]> = [];
|
||||
const approval: WireApproval = {
|
||||
id: "mcp-danger-1",
|
||||
tool: "mcp__srv__wipe",
|
||||
subject: "MCP srv/wipe declares destructive side effects",
|
||||
reason: "This installed MCP tool declares destructive side effects. Review the target and arguments before allowing this call. Auto/YOLO approval cannot answer this decision.",
|
||||
id: "dynamic-danger-1",
|
||||
tool: "extension__wipe",
|
||||
subject: "Dynamic tool declares destructive side effects",
|
||||
reason: "Review the target and arguments before allowing this call.",
|
||||
fresh: true,
|
||||
};
|
||||
|
||||
@@ -149,15 +149,15 @@ console.log("\ndecision surface");
|
||||
});
|
||||
|
||||
const actions = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLButtonElement[];
|
||||
eq(actions.length, 2, "fresh destructive MCP approval only offers allow once and deny");
|
||||
ok(!document.body.textContent?.includes("Always allow"), "fresh destructive MCP approval hides remembered grants");
|
||||
eq(actions.length, 2, "fresh dynamic-tool decision only offers allow once and deny");
|
||||
ok(!document.body.textContent?.includes("Always allow"), "fresh dynamic-tool decision hides remembered grants");
|
||||
|
||||
const confirm = document.querySelector(".decision-confirm-bar__confirm") as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
confirm.click();
|
||||
await flushTimers(220);
|
||||
});
|
||||
eq(JSON.stringify(answers[0]), JSON.stringify([true, false, false]), "fresh destructive MCP approval is one-shot");
|
||||
eq(JSON.stringify(answers[0]), JSON.stringify([true, false, false]), "fresh dynamic-tool decision is one-shot");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { asArray } from "../lib/array";
|
||||
import { app, openExternal } from "../lib/bridge";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { mcpServerLifecycleActions, mcpServerRetryableFromAvailableList } from "../lib/mcpServerLifecycle";
|
||||
import type { CapabilitiesView, MCPApprovalMode, MCPApprovalsReviewer, MCPServerInput, MCPToolPolicy, PluginAgentView, PluginCommandView, PluginCompatibilityIssue, PluginHookView, PluginInstallOptions, PluginMCPServerView, PluginSkillView, PluginView, ServerView, SkillRootSkillView, SkillRootView, SkillsSettingsView, SkillView, TabMeta } from "../lib/types";
|
||||
import type { CapabilitiesView, MCPMarketplaceEntry, MCPMarketplaceView, MCPServerInput, PluginAgentView, PluginCommandView, PluginCompatibilityIssue, PluginHookView, PluginInstallOptions, PluginMCPServerView, PluginSkillView, PluginView, ServerView, SkillRootSkillView, SkillRootView, SkillsSettingsView, SkillView, TabMeta } from "../lib/types";
|
||||
import { InlineConfirmButton } from "./InlineConfirmButton";
|
||||
import { ResizableDrawer } from "./ResizableDrawer";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
@@ -325,7 +325,6 @@ function normalizeServerViews(servers: ServerView[] | null | undefined): ServerV
|
||||
envKeys: asArray(server.envKeys),
|
||||
headerKeys: asArray(server.headerKeys),
|
||||
toolList: asArray(server.toolList),
|
||||
trustedReadOnlyTools: asArray(server.trustedReadOnlyTools),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -1159,9 +1158,6 @@ function EditServerForm({
|
||||
url: isStdio ? "" : url.trim(),
|
||||
env: envText === "" ? null : parseKeyValueText(envText),
|
||||
headers: isStdio || headerText === "" ? null : parseKeyValueText(headerText),
|
||||
// Preserve an existing local reader declaration in the backend. The
|
||||
// basic settings form does not expose this advanced compatibility field.
|
||||
trustedReadOnlyTools: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2271,6 +2267,7 @@ function pluginPlanNotice(plan: PluginInstallPlanView, t: ReturnType<typeof useT
|
||||
type MCPSettingsScreen =
|
||||
| { kind: "list" }
|
||||
| { kind: "add" }
|
||||
| { kind: "marketplace" }
|
||||
| { kind: "detail"; name: string }
|
||||
| { kind: "edit"; name: string };
|
||||
|
||||
@@ -2289,10 +2286,6 @@ type MCPServerEditorDraft = {
|
||||
autoStart?: boolean;
|
||||
callTimeoutSeconds?: number;
|
||||
toolTimeoutSeconds?: Record<string, number>;
|
||||
trustedReadOnlyTools?: string[];
|
||||
defaultToolsApprovalMode?: MCPApprovalMode | "";
|
||||
tools?: Record<string, MCPToolPolicy>;
|
||||
approvalsReviewer?: MCPApprovalsReviewer | "";
|
||||
};
|
||||
|
||||
type MCPServerJSONError = "invalid" | "single" | "name" | "required" | "unsupported";
|
||||
@@ -2495,10 +2488,6 @@ function mcpServerEditorDraft(server?: ServerView): MCPServerEditorDraft {
|
||||
autoStart: server?.autoStart,
|
||||
callTimeoutSeconds: server?.callTimeoutSeconds,
|
||||
toolTimeoutSeconds: server?.toolTimeoutSeconds ? { ...server.toolTimeoutSeconds } : undefined,
|
||||
trustedReadOnlyTools: server?.trustedReadOnlyTools ? [...server.trustedReadOnlyTools] : undefined,
|
||||
defaultToolsApprovalMode: server?.defaultToolsApprovalMode,
|
||||
tools: server?.toolPolicies ? { ...server.toolPolicies } : undefined,
|
||||
approvalsReviewer: server?.approvalsReviewer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2518,12 +2507,26 @@ function mcpServerDraftInput(draft: MCPServerEditorDraft): MCPServerInput {
|
||||
autoStart: draft.autoStart ?? null,
|
||||
callTimeoutSeconds: draft.callTimeoutSeconds ?? null,
|
||||
toolTimeoutSeconds: draft.toolTimeoutSeconds ?? null,
|
||||
// Keep parsing the legacy field for the two-release migration window,
|
||||
// but never generate it in a new or edited server configuration.
|
||||
trustedReadOnlyTools: undefined,
|
||||
defaultToolsApprovalMode: draft.defaultToolsApprovalMode ?? null,
|
||||
tools: draft.tools ?? null,
|
||||
approvalsReviewer: draft.approvalsReviewer ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mcpMarketplaceServerInput(entry: MCPMarketplaceEntry, servers: ServerView[]): MCPServerInput {
|
||||
const used = new Set(servers.map((server) => server.name));
|
||||
const base = entry.suggestedName || entry.name.split("/").filter(Boolean).pop() || "mcp-server";
|
||||
let name = base;
|
||||
for (let suffix = 2; used.has(name); suffix += 1) name = `${base}-${suffix}`;
|
||||
const transport = entry.transport || "stdio";
|
||||
return {
|
||||
name,
|
||||
transport,
|
||||
command: transport === "stdio" ? entry.command || "" : "",
|
||||
args: transport === "stdio" ? [...(entry.args ?? [])] : [],
|
||||
url: transport === "stdio" ? "" : entry.url || "",
|
||||
env: null,
|
||||
headers: null,
|
||||
autoStart: null,
|
||||
callTimeoutSeconds: null,
|
||||
toolTimeoutSeconds: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2540,9 +2543,6 @@ export function mcpServerDraftJSON(draft: MCPServerEditorDraft): string {
|
||||
if (input.autoStart != null) entry.auto_start = input.autoStart;
|
||||
if (input.callTimeoutSeconds != null) entry.call_timeout_seconds = input.callTimeoutSeconds;
|
||||
if (input.toolTimeoutSeconds && Object.keys(input.toolTimeoutSeconds).length > 0) entry.tool_timeout_seconds = input.toolTimeoutSeconds;
|
||||
if (input.defaultToolsApprovalMode != null) entry.default_tools_approval_mode = input.defaultToolsApprovalMode;
|
||||
if (input.tools && Object.keys(input.tools).length > 0) entry.tools = input.tools;
|
||||
if (input.approvalsReviewer != null) entry.approvals_reviewer = input.approvalsReviewer;
|
||||
return JSON.stringify({ [input.name || "server-name"]: entry }, null, 2);
|
||||
}
|
||||
|
||||
@@ -2588,42 +2588,12 @@ function nonNegativeIntegerRecord(value: unknown): Record<string, number> | unde
|
||||
export function withExplicitMCPClears(input: MCPServerInput): MCPServerInput {
|
||||
return {
|
||||
...input,
|
||||
trustedReadOnlyTools: undefined,
|
||||
autoStart: input.autoStart ?? true,
|
||||
callTimeoutSeconds: input.callTimeoutSeconds ?? 0,
|
||||
toolTimeoutSeconds: input.toolTimeoutSeconds ?? {},
|
||||
defaultToolsApprovalMode: input.defaultToolsApprovalMode ?? "",
|
||||
tools: input.tools ?? {},
|
||||
approvalsReviewer: input.approvalsReviewer ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function approvalMode(value: unknown): MCPApprovalMode | undefined {
|
||||
if (value == null || value === "") return undefined;
|
||||
if (value === "auto" || value === "prompt" || value === "writes" || value === "approve") return value;
|
||||
throw new Error("invalid" satisfies MCPServerJSONError);
|
||||
}
|
||||
|
||||
function approvalsReviewer(value: unknown): MCPApprovalsReviewer | undefined {
|
||||
if (value == null || value === "") return undefined;
|
||||
if (value === "user" || value === "auto_review") return value;
|
||||
throw new Error("invalid" satisfies MCPServerJSONError);
|
||||
}
|
||||
|
||||
function mcpToolPolicies(value: unknown): Record<string, MCPToolPolicy> | undefined {
|
||||
if (value == null) return undefined;
|
||||
if (!isRecord(value)) throw new Error("invalid" satisfies MCPServerJSONError);
|
||||
const out: Record<string, MCPToolPolicy> = {};
|
||||
for (const [name, item] of Object.entries(value)) {
|
||||
if (!name.trim() || !isRecord(item)) throw new Error("invalid" satisfies MCPServerJSONError);
|
||||
assertSupportedKeys(item, ["approval_mode"]);
|
||||
const mode = approvalMode(item.approval_mode);
|
||||
if (!mode) throw new Error("invalid" satisfies MCPServerJSONError);
|
||||
out[name] = { approval_mode: mode };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseMCPServerJSON(raw: string, fixedName?: string, options?: { allowIncomplete?: boolean }): { input: MCPServerInput; draft: MCPServerEditorDraft } {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
@@ -2668,16 +2638,9 @@ export function parseMCPServerJSON(raw: string, fixedName?: string, options?: {
|
||||
throw new Error("invalid" satisfies MCPServerJSONError);
|
||||
}
|
||||
if (value.auto_start != null && typeof value.auto_start !== "boolean") throw new Error("invalid" satisfies MCPServerJSONError);
|
||||
if (value.trusted_read_only_tools != null && (!Array.isArray(value.trusted_read_only_tools) || !value.trusted_read_only_tools.every((item) => typeof item === "string"))) {
|
||||
throw new Error("invalid" satisfies MCPServerJSONError);
|
||||
}
|
||||
const autoStart = value.auto_start as boolean | undefined;
|
||||
const callTimeoutSeconds = nonNegativeInteger(value.call_timeout_seconds);
|
||||
const toolTimeoutSeconds = nonNegativeIntegerRecord(value.tool_timeout_seconds);
|
||||
const trustedReadOnlyTools = value.trusted_read_only_tools ? [...value.trusted_read_only_tools as string[]] : undefined;
|
||||
const defaultToolsApprovalMode = value.default_tools_approval_mode === "" ? "" : approvalMode(value.default_tools_approval_mode);
|
||||
const tools = mcpToolPolicies(value.tools);
|
||||
const reviewer = value.approvals_reviewer === "" ? "" : approvalsReviewer(value.approvals_reviewer);
|
||||
const input: MCPServerInput = {
|
||||
name: fixedName || name,
|
||||
transport,
|
||||
@@ -2689,10 +2652,6 @@ export function parseMCPServerJSON(raw: string, fixedName?: string, options?: {
|
||||
autoStart: autoStart ?? null,
|
||||
callTimeoutSeconds: callTimeoutSeconds ?? null,
|
||||
toolTimeoutSeconds: toolTimeoutSeconds ?? null,
|
||||
trustedReadOnlyTools,
|
||||
defaultToolsApprovalMode: defaultToolsApprovalMode ?? null,
|
||||
tools: tools ?? null,
|
||||
approvalsReviewer: reviewer ?? null,
|
||||
};
|
||||
return {
|
||||
input,
|
||||
@@ -2711,10 +2670,6 @@ export function parseMCPServerJSON(raw: string, fixedName?: string, options?: {
|
||||
autoStart,
|
||||
callTimeoutSeconds,
|
||||
toolTimeoutSeconds,
|
||||
trustedReadOnlyTools,
|
||||
defaultToolsApprovalMode,
|
||||
tools,
|
||||
approvalsReviewer: reviewer,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2874,6 +2829,8 @@ export function MCPServersSettingsPage() {
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [screen, setScreen] = useState<MCPSettingsScreen>({ kind: "list" });
|
||||
const [marketplace, setMarketplace] = useState<MCPMarketplaceView | null>(null);
|
||||
const [marketplaceQuery, setMarketplaceQuery] = useState("");
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const [meta, tabs] = await Promise.all([
|
||||
@@ -2916,6 +2873,28 @@ export function MCPServersSettingsPage() {
|
||||
};
|
||||
const authorizeProjectAndConnect = async (name: string) =>
|
||||
mutate(() => app.AuthorizeAndConnectMCPServer(name));
|
||||
const browseMarketplace = async (search = marketplaceQuery) => {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const result = await app.MCPMarketplace(search);
|
||||
setMarketplace({ ...result, servers: asArray(result.servers) });
|
||||
return true;
|
||||
} catch (error) {
|
||||
setErr(String((error as Error)?.message ?? error));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const openMarketplace = () => {
|
||||
setScreen({ kind: "marketplace" });
|
||||
if (marketplace === null) void browseMarketplace("");
|
||||
};
|
||||
const installMarketplaceEntry = async (entry: MCPMarketplaceEntry) => {
|
||||
const current = await app.MCPMarketplaceResolve(entry.name);
|
||||
return app.AddMCPServer(mcpMarketplaceServerInput(current, servers ?? []));
|
||||
};
|
||||
const filteredServers = useMemo(() => {
|
||||
const sorted = sortServersForDisplay(servers ?? []);
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
@@ -2953,6 +2932,10 @@ export function MCPServersSettingsPage() {
|
||||
<RefreshCw aria-hidden size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button className="btn btn--small" disabled={actionBusy} type="button" onClick={openMarketplace}>
|
||||
<Search aria-hidden size={14} />
|
||||
{t("caps.browseRegistry")}
|
||||
</button>
|
||||
<button className="btn btn--primary btn--small cap-mcp-add-btn" disabled={actionBusy} type="button" onClick={() => setScreen({ kind: "add" })}>
|
||||
<Plus aria-hidden size={14} />
|
||||
{t("caps.addServer")}
|
||||
@@ -2987,6 +2970,47 @@ export function MCPServersSettingsPage() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{screen.kind === "marketplace" && (
|
||||
<div className="cap-mcp-subpage">
|
||||
<MCPSettingsSubpageHeader title={t("caps.registryTitle")} description={t("caps.registryHint")} onBack={() => setScreen({ kind: "list" })} />
|
||||
<form className="cap-mcp-search cap-mcp-search--action" onSubmit={(event) => { event.preventDefault(); void browseMarketplace(); }}>
|
||||
<Search aria-hidden size={15} />
|
||||
<input type="search" value={marketplaceQuery} onInput={(event) => setMarketplaceQuery(event.currentTarget.value)} placeholder={t("caps.searchRegistry")} />
|
||||
<button className="btn btn--small" disabled={busy} type="submit">{t("caps.search")}</button>
|
||||
</form>
|
||||
{marketplace?.warning && <div className="banner" role="status">{t("caps.registryCached")} {marketplace.warning}</div>}
|
||||
{busy && marketplace === null && <div className="mem-empty">{t("caps.loading")}</div>}
|
||||
{!busy && marketplace && marketplace.servers.length === 0 && <div className="mem-empty">{t("caps.noRegistryMatches")}</div>}
|
||||
{marketplace && marketplace.servers.length > 0 && (
|
||||
<div className="cap-mcp-list">
|
||||
{marketplace.servers.map((entry) => (
|
||||
<div className="cap-mcp-list-row" key={entry.name}>
|
||||
<div className="cap-mcp-list-row__main">
|
||||
<span className="cap-mcp-list-row__icon" aria-hidden><ServerIcon size={16} strokeWidth={1.8} /></span>
|
||||
<span className="cap-mcp-list-row__copy">
|
||||
<span className="cap-mcp-list-row__head">
|
||||
<span className="cap-mcp-list-row__name">{entry.title || entry.name}</span>
|
||||
{entry.version && <span className="cap-mcp-list-row__transport">{entry.version}</span>}
|
||||
{entry.transport && <span className="cap-mcp-list-row__transport">{entry.transport}</span>}
|
||||
</span>
|
||||
<span className="cap-mcp-list-row__target">{entry.name}</span>
|
||||
<span className="cap-mcp-list-row__summary">{entry.description || entry.unavailableReason}</span>
|
||||
{!entry.installable && entry.unavailableReason && <span className="cap-mcp-list-row__owner">{entry.unavailableReason}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="cap-mcp-list-row__actions">
|
||||
{entry.installable ? (
|
||||
<button className="btn btn--primary btn--small" disabled={actionBusy || marketplace.cached} type="button" onClick={() => void mutate(() => installMarketplaceEntry(entry)).then((ok) => { if (ok) setScreen({ kind: "list" }); })}>
|
||||
{t("caps.install")}
|
||||
</button>
|
||||
) : <span className="cap-mcp-list-row__owner">{t("caps.manualSetup")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{screen.kind === "add" && (
|
||||
<div className="cap-mcp-subpage">
|
||||
<MCPSettingsSubpageHeader title={t("caps.addServerTitle")} description={t("caps.addServerHint")} onBack={() => setScreen({ kind: "list" })} />
|
||||
|
||||
@@ -56,7 +56,9 @@ import type {
|
||||
HookConfigView,
|
||||
HooksSettingsView,
|
||||
JobView,
|
||||
MCPMarketplaceEntry,
|
||||
MCPServerInput,
|
||||
MCPMarketplaceView,
|
||||
MCPToolView,
|
||||
MemorySuggestion,
|
||||
MemorySuggestionsView,
|
||||
@@ -245,6 +247,8 @@ export interface AppBindings {
|
||||
Commands(): Promise<CommandInfo[]>;
|
||||
Capabilities(): Promise<CapabilitiesView>;
|
||||
MCPServers(): Promise<ServerView[]>;
|
||||
MCPMarketplace(query: string): Promise<MCPMarketplaceView>;
|
||||
MCPMarketplaceResolve(registryName: string): Promise<MCPMarketplaceEntry>;
|
||||
SkillsSettings(): Promise<SkillsSettingsView>;
|
||||
CapabilityDiagnostics(includeSessionRuntime: boolean): Promise<CapabilityDiagnosticsReport>;
|
||||
Plugins(): Promise<PluginView[]>;
|
||||
@@ -1133,7 +1137,6 @@ function makeMockApp(): AppBindings {
|
||||
tools: 4,
|
||||
prompts: 2,
|
||||
resources: 0,
|
||||
trustedReadOnlyTools: ["pull_request_read"],
|
||||
toolList: [
|
||||
{ name: "issue_read", description: "Read GitHub issue details and comments.", readOnlyHint: true },
|
||||
{ name: "pull_request_read", description: "Read pull request metadata, files, and review threads.", readOnlyHint: true },
|
||||
@@ -2825,6 +2828,42 @@ function makeMockApp(): AppBindings {
|
||||
async MCPServers() {
|
||||
return capServers.map((s) => ({ ...s }));
|
||||
},
|
||||
async MCPMarketplace(query: string) {
|
||||
const servers = [
|
||||
{
|
||||
name: "io.modelcontextprotocol/server-filesystem",
|
||||
suggestedName: "server-filesystem",
|
||||
title: "Filesystem",
|
||||
description: "Secure file operations through MCP.",
|
||||
version: "1.0.0",
|
||||
installable: true,
|
||||
transport: "stdio",
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/[email protected]"],
|
||||
},
|
||||
{
|
||||
name: "io.example/manual",
|
||||
suggestedName: "manual",
|
||||
title: "Manual setup example",
|
||||
description: "Requires an API key before installation.",
|
||||
version: "1.0.0",
|
||||
installable: false,
|
||||
unavailableReason: "package requires environment variables or arguments",
|
||||
args: [],
|
||||
},
|
||||
];
|
||||
const normalized = query.trim().toLowerCase();
|
||||
return {
|
||||
servers: normalized ? servers.filter((entry) => [entry.name, entry.title, entry.description].join(" ").toLowerCase().includes(normalized)) : servers,
|
||||
cached: false,
|
||||
} as MCPMarketplaceView;
|
||||
},
|
||||
async MCPMarketplaceResolve(registryName: string) {
|
||||
const result = await this.MCPMarketplace(registryName);
|
||||
const entry = result.servers.find((candidate) => candidate.name.toLowerCase() === registryName.trim().toLowerCase());
|
||||
if (!entry) throw new Error(`MCP Registry has no server named ${JSON.stringify(registryName)}`);
|
||||
return entry;
|
||||
},
|
||||
async SkillsSettings() {
|
||||
return {
|
||||
skills: capSkills.map((s) => ({ ...s })),
|
||||
@@ -3002,7 +3041,6 @@ function makeMockApp(): AppBindings {
|
||||
url: input.transport === "stdio" ? "" : input.url,
|
||||
envKeys: input.env ? Object.keys(input.env).sort() : s.envKeys,
|
||||
headerKeys: input.headers ? Object.keys(input.headers).sort() : s.headerKeys,
|
||||
trustedReadOnlyTools: input.trustedReadOnlyTools ?? s.trustedReadOnlyTools,
|
||||
tools: nextTools,
|
||||
error: undefined,
|
||||
authStatus: nextStatus !== "connected" && input.transport !== "stdio" ? "possible" : undefined,
|
||||
|
||||
@@ -703,24 +703,13 @@ export interface ServerView {
|
||||
hasTools?: boolean;
|
||||
error?: string;
|
||||
toolList?: MCPToolView[];
|
||||
trustedReadOnlyTools?: string[];
|
||||
callTimeoutSeconds?: number;
|
||||
toolTimeoutSeconds?: Record<string, number>;
|
||||
defaultToolsApprovalMode?: MCPApprovalMode;
|
||||
toolPolicies?: Record<string, MCPToolPolicy>;
|
||||
approvalsReviewer?: MCPApprovalsReviewer;
|
||||
requiresLaunchApproval?: boolean;
|
||||
authStatus?: "none" | "possible" | "required" | string;
|
||||
authUrl?: string;
|
||||
authConfigured?: boolean;
|
||||
managedByPlugin?: string;
|
||||
catalogSequence?: number;
|
||||
verifiedVersion?: string;
|
||||
}
|
||||
export type MCPApprovalMode = "auto" | "prompt" | "writes" | "approve";
|
||||
export type MCPApprovalsReviewer = "user" | "auto_review";
|
||||
export interface MCPToolPolicy {
|
||||
approval_mode: MCPApprovalMode;
|
||||
}
|
||||
export interface MCPToolView {
|
||||
name: string;
|
||||
@@ -814,13 +803,6 @@ export interface PluginView {
|
||||
mcpServerDetails?: PluginMCPServerView[];
|
||||
warnings?: string[];
|
||||
error?: string;
|
||||
verification?: {
|
||||
catalogEntryId: string;
|
||||
commit: string;
|
||||
packageSha256: string;
|
||||
verifiedAt: string;
|
||||
catalogSequence: number;
|
||||
};
|
||||
}
|
||||
export interface PluginCompatibilityIssue {
|
||||
capability: string;
|
||||
@@ -881,13 +863,30 @@ export interface MCPServerInput {
|
||||
url: string;
|
||||
env?: Record<string, string> | null;
|
||||
headers?: Record<string, string> | null;
|
||||
trustedReadOnlyTools?: string[];
|
||||
autoStart?: boolean | null;
|
||||
callTimeoutSeconds?: number | null;
|
||||
toolTimeoutSeconds?: Record<string, number> | null;
|
||||
defaultToolsApprovalMode?: MCPApprovalMode | "" | null;
|
||||
tools?: Record<string, MCPToolPolicy> | null;
|
||||
approvalsReviewer?: MCPApprovalsReviewer | "" | null;
|
||||
}
|
||||
|
||||
export interface MCPMarketplaceEntry {
|
||||
name: string;
|
||||
suggestedName: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
repositoryUrl?: string;
|
||||
installable: boolean;
|
||||
unavailableReason?: string;
|
||||
transport?: "stdio" | "http" | "sse" | string;
|
||||
command?: string;
|
||||
args: string[];
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface MCPMarketplaceView {
|
||||
servers: MCPMarketplaceEntry[];
|
||||
cached: boolean;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
|
||||
@@ -283,6 +283,15 @@ export const en = {
|
||||
"caps.enableSkill": "Enable skill",
|
||||
"caps.subagent": "Subagent",
|
||||
"caps.addServer": "Add server",
|
||||
"caps.browseRegistry": "Browse registry",
|
||||
"caps.registryTitle": "MCP Registry",
|
||||
"caps.registryHint": "Find installable servers in the official MCP Registry. Registry access only happens while this page is open.",
|
||||
"caps.searchRegistry": "Search the MCP Registry…",
|
||||
"caps.search": "Search",
|
||||
"caps.install": "Install",
|
||||
"caps.manualSetup": "Manual setup",
|
||||
"caps.registryCached": "Showing cached results because the registry is unavailable.",
|
||||
"caps.noRegistryMatches": "No registry servers matched.",
|
||||
"caps.searchServers": "Search MCP servers…",
|
||||
"caps.noServerMatches": "No matching MCP servers.",
|
||||
"caps.configuredServers": "Configured servers",
|
||||
|
||||
@@ -192,6 +192,15 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"caps.enableSkill": "啟用技能",
|
||||
"caps.subagent": "子代理",
|
||||
"caps.addServer": "新增伺服器",
|
||||
"caps.browseRegistry": "瀏覽市集",
|
||||
"caps.registryTitle": "MCP Registry",
|
||||
"caps.registryHint": "從官方 MCP Registry 尋找並安裝伺服器;僅在開啟此頁面時存取 Registry。",
|
||||
"caps.searchRegistry": "搜尋 MCP Registry…",
|
||||
"caps.search": "搜尋",
|
||||
"caps.install": "安裝",
|
||||
"caps.manualSetup": "需手動設定",
|
||||
"caps.registryCached": "Registry 暫時無法使用,目前顯示快取結果。",
|
||||
"caps.noRegistryMatches": "沒有符合的 Registry 伺服器。",
|
||||
"caps.searchServers": "搜尋 MCP 伺服器…",
|
||||
"caps.noServerMatches": "沒有符合的 MCP 伺服器。",
|
||||
"caps.configuredServers": "已設定伺服器",
|
||||
|
||||
@@ -284,6 +284,15 @@ export const zh: Record<DictKey, string> = {
|
||||
"caps.enableSkill": "启用技能",
|
||||
"caps.subagent": "子代理",
|
||||
"caps.addServer": "添加服务器",
|
||||
"caps.browseRegistry": "浏览市场",
|
||||
"caps.registryTitle": "MCP Registry",
|
||||
"caps.registryHint": "从官方 MCP Registry 查找并安装服务器;仅在打开此页面时访问 Registry。",
|
||||
"caps.searchRegistry": "搜索 MCP Registry…",
|
||||
"caps.search": "搜索",
|
||||
"caps.install": "安装",
|
||||
"caps.manualSetup": "需手动配置",
|
||||
"caps.registryCached": "Registry 暂不可用,当前显示缓存结果。",
|
||||
"caps.noRegistryMatches": "没有匹配的 Registry 服务器。",
|
||||
"caps.searchServers": "搜索 MCP 服务器…",
|
||||
"caps.noServerMatches": "没有匹配的 MCP 服务器。",
|
||||
"caps.configuredServers": "已配置服务器",
|
||||
|
||||
@@ -11477,6 +11477,9 @@ body > .mermaid-diagram--fullscreen {
|
||||
background: var(--bg);
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.cap-mcp-search--action {
|
||||
grid-template-columns: 18px minmax(0, 1fr) auto;
|
||||
}
|
||||
.cap-mcp-search:focus-within {
|
||||
border-color: color-mix(in srgb, var(--accent) 58%, var(--border));
|
||||
box-shadow: var(--focus-ring);
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/command"
|
||||
"reasonix/internal/config"
|
||||
@@ -37,15 +36,6 @@ type PluginView struct {
|
||||
MCPServerDetails []PluginMCPServerView `json:"mcpServerDetails,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Verification *PluginVerificationView `json:"verification,omitempty"`
|
||||
}
|
||||
|
||||
type PluginVerificationView struct {
|
||||
CatalogEntryID string `json:"catalogEntryId"`
|
||||
Commit string `json:"commit"`
|
||||
PackageSHA256 string `json:"packageSha256"`
|
||||
VerifiedAt string `json:"verifiedAt"`
|
||||
CatalogSequence uint64 `json:"catalogSequence"`
|
||||
}
|
||||
|
||||
type PluginInstallOptions struct {
|
||||
@@ -123,13 +113,6 @@ func (a *App) Plugins() []PluginView {
|
||||
ManifestKind: p.ManifestKind,
|
||||
Enabled: p.Enabled,
|
||||
}
|
||||
if p.Verification != nil && pluginpkg.VerificationValid(config.ReasonixHomeDir(), p) {
|
||||
view.Verification = &PluginVerificationView{
|
||||
CatalogEntryID: p.Verification.CatalogEntryID, Commit: p.Verification.Commit,
|
||||
PackageSHA256: p.Verification.PackageSHA256, VerifiedAt: p.Verification.VerifiedAt.Format(time.RFC3339),
|
||||
CatalogSequence: p.Verification.CatalogSequence,
|
||||
}
|
||||
}
|
||||
if pkg, warnings, err := pluginpkg.ParseDir(view.Root); err == nil {
|
||||
applyPluginPackageDetails(&view, pkg, warnings)
|
||||
decoratePluginCommandConflicts(&view, activeCommands)
|
||||
@@ -245,7 +228,7 @@ func (a *App) RemovePlugin(name string) error {
|
||||
}
|
||||
// Uninstall disconnects the plugin's MCP servers, so the whole flow holds
|
||||
// the MCP lifecycle lock: an unlocked disconnect can interleave with a
|
||||
// trust preflight, which would then relaunch the just-removed server from
|
||||
// launch-authorization preflight, which would then relaunch the just-removed server from
|
||||
// its stale snapshot.
|
||||
defer a.lockMCPMutation("remove-plugin")()
|
||||
// A global uninstall touches every runtime, so gate every visible and
|
||||
|
||||
+1
-2
@@ -92,8 +92,7 @@ State does not leak between sessions.
|
||||
| `session/delete` | Stops the session and removes its persisted ACP history. |
|
||||
|
||||
`session/new`, `session/load`, and `session/resume` may include `mcpServers`.
|
||||
Reasonix accepts stdio and Streamable HTTP servers; legacy SSE transport is not
|
||||
advertised or accepted. ACP's official `[{"name":"...","value":"..."}]`
|
||||
Reasonix accepts stdio, Streamable HTTP, and legacy SSE servers. ACP's official `[{"name":"...","value":"..."}]`
|
||||
shape is supported for stdio `env` and HTTP `headers`; the older object-map
|
||||
shape remains accepted for compatibility.
|
||||
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@ reasonix acp --profile delivery
|
||||
| `session/delete` | 停止会话并删除其持久化 ACP 历史。 |
|
||||
|
||||
`session/new`、`session/load` 和 `session/resume` 可以携带 `mcpServers`。
|
||||
Reasonix 支持 stdio 和 Streamable HTTP server;不会声明或接受 legacy SSE transport。
|
||||
Reasonix 支持 stdio、Streamable HTTP 和 legacy SSE server。
|
||||
stdio `env` 和 HTTP `headers` 支持 ACP 官方的
|
||||
`[{"name":"...","value":"..."}]` 结构,同时继续接受旧版 object-map 结构。
|
||||
|
||||
|
||||
+48
-80
@@ -59,7 +59,6 @@ default_model = "deepseek-flash" # executor; set [agent].planner_model to add
|
||||
|
||||
[agent]
|
||||
reasoning_language = "auto" # visible reasoning text: auto|zh|en
|
||||
# plan_mode_allowed_tools = ["mcp__legacy__reader"] # legacy MCP read-only alias; does not change Plan availability
|
||||
# plan_mode_read_only_commands = ["gh issue view"] # legacy compatibility only; Plan bash now uses Permissions
|
||||
# planner_model = "deepseek-pro" # optional low-frequency planner
|
||||
# subagent_model = "deepseek-pro" # optional default for runAs=subagent skills
|
||||
@@ -117,11 +116,9 @@ tool_timeout_seconds = { "generate_video" = 1800 } # optional raw MCP tool nam
|
||||
|
||||
For the full schema and every field's contract, see [`SPEC.md` §5](./SPEC.md#5-configuration-toml).
|
||||
|
||||
The legacy `[agent].plan_mode_allowed_tools` field is still decoded and rendered
|
||||
for old configs. Concrete `mcp__<server>__<tool>` entries continue to act as a
|
||||
local read-only trust alias, but prefer each server's `trusted_read_only_tools`
|
||||
with raw MCP names. The field never grants or revokes calls in the main Plan
|
||||
workflow.
|
||||
Newly installed or explicitly authorized MCP servers need no per-tool reader
|
||||
list. Their non-destructive `readOnlyHint` tools are available to planner and
|
||||
read-only sub-agent registries automatically.
|
||||
|
||||
`[agent].plan_mode_read_only_commands` is also retained for config round trips,
|
||||
but the main Plan workflow no longer has a separate bash allowlist or trust
|
||||
@@ -535,7 +532,7 @@ Mode meanings:
|
||||
| Ask | Prompts for fallback writer approvals. |
|
||||
| Auto | Auto-allows fallback approvals; explicit `ask` / `deny` rules still apply. |
|
||||
| YOLO | Skips ordinary tool approval prompts; `deny`, user `ask` questions, and plan approval prompts still wait. |
|
||||
| Plan | Directs the model to plan first — a plan-first workflow, not an all-tools read-only mode. Built-in writers still follow the active Ask/Auto/YOLO rules and Sandbox; installed MCP writers, destructive targets, and untrusted readers are hard-blocked for the whole planning phase (approval cannot release them; they return once Plan exits), and explicit phase-only tools such as `complete_step` wait until approval. |
|
||||
| Plan | Directs the model to plan first — a plan-first workflow, not an all-tools read-only mode. Built-in writers still follow the active Ask/Auto/YOLO rules and Sandbox; installed MCP writers, destructive targets, and readers from unauthorized servers are hard-blocked for the whole planning phase (approval cannot release them; they return once Plan exits), and explicit phase-only tools such as `complete_step` wait until approval. |
|
||||
| Goal | Pursues a saved objective until complete, blocked, or cleared. |
|
||||
|
||||
## Permissions & sandbox
|
||||
@@ -631,16 +628,23 @@ Reasonix is an MCP client. A `[[plugins]]` entry's `type` selects the transport:
|
||||
`stdio` (default) launches a local subprocess (`command`/`args`/`env`); `http`
|
||||
(Streamable HTTP) connects to a remote `url` with optional static `headers`
|
||||
(`${VAR}` / `${VAR:-default}` expanded from the environment, so tokens stay out
|
||||
of the file).
|
||||
of the file); `sse` connects to servers that still use the legacy persistent
|
||||
GET + announced POST endpoint transport.
|
||||
|
||||
Browse the official MCP Registry from **Settings → MCP servers → Browse
|
||||
registry**, or use `reasonix mcp browse [query]` and
|
||||
`reasonix mcp install <registry-name>`. Registry access is explicit and never
|
||||
runs during startup. Entries that need secrets or required arguments are shown
|
||||
as manual setup instead of being installed with an incomplete configuration;
|
||||
query-specific cached results remain available during a registry outage.
|
||||
|
||||
The normal setup path is intentionally one step. Use Desktop's **Add and
|
||||
connect**, `/mcp add`, or ask Reasonix to install a package, URL, or `.mcp.json`.
|
||||
That explicit install is also authorization: the server is saved and connected
|
||||
in the current session, and no second trust step appears now or on the next
|
||||
startup. Explicit deny rules still win. With no advanced approval override, the
|
||||
installed server's calls run directly, including tools that declare
|
||||
`destructiveHint`; choose `auto`, `prompt`, or `writes` when those calls should
|
||||
retain fresh review. Plan/read-only sub-agents still expose only eligible tool
|
||||
startup. Explicit deny rules still win. The installed server's calls run
|
||||
directly, including tools that declare
|
||||
`destructiveHint`. Plan/read-only sub-agents still expose only eligible tool
|
||||
identities. A server merely discovered in repository-controlled
|
||||
`reasonix.toml` or `.mcp.json` is different: Reasonix asks once to confirm the
|
||||
exact command or endpoint, records that decision without launching a temporary
|
||||
@@ -650,70 +654,34 @@ while that value is unchanged and asks again only after a change.
|
||||
stdio servers keep one process for initialize, reads, and writes, so stateful
|
||||
servers such as browsers retain sessions and open pages. Because an OS sandbox
|
||||
is fixed when a process starts, this shared process uses the server's normal
|
||||
writer sandbox for every call; `readOnlyHint` and read-only sub-agent filtering
|
||||
process sandbox for every call; `readOnlyHint` and read-only sub-agent filtering
|
||||
are dispatch policy, not a second per-call process sandbox.
|
||||
|
||||
Tools surface to the model as `mcp__<server>__<tool>`. A tool declaring MCP's
|
||||
`readOnlyHint: true` joins parallel dispatch and the ordinary
|
||||
permission reader-default. Because the annotation is supplied by a third-party
|
||||
server, it is accepted by the main Plan workflow only as ordinary permission
|
||||
classification; it does not grant access to the dedicated planner or read-only
|
||||
research sub-agents. Use the local `trusted_read_only_tools` override for a
|
||||
reader you have audited. Tools without the hint remain write-capable. While planning, built-in
|
||||
`readOnlyHint: true` joins parallel dispatch and the strict read-only tool
|
||||
surfaces. Installing a server, or confirming an exact
|
||||
repository-provided server once, authorizes its non-destructive reader metadata;
|
||||
those tools are also available to the dedicated planner and read-only research
|
||||
sub-agents without another per-tool setting. Tools without the hint remain
|
||||
write-capable. While planning, built-in
|
||||
writers keep the ordinary permission posture; installed MCP and proxy-resolved
|
||||
MCP writers, destructive targets, and untrusted readers are hard-blocked before
|
||||
any approval and return to their normal approval flow once Plan exits.
|
||||
MCP writers, destructive targets, and readers from unauthorized servers are hard-blocked before
|
||||
any approval and become directly usable once Plan exits.
|
||||
|
||||
MCP `destructiveHint: true` is stricter than the read-only classification. Under
|
||||
`auto`, `prompt`, or `writes`, every destructive call requires fresh human
|
||||
approval even if the tool also reports `readOnlyHint`, the current posture is
|
||||
Auto/YOLO, or an allow rule was saved — Guardian, `auto_review`, and session
|
||||
grants cannot authorize it. An effective `approve` mode records that the user
|
||||
authorized the server or tool and permits the call directly.
|
||||
Installing an MCP server is the authorization decision. After installation, all
|
||||
of its tools run directly without a second server-level, per-tool, writer, or
|
||||
destructive approval setting. Explicit global deny rules still win. The host
|
||||
keeps `readOnlyHint` and `destructiveHint` internally for parallel scheduling,
|
||||
Plan restrictions, strict read-only sub-agents, and cached-to-live safety
|
||||
reclassification; these hints do not add user configuration.
|
||||
Reasonix deliberately trusts an installed server to describe those hints
|
||||
honestly. Planner/read-only filtering is therefore a workflow boundary for
|
||||
trusted servers, not containment against a malicious MCP server; explicit deny
|
||||
rules and the process sandbox remain host-controlled boundaries.
|
||||
|
||||
`approvals_reviewer = "auto_review"` routes the calls that actually need a
|
||||
review — `prompt` mode, writer hits under `writes`, and `auto` calls the global
|
||||
posture would Ask about — to the session Guardian, and a successful verdict
|
||||
(allow or deny) is final. When the reviewer is missing, times out, fails, or
|
||||
returns no verdict, the call falls back to fresh human approval: a prompt that
|
||||
Auto/YOLO, the approved-plan window, and session grants cannot answer.
|
||||
Non-interactive runs and sub-agents fail closed in every reviewer-required
|
||||
case.
|
||||
|
||||
Server and raw-tool approval policy stays local and never changes the schema
|
||||
sent to the model:
|
||||
|
||||
```toml
|
||||
[[plugins]]
|
||||
name = "github"
|
||||
command = "github-mcp"
|
||||
default_tools_approval_mode = "writes" # auto|prompt|writes|approve
|
||||
tools = { "delete_repository" = { approval_mode = "prompt" } }
|
||||
approvals_reviewer = "auto_review" # user|auto_review
|
||||
trusted_read_only_tools = ["issue_read", "pull_request_read"]
|
||||
```
|
||||
|
||||
For a user-authorized server, omitting these advanced approval fields permits
|
||||
all calls directly. If a field is present, `auto` delegates to the global
|
||||
Ask/Auto/YOLO permission posture; `prompt`
|
||||
reviews every call; `writes` reviews only writer-classified calls; and `approve`
|
||||
allows calls directly, including destructive calls. Explicit deny rules always
|
||||
win; `destructiveHint` forces a new review for every mode except `approve`. A
|
||||
raw-tool `tools` entry overrides the server default. `trusted_read_only_tools`
|
||||
remains a compatibility field and explicit local declaration for audited
|
||||
readers on servers that omit or cannot reliably maintain annotations.
|
||||
|
||||
Two boundaries are worth knowing. `writes` trusts the server's read-only
|
||||
classification, so a server that mislabels a writer as `readOnlyHint` escapes
|
||||
that review — use `prompt` for servers you do not trust to maintain
|
||||
annotations. And when Guardian is enabled with no `approvals_reviewer`
|
||||
configured, `prompt`/`writes` reviews keep the legacy routing: Guardian
|
||||
pre-screens the call and may allow it without a human prompt; set
|
||||
`approvals_reviewer = "user"` when every review must reach a person. A
|
||||
project's `.mcp.json` merges these fields into the session, so review
|
||||
`approve`/`writes` policies in a repository you did not author like any other
|
||||
code — explicit deny rules still apply, and destructive reviews apply unless
|
||||
the effective mode is `approve`.
|
||||
The retired `trusted_read_only_tools`, `default_tools_approval_mode`,
|
||||
`tools.<raw>.approval_mode`, and `approvals_reviewer` fields are ignored when
|
||||
loading older files and removed the next time Reasonix saves that MCP entry.
|
||||
|
||||
A server's **prompts** surface as `/mcp__<server>__<prompt>` slash commands
|
||||
(positional args after the command); its **resources** are pulled in by writing
|
||||
@@ -975,11 +943,10 @@ Every strict read-only child is built through one shared construction
|
||||
pairing — `RunReadOnlySubAgentWithSession` for batch children and
|
||||
`NewReadOnlyAgent` for the interactive two-model planner — which marks the
|
||||
child permanently read-only and applies a final registry filter. The filter
|
||||
removes writers, destructive MCP targets, MCP readers backed only by a
|
||||
third-party server hint, and every host-mutating tool. An MCP reader is
|
||||
eligible only when explicitly declared in local configuration or declared by
|
||||
a currently verified signed official package. Eligible readers may still
|
||||
start on demand. These are
|
||||
removes writers, destructive MCP targets, readers from unauthorized servers,
|
||||
and every host-mutating tool. A user-installed server is authorized immediately;
|
||||
a repository-declared server becomes eligible after its exact identity is
|
||||
confirmed once. Eligible readers may still start on demand. These are
|
||||
the strict read-only entrances:
|
||||
|
||||
| Entrance | Purpose |
|
||||
@@ -994,11 +961,12 @@ the strict read-only entrances:
|
||||
|
||||
Inside a strict child, `use_capability` re-checks the resolved target before
|
||||
commit/permission/hooks/execution. An unconnected eligible MCP reader may start
|
||||
on demand from the current schema cache; its cached security fingerprint is
|
||||
checked against the live initialize/tools-list result before `tools/call`.
|
||||
Schema or safety drift means zero executions and a normal retry with current
|
||||
policy. `auto_review` cannot raise
|
||||
privileges there; a reader that would need a local prompt fails closed. This
|
||||
on demand from the current schema cache. Before `tools/call`, cached
|
||||
`readOnlyHint`/`destructiveHint` facts are checked against the live
|
||||
initialize/tools-list result; a reader-to-writer change or destructive promotion
|
||||
means zero executions and a normal retry through the current boundary. A
|
||||
schema-only change refreshes the cache for the next session without interrupting
|
||||
the authorized call. An unauthorized server cannot raise privileges there. This
|
||||
is a stricter layer than the main Plan workflow: Plan hard-blocks MCP
|
||||
writer/destructive targets for the entire planning phase — no approval can
|
||||
release them until Plan exits — while built-in writers keep
|
||||
|
||||
+31
-53
@@ -53,7 +53,6 @@ default_model = "deepseek-flash" # 执行器;设 [agent].planner_model 可
|
||||
|
||||
[agent]
|
||||
reasoning_language = "auto" # 可见思考过程语言:auto|zh|en
|
||||
# plan_mode_allowed_tools = ["mcp__legacy__reader"] # 旧 MCP 只读信任别名;不改变 Plan 可用性
|
||||
# plan_mode_read_only_commands = ["gh issue view"] # 仅兼容旧配置;Plan bash 现由 Permissions 决定
|
||||
# planner_model = "deepseek-pro" # 可选的低频规划器
|
||||
# subagent_model = "deepseek-pro" # runAs=subagent skill 的默认模型
|
||||
@@ -111,9 +110,8 @@ tool_timeout_seconds = { "generate_video" = 1800 } # 可选:raw MCP tool 名
|
||||
|
||||
完整 schema 与每个字段的契约见 [`SPEC.md` §5](./SPEC.md#5-configuration-toml)。
|
||||
|
||||
旧字段 `[agent].plan_mode_allowed_tools` 仍会被解析并重新渲染。具体的
|
||||
`mcp__<server>__<tool>` 条目继续充当本地只读信任别名,但推荐把已审计的 raw MCP reader
|
||||
名称写到对应 server 的 `trusted_read_only_tools`。这个字段不会放行或阻断主 Plan 工作流中的调用。
|
||||
新安装或明确确认过的 MCP server 不需要逐工具 reader 名单,其非破坏性的
|
||||
`readOnlyHint` 工具会自动进入 planner 和只读 subagent。
|
||||
|
||||
`[agent].plan_mode_read_only_commands` 也继续参与配置 round-trip,但主 Plan 工作流不再维护独立的
|
||||
bash allowlist 或信任提示。Plan 与常规模式使用相同的 Permissions 规则做 bash 分类和审批;Sandbox
|
||||
@@ -517,64 +515,44 @@ reasonix doctor capabilities --live --timeout 5s
|
||||
Reasonix 是一个 MCP 客户端。`[[plugins]]` 的 `type` 选择传输:`stdio`(默认)启动本地子进
|
||||
程(`command`/`args`/`env`);`http`(Streamable HTTP)连接远程 `url`,可带静态
|
||||
`headers`(`${VAR}` / `${VAR:-default}` 从环境展开,密钥不入文件)。
|
||||
`sse` 则兼容仍使用持久 GET 与 server 公布 POST endpoint 的旧版远程 server。
|
||||
|
||||
可在 **设置 → MCP 服务器 → 浏览市场** 打开官方 MCP Registry,也可使用
|
||||
`reasonix mcp browse [query]` 与 `reasonix mcp install <registry-name>`。Registry
|
||||
只在用户显式浏览或安装时联网,不进入启动路径。需要 secret 或必填参数的条目只显示为手动配置,
|
||||
不会写入不完整配置;Registry 故障时可回退到同一查询的缓存结果。
|
||||
|
||||
普通配置流程现在只有一步:使用桌面端的“添加并连接”、`/mcp add`,或直接让 Reasonix
|
||||
安装一个 package、URL 或 `.mcp.json`。这次明确安装本身就是授权:server 会保存并在当前
|
||||
会话连接,现在和下次启动都不会再弹出第二套信任步骤。显式 deny 仍然优先;未配置高级审批
|
||||
覆盖时,包括声明 `destructiveHint` 的工具在内都直接执行。如需保留新鲜审查,可主动设置
|
||||
`auto`、`prompt` 或 `writes`。Plan 与只读 subagent 仍只暴露符合条件的工具身份。只有被动从仓库
|
||||
会话连接,现在和下次启动都不会再弹出第二套信任步骤。显式 deny 仍然优先;包括声明
|
||||
`destructiveHint` 的工具在内都直接执行。Plan 与只读 subagent 仍只暴露符合条件的工具身份。只有被动从仓库
|
||||
`reasonix.toml` 或 `.mcp.json` 发现的 server 会在第一次启动前,请用户确认一次精确命令或
|
||||
地址;Reasonix 会先记录该决定而不启动临时检查进程,然后只启动一次正式连接。内容不变时
|
||||
以后自动连接,发生变化时才重新确认。
|
||||
|
||||
stdio server 从初始化到读写都复用同一个进程,因此浏览器等有状态 MCP 能保留会话和
|
||||
已打开页面。由于进程启动后无法按调用切换 OS 沙箱,这个共享进程始终使用该 server 的普通
|
||||
writer 沙箱;`readOnlyHint` 与只读 subagent 过滤属于调用分发策略,不再对应第二个按调用隔离
|
||||
进程沙箱;`readOnlyHint` 与只读 subagent 过滤属于调用分发策略,不再对应第二个按调用隔离
|
||||
的进程沙箱。
|
||||
|
||||
工具以 `mcp__<server>__<tool>` 暴露给模型,与 Claude Code 一致;声明 MCP `readOnlyHint: true`
|
||||
的工具会参与并行调度并命中普通权限层的只读默认放行。这个标注来自第三方 server,主 Plan 只把它
|
||||
当作普通权限分类;它不会让工具进入独立 planner 或只读研究 subagent。已审计的 reader 应写入本地
|
||||
`trusted_read_only_tools`。没有 `readOnlyHint` 的工具仍按写工具处理。计划期间,内置 writer 仍走
|
||||
Permissions/Sandbox;已安装 MCP 与代理解析后的 MCP writer、destructive 目标和未信任
|
||||
reader 在任何审批前硬阻断,退出 Plan 后才恢复正常审批流程。
|
||||
的工具会参与并行调度并命中普通权限层的只读默认放行。用户安装 server,或首次确认仓库提供的
|
||||
精确 server 身份后,其非破坏性 reader 元数据即获得授权,这些工具会自动进入独立 planner 和
|
||||
只读研究 subagent,不再需要逐工具设置。没有 `readOnlyHint` 的工具仍按写工具处理。计划期间,内置 writer 仍走
|
||||
Permissions/Sandbox;已安装 MCP 与代理解析后的 MCP writer、destructive 目标和未授权
|
||||
reader 在任何审批前硬阻断,退出 Plan 后即可直接使用。
|
||||
|
||||
MCP `destructiveHint: true` 比只读分类更严格。在 `auto`、`prompt` 或 `writes` 下,即使工具
|
||||
同时声明 `readOnlyHint`、当前是 Auto/YOLO,或已经存在 allow 规则,每次破坏性调用仍需要
|
||||
全新的人工审批——Guardian、`auto_review`、自动/YOLO 与会话授权都不能代答。最终策略为
|
||||
`approve` 表示用户已经授权该 server 或工具,因此调用直接执行。
|
||||
安装 MCP server 本身就是授权决定。安装完成后,该 server 的所有工具都直接执行,不再存在
|
||||
server、raw tool、writer 或 destructive 的第二套审批设置;显式全局 deny 规则仍然优先。
|
||||
`readOnlyHint` 与 `destructiveHint` 只作为内部事实,用于并行调度、Plan 限制、严格只读
|
||||
subagent 和缓存到实时安全分类复核,不增加用户配置。
|
||||
Reasonix 明确信任已安装 server 会如实描述这些 hint。因此,planner/只读 subagent 的过滤是
|
||||
面向可信 server 的工作流边界,不是针对恶意 MCP server 的隔离边界;显式 deny 与进程沙箱
|
||||
仍由 host 控制。
|
||||
|
||||
`approvals_reviewer = "auto_review"` 只把真正需要审查的调用——`prompt` 模式、`writes`
|
||||
命中的写调用、以及全局策略本会 Ask 的 `auto` 调用——交给当前会话的 Guardian;成功给出的
|
||||
allow/deny 结论立即最终生效。reviewer 缺失、超时、失败或未给出结论时,调用降级为一次
|
||||
全新的人工审批:自动/YOLO、计划批准窗口与会话授权都不能代答。非交互运行和子代理在所有
|
||||
需要 reviewer 的场景下直接拒绝。
|
||||
|
||||
server 和 raw tool 的审批策略只存在于本地,不会改变发给模型的 schema:
|
||||
|
||||
```toml
|
||||
[[plugins]]
|
||||
name = "github"
|
||||
command = "github-mcp"
|
||||
default_tools_approval_mode = "writes" # auto|prompt|writes|approve
|
||||
tools = { "delete_repository" = { approval_mode = "prompt" } }
|
||||
approvals_reviewer = "auto_review" # user|auto_review
|
||||
trusted_read_only_tools = ["issue_read", "pull_request_read"]
|
||||
```
|
||||
|
||||
用户已授权的 server 若省略这些高级审批字段,所有调用都会直接放行。显式配置后,`auto`
|
||||
交给全局 Ask/Auto/YOLO;`prompt` 每次调用都审查;`writes` 只审查写工具;`approve`
|
||||
直接放行所有调用,包括 destructive 调用。显式 deny 永远优先;除 `approve` 外,
|
||||
`destructiveHint` 会在其余模式下强制一次新审查。`tools` 中的 raw tool 配置覆盖 server 默认值。
|
||||
`trusted_read_only_tools` 继续作为兼容字段和显式本地声明,用于已审计、但没有可靠 annotation
|
||||
的 reader。
|
||||
|
||||
两条边界值得注意:`writes` 信任 server 自己的只读分类,把写工具谎报成 `readOnlyHint`
|
||||
的 server 会绕过这层审查——对不可信的 server 请用 `prompt`。启用 Guardian 且未配置
|
||||
`approvals_reviewer` 时,`prompt`/`writes` 的审查保留 legacy 路由:Guardian 预审通过即可放行、
|
||||
不再弹人工提示;要求每次都由人决定时请显式配置 `approvals_reviewer = "user"`。项目
|
||||
`.mcp.json` 会把这些字段并入会话,所以像审代码一样审查别人仓库里的 `approve`/`writes`
|
||||
策略——显式 deny 规则仍然生效;最终策略不是 `approve` 时,`destructiveHint` 审查仍然生效。
|
||||
旧的 `trusted_read_only_tools`、`default_tools_approval_mode`、
|
||||
`tools.<raw>.approval_mode` 与 `approvals_reviewer` 字段在加载旧文件时会被忽略,并在 Reasonix
|
||||
下次保存该 MCP 条目时自动移除。
|
||||
|
||||
服务器的 **prompts** 会暴露成 `/mcp__<server>__<prompt>` 斜杠命令(命令后空格分隔参
|
||||
数);**resources** 通过在消息里写 `@<server>:<uri>` 拉入;`/mcp` 列出已连接服务器及
|
||||
@@ -772,8 +750,8 @@ source 也可在 Plan 中加载,后续 writer 调用仍通过 Permissions/Sand
|
||||
所有严格只读子会话都经过同一对共享构造入口——批处理子会话用
|
||||
`RunReadOnlySubAgentWithSession`,交互式双模型 planner 用 `NewReadOnlyAgent`——
|
||||
两者都会把子会话标记为永久只读并做最终 registry 过滤:移除 writer、destructive MCP
|
||||
目标、只有第三方 server hint 的 reader,以及一切会改变 host capability 的工具。MCP reader
|
||||
只有在本地配置中显式声明,或由当前已验证的官方签名 package 声明时才符合条件;符合条件的
|
||||
目标、来自未授权 server 的 reader,以及一切会改变 host capability 的工具。用户安装的
|
||||
server 会立即获得授权;仓库声明的 server 则在其精确身份确认一次后符合条件。符合条件的
|
||||
reader 仍可按需启动。严格只读入口一览:
|
||||
|
||||
| 入口 | 用途 |
|
||||
@@ -788,9 +766,9 @@ reader 仍可按需启动。严格只读入口一览:
|
||||
|
||||
在严格只读子会话内:`use_capability` 在 Commit/permission/hook/执行前会对解析出的
|
||||
真实目标再次校验;未连接且符合条件的 MCP reader 可从当前 schema cache 按需启动,
|
||||
initialize/tools-list 后会在 `tools/call` 前核对缓存与 live security 指纹;发现 schema 或
|
||||
safety 漂移则零执行,普通重试会使用当前策略。
|
||||
`auto_review` 在这里不能提升权限;需要本地人工审批的 reader 直接 fail-closed。
|
||||
initialize/tools-list 后会在 `tools/call` 前核对缓存与 live 的 `readOnlyHint`/
|
||||
`destructiveHint`;reader 变 writer 或升级为 destructive 时零执行,普通重试会重新经过当前
|
||||
边界。仅 schema 变化会静默刷新下一会话的缓存,不再中断已授权调用。未授权 server 无法在这里提升权限。
|
||||
这一层比主 Plan 更严格:Plan 在整个规划阶段硬阻断 MCP writer/destructive 目标——
|
||||
审批也不能放行,退出 Plan 后才恢复——内置 writer 仍走 Permissions/Sandbox,
|
||||
而严格只读子会话根本不暴露 writer。
|
||||
|
||||
+18
-24
@@ -98,15 +98,15 @@ and DeepSeek prefix-cache–oriented design.
|
||||
search + tree-sitter symbol index is not bundled in v2 yet, and CodeGraph is no
|
||||
longer shipped as an internal MCP server.
|
||||
- **Plan mode** + `complete_step` (evidence-backed step sign-off).
|
||||
- **MCP identity and schema-cache URLs are credential-aware**: userinfo and
|
||||
credential query values (token, api_key, password, ...) do not enter the
|
||||
host-local identity or cache fingerprints, so credential rotation keeps an
|
||||
unchanged project launch authorization. An exact old workspace receipt can
|
||||
migrate once into that launch grant; its tool snapshot is ignored.
|
||||
- **MCP project identity and schema-cache URLs are credential-aware**: userinfo
|
||||
and credential query values (token, api_key, password, ...) do not enter the
|
||||
project launch identity digest or schema cache key, so credential rotation
|
||||
keeps an unchanged project launch authorization. User-installed servers do
|
||||
not compute a project identity digest. Legacy per-tool reader receipts are
|
||||
ignored and removed on the next authorization-state write.
|
||||
- **MCP setup is now add-and-use.** Servers added by the user (Desktop, user
|
||||
config, legacy user import, or an installed verified plugin package) connect
|
||||
immediately and permit all calls when no explicit
|
||||
MCP approval policy is configured. Repository `reasonix.toml` / `.mcp.json`
|
||||
config, legacy user import, or a user-installed plugin package) connect
|
||||
immediately and permit all calls. Repository `reasonix.toml` / `.mcp.json`
|
||||
servers instead require one pre-launch confirmation for their exact stable
|
||||
identity, before a subprocess or network request exists. Host sandbox,
|
||||
read-root, and write-root policy changes do not invalidate server identity.
|
||||
@@ -115,26 +115,20 @@ and DeepSeek prefix-cache–oriented design.
|
||||
- **Plan mode and permission policy are now independent**: Plan directs the
|
||||
model to plan first. Ordinary built-in and Bash calls still use the active
|
||||
Ask/Auto/YOLO rules and Sandbox, while installed MCP and proxy-resolved MCP
|
||||
writer/destructive targets plus untrusted readers stay hard-blocked for the
|
||||
writer/destructive targets plus readers from unauthorized servers stay hard-blocked for the
|
||||
whole planning phase. Explicit execution-phase tools such as `complete_step` also
|
||||
remain unavailable until plan approval. `[agent].plan_mode_allowed_tools` and
|
||||
`plan_mode_read_only_commands` are still parsed and round-tripped so old
|
||||
configs do not break, but they no longer control main Plan availability.
|
||||
Concrete MCP names in `plan_mode_allowed_tools` remain legacy local reader
|
||||
aliases; prefer audited raw names in `trusted_read_only_tools` for the
|
||||
dedicated planner/read-only sub-agent registries. Use `read_only_task` /
|
||||
remain unavailable until plan approval. `plan_mode_read_only_commands` is
|
||||
still parsed and round-tripped for old configs, but it no longer controls
|
||||
main Plan availability. Installed or explicitly authorized servers contribute their
|
||||
non-destructive `readOnlyHint` tools to planner/read-only registries
|
||||
automatically. Use `read_only_task` /
|
||||
`read_only_skill` when a child must be technically restricted to read-only;
|
||||
ordinary `task` / `run_skill` calls remain writer-capable and permission-gated
|
||||
in Plan. Installed MCP tools use the server's `readOnlyHint` for ordinary
|
||||
permission and dispatch, but third-party hints do not grant dedicated
|
||||
planner/read-only sub-agent trust. Tools without the hint remain
|
||||
writer-classified. New optional MCP-local fields
|
||||
(`default_tools_approval_mode`, `tools.<raw>.approval_mode`, and
|
||||
`approvals_reviewer`) override the new source-aware default when present. MCP tools
|
||||
declaring `destructiveHint: true` require fresh human approval on every call
|
||||
under `auto`, `prompt`, or `writes` — the configured reviewer is never
|
||||
consulted for them — while an effective `approve` mode permits them directly.
|
||||
Non-interactive sessions fail closed whenever a fresh review is required.
|
||||
dispatch. Tools without the hint remain writer-classified. The retired
|
||||
`default_tools_approval_mode`, `tools.<raw>.approval_mode`, and
|
||||
`approvals_reviewer` fields are ignored and removed on the next save; installing
|
||||
or explicitly authorizing a server now makes all of its tools directly usable.
|
||||
- **Read-only subagent research**: use `read_only_task` for generic isolated
|
||||
research in plan mode, or `read_only_skill` when the work should follow an
|
||||
existing skill. Both expose only read-only tools and safe foreground bash, do
|
||||
|
||||
@@ -75,13 +75,13 @@ agent 核心延续了原有能力:循环、读写编辑与 glob/grep/bash 等
|
||||
|
||||
- **代码智能**:Go 重写版通过 LSP 辅助代码读取,并结合 `grep`、`read_file` 和 `glob` 理解本地代码。v1 的语义搜索与 tree-sitter 符号索引尚未移植,CodeGraph 也不再以内置 MCP server 形式提供。
|
||||
- **Plan 模式**:新增 `complete_step`,用于基于证据确认步骤完成。
|
||||
- **MCP 身份与 schema 缓存 URL 感知凭据**:userinfo 和 token/api_key/password 等查询值不会进入本机身份或缓存指纹,因此轮换凭据不会使项目授权失效。旧工作区凭据可一次性迁移为启动授权,旧工具快照不会沿用。
|
||||
- **MCP 添加后即可使用**:用户通过桌面端、全局配置、旧配置导入或已验证插件包添加的 server 会立即连接;未显式配置 MCP 审批策略时默认允许调用。仓库内 `reasonix.toml` / `.mcp.json` 声明的 server 则必须先针对稳定身份确认一次,确认前不会启动进程或发起网络请求。
|
||||
- **MCP 项目身份与 schema 缓存 URL 感知凭据**:userinfo 和 token/api_key/password 等查询值不会进入项目启动身份摘要或 schema 缓存键,因此轮换凭据不会使项目授权失效。用户安装的 server 不计算项目身份摘要。旧的逐工具 reader 回执不再使用,并会在下一次写入授权状态时移除。
|
||||
- **MCP 添加后即可使用**:用户通过桌面端、全局配置、旧配置导入或主动安装插件包添加的 server 会立即连接并允许调用。仓库内 `reasonix.toml` / `.mcp.json` 声明的 server 则必须先针对稳定身份确认一次,确认前不会启动进程或发起网络请求。
|
||||
- **stdio MCP 连接持久化**:writer 调用不再创建新进程,浏览器或会话类 server 的状态可以保留。
|
||||
- **Plan 与权限策略相互独立**:普通内置工具和 Bash 仍遵循 Ask/Auto/YOLO 与 Sandbox;已安装或代理解析的 MCP 写入/破坏性工具及不受信任的读取工具在整个规划阶段保持阻止。`complete_step` 等执行阶段工具也要等计划获批后才能使用。
|
||||
- `[agent].plan_mode_allowed_tools` 与 `plan_mode_read_only_commands` 仍可解析和保存,以兼容旧配置,但不再决定主 Plan 流程能否调用工具。需要可信读取能力时,应在 `trusted_read_only_tools` 中声明已审计的原始 MCP 工具名。
|
||||
- 使用 `read_only_task` / `read_only_skill` 创建技术上只读的子智能体;普通 `task` / `run_skill` 仍可写入,并受权限与 Sandbox 控制。第三方 MCP 的 `readOnlyHint` 只影响常规权限和调度,不会自动获得专用 planner 或只读子智能体的信任。
|
||||
- MCP 可通过 `default_tools_approval_mode`、`tools.<raw>.approval_mode` 和 `approvals_reviewer` 覆盖默认审批策略。标记 `destructiveHint: true` 的调用在 `auto`、`prompt` 或 `writes` 下每次都需要人工重新批准;有效的 `approve` 模式则直接允许。
|
||||
- **Plan 与权限策略相互独立**:普通内置工具和 Bash 仍遵循 Ask/Auto/YOLO 与 Sandbox;已安装或代理解析的 MCP 写入/破坏性工具,以及来自未授权 server 的读取工具,在整个规划阶段保持阻止。`complete_step` 等执行阶段工具也要等计划获批后才能使用。
|
||||
- `plan_mode_read_only_commands` 仍可解析和保存,以兼容旧配置,但不再决定主 Plan 流程能否调用工具。安装或明确确认 MCP server 后,其非破坏性的 `readOnlyHint` 工具会自动进入 planner 与只读子智能体,不需要逐工具信任配置。
|
||||
- 使用 `read_only_task` / `read_only_skill` 创建技术上只读的子智能体;普通 `task` / `run_skill` 仍可写入,并受权限与 Sandbox 控制。未声明 `readOnlyHint` 的 MCP 工具仍按 writer 处理。
|
||||
- `default_tools_approval_mode`、`tools.<raw>.approval_mode` 和 `approvals_reviewer` 已停用,加载时忽略并在下次保存时移除;安装或明确授权 server 后,其所有工具直接可用。
|
||||
- **Web Dashboard 仍然可用,桌面端更推荐**:需要浏览器访问时,可运行
|
||||
`reasonix serve` 启动本地 Web UI;日常可视化使用优先选择 Wails 桌面端,
|
||||
终端工作流继续使用 CLI/TUI。
|
||||
|
||||
+35
-43
@@ -143,22 +143,28 @@ interface (`call` / `notify` / `close`) abstracts that, so the MCP-level logic
|
||||
any server notifications). The `Mcp-Session-Id` response header, once seen,
|
||||
is echoed on subsequent requests. Static `headers` (e.g. a bearer token) are
|
||||
sent on every request. OAuth is out of scope for now (see §9).
|
||||
- `sse` — the legacy 2024-11-05 HTTP+SSE transport; recognised but deferred
|
||||
(deprecated upstream — use `http`). Configuring it returns a clear error.
|
||||
- `sse` — the legacy 2024-11-05 HTTP+SSE transport. A persistent GET stream
|
||||
receives an announced relative POST endpoint, JSON-RPC responses, and server
|
||||
messages. Cross-origin announced endpoints are rejected so static headers
|
||||
cannot leak.
|
||||
- `${VAR}` / `${VAR:-default}` are expanded in `command`, `args`, `env`, `url`,
|
||||
and `headers` so secrets come from the environment, not the config file.
|
||||
- Lifecycle: `initialize` → `notifications/initialized` → `tools/list`;
|
||||
invocation via `tools/call {name, arguments}`.
|
||||
- When a workspace root exists, initialize advertises `roots` and transports
|
||||
answer `roots/list` with its file URI. `tools/call` includes a per-call
|
||||
`_meta.progressToken`; matching `notifications/progress` messages stream into
|
||||
the existing tool-progress event path.
|
||||
- A stdio server uses one persistent transport for initialize, reads, and
|
||||
writes, preserving state such as browser sessions across tool calls. The
|
||||
process uses the server's writer sandbox because process confinement cannot
|
||||
change per RPC; read-only eligibility and destructive approval remain local
|
||||
dispatch gates rather than separate process sandboxes.
|
||||
process uses the server's process sandbox because process confinement cannot
|
||||
change per RPC; read-only eligibility and destructive filtering remain local
|
||||
workflow gates rather than separate process sandboxes.
|
||||
- Configuration provenance is runtime metadata. Explicit installation from the
|
||||
user config, Desktop, `/mcp add`, or `install_source` is authorization: the
|
||||
host connects the server immediately when installation happens in a live
|
||||
session, records a durable exact command/endpoint launch grant for
|
||||
project-scoped installs, and calls default to direct approval.
|
||||
project-scoped installs, and runs authorized calls directly.
|
||||
Project `reasonix.toml` and `.mcp.json` servers that are only
|
||||
discovered from the repository require one durable launch confirmation before
|
||||
any process or network transport is created. Confirmation records the exact
|
||||
@@ -174,6 +180,11 @@ interface (`call` / `notify` / `close`) abstracts that, so the MCP-level logic
|
||||
to false (a remote tool is opaque — we can't see its side effects), so a
|
||||
plugin opts a tool into parallel-batch dispatch and the permission layer's
|
||||
reader-default by declaring `readOnlyHint: true` in `tools/list`.
|
||||
- Installation is the trust decision for tool metadata. Reasonix assumes an
|
||||
installed server reports `readOnlyHint` and `destructiveHint` honestly;
|
||||
planner/read-only filtering is a workflow boundary for trusted servers, not
|
||||
containment against a malicious MCP server. Explicit deny rules and the
|
||||
process sandbox remain host-controlled boundaries.
|
||||
- `prompts/list` + `prompts/get` surface as `/mcp__<server>__<prompt>` slash
|
||||
commands; `resources/list` + `resources/read` are referenced as
|
||||
`@<server>:<uri>` in chat. `/mcp` shows connected servers and their counts.
|
||||
@@ -331,45 +342,31 @@ func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Dec
|
||||
it resolves `Ask` to **allow** — preserving autonomous behaviour. A `Deny` is a
|
||||
hard block in *every* mode: the tool never executes and the model receives a
|
||||
"blocked" result it can adapt to (the same shape as a plan-mode refusal).
|
||||
- **MCP approval policy.** Installed MCP tools may set a server default and raw
|
||||
tool overrides using `auto|prompt|writes|approve`. A raw-tool mode overrides
|
||||
the server or source-aware default; explicit deny still wins, `approve`
|
||||
permits the call directly, and `destructiveHint` requires a fresh review for
|
||||
every remaining mode before global Ask/Auto/YOLO is considered.
|
||||
A user-authorized server with no explicit MCP approval fields defaults to
|
||||
`approve`; repository-provided servers retain `auto` until their exact launch
|
||||
is authorized, then the same source-aware default applies. `auto` delegates
|
||||
to the ordinary permission decision; `prompt` reviews every call; `writes`
|
||||
reviews writers only; and `approve` allows all calls directly.
|
||||
`approvals_reviewer = "user"` uses the interactive user, while `auto_review`
|
||||
sends the calls that need review (`prompt`, writer hits under `writes`, and
|
||||
`auto` calls the global posture would Ask about) to the session Guardian; a
|
||||
successful allow/deny verdict is final. A missing, timed-out, failed, or
|
||||
indeterminate reviewer degrades to fresh human approval — a prompt that
|
||||
Auto/YOLO, the approved-plan window, and session grants cannot answer — and
|
||||
non-interactive sessions fail closed. For `auto`, `prompt`, and `writes`, a
|
||||
destructive call requires fresh human approval on every invocation: no
|
||||
reviewer, session grant, or Auto/YOLO posture can authorize it. An effective
|
||||
`approve` mode represents the user's install/authorization decision and
|
||||
permits destructive calls directly. All of this is local metadata and is
|
||||
absent from provider-visible tool schemas.
|
||||
- **MCP authorization.** Installing an MCP server authorizes all of its tools;
|
||||
there is no second server, raw-tool, writer, or destructive approval policy.
|
||||
Explicit global deny rules still win. Repository-declared servers require one
|
||||
exact identity confirmation before startup and require confirmation again only
|
||||
if that identity changes. `readOnlyHint` and `destructiveHint` remain internal
|
||||
facts for scheduling, Plan/read-only restrictions, and cached-to-live safety
|
||||
reclassification. Planner and strict read-only registries expose only
|
||||
authorized tools with `readOnlyHint: true` and no `destructiveHint`.
|
||||
Schema-only changes refresh the next-session cache without adding an execution
|
||||
approval or retry.
|
||||
- **Relationship to plan mode.** Plan mode (§3.4) is a plan-first collaboration
|
||||
workflow, not an all-tools read-only mode. Before Permissions/Sandbox, the
|
||||
host enforces explicit phase opt-outs (`complete_step` is read-only but
|
||||
belongs to the post-approval execution phase, so it self-reports plan-unsafe
|
||||
and is refused) and hard-blocks installed MCP and proxy-resolved MCP
|
||||
writer/destructive targets plus untrusted readers for the entire planning
|
||||
writer/destructive targets plus readers from unauthorized servers for the entire planning
|
||||
phase — no approval releases them while Plan is active.
|
||||
Ordinary built-in and Bash calls then use the same Ask/Auto/YOLO, explicit
|
||||
`ask`/`deny`, and Sandbox path as Standard mode; blocked MCP writers regain
|
||||
their normal approval flow after Plan exits. A third-party MCP `readOnlyHint` affects ordinary permission and dispatch
|
||||
classification, but it does not grant access to the dedicated planner or
|
||||
read-only sub-agent registries; use a locally audited
|
||||
`trusted_read_only_tools` entry for those. The legacy
|
||||
`[agent].plan_mode_allowed_tools` field is still decoded and can act as a
|
||||
concrete MCP read-only compatibility alias, while `plan_mode_read_only_commands` is
|
||||
retained for config/session round trips. Neither field grants or revokes calls
|
||||
in the main Plan workflow. `read_only_task` and `read_only_skill` remain strict
|
||||
direct execution after Plan exits. A third-party MCP `readOnlyHint` affects dispatch
|
||||
classification. Once the server is installed or its exact project identity is
|
||||
confirmed, non-destructive readers also enter the dedicated planner and
|
||||
read-only sub-agent registries automatically. `plan_mode_read_only_commands` is
|
||||
retained for config/session round trips and does not grant or revoke calls in
|
||||
the main Plan workflow. `read_only_task` and `read_only_skill` remain strict
|
||||
read-only capabilities with their own tool registry and safe foreground Bash;
|
||||
writer-capable `task` and skill execution remain permission-gated instead of
|
||||
Plan-blocked, and their child turns inherit the Plan workflow marker and
|
||||
@@ -427,7 +424,7 @@ func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Dec
|
||||
| Need approval / `ask` | Follow permission policy (`Ask` prompts interactively) | Waits for user | Waits for user |
|
||||
| Auto approve / `auto` | Writer fallback auto-allowed; explicit ask/deny rules still apply | Waits for user | Waits for user |
|
||||
| YOLO approval / `yolo` | Ordinary prompts auto-allowed; deny rules and fresh reviews remain | Waits for user | Waits for user |
|
||||
| Approved-plan execution window | Approved plan's writer fallback is auto-allowed; explicit `ask` / `deny`, MCP `prompt` / `writes`, and fresh reviews remain | Future plans still wait | Waits for user |
|
||||
| Approved-plan execution window | Approved plan's writer fallback is auto-allowed; explicit `ask` / `deny` rules remain | Future plans still wait | Waits for user |
|
||||
|
||||
Out of the box (`mode = "ask"`, no rules) `reasonix run` behaves exactly as before
|
||||
(writers resolve `Ask`→allow with no TTY), while `reasonix` now prompts before
|
||||
@@ -605,7 +602,6 @@ default_model = "deepseek" # provider name (→ its default model) or "provide
|
||||
system_prompt = "You are Reasonix, a coding agent..." # or system_prompt_file = "..."
|
||||
temperature = 0.0
|
||||
reasoning_language = "auto" # visible reasoning text: auto|zh|en
|
||||
# plan_mode_allowed_tools = ["mcp__legacy__reader"] # legacy MCP read-only alias; does not change Plan availability
|
||||
# plan_mode_read_only_commands = ["gh issue view"] # legacy compatibility only; Plan bash uses Permissions
|
||||
# planner_model = "deepseek-pro" # optional: two-model collaboration (low-frequency planner)
|
||||
# subagent_model = "deepseek-pro" # optional default for runAs=subagent skills
|
||||
@@ -674,10 +670,6 @@ args = []
|
||||
# env = { FOO = "bar" }
|
||||
# call_timeout_seconds = 600 # per-server MCP call timeout; 0 = global/default cap
|
||||
# tool_timeout_seconds = { "generate_video" = 1800 } # raw MCP tool names
|
||||
# trusted_read_only_tools = ["search"] # locally audited Plan/read-only-research readers
|
||||
# default_tools_approval_mode = "auto" # auto|prompt|writes|approve
|
||||
# tools = { "delete_all" = { approval_mode = "prompt" } }
|
||||
# approvals_reviewer = "user" # user|auto_review
|
||||
# [[plugins]] # a remote MCP server over Streamable HTTP
|
||||
# name = "stripe"
|
||||
# type = "http" # "stdio" (default) | "http" | "sse"
|
||||
|
||||
+4
-2
@@ -97,10 +97,12 @@ type Tool interface {
|
||||
|
||||
- `stdio`:本地持久子进程,每行一条 JSON 消息。
|
||||
- `http` / `streamable-http`:向远程 `url` POST,支持 `application/json` 和 SSE 响应,并复用 `Mcp-Session-Id`。
|
||||
- `sse`:识别旧版 2024-11-05 传输,但当前明确拒绝并提示改用 `http`。
|
||||
- `sse`:兼容旧版 2024-11-05 HTTP+SSE;持久 GET 接收 server 公布的相对 POST endpoint、JSON-RPC 响应与 server 消息。为避免静态 header 泄漏,会拒绝跨域 endpoint。
|
||||
|
||||
`${VAR}` 与 `${VAR:-default}` 可用于 `command`、`args`、`env`、`url` 和 `headers`,使 secret 留在环境中。生命周期为 `initialize` → `notifications/initialized` → `tools/list`,调用使用 `tools/call`。
|
||||
|
||||
存在工作区根目录时,初始化会声明 `roots` 能力,并用文件 URI 响应 `roots/list`。`tools/call` 会附带逐调用 `_meta.progressToken`;匹配的 `notifications/progress` 会进入现有工具进度事件链路。
|
||||
|
||||
远程工具适配为 `Tool`,命名为 `mcp__<server>__<tool>`。`annotations.readOnlyHint` 映射为 `Tool.ReadOnly()`,默认 false;只有显式声明为只读的工具才进入并行读取与默认只读权限路径。MCP prompt 暴露为 slash command,resource 可通过 `@<server>:<uri>` 引用。
|
||||
|
||||
### 3.4 Agent loop(`internal/agent`)
|
||||
@@ -151,7 +153,7 @@ func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Dec
|
||||
- rule 可以是 `Tool` 或 `Tool(specifier)`,例如 `Bash(go test:*)`、`Edit(docs/**)`。
|
||||
- 优先级为 `deny > ask > allow > fallback`;只读工具 fallback 为 Allow,写工具 fallback 使用 `Mode`。
|
||||
- 交互模式中的 Ask 由用户选择单次允许、session scope 允许、持久允许或拒绝;显式 Deny 在所有模式下都不可绕过。
|
||||
- MCP 可设置 `auto|prompt|writes|approve`。标记 `destructiveHint: true` 的调用在 `auto`、`prompt` 或 `writes` 下每次都需要人工重新确认。
|
||||
- 安装 MCP server 即授权其全部工具,不再有 server、raw tool、writer 或 destructive 的第二套审批策略;显式全局 `deny` 仍然优先。仓库声明的 server 在启动前只确认一次精确身份,身份变化时才重新确认。`readOnlyHint` 与 `destructiveHint` 仅用于调度、Plan/严格只读边界及缓存到实时安全分类复核,不会新增逐调用审批。
|
||||
- Plan 是协作流程,不等于全工具只读。普通 built-in 与 Bash 仍走 Ask/Auto/YOLO 和 Sandbox;MCP 写入/破坏性目标及不受信任读取工具在规划阶段保持阻止。
|
||||
- Plan 只能由用户显式选择进入,与当前工具审批姿态相互独立;普通聊天不会自动切换到 Plan。Auto/YOLO 不会回答 `ask`,也不会替用户批准 `exit_plan_mode`,获批计划的短期自动执行窗口也不会自动批准后续计划。
|
||||
- 桌面端协作模式分为 `normal`、`plan` 和 `goal`。Goal 会持续推进目标,直到完成、同一阻塞状态重复三次、用户停止或达到安全续跑边界。只有用户在输入框中选择 Goal 或运行 `/goal` 显式启动后,长周期研究、调试、优化或实现目标才可启用 AutoResearch;普通聊天不会隐式切换协作模式,也不会创建持久化 AutoResearch 状态。动态状态保存在 `.reasonix/autoresearch/.../`。
|
||||
|
||||
@@ -37,7 +37,7 @@ Reasonix 桌面端输入框下方的“询问 / 自动 / Yolo”控制的是工
|
||||
### 注意事项
|
||||
|
||||
- 询问模式更安全,但会增加交互次数。
|
||||
- 询问不等于只读:批准 writer 后,写文件、命令或 MCP 调用仍会执行;Sandbox 才是强制能力边界。
|
||||
- 询问不等于只读:批准普通 writer 后,写文件或命令仍会执行;已安装或明确授权的 MCP 不走逐调用审批。Sandbox 才是强制能力边界。
|
||||
- 审批卡片里的“本会话允许”和“总是允许”是权限规则,不是切换到自动模式。
|
||||
- 如果你已经确认任务范围很清楚,且操作都在可信工作区内,自动模式通常更顺手。
|
||||
|
||||
@@ -51,7 +51,7 @@ Reasonix 桌面端输入框下方的“询问 / 自动 / Yolo”控制的是工
|
||||
- 显式 `ask` 规则仍然会弹出审批。
|
||||
- 计划模式的“开始执行”确认仍然需要你选择。
|
||||
- `remember` / `forget` 等记忆写入或删除操作仍需要新鲜人工审批,Guardian/安全审查不能代替用户批准。
|
||||
- MCP 最终策略为 `auto`、`prompt` 或 `writes` 时,声明 `destructiveHint` 的调用需要全新的人工审批;Guardian/`auto_review`、自动/YOLO 与会话授权都不能代答。用户明确安装且未配置高级审批策略的 MCP 默认为 `approve`,包括 destructive 调用在内都直接执行。
|
||||
- 用户安装或明确授权的 MCP 直接执行,不再受这套逐调用模式影响;显式 `deny`、Plan 和严格只读子会话边界仍然生效。
|
||||
- ask 问题仍然等待你回答,不会由自动模式代选。
|
||||
|
||||
### 建议使用场景
|
||||
@@ -88,14 +88,14 @@ Yolo 模式用于最大化连续执行。开启后,普通工具审批会被跳
|
||||
|
||||
- Yolo 只跳过工具权限审批,不会跳过计划确认和 ask 问题。
|
||||
- Yolo 不会绕过后端的 `deny` 规则、系统权限、工作区限制或沙箱限制。
|
||||
- 记忆写入/删除和计划确认等强制新鲜审批仍然会等待你处理。MCP 最终策略不是 `approve` 时,声明 `destructiveHint` 的调用每次重新审查;非交互运行和子代理会拒绝需要人工审查的调用,而不是自动批准。
|
||||
- 记忆写入/删除和计划确认等强制新鲜审批仍然会等待你处理。MCP 授权发生在用户安装或精确项目身份确认时;之后不会因为 writer 分类或 `destructiveHint` 再弹二次审批。显式 `deny`、Plan 与严格只读子代理的工具过滤仍然生效。
|
||||
- 不建议在生产环境、敏感目录、密钥文件、发布流程或需求不清楚的任务里开启。
|
||||
|
||||
## 与协作方式的组合
|
||||
|
||||
| 组合 | 行为 |
|
||||
| --- | --- |
|
||||
| 计划 + 询问 | 规划期间,需审批的调用会等待你;确认计划后的执行回合自动放行普通 writer fallback,但显式 `ask` / `deny`、MCP `prompt` / `writes` 和强制新鲜审批仍生效。 |
|
||||
| 计划 + 询问 | 规划期间,普通内置工具仍按询问模式处理;MCP writer、destructive 目标与未授权 reader 会被 Plan 直接阻止。确认计划后,普通 writer fallback 自动放行,显式 `ask` / `deny` 与强制新鲜审批仍生效。 |
|
||||
| 计划 + 自动 | 计划确认仍需要你批准;开始执行后,普通工具权限自动放行。 |
|
||||
| 计划 + Yolo | 计划确认仍需要你批准;开始执行后,普通工具审批尽量不再打断。 |
|
||||
| 目标 + 询问 | 目标会持续推进,但遇到工具审批会停下来等你。 |
|
||||
|
||||
@@ -5,7 +5,6 @@ go 1.25.0
|
||||
toolchain go1.26.5
|
||||
|
||||
require (
|
||||
aead.dev/minisign v0.3.0
|
||||
charm.land/bubbles/v2 v2.1.0
|
||||
charm.land/bubbletea/v2 v2.0.7
|
||||
charm.land/lipgloss/v2 v2.0.4
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
aead.dev/minisign v0.3.0 h1:8Xafzy5PEVZqYDNP60yJHARlW1eOQtsKNp/Ph2c0vRA=
|
||||
aead.dev/minisign v0.3.0/go.mod h1:NLvG3Uoq3skkRMDuc3YHpWUTMTrSExqm+Ij73W13F6Y=
|
||||
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
|
||||
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
|
||||
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
|
||||
|
||||
@@ -530,14 +530,14 @@ func TestUpdateSinkApprovalUsesTurnContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalOptionsFreshDynamicMCPOnlyAllowOnceOrReject(t *testing.T) {
|
||||
options := approvalOptions("mcp__srv__wipe", "srv/wipe", true)
|
||||
func TestApprovalOptionsFreshDynamicToolOnlyAllowOnceOrReject(t *testing.T) {
|
||||
options := approvalOptions("extension__wipe", "extension/wipe", true)
|
||||
if len(options) != 2 || options[0].Kind != OptAllowOnce || options[1].Kind != OptRejectOnce {
|
||||
t.Fatalf("fresh destructive MCP options = %+v, want allow-once/reject", options)
|
||||
t.Fatalf("fresh dynamic-tool options = %+v, want allow-once/reject", options)
|
||||
}
|
||||
for _, option := range options {
|
||||
if option.Kind == OptAllowAlways {
|
||||
t.Fatalf("fresh destructive MCP offered remembered permission: %+v", options)
|
||||
t.Fatalf("fresh dynamic-tool decision offered remembered permission: %+v", options)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,9 @@ func TestMcpSpecsConversion(t *testing.T) {
|
||||
if got[0].Dir != "/workspace" {
|
||||
t.Errorf("dir = %q, want /workspace", got[0].Dir)
|
||||
}
|
||||
if got[0].WorkspaceRoot != "/workspace" || got[1].WorkspaceRoot != "/workspace" {
|
||||
t.Errorf("workspace roots = %q, %q, want /workspace", got[0].WorkspaceRoot, got[1].WorkspaceRoot)
|
||||
}
|
||||
if got[1].Name != "remote" || got[1].Type != "http" || got[1].URL != "https://mcp.example.test" {
|
||||
t.Errorf("http spec = %+v", got[1])
|
||||
}
|
||||
@@ -256,7 +259,11 @@ func TestMCPHeadersAcceptsLegacyMap(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMcpSpecsRejectsUnsupportedTransport(t *testing.T) {
|
||||
_, err := mcpSpecs([]MCPServerSpec{{Name: "remote", Type: "sse", URL: "https://example.test/sse"}}, "/tmp")
|
||||
got, err := mcpSpecs([]MCPServerSpec{{Name: "remote", Type: "sse", URL: "https://example.test/sse"}}, "/tmp")
|
||||
if err != nil || len(got) != 1 || got[0].Type != "sse" {
|
||||
t.Fatalf("mcpSpecs legacy SSE = %+v, %v", got, err)
|
||||
}
|
||||
_, err = mcpSpecs([]MCPServerSpec{{Name: "remote", Type: "websocket", URL: "https://example.test/ws"}}, "/tmp")
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported transport") {
|
||||
t.Fatalf("mcpSpecs unsupported transport err = %v", err)
|
||||
}
|
||||
|
||||
+13
-10
@@ -2555,23 +2555,26 @@ func mcpSpecs(in []MCPServerSpec, cwd string) ([]plugin.Spec, error) {
|
||||
if strings.TrimSpace(m.Command) == "" {
|
||||
return nil, fmt.Errorf("MCP server %q command is required", m.Name)
|
||||
}
|
||||
case "http", "streamable-http", "streamable_http":
|
||||
case "http", "streamable-http", "streamable_http", "sse":
|
||||
if strings.TrimSpace(m.URL) == "" {
|
||||
return nil, fmt.Errorf("MCP server %q url is required", m.Name)
|
||||
}
|
||||
typ = "http"
|
||||
if typ != "sse" {
|
||||
typ = "http"
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("MCP server %q uses unsupported transport %q", m.Name, m.Type)
|
||||
}
|
||||
out = append(out, plugin.Spec{
|
||||
Name: strings.TrimSpace(m.Name),
|
||||
Type: typ,
|
||||
Command: strings.TrimSpace(m.Command),
|
||||
Args: append([]string(nil), m.Args...),
|
||||
Env: mapString(m.Env),
|
||||
URL: strings.TrimSpace(m.URL),
|
||||
Headers: mapString(m.Headers),
|
||||
Dir: cwd,
|
||||
Name: strings.TrimSpace(m.Name),
|
||||
Type: typ,
|
||||
Command: strings.TrimSpace(m.Command),
|
||||
Args: append([]string(nil), m.Args...),
|
||||
Env: mapString(m.Env),
|
||||
URL: strings.TrimSpace(m.URL),
|
||||
Headers: mapString(m.Headers),
|
||||
Dir: cwd,
|
||||
WorkspaceRoot: cwd,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
|
||||
+39
-103
@@ -188,20 +188,12 @@ type Gate interface {
|
||||
Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (allow bool, reason string, err error)
|
||||
}
|
||||
|
||||
// FreshApprovalGate is an optional Gate extension for calls that must be
|
||||
// answered by a current human decision even when Auto/YOLO or an allow rule
|
||||
// would normally bypass approval. MCP destructiveHint uses it as a conservative
|
||||
// fallback when the gate does not implement MCP-local approval policy.
|
||||
type FreshApprovalGate interface {
|
||||
CheckFresh(ctx context.Context, toolName, subject string, args json.RawMessage, readOnly bool) (allow bool, reason string, err error)
|
||||
}
|
||||
|
||||
// MCPApprovalGate applies local per-server/per-tool MCP policy without changing
|
||||
// provider-visible tool metadata. Destructive calls are identified separately
|
||||
// so the gate can apply the effective approval mode before deciding whether a
|
||||
// fresh review is required.
|
||||
type MCPApprovalGate interface {
|
||||
CheckMCP(ctx context.Context, toolName, subject string, args json.RawMessage, readOnly, destructive bool, mode, reviewer string) (allow bool, reason string, err error)
|
||||
// ExplicitDenyGate exposes the only global permission decision that applies to
|
||||
// an already-authorized MCP server. Installing or approving a server is the
|
||||
// user's authorization boundary; ordinary ask/fallback posture must not add a
|
||||
// second per-call prompt, while explicit deny rules remain authoritative.
|
||||
type ExplicitDenyGate interface {
|
||||
ExplicitlyDenies(toolName string, args json.RawMessage) bool
|
||||
}
|
||||
|
||||
const PlanModeReadOnlyCommandApprovalTool = "plan_mode_read_only_command"
|
||||
@@ -909,10 +901,6 @@ type Options struct {
|
||||
// user-turn context. Empty/auto keeps the stable same-as-user policy.
|
||||
ResponseLanguage string
|
||||
|
||||
// PlanModeAllowedTools is retained for source compatibility. MCP assembly may
|
||||
// still translate legacy entries into local read-only trust, but it does not
|
||||
// grant or revoke calls in the main Plan workflow.
|
||||
PlanModeAllowedTools []string
|
||||
// PlanModeReadOnlyCommands is retained for old config/controller data. Main
|
||||
// Plan execution classifies bash through Permissions instead.
|
||||
PlanModeReadOnlyCommands []string
|
||||
@@ -2942,7 +2930,7 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
|
||||
safety = planmode.PlanSafetyUnsafe
|
||||
}
|
||||
}
|
||||
if decision := a.planModeDecision(canonicalName, t.ReadOnly(), planModeUntrustedReadOnly(t), safety, json.RawMessage(call.Arguments)); decision.Blocked {
|
||||
if decision := a.planModeDecision(canonicalName, t.ReadOnly(), safety, json.RawMessage(call.Arguments)); decision.Blocked {
|
||||
return toolOutcome{
|
||||
output: decision.Message,
|
||||
blocked: true,
|
||||
@@ -3032,7 +3020,7 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
|
||||
safety = planmode.PlanSafetyUnsafe
|
||||
}
|
||||
}
|
||||
if decision := a.planModeDecision(permName, resolved.ReadOnly, planModeUntrustedReadOnly(execTool), safety, permArgs); decision.Blocked {
|
||||
if decision := a.planModeDecision(permName, resolved.ReadOnly, safety, permArgs); decision.Blocked {
|
||||
return toolOutcome{
|
||||
output: decision.Message,
|
||||
blocked: true,
|
||||
@@ -3040,11 +3028,15 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
|
||||
}
|
||||
}
|
||||
}
|
||||
if a.planMode.Load() && isMCPExecutionTarget(execTool, permName) && (!readOnly || planModeUntrustedReadOnly(execTool) || mcpDestructiveHint(execTool)) {
|
||||
if a.planMode.Load() && isMCPExecutionTarget(execTool, permName) && (!readOnly || !mcpServerAuthorized(execTool) || mcpDestructiveHint(execTool)) {
|
||||
reason := "writer/destructive target"
|
||||
if readOnly && !mcpServerAuthorized(execTool) {
|
||||
reason = "reader from an unauthorized server"
|
||||
}
|
||||
return toolOutcome{
|
||||
output: fmt.Sprintf("blocked: MCP writer/destructive target %q is unavailable during Plan mode; finish or exit Plan mode before requesting this call", permName),
|
||||
output: fmt.Sprintf("blocked: MCP %s %q is unavailable during Plan mode; finish or exit Plan mode before requesting this call", reason, permName),
|
||||
blocked: true,
|
||||
errMsg: "blocked: MCP writer is unavailable during planning",
|
||||
errMsg: "blocked: MCP target is unavailable during planning",
|
||||
}
|
||||
}
|
||||
if a.deliveryProfile && evidenceName == "bash" && evidence.BashToolCallMasksVerificationExit(evidenceArgs) {
|
||||
@@ -3084,44 +3076,19 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
|
||||
errMsg: "blocked: active delivery todo required",
|
||||
}
|
||||
}
|
||||
destructive := mcpDestructiveHint(execTool)
|
||||
if isInstalledMCPTool(execTool) {
|
||||
mode, reviewer := mcpApprovalPolicy(execTool)
|
||||
var allow bool
|
||||
var reason string
|
||||
var err error
|
||||
if mcpGate, ok := a.gate.(MCPApprovalGate); ok {
|
||||
allow, reason, err = mcpGate.CheckMCP(ctx, permName, mcpApprovalSubject(execTool, permName), permArgs, readOnly, destructive, mode, reviewer)
|
||||
} else if destructive {
|
||||
freshGate, ok := a.gate.(FreshApprovalGate)
|
||||
if !ok {
|
||||
return toolOutcome{
|
||||
output: "blocked: this destructive MCP tool requires fresh human approval or a configured automatic approval reviewer before execution.",
|
||||
blocked: true,
|
||||
errMsg: "blocked: destructive MCP approval required",
|
||||
}
|
||||
}
|
||||
allow, reason, err = freshGate.CheckFresh(ctx, permName, mcpApprovalSubject(execTool, permName), permArgs, readOnly)
|
||||
} else if a.gate != nil {
|
||||
allow, reason, err = a.gate.Check(ctx, permName, permArgs, readOnly)
|
||||
} else {
|
||||
allow = true
|
||||
}
|
||||
if err != nil {
|
||||
if !mcpServerAuthorized(execTool) {
|
||||
return toolOutcome{
|
||||
output: fmt.Sprintf("blocked: %s (%v)", reason, err),
|
||||
output: "blocked: this project MCP server identity has not been authorized; approve the server from a parent session and retry",
|
||||
blocked: true,
|
||||
errMsg: fmt.Sprintf("blocked: %v", err),
|
||||
errMsg: "blocked: MCP server identity is not authorized",
|
||||
}
|
||||
}
|
||||
if !allow {
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
reason = "the MCP tool call was not approved"
|
||||
}
|
||||
if denyGate, ok := a.gate.(ExplicitDenyGate); ok && denyGate.ExplicitlyDenies(permName, permArgs) {
|
||||
return toolOutcome{
|
||||
output: "blocked: " + reason,
|
||||
output: "blocked: denied by permission policy — this tool/command is on the deny list. Do not retry it; choose another approach or stop and explain.",
|
||||
blocked: true,
|
||||
errMsg: "blocked by MCP approval policy",
|
||||
errMsg: "blocked by permission policy",
|
||||
}
|
||||
}
|
||||
} else if a.gate != nil {
|
||||
@@ -3251,14 +3218,10 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
|
||||
var err error
|
||||
// A call that was authorized under reader classification carries that
|
||||
// basis into dispatch: the MCP execution layer re-verifies it linearizably
|
||||
// against live trust state and refuses to promote it into a writer lane if
|
||||
// a concurrent revocation or reclassification landed after the gate.
|
||||
if readOnly && isInstalledMCPTool(runTool) && !mcpDestructiveHint(runTool) {
|
||||
fingerprint := ""
|
||||
if fp, ok := runTool.(tool.MCPCapabilityFingerprint); ok {
|
||||
fingerprint = fp.MCPCapabilityFingerprint()
|
||||
}
|
||||
cctx = tool.WithReaderExecutionIntent(cctx, fingerprint)
|
||||
// against server authorization and live safety metadata, and refuses to
|
||||
// promote it into a writer lane if reclassification landed after the gate.
|
||||
if readOnly && isInstalledMCPTool(runTool) && mcpServerAuthorized(runTool) && !mcpDestructiveHint(runTool) {
|
||||
cctx = tool.WithReaderExecutionIntent(cctx)
|
||||
}
|
||||
if it, ok := runTool.(tool.ImageTool); ok {
|
||||
result, images, err = it.ExecuteWithImages(cctx, runArgs)
|
||||
@@ -3344,8 +3307,8 @@ func (a *Agent) readOnlyExecutionBlock(visible tool.Tool, resolved *tool.Resolve
|
||||
}
|
||||
return block("execute a state-changing tool")
|
||||
}
|
||||
if u, ok := visible.(tool.PlanModeUntrustedReadOnly); ok && u.PlanModeUntrustedReadOnly() {
|
||||
return block("trust an externally asserted read-only target")
|
||||
if isInstalledMCPTool(visible) && !mcpServerAuthorized(visible) {
|
||||
return block("execute a reader from an unauthorized MCP server")
|
||||
}
|
||||
if readOnlyExecutionMCPDestructive(visible) {
|
||||
return block("execute a destructive MCP capability")
|
||||
@@ -3374,8 +3337,8 @@ func (a *Agent) readOnlyExecutionBlock(visible tool.Tool, resolved *tool.Resolve
|
||||
}
|
||||
return block("execute a state-changing dynamic capability")
|
||||
}
|
||||
if u, ok := resolved.Target.(tool.PlanModeUntrustedReadOnly); ok && u.PlanModeUntrustedReadOnly() {
|
||||
return block("trust an externally asserted read-only dynamic capability")
|
||||
if isInstalledMCPTool(resolved.Target) && !mcpServerAuthorized(resolved.Target) {
|
||||
return block("execute a dynamic reader from an unauthorized MCP server")
|
||||
}
|
||||
if readOnlyExecutionMCPDestructive(resolved.Target) {
|
||||
return block("execute a destructive MCP capability")
|
||||
@@ -3397,21 +3360,14 @@ func readOnlyExecutionAllowsMCPStartup(t tool.Tool) bool {
|
||||
if t == nil || !t.ReadOnly() || readOnlyExecutionMCPDestructive(t) {
|
||||
return false
|
||||
}
|
||||
if untrusted, ok := t.(tool.PlanModeUntrustedReadOnly); ok && untrusted.PlanModeUntrustedReadOnly() {
|
||||
if !mcpServerAuthorized(t) {
|
||||
return false
|
||||
}
|
||||
meta, ok := t.(tool.MCPMetadata)
|
||||
if !ok || strings.TrimSpace(meta.MCPServerName()) == "" || strings.TrimSpace(meta.MCPRawToolName()) == "" {
|
||||
return false
|
||||
}
|
||||
// A reader classification only admits a host start when explicit local or
|
||||
// signed package policy backs it. A server hint alone never satisfies the
|
||||
// strict boundary.
|
||||
if authority, ok := t.(tool.ReadOnlyExecutionAuthority); !ok || !authority.ReadOnlyExecutionAuthority() {
|
||||
return false
|
||||
}
|
||||
_, governed := t.(tool.MCPApprovalPolicy)
|
||||
return governed
|
||||
return true
|
||||
}
|
||||
|
||||
func isInstalledMCPTool(t tool.Tool) bool {
|
||||
@@ -3423,9 +3379,9 @@ func isMCPExecutionTarget(t tool.Tool, name string) bool {
|
||||
return isInstalledMCPTool(t) || strings.HasPrefix(strings.TrimSpace(name), "mcp__")
|
||||
}
|
||||
|
||||
func planModeUntrustedReadOnly(t tool.Tool) bool {
|
||||
untrusted, ok := t.(tool.PlanModeUntrustedReadOnly)
|
||||
return ok && untrusted.PlanModeUntrustedReadOnly()
|
||||
func mcpServerAuthorized(t tool.Tool) bool {
|
||||
authority, ok := t.(tool.MCPServerAuthorization)
|
||||
return ok && authority.MCPServerAuthorized()
|
||||
}
|
||||
|
||||
func mcpDestructiveHint(t tool.Tool) bool {
|
||||
@@ -3433,32 +3389,12 @@ func mcpDestructiveHint(t tool.Tool) bool {
|
||||
return ok && annotations.MCPDestructiveHint()
|
||||
}
|
||||
|
||||
func mcpApprovalPolicy(t tool.Tool) (mode, reviewer string) {
|
||||
policy, ok := t.(tool.MCPApprovalPolicy)
|
||||
if !ok {
|
||||
return tool.MCPApprovalAuto, ""
|
||||
}
|
||||
return tool.NormalizeMCPApprovalMode(policy.MCPApprovalMode()), tool.NormalizeMCPApprovalReviewer(policy.MCPApprovalReviewer())
|
||||
}
|
||||
|
||||
func mcpApprovalSubject(t tool.Tool, fallback string) string {
|
||||
if meta, ok := t.(tool.MCPMetadata); ok {
|
||||
server := strings.TrimSpace(meta.MCPServerName())
|
||||
raw := strings.TrimSpace(meta.MCPRawToolName())
|
||||
if server != "" && raw != "" {
|
||||
return server + "/" + raw
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
|
||||
func (a *Agent) planModeDecision(toolName string, readOnly, untrustedReadOnly bool, safety planmode.PlanSafety, args json.RawMessage) planmode.Decision {
|
||||
func (a *Agent) planModeDecision(toolName string, readOnly bool, safety planmode.PlanSafety, args json.RawMessage) planmode.Decision {
|
||||
return (planmode.Policy{}).Decide(planmode.Call{
|
||||
Name: toolName,
|
||||
ReadOnly: readOnly,
|
||||
UntrustedReadOnly: untrustedReadOnly,
|
||||
Safety: safety,
|
||||
Args: args,
|
||||
Name: toolName,
|
||||
ReadOnly: readOnly,
|
||||
Safety: safety,
|
||||
Args: args,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ func (t mcpAliasTool) MCPServerName() string { return t.server }
|
||||
func (t mcpAliasTool) MCPRawToolName() string { return t.raw }
|
||||
func (t mcpAliasTool) MCPVisibleToolName() string { return t.visible }
|
||||
func (t mcpAliasTool) MCPPackageName() string { return t.pkg }
|
||||
func (t mcpAliasTool) MCPServerAuthorized() bool { return true }
|
||||
|
||||
// TestFailedCallsSurfaceError guards the bug where a failed tool call (an unknown
|
||||
// tool, e.g. a hallucinated "find", or a permission-denied writer) was reported
|
||||
@@ -65,8 +66,8 @@ func TestPortableMCPCallUsesCanonicalSecurityIdentity(t *testing.T) {
|
||||
if out.errMsg != "" {
|
||||
t.Fatalf("portable MCP call failed: %+v", out)
|
||||
}
|
||||
if len(gate.calls) != 1 || gate.calls[0].name != "mcp__figma__get_design_context" {
|
||||
t.Fatalf("permission gate calls = %v, want canonical MCP name", gate.calls)
|
||||
if len(gate.denyCalls) != 1 || gate.denyCalls[0] != "mcp__figma__get_design_context" {
|
||||
t.Fatalf("permission deny checks = %v, want canonical MCP name", gate.denyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,16 @@ type permissionCall struct {
|
||||
}
|
||||
|
||||
type recordingPermissionGate struct {
|
||||
allow bool
|
||||
reason string
|
||||
calls []permissionCall
|
||||
allow bool
|
||||
reason string
|
||||
calls []permissionCall
|
||||
denied bool
|
||||
denyCalls []string
|
||||
}
|
||||
|
||||
func (g *recordingPermissionGate) ExplicitlyDenies(name string, _ json.RawMessage) bool {
|
||||
g.denyCalls = append(g.denyCalls, name)
|
||||
return g.denied
|
||||
}
|
||||
|
||||
func (g *recordingPermissionGate) Check(_ context.Context, name string, _ json.RawMessage, readOnly bool) (bool, string, error) {
|
||||
@@ -45,25 +52,22 @@ func (g *legacyPlanTrustGate) CheckPlanModeReadOnlyTrust(context.Context, PlanMo
|
||||
|
||||
type annotatedMCPTool struct {
|
||||
fakeTool
|
||||
server string
|
||||
raw string
|
||||
destructive bool
|
||||
untrustedReadOnly bool
|
||||
server string
|
||||
raw string
|
||||
destructive bool
|
||||
serverAuthorized bool
|
||||
}
|
||||
|
||||
func (t annotatedMCPTool) MCPServerName() string { return t.server }
|
||||
func (t annotatedMCPTool) MCPRawToolName() string { return t.raw }
|
||||
func (t annotatedMCPTool) MCPDestructiveHint() bool { return t.destructive }
|
||||
func (t annotatedMCPTool) PlanModeUntrustedReadOnly() bool { return t.untrustedReadOnly }
|
||||
func (t annotatedMCPTool) MCPServerName() string { return t.server }
|
||||
func (t annotatedMCPTool) MCPRawToolName() string { return t.raw }
|
||||
func (t annotatedMCPTool) MCPDestructiveHint() bool { return t.destructive }
|
||||
func (t annotatedMCPTool) MCPServerAuthorized() bool { return t.serverAuthorized }
|
||||
|
||||
type mcpPermissionRecordingGate struct {
|
||||
normalCalls int
|
||||
freshCalls int
|
||||
readOnly []bool
|
||||
allowNormal bool
|
||||
allowFresh bool
|
||||
reason string
|
||||
subject string
|
||||
}
|
||||
|
||||
func (g *mcpPermissionRecordingGate) Check(_ context.Context, _ string, _ json.RawMessage, readOnly bool) (bool, string, error) {
|
||||
@@ -72,13 +76,6 @@ func (g *mcpPermissionRecordingGate) Check(_ context.Context, _ string, _ json.R
|
||||
return g.allowNormal, g.reason, nil
|
||||
}
|
||||
|
||||
func (g *mcpPermissionRecordingGate) CheckFresh(_ context.Context, _ string, subject string, _ json.RawMessage, readOnly bool) (bool, string, error) {
|
||||
g.freshCalls++
|
||||
g.subject = subject
|
||||
g.readOnly = append(g.readOnly, readOnly)
|
||||
return g.allowFresh, g.reason, nil
|
||||
}
|
||||
|
||||
func TestPlanModeRoutesOrdinaryToolsThroughPermissionGate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -90,12 +87,12 @@ func TestPlanModeRoutesOrdinaryToolsThroughPermissionGate(t *testing.T) {
|
||||
{name: "shell writer", tool: fakeTool{name: "bash"}, args: `{"command":"rm -rf build"}`},
|
||||
{name: "reader", tool: fakeTool{name: "read_file", readOnly: true}, readOnly: true},
|
||||
{
|
||||
name: "host-trusted MCP reader",
|
||||
name: "authorized MCP reader",
|
||||
tool: annotatedMCPTool{
|
||||
fakeTool: fakeTool{name: "mcp__srv__query", readOnly: true},
|
||||
server: "srv",
|
||||
raw: "query",
|
||||
untrustedReadOnly: false,
|
||||
fakeTool: fakeTool{name: "mcp__srv__query", readOnly: true},
|
||||
server: "srv",
|
||||
raw: "query",
|
||||
serverAuthorized: true,
|
||||
},
|
||||
readOnly: true,
|
||||
},
|
||||
@@ -112,7 +109,11 @@ func TestPlanModeRoutesOrdinaryToolsThroughPermissionGate(t *testing.T) {
|
||||
if out.blocked || out.errMsg != "" || !strings.Contains(out.output, "done") {
|
||||
t.Fatalf("ordinary Plan call did not execute after permission approval: %+v", out)
|
||||
}
|
||||
if len(gate.calls) != 1 || gate.calls[0].name != tc.tool.Name() || gate.calls[0].readOnly != tc.readOnly {
|
||||
if isInstalledMCPTool(tc.tool) {
|
||||
if len(gate.calls) != 0 || len(gate.denyCalls) != 1 || gate.denyCalls[0] != tc.tool.Name() {
|
||||
t.Fatalf("authorized MCP permission calls=%+v deny checks=%+v", gate.calls, gate.denyCalls)
|
||||
}
|
||||
} else if len(gate.calls) != 1 || gate.calls[0].name != tc.tool.Name() || gate.calls[0].readOnly != tc.readOnly {
|
||||
t.Fatalf("permission calls = %+v, want %q readOnly=%v", gate.calls, tc.tool.Name(), tc.readOnly)
|
||||
}
|
||||
})
|
||||
@@ -136,6 +137,32 @@ func TestPlanModePermissionDenialStopsWriterBeforeExecution(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizedMCPUsesInstallAuthorizationAndExplicitDenyOnly(t *testing.T) {
|
||||
var executions int32
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(annotatedMCPTool{
|
||||
fakeTool: fakeTool{name: "mcp__srv__write", calls: &executions},
|
||||
server: "srv",
|
||||
raw: "write",
|
||||
serverAuthorized: true,
|
||||
})
|
||||
|
||||
// The ordinary writer fallback would deny, but an authorized MCP server must
|
||||
// not re-enter that per-call approval path.
|
||||
gate := &recordingPermissionGate{allow: false, reason: "ordinary ask declined"}
|
||||
a := New(nil, reg, NewSession(""), Options{Gate: gate}, event.Discard)
|
||||
out := a.executeOne(context.Background(), provider.ToolCall{Name: "mcp__srv__write"})
|
||||
if out.blocked || out.errMsg != "" || executions != 1 || len(gate.calls) != 0 || len(gate.denyCalls) != 1 {
|
||||
t.Fatalf("authorized MCP outcome=%+v gate=%+v executions=%d", out, gate, executions)
|
||||
}
|
||||
|
||||
gate.denied = true
|
||||
out = a.executeOne(context.Background(), provider.ToolCall{Name: "mcp__srv__write"})
|
||||
if !out.blocked || !strings.Contains(out.output, "deny list") || executions != 1 {
|
||||
t.Fatalf("explicitly denied MCP outcome=%+v executions=%d", out, executions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanModeUnsafePhaseToolStopsBeforePermission(t *testing.T) {
|
||||
var executions int32
|
||||
reg := tool.NewRegistry()
|
||||
@@ -201,7 +228,6 @@ func TestPlanModeLegacyOverridesDoNotBypassPermissions(t *testing.T) {
|
||||
gate := &recordingPermissionGate{reason: "denied"}
|
||||
a := New(nil, reg, NewSession(""), Options{
|
||||
Gate: gate,
|
||||
PlanModeAllowedTools: []string{"write_file"},
|
||||
PlanModeReadOnlyCommands: []string{"gh issue view"},
|
||||
}, event.Discard)
|
||||
a.SetPlanMode(true)
|
||||
@@ -281,14 +307,14 @@ func serializeToolSchemas(t *testing.T, schemas []provider.ToolSchema) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestUntrustedMCPReaderBlockedInMainPlanAndExcludedFromReadOnlyAgents(t *testing.T) {
|
||||
func TestUnauthorizedMCPReaderBlockedInMainPlanAndExcludedFromReadOnlyAgents(t *testing.T) {
|
||||
parent := tool.NewRegistry()
|
||||
parent.Add(fakeTool{name: "read_file", readOnly: true})
|
||||
parent.Add(annotatedMCPTool{
|
||||
fakeTool: fakeTool{name: "mcp__srv__query", readOnly: true},
|
||||
server: "srv",
|
||||
raw: "query",
|
||||
untrustedReadOnly: true,
|
||||
fakeTool: fakeTool{name: "mcp__srv__query", readOnly: true},
|
||||
server: "srv",
|
||||
raw: "query",
|
||||
serverAuthorized: false,
|
||||
})
|
||||
gate := &recordingPermissionGate{allow: true}
|
||||
a := New(nil, parent, NewSession(""), Options{Gate: gate}, event.Discard)
|
||||
@@ -307,7 +333,7 @@ func TestUntrustedMCPReaderBlockedInMainPlanAndExcludedFromReadOnlyAgents(t *tes
|
||||
t.Fatalf("%s registry lost local reader", name)
|
||||
}
|
||||
if _, ok := filtered.Get("mcp__srv__query"); ok {
|
||||
t.Fatalf("%s registry admitted externally asserted reader", name)
|
||||
t.Fatalf("%s registry admitted reader from unauthorized server", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -320,7 +346,7 @@ func TestPlanModeMCPWriterIsHardBlockedBeforePermission(t *testing.T) {
|
||||
a.SetPlanMode(true)
|
||||
|
||||
out := a.executeOne(context.Background(), provider.ToolCall{Name: "mcp__srv__write"})
|
||||
if !out.blocked || gate.normalCalls != 0 || gate.freshCalls != 0 {
|
||||
if !out.blocked || gate.normalCalls != 0 {
|
||||
t.Fatalf("MCP writer outcome=%+v gate=%+v", out, gate)
|
||||
}
|
||||
}
|
||||
@@ -351,12 +377,12 @@ func TestDestructiveMCPUsesFreshApprovalInPlanEvenWhenReadOnly(t *testing.T) {
|
||||
raw: "danger/raw",
|
||||
destructive: true,
|
||||
})
|
||||
gate := &mcpPermissionRecordingGate{allowNormal: true, allowFresh: true}
|
||||
gate := &mcpPermissionRecordingGate{allowNormal: true}
|
||||
a := New(nil, reg, NewSession(""), Options{Gate: gate}, event.Discard)
|
||||
a.SetPlanMode(true)
|
||||
|
||||
out := a.executeOne(context.Background(), provider.ToolCall{Name: "mcp__srv__danger"})
|
||||
if !out.blocked || gate.normalCalls != 0 || gate.freshCalls != 0 {
|
||||
if !out.blocked || gate.normalCalls != 0 {
|
||||
t.Fatalf("destructive MCP outcome=%+v gate=%+v", out, gate)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,16 @@ type subagentCapabilityProxy struct {
|
||||
|
||||
type subagentMCPTool struct {
|
||||
subagentRegistryTool
|
||||
server string
|
||||
raw string
|
||||
server string
|
||||
raw string
|
||||
destructive bool
|
||||
serverAuthorized bool
|
||||
}
|
||||
|
||||
func (t subagentMCPTool) MCPServerName() string { return t.server }
|
||||
func (t subagentMCPTool) MCPRawToolName() string { return t.raw }
|
||||
func (t subagentMCPTool) MCPServerName() string { return t.server }
|
||||
func (t subagentMCPTool) MCPRawToolName() string { return t.raw }
|
||||
func (t subagentMCPTool) MCPDestructiveHint() bool { return t.destructive }
|
||||
func (t subagentMCPTool) MCPServerAuthorized() bool { return t.serverAuthorized }
|
||||
|
||||
func (t subagentCapabilityProxy) ResolveCall(_ context.Context, args json.RawMessage) (tool.ResolvedCall, error) {
|
||||
var p struct {
|
||||
@@ -162,6 +166,7 @@ func TestSubagentToolRegistryRestrictsCapabilityProxyToAllowedMCPIDs(t *testing.
|
||||
subagentRegistryTool: subagentRegistryTool{name: "mcp__figma__search", readOnly: true},
|
||||
server: "figma",
|
||||
raw: "search",
|
||||
serverAuthorized: true,
|
||||
})
|
||||
direct := SubagentToolRegistry(parent, []string{"mcp__figma__search", allowedID})
|
||||
if _, ok := direct.Get("mcp__figma__search"); !ok {
|
||||
@@ -263,7 +268,12 @@ func TestReadOnlySubagentToolRegistryAllowsOnlyReadOnlyDelegationBeforeDepthLimi
|
||||
func TestReadOnlySubagentToolRegistryIncludesMCPReadOnlyHint(t *testing.T) {
|
||||
parent := tool.NewRegistry()
|
||||
parent.Add(subagentRegistryTool{name: "read_file", readOnly: true})
|
||||
parent.Add(fakeTool{name: "mcp__srv__read", readOnly: true})
|
||||
parent.Add(subagentMCPTool{
|
||||
subagentRegistryTool: subagentRegistryTool{name: "mcp__srv__read", readOnly: true},
|
||||
server: "srv",
|
||||
raw: "read",
|
||||
serverAuthorized: true,
|
||||
})
|
||||
|
||||
sub := ReadOnlySubagentToolRegistry(parent, nil)
|
||||
if _, ok := sub.Get("mcp__srv__read"); !ok {
|
||||
@@ -274,6 +284,49 @@ func TestReadOnlySubagentToolRegistryIncludesMCPReadOnlyHint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPToolAvailabilityAcrossGeneralAndReadOnlySubagents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
readOnly bool
|
||||
destructive bool
|
||||
authorized bool
|
||||
wantGeneral bool
|
||||
wantStrictReadOnly bool
|
||||
}{
|
||||
{name: "authorized reader", readOnly: true, authorized: true, wantGeneral: true, wantStrictReadOnly: true},
|
||||
{name: "authorized writer", authorized: true, wantGeneral: true},
|
||||
{name: "authorized destructive reader", readOnly: true, destructive: true, authorized: true, wantGeneral: true},
|
||||
{name: "unauthorized reader", readOnly: true, wantGeneral: true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
const name = "mcp__srv__tool"
|
||||
parent := tool.NewRegistry()
|
||||
parent.Add(subagentMCPTool{
|
||||
subagentRegistryTool: subagentRegistryTool{name: name, readOnly: tc.readOnly},
|
||||
server: "srv",
|
||||
raw: "tool",
|
||||
destructive: tc.destructive,
|
||||
serverAuthorized: tc.authorized,
|
||||
})
|
||||
|
||||
_, gotGeneral := SubagentToolRegistry(parent, nil).Get(name)
|
||||
if gotGeneral != tc.wantGeneral {
|
||||
t.Fatalf("general subagent availability = %v, want %v", gotGeneral, tc.wantGeneral)
|
||||
}
|
||||
_, gotStrictReadOnly := ReadOnlySubagentToolRegistry(parent, nil).Get(name)
|
||||
if gotStrictReadOnly != tc.wantStrictReadOnly {
|
||||
t.Fatalf("read-only subagent availability = %v, want %v", gotStrictReadOnly, tc.wantStrictReadOnly)
|
||||
}
|
||||
_, gotPlanner := FilterReadOnlyRegistry(parent).Get(name)
|
||||
if gotPlanner != tc.wantStrictReadOnly {
|
||||
t.Fatalf("planner availability = %v, want %v", gotPlanner, tc.wantStrictReadOnly)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskToolBuildSubRegUsesSubagentToolRegistry(t *testing.T) {
|
||||
parent := tool.NewRegistry()
|
||||
parent.Add(subagentRegistryTool{name: "task"})
|
||||
|
||||
+13
-14
@@ -1065,7 +1065,7 @@ func addRestrictedCapabilityProxy(parent, sub *tool.Registry, names []string, ex
|
||||
return
|
||||
}
|
||||
inner, ok := parent.Get("use_capability")
|
||||
if !ok || requireReadOnly && (!inner.ReadOnly() || planModeUntrustedReadOnly(inner) || mcpDestructiveHint(inner)) {
|
||||
if !ok || requireReadOnly && (!inner.ReadOnly() || mcpDestructiveHint(inner)) {
|
||||
return
|
||||
}
|
||||
resolver, ok := inner.(tool.CallResolver)
|
||||
@@ -1108,7 +1108,8 @@ func ReadOnlySubagentToolRegistry(parent *tool.Registry, names []string) *tool.R
|
||||
|
||||
// ReadOnlySubagentToolRegistryForDepth returns the tool set exposed to read-only
|
||||
// subagents. It permits only read-only delegation tools while another depth
|
||||
// layer is available.
|
||||
// layer is available. MCP tools must additionally come from an authorized
|
||||
// server and must not carry destructiveHint.
|
||||
func ReadOnlySubagentToolRegistryForDepth(parent *tool.Registry, names []string, childDepth, maxDepth int) *tool.Registry {
|
||||
exclude := append([]string(nil), subagentAlwaysHiddenTools...)
|
||||
if childDepth >= NormalizeMaxSubagentDepth(maxDepth) {
|
||||
@@ -1148,7 +1149,7 @@ func ReadOnlySubagentToolRegistryForDepth(parent *tool.Registry, names []string,
|
||||
if !tl.ReadOnly() {
|
||||
continue
|
||||
}
|
||||
if u, ok := tl.(tool.PlanModeUntrustedReadOnly); ok && u.PlanModeUntrustedReadOnly() {
|
||||
if isInstalledMCPTool(tl) && (!mcpServerAuthorized(tl) || mcpDestructiveHint(tl)) {
|
||||
continue
|
||||
}
|
||||
sub.Add(tl)
|
||||
@@ -1187,7 +1188,9 @@ func expandToolPatterns(parent *tool.Registry, names []string) []string {
|
||||
}
|
||||
|
||||
// FilterReadOnlyRegistry builds a sub-registry containing only tools whose
|
||||
// ReadOnly contract is true, minus explicit exclusions.
|
||||
// ReadOnly contract is true, minus explicit exclusions. MCP tools must
|
||||
// additionally come from an authorized server and must not carry
|
||||
// destructiveHint.
|
||||
func FilterReadOnlyRegistry(parent *tool.Registry, exclude ...string) *tool.Registry {
|
||||
ex := make(map[string]bool, len(exclude))
|
||||
for _, e := range exclude {
|
||||
@@ -1205,7 +1208,7 @@ func FilterReadOnlyRegistry(parent *tool.Registry, exclude ...string) *tool.Regi
|
||||
if !ok || !tl.ReadOnly() {
|
||||
continue
|
||||
}
|
||||
if u, ok := tl.(tool.PlanModeUntrustedReadOnly); ok && u.PlanModeUntrustedReadOnly() {
|
||||
if isInstalledMCPTool(tl) && (!mcpServerAuthorized(tl) || mcpDestructiveHint(tl)) {
|
||||
continue
|
||||
}
|
||||
sub.Add(tl)
|
||||
@@ -1450,8 +1453,8 @@ func RunReadOnlySubAgentWithSession(ctx context.Context, prov provider.Provider,
|
||||
// strictReadOnlyExecutionRegistry is the final construction-time filter shared
|
||||
// by every strict child. Callers still apply role-specific filtering (review,
|
||||
// planner, profile allowlists), while this layer guarantees that a missed call
|
||||
// site cannot expose writers, destructive MCP tools, untrusted readers, or an
|
||||
// untrusted host-starting target to the model.
|
||||
// site cannot expose writers, destructive MCP tools, readers from unauthorized
|
||||
// servers, or an unauthorized host-starting target to the model.
|
||||
func strictReadOnlyExecutionRegistry(reg *tool.Registry) *tool.Registry {
|
||||
filtered := tool.NewRegistry()
|
||||
if reg == nil {
|
||||
@@ -1459,15 +1462,11 @@ func strictReadOnlyExecutionRegistry(reg *tool.Registry) *tool.Registry {
|
||||
}
|
||||
for _, name := range reg.Names() {
|
||||
target, ok := reg.Get(name)
|
||||
if !ok || !target.ReadOnly() || planModeUntrustedReadOnly(target) || mcpDestructiveHint(target) {
|
||||
if !ok || !target.ReadOnly() || mcpDestructiveHint(target) {
|
||||
continue
|
||||
}
|
||||
// An installed MCP reader needs an explicit local or signed package
|
||||
// declaration, not a server hint carried through a compatibility path.
|
||||
if isInstalledMCPTool(target) {
|
||||
if authority, ok := target.(tool.ReadOnlyExecutionAuthority); !ok || !authority.ReadOnlyExecutionAuthority() {
|
||||
continue
|
||||
}
|
||||
if isInstalledMCPTool(target) && !mcpServerAuthorized(target) {
|
||||
continue
|
||||
}
|
||||
if mutation, ok := target.(tool.ReadOnlyExecutionHostMutation); ok && mutation.ReadOnlyExecutionHostMutation() && !readOnlyExecutionAllowsMCPStartup(target) {
|
||||
continue
|
||||
|
||||
@@ -27,7 +27,7 @@ type UseCapabilityTool struct {
|
||||
// timeout. nil falls back to context.Background() for direct/test use.
|
||||
lifeCtx context.Context
|
||||
// specs are the boot-converted plugin specs (env expansion, workspace
|
||||
// overrides, timeouts, trusted read-only tools). The proxy never rebuilds
|
||||
// overrides and timeouts). The proxy never rebuilds
|
||||
// specs from raw config entries — that would fork the conversion logic.
|
||||
specs []plugin.Spec
|
||||
registry *tool.Registry // live registry for already-exposed MCP tools
|
||||
@@ -358,6 +358,7 @@ func (t *UseCapabilityTool) resolveCall(ctx context.Context, id string, args jso
|
||||
if !ok {
|
||||
return t.resolveUnavailable(base, id, modelName, fmt.Sprintf("MCP server %q is not configured", server)), nil
|
||||
}
|
||||
spec = plugin.ResolveStoredAuthorization(ctx, spec)
|
||||
destructive := false
|
||||
if t.catalog != nil {
|
||||
if entry, found := t.catalog().Lookup(id); found {
|
||||
@@ -365,29 +366,16 @@ func (t *UseCapabilityTool) resolveCall(ctx context.Context, id string, args jso
|
||||
}
|
||||
}
|
||||
readOnly := false
|
||||
trustedReader := false
|
||||
capabilityFingerprint := ""
|
||||
if cached, found := plugin.CachedToolSafetyForSpec(spec, raw); found {
|
||||
destructive = destructive || cached.Destructive
|
||||
readOnly = cached.ReadOnly
|
||||
trustedReader = cached.TrustedReader
|
||||
capabilityFingerprint = cached.CapabilityFingerprint
|
||||
} else if spec.LaunchManager == nil {
|
||||
// Compatibility for direct library users that have no host trust store.
|
||||
readOnly = spec.ReadOnlyToolNames[raw] || spec.ReadOnlyModelToolNames[modelName]
|
||||
trustedReader = readOnly
|
||||
}
|
||||
lazy := &onDemandMCPTool{proxy: t, spec: spec, server: server, raw: raw, modelName: modelName, destructive: destructive}
|
||||
lazy.readOnly = readOnly
|
||||
lazy.readOnlyTrusted = trustedReader
|
||||
lazy.capabilityFingerprint = capabilityFingerprint
|
||||
// Strict read-only execution requires host-local policy authority. A server
|
||||
// hint alone keeps ordinary approval smooth but cannot admit a strict child.
|
||||
lazy.readerAuthority = spec.LaunchManager != nil
|
||||
base.Target = lazy
|
||||
base.TargetName = modelName
|
||||
// Cached server hints control ordinary approval. Strict read-only execution
|
||||
// additionally requires an explicit local or signed reader declaration.
|
||||
// additionally requires server authorization and live read-only metadata.
|
||||
base.ReadOnly = lazy.ReadOnly()
|
||||
if len(args) == 0 {
|
||||
base.Args = json.RawMessage(`{}`)
|
||||
@@ -434,8 +422,7 @@ func findMCPTool(tools []tool.Tool, raw, modelName string) tool.Tool {
|
||||
|
||||
// onDemandMCPTool defers MCP server startup to Execute so permission and hook
|
||||
// gates always run before any subprocess or network side effect. Before the live
|
||||
// handshake it can only use a backward-compatible local read-only override;
|
||||
// otherwise it remains write-capable until the resolved MCP tool is classified.
|
||||
// handshake it remains write-capable until the resolved MCP tool is classified.
|
||||
type onDemandMCPTool struct {
|
||||
proxy *UseCapabilityTool
|
||||
spec plugin.Spec
|
||||
@@ -443,18 +430,16 @@ type onDemandMCPTool struct {
|
||||
raw string
|
||||
modelName string
|
||||
// destructive comes from the schema cache when available. A live promotion
|
||||
// is detected in Execute and deferred to a fresh-approved retry.
|
||||
destructive bool
|
||||
readOnly bool
|
||||
readOnlyTrusted bool
|
||||
capabilityFingerprint string
|
||||
readerAuthority bool
|
||||
// is detected in Execute so a retry re-enters the current Plan/read-only
|
||||
// execution boundary.
|
||||
destructive bool
|
||||
readOnly bool
|
||||
}
|
||||
|
||||
func (o *onDemandMCPTool) Name() string { return o.modelName }
|
||||
|
||||
func (o *onDemandMCPTool) Description() string {
|
||||
return "on-demand MCP tool " + o.server + "/" + o.raw + " (connects after approval)"
|
||||
return "on-demand MCP tool " + o.server + "/" + o.raw + " (connects when first used)"
|
||||
}
|
||||
|
||||
func (o *onDemandMCPTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||||
@@ -463,37 +448,22 @@ func (o *onDemandMCPTool) ReadOnly() bool {
|
||||
return o.readOnly
|
||||
}
|
||||
|
||||
func (o *onDemandMCPTool) PlanModeUntrustedReadOnly() bool {
|
||||
return o.readOnly && !o.readOnlyTrusted
|
||||
}
|
||||
|
||||
func (o *onDemandMCPTool) ReadOnlyExecutionHostMutation() bool { return true }
|
||||
|
||||
func (o *onDemandMCPTool) ReadOnlyExecutionAuthority() bool { return o.readerAuthority }
|
||||
func (o *onDemandMCPTool) MCPServerAuthorized() bool { return o.spec.ServerAuthorized() }
|
||||
|
||||
func (o *onDemandMCPTool) ReadOnlyExecutionBlockReason() string {
|
||||
return "start this MCP capability from a parent session or explicitly allow it as a read-only tool"
|
||||
return "connect this MCP capability from a parent session first"
|
||||
}
|
||||
|
||||
// MCPServerName/MCPRawToolName expose the deferred target for audit and
|
||||
// diagnostics (tool.MCPMetadata).
|
||||
func (o *onDemandMCPTool) MCPServerName() string { return o.server }
|
||||
func (o *onDemandMCPTool) MCPRawToolName() string { return o.raw }
|
||||
func (o *onDemandMCPTool) MCPCapabilityFingerprint() string {
|
||||
return o.capabilityFingerprint
|
||||
}
|
||||
func (o *onDemandMCPTool) MCPDestructiveHint() bool {
|
||||
return o.destructive
|
||||
}
|
||||
|
||||
func (o *onDemandMCPTool) MCPApprovalMode() string {
|
||||
return o.spec.ToolApprovalMode(o.raw)
|
||||
}
|
||||
|
||||
func (o *onDemandMCPTool) MCPApprovalReviewer() string {
|
||||
return tool.NormalizeMCPApprovalReviewer(o.spec.ApprovalsReviewer)
|
||||
}
|
||||
|
||||
func (o *onDemandMCPTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
|
||||
tools, err := o.proxy.ensureServerTools(ctx, o.server)
|
||||
if err != nil {
|
||||
@@ -512,22 +482,15 @@ func (o *onDemandMCPTool) Execute(ctx context.Context, args json.RawMessage) (st
|
||||
}
|
||||
return "", fmt.Errorf("%s", msg)
|
||||
}
|
||||
if live, ok := target.(tool.MCPCapabilityFingerprint); ok && o.capabilityFingerprint != "" && live.MCPCapabilityFingerprint() != "" && live.MCPCapabilityFingerprint() != o.capabilityFingerprint {
|
||||
return "", fmt.Errorf("MCP server %q changed the security schema for tool %q; the current call was blocked before tools/call — retry so current policy is applied", o.server, o.raw)
|
||||
}
|
||||
if o.readOnlyTrusted && (!target.ReadOnly() || planModeUntrustedReadOnly(target)) {
|
||||
return "", fmt.Errorf("MCP server %q no longer exposes tool %q as an allowed reader; the current call was blocked before tools/call — retry so current policy is applied", o.server, o.raw)
|
||||
}
|
||||
if annotations, ok := target.(tool.MCPAnnotations); ok && annotations.MCPDestructiveHint() && !o.destructive {
|
||||
return "", destructiveMCPDiscoveryError(o.server, o.raw)
|
||||
if _, err := plugin.ReconcileCachedToolSafety(o.server, o.raw, plugin.CachedToolSafety{
|
||||
ReadOnly: o.readOnly,
|
||||
Destructive: o.destructive,
|
||||
}, target); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return target.Execute(ctx, args)
|
||||
}
|
||||
|
||||
func destructiveMCPDiscoveryError(server, rawTool string) error {
|
||||
return fmt.Errorf("MCP server %q marks tool %q as destructive; retry so Reasonix can apply the current approval policy before execution", server, rawTool)
|
||||
}
|
||||
|
||||
func (t *UseCapabilityTool) ensureServerTools(ctx context.Context, server string) ([]tool.Tool, error) {
|
||||
server = strings.TrimSpace(server)
|
||||
if server == "" {
|
||||
@@ -620,7 +583,7 @@ func (t *UseCapabilityTool) ConnectedProxyTools() map[string][]plugin.CachedTool
|
||||
|
||||
// specFor looks up the boot-converted spec for server. The proxy deliberately
|
||||
// holds []plugin.Spec, not raw config entries: env expansion, workspace
|
||||
// overrides, call timeouts, and trusted read-only tool names all live in the
|
||||
// overrides, call timeouts, and read-only tool names all live in the
|
||||
// shared conversion and must not be re-derived here.
|
||||
func (t *UseCapabilityTool) specFor(server string) (plugin.Spec, bool) {
|
||||
for _, s := range t.specs {
|
||||
|
||||
@@ -43,7 +43,6 @@ func (completedProxyCallTool) Execute(context.Context, json.RawMessage) (string,
|
||||
type readOnlyBoundaryTarget struct {
|
||||
name string
|
||||
readOnly bool
|
||||
untrusted bool
|
||||
hostStart bool
|
||||
calls *int
|
||||
}
|
||||
@@ -52,7 +51,6 @@ func (t readOnlyBoundaryTarget) Name() string { return t.
|
||||
func (readOnlyBoundaryTarget) Description() string { return "" }
|
||||
func (readOnlyBoundaryTarget) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||||
func (t readOnlyBoundaryTarget) ReadOnly() bool { return t.readOnly }
|
||||
func (t readOnlyBoundaryTarget) PlanModeUntrustedReadOnly() bool { return t.untrusted }
|
||||
func (t readOnlyBoundaryTarget) ReadOnlyExecutionHostMutation() bool { return t.hostStart }
|
||||
func (t readOnlyBoundaryTarget) Execute(context.Context, json.RawMessage) (string, error) {
|
||||
if t.calls != nil {
|
||||
@@ -78,20 +76,15 @@ func (p readOnlyBoundaryProxy) ResolveCall(context.Context, json.RawMessage) (to
|
||||
|
||||
type layeredReadOnlyMCPBoundaryTarget struct {
|
||||
readOnlyBoundaryTarget
|
||||
destructive bool
|
||||
destructive bool
|
||||
serverAuthorized bool
|
||||
}
|
||||
|
||||
func (layeredReadOnlyMCPBoundaryTarget) MCPServerName() string { return "test" }
|
||||
func (layeredReadOnlyMCPBoundaryTarget) MCPRawToolName() string { return "read" }
|
||||
func (layeredReadOnlyMCPBoundaryTarget) MCPApprovalMode() string { return "approve" }
|
||||
func (layeredReadOnlyMCPBoundaryTarget) MCPApprovalReviewer() string { return "user" }
|
||||
func (t layeredReadOnlyMCPBoundaryTarget) MCPDestructiveHint() bool { return t.destructive }
|
||||
func (layeredReadOnlyMCPBoundaryTarget) MCPServerName() string { return "test" }
|
||||
func (layeredReadOnlyMCPBoundaryTarget) MCPRawToolName() string { return "read" }
|
||||
func (t layeredReadOnlyMCPBoundaryTarget) MCPDestructiveHint() bool { return t.destructive }
|
||||
|
||||
// The fake models a explicitly declared reader: explicit local launch policy stands behind
|
||||
// its classification unless the case is explicitly the untrusted server hint.
|
||||
func (t layeredReadOnlyMCPBoundaryTarget) ReadOnlyExecutionAuthority() bool {
|
||||
return !t.untrusted
|
||||
}
|
||||
func (t layeredReadOnlyMCPBoundaryTarget) MCPServerAuthorized() bool { return t.serverAuthorized }
|
||||
|
||||
func executeReadOnlyBoundaryCall(t *testing.T, resolved tool.ResolvedCall) toolOutcome {
|
||||
t.Helper()
|
||||
@@ -128,7 +121,7 @@ func TestReadOnlyExecutionBlocksResolvedWriterAndHostStartup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyExecutionAllowsInspectAndTrustedReadOnlyCall(t *testing.T) {
|
||||
func TestReadOnlyExecutionAllowsInspectAndOrdinaryReadOnlyCall(t *testing.T) {
|
||||
inspect := executeReadOnlyBoundaryCall(t, tool.ResolvedCall{
|
||||
ProxyAction: "inspect", SkipExecute: true, ReadOnly: true, Result: "metadata",
|
||||
})
|
||||
@@ -142,31 +135,31 @@ func TestReadOnlyExecutionAllowsInspectAndTrustedReadOnlyCall(t *testing.T) {
|
||||
ProxyAction: "call", TargetName: target.Name(), Target: target, ReadOnly: true, Args: json.RawMessage(`{}`),
|
||||
})
|
||||
if call.blocked || call.errMsg != "" || !strings.Contains(call.output, "target executed") {
|
||||
t.Fatalf("trusted read-only call outcome = %+v", call)
|
||||
t.Fatalf("read-only call outcome = %+v", call)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("target Execute calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyExecutionAllowsOnlyLayeredTrustedReadOnlyMCPStartup(t *testing.T) {
|
||||
func TestReadOnlyExecutionAllowsOnlyAuthorizedReadOnlyMCPStartup(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
untrusted bool
|
||||
authorized bool
|
||||
destructive bool
|
||||
wantBlocked bool
|
||||
}{
|
||||
{name: "locally trusted reader"},
|
||||
{name: "untrusted server hint", untrusted: true, wantBlocked: true},
|
||||
{name: "destructive reader", destructive: true, wantBlocked: true},
|
||||
{name: "authorized reader", authorized: true},
|
||||
{name: "unauthorized server", wantBlocked: true},
|
||||
{name: "destructive reader", authorized: true, destructive: true, wantBlocked: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
calls := 0
|
||||
target := layeredReadOnlyMCPBoundaryTarget{
|
||||
readOnlyBoundaryTarget: readOnlyBoundaryTarget{
|
||||
name: "mcp__test__read", readOnly: true, untrusted: tc.untrusted, hostStart: true, calls: &calls,
|
||||
name: "mcp__test__read", readOnly: true, hostStart: true, calls: &calls,
|
||||
},
|
||||
destructive: tc.destructive,
|
||||
destructive: tc.destructive, serverAuthorized: tc.authorized,
|
||||
}
|
||||
out := executeReadOnlyBoundaryCall(t, tool.ResolvedCall{
|
||||
ProxyAction: "call", TargetName: target.Name(), Target: target, ReadOnly: true, Args: json.RawMessage(`{}`),
|
||||
@@ -189,9 +182,9 @@ func TestStrictReadOnlyExecutionRegistryFailsClosed(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(fakeTool{name: "writer", readOnly: false})
|
||||
reg.Add(readOnlyBoundaryTarget{name: "ordinary_read", readOnly: true})
|
||||
reg.Add(readOnlyBoundaryTarget{name: "untrusted_read", readOnly: true, untrusted: true})
|
||||
reg.Add(layeredReadOnlyMCPBoundaryTarget{
|
||||
readOnlyBoundaryTarget: readOnlyBoundaryTarget{name: "mcp__test__trusted", readOnly: true, hostStart: true},
|
||||
serverAuthorized: true,
|
||||
})
|
||||
reg.Add(layeredReadOnlyMCPBoundaryTarget{
|
||||
readOnlyBoundaryTarget: readOnlyBoundaryTarget{name: "mcp__test__destructive", readOnly: true, hostStart: true},
|
||||
@@ -204,14 +197,14 @@ func TestStrictReadOnlyExecutionRegistryFailsClosed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyExecutionBlocksUntrustedHintAndDecline(t *testing.T) {
|
||||
func TestReadOnlyExecutionBlocksUnauthorizedMCPAndDecline(t *testing.T) {
|
||||
calls := 0
|
||||
target := readOnlyBoundaryTarget{name: "mcp__test__hint", readOnly: true, untrusted: true, calls: &calls}
|
||||
target := layeredReadOnlyMCPBoundaryTarget{readOnlyBoundaryTarget: readOnlyBoundaryTarget{name: "mcp__test__hint", readOnly: true, calls: &calls}}
|
||||
out := executeReadOnlyBoundaryCall(t, tool.ResolvedCall{
|
||||
ProxyAction: "call", TargetName: target.Name(), Target: target, ReadOnly: true, Args: json.RawMessage(`{}`),
|
||||
})
|
||||
if !out.blocked || calls != 0 {
|
||||
t.Fatalf("untrusted read-only outcome = %+v calls=%d", out, calls)
|
||||
t.Fatalf("unauthorized read-only outcome = %+v calls=%d", out, calls)
|
||||
}
|
||||
|
||||
ledger := capability.NewLedger()
|
||||
@@ -241,7 +234,7 @@ func TestReadOnlyExecutionBlocksUntrustedHintAndDecline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyExecutionDoesNotStartUntrustedUnconnectedMCP(t *testing.T) {
|
||||
func TestReadOnlyExecutionDoesNotStartUnauthorizedUnconnectedMCP(t *testing.T) {
|
||||
host := plugin.NewHost()
|
||||
defer host.Close()
|
||||
proxy := NewUseCapabilityTool(context.Background(), host, []plugin.Spec{{
|
||||
@@ -303,7 +296,7 @@ func explicitReaderMCPServer(t *testing.T, schemaDrift *atomic.Bool, toolCalls *
|
||||
func cacheExplicitReaderSchema(t *testing.T, spec plugin.Spec) {
|
||||
t.Helper()
|
||||
err := plugin.SaveCachedSchema(spec.Name, plugin.CachedSchema{
|
||||
SpecHash: plugin.SpecFingerprint(spec),
|
||||
CacheKey: plugin.SchemaCacheKey(spec),
|
||||
Tools: []plugin.CachedTool{{
|
||||
Name: "search", Description: "search",
|
||||
Schema: json.RawMessage(`{"type":"object","properties":{"q":{"type":"string"}}}`),
|
||||
@@ -315,7 +308,7 @@ func cacheExplicitReaderSchema(t *testing.T, spec plugin.Spec) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyExecutionStartsExplicitUnconnectedMCPReader(t *testing.T) {
|
||||
func TestReadOnlyExecutionStartsInstalledUnconnectedMCPReader(t *testing.T) {
|
||||
t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
|
||||
var toolCalls atomic.Int32
|
||||
server := explicitReaderMCPServer(t, nil, &toolCalls)
|
||||
@@ -325,7 +318,7 @@ func TestReadOnlyExecutionStartsExplicitUnconnectedMCPReader(t *testing.T) {
|
||||
spec := plugin.Spec{
|
||||
Name: "explicit-reader", Type: "http", URL: server.URL,
|
||||
LaunchManager: manager, ConfigSource: "workspace_config",
|
||||
ReadOnlyToolNames: map[string]bool{"search": true},
|
||||
Authorized: true,
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -338,18 +331,54 @@ func TestReadOnlyExecutionStartsExplicitUnconnectedMCPReader(t *testing.T) {
|
||||
reg.Add(proxy)
|
||||
a := New(nil, reg, NewSession("sys"), Options{ReadOnlyExecution: true}, event.Discard)
|
||||
out := a.executeOne(ctx, provider.ToolCall{
|
||||
ID: "trusted-lazy-1", Name: "use_capability",
|
||||
ID: "installed-reader-1", Name: "use_capability",
|
||||
Arguments: `{"action":"call","capability_id":"mcp-tool:explicit-reader/search","arguments":{}}`,
|
||||
})
|
||||
if out.blocked || out.errMsg != "" || !strings.Contains(out.output, "reader result") {
|
||||
t.Fatalf("trusted lazy reader outcome = %+v", out)
|
||||
t.Fatalf("installed lazy reader outcome = %+v", out)
|
||||
}
|
||||
if got := toolCalls.Load(); got != 1 {
|
||||
t.Fatalf("reader tools/call count = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyExecutionBlocksCachedSchemaDriftBeforeToolCall(t *testing.T) {
|
||||
func TestReadOnlyExecutionStartsPreviouslyAuthorizedProjectMCPReaderOnDemand(t *testing.T) {
|
||||
t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
|
||||
var toolCalls atomic.Int32
|
||||
server := explicitReaderMCPServer(t, nil, &toolCalls)
|
||||
defer server.Close()
|
||||
|
||||
manager := mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), t.TempDir())
|
||||
spec := plugin.Spec{
|
||||
Name: "project-reader", Type: "http", URL: server.URL,
|
||||
LaunchManager: manager, ConfigSource: "project_config", RequireLaunchApproval: true,
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := plugin.AuthorizeSpecLaunch(ctx, spec); err != nil {
|
||||
t.Fatalf("AuthorizeSpecLaunch: %v", err)
|
||||
}
|
||||
cacheExplicitReaderSchema(t, spec)
|
||||
|
||||
host := plugin.NewHost()
|
||||
defer host.Close()
|
||||
proxy := NewUseCapabilityTool(ctx, host, []plugin.Spec{spec}, tool.NewRegistry(), capability.NewLedger(), nil, nil)
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(proxy)
|
||||
a := New(nil, reg, NewSession("sys"), Options{ReadOnlyExecution: true}, event.Discard)
|
||||
out := a.executeOne(ctx, provider.ToolCall{
|
||||
ID: "authorized-project-1", Name: "use_capability",
|
||||
Arguments: `{"action":"call","capability_id":"mcp-tool:project-reader/search","arguments":{}}`,
|
||||
})
|
||||
if out.blocked || out.errMsg != "" || !strings.Contains(out.output, "reader result") {
|
||||
t.Fatalf("authorized project reader outcome = %+v", out)
|
||||
}
|
||||
if got := toolCalls.Load(); got != 1 {
|
||||
t.Fatalf("project reader tools/call count = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyExecutionAllowsSchemaOnlyDriftForAuthorizedReader(t *testing.T) {
|
||||
t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
|
||||
var schemaDrift atomic.Bool
|
||||
var toolCalls atomic.Int32
|
||||
@@ -360,7 +389,7 @@ func TestReadOnlyExecutionBlocksCachedSchemaDriftBeforeToolCall(t *testing.T) {
|
||||
spec := plugin.Spec{
|
||||
Name: "explicit-reader", Type: "http", URL: server.URL,
|
||||
LaunchManager: manager, ConfigSource: "workspace_config",
|
||||
ReadOnlyToolNames: map[string]bool{"search": true},
|
||||
Authorized: true,
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -377,11 +406,11 @@ func TestReadOnlyExecutionBlocksCachedSchemaDriftBeforeToolCall(t *testing.T) {
|
||||
ID: "drifted-lazy-1", Name: "use_capability",
|
||||
Arguments: `{"action":"call","capability_id":"mcp-tool:explicit-reader/search","arguments":{}}`,
|
||||
})
|
||||
if out.errMsg == "" || !strings.Contains(out.output, "changed the security schema") {
|
||||
t.Fatalf("drifted lazy reader outcome = %+v", out)
|
||||
if out.blocked || out.errMsg != "" || !strings.Contains(out.output, "reader result") {
|
||||
t.Fatalf("schema-only drifted reader outcome = %+v", out)
|
||||
}
|
||||
if got := toolCalls.Load(); got != 0 {
|
||||
t.Fatalf("drifted reader tools/call count = %d, want 0", got)
|
||||
if got := toolCalls.Load(); got != 1 {
|
||||
t.Fatalf("schema-only drifted reader tools/call count = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,40 +689,6 @@ func TestOnDemandModelNameMatchesPluginCanonicalName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnDemandMCPToolUsesSharedApprovalResolution(t *testing.T) {
|
||||
host := plugin.NewHost()
|
||||
defer host.Close()
|
||||
specs := []plugin.Spec{
|
||||
{Name: "user", Type: "stdio", Command: "reasonix-test-definitely-missing-binary", ImplicitApproval: true},
|
||||
{Name: "scoped", Type: "stdio", Command: "reasonix-test-definitely-missing-binary", ImplicitApproval: true,
|
||||
ToolApprovalModes: map[string]string{"wipe": "prompt"}},
|
||||
{Name: "project", Type: "stdio", Command: "reasonix-test-definitely-missing-binary"},
|
||||
}
|
||||
tl := NewUseCapabilityTool(context.Background(), host, specs, tool.NewRegistry(), capability.NewLedger(), nil, nil)
|
||||
cases := []struct{ id, want string }{
|
||||
// A user-configured server carries implicit approval; the delivery
|
||||
// on-demand path must resolve it to direct approval exactly like the
|
||||
// direct MCP tool path, not fall back to the global posture.
|
||||
{"mcp-tool:user/write", tool.MCPApprovalApprove},
|
||||
{"mcp-tool:scoped/wipe", tool.MCPApprovalPrompt},
|
||||
{"mcp-tool:project/write", tool.MCPApprovalAuto},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
resolved, err := tl.ResolveCall(context.Background(),
|
||||
json.RawMessage(`{"action":"call","capability_id":"`+tc.id+`"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.id, err)
|
||||
}
|
||||
policy, ok := resolved.Target.(tool.MCPApprovalPolicy)
|
||||
if !ok {
|
||||
t.Fatalf("%s: resolved target %T does not expose an MCP approval policy", tc.id, resolved.Target)
|
||||
}
|
||||
if got := policy.MCPApprovalMode(); got != tc.want {
|
||||
t.Fatalf("%s: on-demand approval mode = %q, want %q (plugin.Spec.ToolApprovalMode parity)", tc.id, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyCallAuditCountsOnAgentPath(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(fakeTool{name: "mcp__github__search_issues", readOnly: true})
|
||||
@@ -773,10 +768,9 @@ func TestUseCapabilityResolveCallIsSideEffectFree(t *testing.T) {
|
||||
host := plugin.NewHost()
|
||||
defer host.Close()
|
||||
specs := []plugin.Spec{{
|
||||
Name: "lazy",
|
||||
Type: "stdio",
|
||||
Command: "reasonix-test-definitely-missing-binary",
|
||||
ReadOnlyToolNames: map[string]bool{"read_thing": true},
|
||||
Name: "lazy",
|
||||
Type: "stdio",
|
||||
Command: "reasonix-test-definitely-missing-binary",
|
||||
}}
|
||||
tl := NewUseCapabilityTool(context.Background(), host, specs, tool.NewRegistry(), capability.NewLedger(), nil, nil)
|
||||
|
||||
@@ -788,22 +782,11 @@ func TestUseCapabilityResolveCallIsSideEffectFree(t *testing.T) {
|
||||
t.Fatalf("expected a deferred target, got %+v", resolved)
|
||||
}
|
||||
if resolved.ReadOnly {
|
||||
t.Fatal("unstarted, untrusted tool must resolve as a writer")
|
||||
t.Fatal("unstarted tool without read-only metadata must resolve as a writer")
|
||||
}
|
||||
if host.HasClient("lazy") {
|
||||
t.Fatal("ResolveCall must not start the MCP server")
|
||||
}
|
||||
// Config-trusted read-only tool keeps its trust without a handshake.
|
||||
roResolved, err := tl.ResolveCall(context.Background(), json.RawMessage(`{"action":"call","capability_id":"mcp-tool:lazy/read_thing"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !roResolved.ReadOnly {
|
||||
t.Fatal("spec-trusted read-only tool should resolve read-only")
|
||||
}
|
||||
if host.HasClient("lazy") {
|
||||
t.Fatal("read-only resolution must not start the MCP server either")
|
||||
}
|
||||
// Execution is where the connect finally happens — and fails for the
|
||||
// missing binary, marking the capability unavailable.
|
||||
ledger := capability.NewLedger()
|
||||
@@ -840,7 +823,7 @@ func TestUseCapabilityInspectDoesNotStartServer(t *testing.T) {
|
||||
func TestPlanModeBlocksInstalledWriteMCPResolvedThroughUseCapability(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(annotatedMCPTool{fakeTool: fakeTool{name: "mcp__github__create_issue", readOnly: false}, server: "github", raw: "create_issue"})
|
||||
reg.Add(annotatedMCPTool{fakeTool: fakeTool{name: "mcp__github__search_issues", readOnly: true}, server: "github", raw: "search_issues"})
|
||||
reg.Add(annotatedMCPTool{fakeTool: fakeTool{name: "mcp__github__search_issues", readOnly: true}, server: "github", raw: "search_issues", serverAuthorized: true})
|
||||
uc := NewUseCapabilityTool(context.Background(), nil, nil, reg, capability.NewLedger(), nil, nil)
|
||||
reg.Add(uc)
|
||||
gate := &mcpPermissionRecordingGate{allowNormal: true}
|
||||
@@ -882,17 +865,18 @@ func TestPlanModeMCPStyleNameWithoutMetadataStillUsesPermission(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDestructiveMCPThroughUseCapabilityUsesFreshApproval(t *testing.T) {
|
||||
func TestPlanModeBlocksAuthorizedDestructiveMCPThroughUseCapability(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(annotatedMCPTool{
|
||||
fakeTool: fakeTool{name: "mcp__github__delete_issue", readOnly: false},
|
||||
server: "github",
|
||||
raw: "delete_issue",
|
||||
destructive: true,
|
||||
fakeTool: fakeTool{name: "mcp__github__delete_issue", readOnly: false},
|
||||
server: "github",
|
||||
raw: "delete_issue",
|
||||
destructive: true,
|
||||
serverAuthorized: true,
|
||||
})
|
||||
uc := NewUseCapabilityTool(context.Background(), nil, nil, reg, capability.NewLedger(), nil, nil)
|
||||
reg.Add(uc)
|
||||
gate := &mcpPermissionRecordingGate{allowNormal: true, allowFresh: true}
|
||||
gate := &mcpPermissionRecordingGate{allowNormal: true}
|
||||
a := New(&scriptedProvider{name: "p"}, reg, NewSession("sys"), Options{Gate: gate}, event.Discard)
|
||||
a.planMode.Store(true)
|
||||
|
||||
@@ -900,8 +884,8 @@ func TestDestructiveMCPThroughUseCapabilityUsesFreshApproval(t *testing.T) {
|
||||
ID: "1", Name: "use_capability",
|
||||
Arguments: `{"action":"call","capability_id":"mcp-tool:github/delete_issue","arguments":{"number":1}}`,
|
||||
})
|
||||
if !out.blocked || gate.normalCalls != 0 || gate.freshCalls != 0 {
|
||||
t.Fatalf("destructive proxy outcome=%+v normal=%d fresh=%d subject=%q", out, gate.normalCalls, gate.freshCalls, gate.subject)
|
||||
if !out.blocked || gate.normalCalls != 0 {
|
||||
t.Fatalf("destructive proxy should be blocked before permission, outcome=%+v normal=%d", out, gate.normalCalls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -919,35 +903,3 @@ func TestCapabilityGateAppliesToReadOnlyTasks(t *testing.T) {
|
||||
t.Fatalf("read-only answer must not skip the require gate; reason = %q", check.reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictReadOnlyFailsClosedWithoutTrustAuthority(t *testing.T) {
|
||||
host := plugin.NewHost()
|
||||
defer host.Close()
|
||||
// No LaunchManager: the compatibility path still classifies the config
|
||||
// reader for ordinary permissions, but it carries no explicit reader authority.
|
||||
specs := []plugin.Spec{{
|
||||
Name: "lazy",
|
||||
Type: "stdio",
|
||||
Command: "reasonix-test-definitely-missing-binary",
|
||||
ReadOnlyToolNames: map[string]bool{"read_thing": true},
|
||||
}}
|
||||
tl := NewUseCapabilityTool(context.Background(), host, specs, tool.NewRegistry(), capability.NewLedger(), nil, nil)
|
||||
resolved, err := tl.ResolveCall(context.Background(), json.RawMessage(`{"action":"call","capability_id":"mcp-tool:lazy/read_thing"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !resolved.ReadOnly {
|
||||
t.Fatal("compatibility classification should still resolve read-only for ordinary sessions")
|
||||
}
|
||||
if readOnlyExecutionAllowsMCPStartup(resolved.Target) {
|
||||
t.Fatal("hint/config-classified reader without a trust store must not start hosts in strict mode")
|
||||
}
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(resolved.Target)
|
||||
if names := strictReadOnlyExecutionRegistry(reg).Names(); len(names) != 0 {
|
||||
t.Fatalf("strict registry admitted an authority-less MCP reader: %v", names)
|
||||
}
|
||||
if host.HasClient("lazy") {
|
||||
t.Fatal("strict evaluation must not start the MCP server")
|
||||
}
|
||||
}
|
||||
+52
-285
@@ -19,7 +19,6 @@ import (
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
@@ -36,7 +35,6 @@ import (
|
||||
"reasonix/internal/instruction"
|
||||
"reasonix/internal/jobs"
|
||||
"reasonix/internal/lsp"
|
||||
"reasonix/internal/mcpcatalog"
|
||||
"reasonix/internal/mcplaunch"
|
||||
"reasonix/internal/memory"
|
||||
"reasonix/internal/migration"
|
||||
@@ -44,7 +42,6 @@ import (
|
||||
"reasonix/internal/outputstyle"
|
||||
"reasonix/internal/permission"
|
||||
"reasonix/internal/plugin"
|
||||
"reasonix/internal/pluginpkg"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/sandbox"
|
||||
"reasonix/internal/secrets"
|
||||
@@ -479,16 +476,14 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
// Partition configured plugins by tier so eager can block when explicitly
|
||||
// requested while every other enabled MCP warms up in the background.
|
||||
pluginSpecOptions := PluginSpecOptions{
|
||||
DefaultCallTimeout: time.Duration(cfg.MCPCallTimeoutSeconds()) * time.Second,
|
||||
PlanModeAllowedTools: cfg.Agent.PlanModeAllowedTools,
|
||||
LaunchManager: mcplaunch.ForWorkspace(config.ReasonixHomeDir(), root),
|
||||
ConfigSource: "workspace_config",
|
||||
StateHome: config.ReasonixHomeDir(),
|
||||
WriterRoots: writeRoots,
|
||||
ForbidReadRoots: forbidReadRoots,
|
||||
Network: cfg.Sandbox.Network,
|
||||
OfficialServers: LoadVerifiedMCPPackages(ctx, cfg),
|
||||
PackageOwners: pluginPackageOwners(cfg),
|
||||
DefaultCallTimeout: time.Duration(cfg.MCPCallTimeoutSeconds()) * time.Second,
|
||||
LaunchManager: mcplaunch.ForWorkspace(config.ReasonixHomeDir(), root),
|
||||
ConfigSource: "workspace_config",
|
||||
StateHome: config.ReasonixHomeDir(),
|
||||
WriterRoots: writeRoots,
|
||||
ForbidReadRoots: forbidReadRoots,
|
||||
Network: cfg.Sandbox.Network,
|
||||
PackageOwners: pluginPackageOwners(cfg),
|
||||
}
|
||||
autoStartEntries := cfg.AutoStartPlugins()
|
||||
eagerEntries, bgEntries := partitionByTier(autoStartEntries)
|
||||
@@ -500,16 +495,25 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
extraPlugins = nil
|
||||
}
|
||||
extraSpecs := applyDefaultMCPCallTimeout(
|
||||
applyPlanModeAllowedMCPReaders(applyKnownPluginOverrides(extraPlugins, root), cfg.Agent.PlanModeAllowedTools),
|
||||
applyKnownPluginOverrides(extraPlugins, root),
|
||||
pluginSpecOptions.DefaultCallTimeout,
|
||||
)
|
||||
for i := range extraSpecs {
|
||||
if strings.TrimSpace(extraSpecs[i].WorkspaceRoot) == "" {
|
||||
extraSpecs[i].WorkspaceRoot = root
|
||||
}
|
||||
if extraSpecs[i].LaunchManager == nil {
|
||||
extraSpecs[i].LaunchManager = pluginSpecOptions.LaunchManager
|
||||
}
|
||||
if strings.TrimSpace(extraSpecs[i].ConfigSource) == "" {
|
||||
extraSpecs[i].ConfigSource = "host_session"
|
||||
}
|
||||
if !extraSpecs[i].RequireLaunchApproval {
|
||||
// Session-scoped MCP specs arrive through an explicit host/user action
|
||||
// (for example ACP session/new), so they follow installed-server
|
||||
// authorization without another per-tool or per-session prompt.
|
||||
extraSpecs[i].Authorized = true
|
||||
}
|
||||
applyMCPIsolation(&extraSpecs[i], root, pluginSpecOptions)
|
||||
}
|
||||
onDemandMCPSpecs := map[string]plugin.Spec{}
|
||||
@@ -1411,9 +1415,9 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
var capLedger *capability.Ledger
|
||||
var capAudit *capability.Audit
|
||||
capSpecs := PluginSpecsForRootWithOptions(cfg.Plugins, root, pluginSpecOptions)
|
||||
cachedTools, cacheHashOK := capability.LoadCachedToolsForSpecs(capSpecs)
|
||||
cachedTools, cacheKeyOK := capability.LoadCachedToolsForSpecs(capSpecs)
|
||||
skillStore.ConfigureToolBindings(func(sk skill.Skill) []tool.MCPBinding {
|
||||
return skillMCPBindings(sk, reg, capSpecs, cachedTools, cacheHashOK)
|
||||
return skillMCPBindings(sk, reg, capSpecs, cachedTools, cacheKeyOK)
|
||||
})
|
||||
var capProxy *agent.UseCapabilityTool
|
||||
if tokenDelivery {
|
||||
@@ -1426,7 +1430,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
}
|
||||
}
|
||||
// The proxy and the catalog share the boot-converted specs (env
|
||||
// expansion, workspace overrides, timeouts, trusted read-only tools) —
|
||||
// expansion, workspace overrides, and timeouts) —
|
||||
// every configured server, including auto_start=false, is proxy-callable.
|
||||
catalogFn := func() capability.Catalog {
|
||||
conn := map[string]bool{}
|
||||
@@ -1443,7 +1447,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
Connected: conn,
|
||||
Failed: failed,
|
||||
CachedTools: cachedTools,
|
||||
CacheHashOK: cacheHashOK,
|
||||
CacheKeyOK: cacheKeyOK,
|
||||
}
|
||||
// Live proxy-observed tools keep mcp-tool entries routable after an
|
||||
// on-demand connect (proxied tools never enter the registry).
|
||||
@@ -1481,7 +1485,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
Connected: connected,
|
||||
Failed: failed,
|
||||
CachedTools: cachedTools,
|
||||
CacheHashOK: cacheHashOK,
|
||||
CacheKeyOK: cacheKeyOK,
|
||||
ProxyTools: proxyTools,
|
||||
})
|
||||
_, missing := catalog.RequiresReady(requires)
|
||||
@@ -1515,7 +1519,6 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
ArchiveDir: config.ArchiveDir(),
|
||||
KeepPolicy: keepPolicy,
|
||||
ReasoningLanguage: cfg.ReasoningLanguage(),
|
||||
PlanModeAllowedTools: cfg.Agent.PlanModeAllowedTools,
|
||||
PlanModeReadOnlyCommands: cfg.Agent.PlanModeReadOnlyCommands,
|
||||
SubagentDepth: 0,
|
||||
MaxSubagentDepth: maxSubagentDepth,
|
||||
@@ -1595,7 +1598,6 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
spec.ConfigSource = pluginSpecOptions.ConfigSource
|
||||
}
|
||||
applyMCPIsolation(spec, root, pluginSpecOptions)
|
||||
applyVerifiedMCPPackage(spec, pluginSpecOptions)
|
||||
},
|
||||
WorkspaceRoot: root,
|
||||
ExternalFolderToolRefs: readPathResolver,
|
||||
@@ -1603,7 +1605,6 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
ReasoningLanguage: cfg.ReasoningLanguage(),
|
||||
DisableColdResumePrune: !cfg.ColdResumePruneEnabled(),
|
||||
Shell: shell,
|
||||
PlanModeAllowedTools: cfg.Agent.PlanModeAllowedTools,
|
||||
ApprovalTimeout: opts.ApprovalTimeout,
|
||||
RuntimeProfile: runtimeProfile,
|
||||
OnRemember: func(rule string) control.RememberResult {
|
||||
@@ -1636,7 +1637,6 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
}
|
||||
}
|
||||
ctrl := control.New(ctrlOpts)
|
||||
refreshMCPPackageRevocationsInBackground(pluginHost)
|
||||
if tokenDelivery {
|
||||
var router *capability.SemanticRouter
|
||||
// Prefer agent.subagent_models["capability-router"] when configured.
|
||||
@@ -1657,59 +1657,6 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
return ctrl, nil
|
||||
}
|
||||
|
||||
var mcpPackageRevocationRefresh struct {
|
||||
sync.Mutex
|
||||
running bool
|
||||
last time.Time
|
||||
index mcpcatalog.Index
|
||||
hosts map[*plugin.Host]struct{}
|
||||
}
|
||||
|
||||
func refreshMCPPackageRevocationsInBackground(host *plugin.Host) {
|
||||
if host == nil {
|
||||
return
|
||||
}
|
||||
mcpPackageRevocationRefresh.Lock()
|
||||
if !mcpPackageRevocationRefresh.last.IsZero() && time.Since(mcpPackageRevocationRefresh.last) < 6*time.Hour {
|
||||
index := mcpPackageRevocationRefresh.index
|
||||
mcpPackageRevocationRefresh.Unlock()
|
||||
host.ApplyCatalogRevocations(index.RevokedEntryIDs())
|
||||
return
|
||||
}
|
||||
if mcpPackageRevocationRefresh.hosts == nil {
|
||||
mcpPackageRevocationRefresh.hosts = map[*plugin.Host]struct{}{}
|
||||
}
|
||||
mcpPackageRevocationRefresh.hosts[host] = struct{}{}
|
||||
if mcpPackageRevocationRefresh.running {
|
||||
mcpPackageRevocationRefresh.Unlock()
|
||||
return
|
||||
}
|
||||
mcpPackageRevocationRefresh.running = true
|
||||
mcpPackageRevocationRefresh.Unlock()
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
result, err := (mcpcatalog.Loader{CacheDir: config.CacheDir()}).Load(ctx, true)
|
||||
mcpPackageRevocationRefresh.Lock()
|
||||
hosts := mcpPackageRevocationRefresh.hosts
|
||||
mcpPackageRevocationRefresh.hosts = map[*plugin.Host]struct{}{}
|
||||
mcpPackageRevocationRefresh.running = false
|
||||
if err == nil {
|
||||
mcpPackageRevocationRefresh.last = time.Now()
|
||||
mcpPackageRevocationRefresh.index = result.Index
|
||||
}
|
||||
mcpPackageRevocationRefresh.Unlock()
|
||||
if err != nil {
|
||||
slog.Warn("refresh signed MCP catalog", "err", err)
|
||||
return
|
||||
}
|
||||
revoked := result.Index.RevokedEntryIDs()
|
||||
for target := range hosts {
|
||||
target.ApplyCatalogRevocations(revoked)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func rememberPermissionRule(workspaceRoot, rule string) control.RememberResult {
|
||||
path := rememberPermissionConfigPath(workspaceRoot)
|
||||
edit := config.LoadForEdit(path)
|
||||
@@ -2214,43 +2161,20 @@ func PluginSpecs(entries []config.PluginEntry) []plugin.Spec {
|
||||
// PluginSpecsForRoot maps configured plugin entries to plugin.Spec and applies
|
||||
// workspace-aware compatibility overrides for known cwd-sensitive servers.
|
||||
func PluginSpecsForRoot(entries []config.PluginEntry, workspaceRoot string) []plugin.Spec {
|
||||
return PluginSpecsForRootWithPlanModeAllowedTools(entries, workspaceRoot, nil)
|
||||
return PluginSpecsForRootWithOptions(entries, workspaceRoot, PluginSpecOptions{})
|
||||
}
|
||||
|
||||
// PluginSpecOptions carries runtime policy that is not stored on each plugin
|
||||
// entry but still needs to reach plugin.Spec.
|
||||
type PluginSpecOptions struct {
|
||||
DefaultCallTimeout time.Duration
|
||||
PlanModeAllowedTools []string
|
||||
LaunchManager *mcplaunch.Manager
|
||||
ConfigSource string
|
||||
StateHome string
|
||||
WriterRoots []string
|
||||
ForbidReadRoots []string
|
||||
Network bool
|
||||
OfficialServers map[string]VerifiedMCPPackage
|
||||
PackageOwners map[string]string
|
||||
}
|
||||
|
||||
type VerifiedMCPPackage struct {
|
||||
CatalogEntryID string
|
||||
Readers []string
|
||||
PackageDigest string
|
||||
PackageRoot string
|
||||
Version string
|
||||
CatalogSequence uint64
|
||||
Network bool
|
||||
Transport string
|
||||
}
|
||||
|
||||
// PluginSpecsForRootWithPlanModeAllowedTools promotes legacy model-visible MCP
|
||||
// names from agent.plan_mode_allowed_tools to trusted read-only names for their
|
||||
// matching server. The alias remains useful to planner/read-only research
|
||||
// registries but does not control the main Plan workflow.
|
||||
func PluginSpecsForRootWithPlanModeAllowedTools(entries []config.PluginEntry, workspaceRoot string, allowedTools []string) []plugin.Spec {
|
||||
return PluginSpecsForRootWithOptions(entries, workspaceRoot, PluginSpecOptions{
|
||||
PlanModeAllowedTools: allowedTools,
|
||||
})
|
||||
DefaultCallTimeout time.Duration
|
||||
LaunchManager *mcplaunch.Manager
|
||||
ConfigSource string
|
||||
StateHome string
|
||||
WriterRoots []string
|
||||
ForbidReadRoots []string
|
||||
Network bool
|
||||
PackageOwners map[string]string
|
||||
}
|
||||
|
||||
// PluginSpecsForRootWithOptions maps configured plugin entries to plugin.Spec
|
||||
@@ -2260,7 +2184,7 @@ func PluginSpecsForRootWithOptions(entries []config.PluginEntry, workspaceRoot s
|
||||
for i, e := range entries {
|
||||
specs[i] = pluginSpecFromEntryWithOptions(e, workspaceRoot, opts)
|
||||
}
|
||||
return applyPlanModeAllowedMCPReaders(specs, opts.PlanModeAllowedTools)
|
||||
return specs
|
||||
}
|
||||
|
||||
func pluginSpecFromEntryWithOptions(e config.PluginEntry, workspaceRoot string, opts PluginSpecOptions) plugin.Spec {
|
||||
@@ -2270,28 +2194,24 @@ func pluginSpecFromEntryWithOptions(e config.PluginEntry, workspaceRoot string,
|
||||
configSource = opts.ConfigSource
|
||||
}
|
||||
spec := plugin.ApplyKnownOverrides(plugin.Spec{
|
||||
Name: e.Name,
|
||||
Package: strings.TrimSpace(opts.PackageOwners[e.Name]),
|
||||
Type: e.Type,
|
||||
Command: e.Command,
|
||||
Args: e.Args,
|
||||
Env: e.Env,
|
||||
URL: e.URL,
|
||||
Headers: e.Headers,
|
||||
DefaultCallTimeout: opts.DefaultCallTimeout,
|
||||
CallTimeout: secondsDuration(e.CallTimeoutSeconds),
|
||||
ToolTimeouts: toolTimeoutDurations(e.ToolTimeoutSeconds),
|
||||
ReadOnlyToolNames: legacyRawReadOnlyToolNames(e.TrustedReadOnlyTools),
|
||||
DefaultToolsApprovalMode: e.DefaultToolsApprovalMode,
|
||||
ToolApprovalModes: mcpToolApprovalModes(e.Tools),
|
||||
ApprovalsReviewer: e.ApprovalsReviewer,
|
||||
LaunchManager: opts.LaunchManager,
|
||||
ConfigSource: configSource,
|
||||
ImplicitApproval: e.Source.UserAuthorized(),
|
||||
RequireLaunchApproval: e.Source.RequiresLaunchApproval(),
|
||||
Name: e.Name,
|
||||
Package: strings.TrimSpace(opts.PackageOwners[e.Name]),
|
||||
Type: e.Type,
|
||||
Command: e.Command,
|
||||
Args: e.Args,
|
||||
Env: e.Env,
|
||||
URL: e.URL,
|
||||
Headers: e.Headers,
|
||||
DefaultCallTimeout: opts.DefaultCallTimeout,
|
||||
CallTimeout: secondsDuration(e.CallTimeoutSeconds),
|
||||
ToolTimeouts: toolTimeoutDurations(e.ToolTimeoutSeconds),
|
||||
WorkspaceRoot: strings.TrimSpace(workspaceRoot),
|
||||
LaunchManager: opts.LaunchManager,
|
||||
ConfigSource: configSource,
|
||||
Authorized: e.Source.UserAuthorized(),
|
||||
RequireLaunchApproval: e.Source.RequiresLaunchApproval(),
|
||||
}, workspaceRoot)
|
||||
applyMCPIsolation(&spec, workspaceRoot, opts)
|
||||
applyVerifiedMCPPackage(&spec, opts)
|
||||
return spec
|
||||
}
|
||||
|
||||
@@ -2308,7 +2228,7 @@ func pluginPackageOwners(cfg *config.Config) map[string]string {
|
||||
return out
|
||||
}
|
||||
|
||||
func skillMCPBindings(sk skill.Skill, reg *tool.Registry, specs []plugin.Spec, cachedTools map[string][]plugin.CachedTool, cacheHashOK map[string]bool) []tool.MCPBinding {
|
||||
func skillMCPBindings(sk skill.Skill, reg *tool.Registry, specs []plugin.Spec, cachedTools map[string][]plugin.CachedTool, cacheKeyOK map[string]bool) []tool.MCPBinding {
|
||||
var out []tool.MCPBinding
|
||||
liveServers := map[string]bool{}
|
||||
if reg != nil {
|
||||
@@ -2325,7 +2245,7 @@ func skillMCPBindings(sk skill.Skill, reg *tool.Registry, specs []plugin.Spec, c
|
||||
// package server before it is connected. The skill can then route through
|
||||
// use_capability without inventing Reasonix's canonical name.
|
||||
for _, spec := range specs {
|
||||
if spec.Package != sk.Plugin || liveServers[spec.Name] || !cacheHashOK[spec.Name] {
|
||||
if spec.Package != sk.Plugin || liveServers[spec.Name] || !cacheKeyOK[spec.Name] {
|
||||
continue
|
||||
}
|
||||
for _, cached := range cachedTools[spec.Name] {
|
||||
@@ -2346,80 +2266,6 @@ func skillMCPBindings(sk skill.Skill, reg *tool.Registry, specs []plugin.Spec, c
|
||||
return out
|
||||
}
|
||||
|
||||
func applyVerifiedMCPPackage(spec *plugin.Spec, opts PluginSpecOptions) {
|
||||
if spec == nil {
|
||||
return
|
||||
}
|
||||
if official, ok := opts.OfficialServers[spec.Name]; ok {
|
||||
if normalizeMCPTransport(spec.Type) != normalizeMCPTransport(official.Transport) {
|
||||
return
|
||||
}
|
||||
spec.OfficialCatalogEntryID = official.CatalogEntryID
|
||||
spec.OfficialReaderNames = append([]string(nil), official.Readers...)
|
||||
spec.PackageDigest = official.PackageDigest
|
||||
spec.PackageRoot = official.PackageRoot
|
||||
spec.VerifiedVersion = official.Version
|
||||
spec.CatalogSequence = official.CatalogSequence
|
||||
spec.ImplicitApproval = true
|
||||
spec.RequireLaunchApproval = false
|
||||
spec.ReaderSandbox.Network = official.Network
|
||||
spec.WriterSandbox.Network = official.Network
|
||||
}
|
||||
}
|
||||
|
||||
// LoadVerifiedMCPPackages resolves installed package metadata against the
|
||||
// signed catalog so every runtime uses the same verified package policy.
|
||||
func LoadVerifiedMCPPackages(ctx context.Context, cfg *config.Config) map[string]VerifiedMCPPackage {
|
||||
out := map[string]VerifiedMCPPackage{}
|
||||
if cfg == nil {
|
||||
return out
|
||||
}
|
||||
home := config.ReasonixHomeDir()
|
||||
result, err := (mcpcatalog.Loader{CacheDir: config.CacheDir()}).Load(ctx, false)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for _, configured := range cfg.Plugins {
|
||||
owner, ok := cfg.PluginPackageOwner(configured.Name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
installed, ok, err := pluginpkg.FindInstalled(home, owner)
|
||||
if err != nil || !ok || installed.Verification == nil || !pluginpkg.VerificationValid(home, installed) {
|
||||
continue
|
||||
}
|
||||
for _, entry := range result.Index.Entries {
|
||||
if entry.ID != installed.Verification.CatalogEntryID || result.Index.IsRevoked(entry.ID) ||
|
||||
entry.Name != installed.Name || entry.Version != installed.Version || entry.Source != installed.Source ||
|
||||
!strings.EqualFold(entry.Commit, installed.Commit) || !strings.EqualFold(entry.PackageSHA256, installed.Verification.PackageSHA256) {
|
||||
continue
|
||||
}
|
||||
for _, server := range entry.Servers {
|
||||
if server.Name != configured.Name {
|
||||
continue
|
||||
}
|
||||
out[configured.Name] = VerifiedMCPPackage{
|
||||
CatalogEntryID: entry.ID, Readers: append([]string(nil), server.Readers...),
|
||||
PackageDigest: entry.PackageSHA256, PackageRoot: pluginpkg.ResolveRoot(home, installed.Root), Version: entry.Version,
|
||||
CatalogSequence: result.Index.Sequence, Network: server.Network, Transport: server.Transport,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeMCPTransport(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "stdio":
|
||||
return "stdio"
|
||||
case "http", "streamable-http", "streamable_http":
|
||||
return "http"
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
}
|
||||
|
||||
func applyMCPIsolation(spec *plugin.Spec, workspaceRoot string, opts PluginSpecOptions) {
|
||||
if spec == nil || strings.TrimSpace(opts.StateHome) == "" {
|
||||
return
|
||||
@@ -2431,14 +2277,7 @@ func applyMCPIsolation(spec *plugin.Spec, workspaceRoot string, opts PluginSpecO
|
||||
readerRoots = appendUniquePaths(readerRoots, home)
|
||||
}
|
||||
spec.StateDir = stateDir
|
||||
spec.ReaderSandbox = sandbox.Spec{
|
||||
Mode: "enforce", WriteRoots: []string{stateDir},
|
||||
ReadRoots: readerRoots,
|
||||
AppContainerWriteRoots: []string{stateDir},
|
||||
ForbidReadRoots: append([]string(nil), opts.ForbidReadRoots...),
|
||||
Network: opts.Network, MinimalWrites: true,
|
||||
}
|
||||
spec.WriterSandbox = sandbox.Spec{
|
||||
spec.Sandbox = sandbox.Spec{
|
||||
Mode: "enforce", WriteRoots: writerRoots,
|
||||
ReadRoots: readerRoots,
|
||||
AppContainerWriteRoots: append([]string(nil), writerRoots...),
|
||||
@@ -2447,23 +2286,6 @@ func applyMCPIsolation(spec *plugin.Spec, workspaceRoot string, opts PluginSpecO
|
||||
}
|
||||
}
|
||||
|
||||
func mcpToolApprovalModes(policies map[string]config.MCPToolPolicy) map[string]string {
|
||||
if len(policies) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(policies))
|
||||
for name, policy := range policies {
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" {
|
||||
out[name] = policy.ApprovalMode
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func secondsDuration(seconds int) time.Duration {
|
||||
if seconds <= 0 {
|
||||
return 0
|
||||
@@ -2511,61 +2333,6 @@ func applyDefaultMCPCallTimeout(specs []plugin.Spec, timeout time.Duration) []pl
|
||||
return out
|
||||
}
|
||||
|
||||
func applyPlanModeAllowedMCPReaders(specs []plugin.Spec, allowedTools []string) []plugin.Spec {
|
||||
if len(specs) == 0 || len(allowedTools) == 0 {
|
||||
return specs
|
||||
}
|
||||
out := make([]plugin.Spec, len(specs))
|
||||
for i, spec := range specs {
|
||||
out[i] = spec
|
||||
prefix := plugin.ToolPrefix(spec.Name)
|
||||
clonedModelNames := false
|
||||
for _, name := range allowedTools {
|
||||
name = strings.TrimSpace(name)
|
||||
if !strings.HasPrefix(name, prefix) || len(name) <= len(prefix) {
|
||||
continue
|
||||
}
|
||||
if out[i].ReadOnlyModelToolNames == nil {
|
||||
out[i].ReadOnlyModelToolNames = map[string]bool{}
|
||||
clonedModelNames = true
|
||||
} else if !clonedModelNames {
|
||||
out[i].ReadOnlyModelToolNames = cloneBoolMap(spec.ReadOnlyModelToolNames)
|
||||
clonedModelNames = true
|
||||
}
|
||||
out[i].ReadOnlyModelToolNames[name] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func legacyRawReadOnlyToolNames(names []string) map[string]bool {
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for _, name := range names {
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" {
|
||||
out[name] = true
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneBoolMap(in map[string]bool) map[string]bool {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]bool, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// autoShellPrefer reports whether [tools.shell] left the interpreter to
|
||||
// auto-detection, so the "fell back to PowerShell" hint is suppressed once the
|
||||
// user has explicitly chosen a shell.
|
||||
|
||||
+48
-159
@@ -2436,7 +2436,7 @@ env = { GO_WANT_HELPER_PROCESS = "1" }
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTokenEconomyPlanModeKeepsLegacyMCPReadOnlyOverride(t *testing.T) {
|
||||
func TestBuildTokenEconomyPlanModeUsesInstalledMCPReaderHint(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
@@ -2467,8 +2467,7 @@ model = "x"
|
||||
name = "mockmcp"
|
||||
command = %q
|
||||
args = ["-test.run=TestHelperProcess", "--"]
|
||||
env = { GO_WANT_HELPER_PROCESS = "1" }
|
||||
trusted_read_only_tools = ["echo"]
|
||||
env = { GO_WANT_HELPER_PROCESS = "1", GO_WANT_HELPER_READ_ONLY = "1" }
|
||||
`, os.Args[0]))
|
||||
|
||||
ctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})
|
||||
@@ -2486,11 +2485,11 @@ trusted_read_only_tools = ["echo"]
|
||||
t.Fatalf("requests = %d, want 2", len(reqs))
|
||||
}
|
||||
if !requestHasTool(reqs[1], "mcp__mockmcp__echo") {
|
||||
t.Fatalf("second request should expose MCP source with a legacy read-only override; tools=%v", toolSchemaNames(reqs[1].Tools))
|
||||
t.Fatalf("second request should expose the installed MCP reader; tools=%v", toolSchemaNames(reqs[1].Tools))
|
||||
}
|
||||
for _, msg := range ctrl.History() {
|
||||
if msg.Role == provider.RoleTool && msg.Name == "connect_tool_source" && strings.Contains(msg.Content, "blocked:") {
|
||||
t.Fatalf("connect_tool_source should not block MCP with a legacy override in plan mode, got:\n%s", msg.Content)
|
||||
t.Fatalf("connect_tool_source should not block an installed MCP reader in plan mode, got:\n%s", msg.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2673,47 +2672,6 @@ model = "x"
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLegacyPlanModeAllowedToolsDoesNotEmitGateWarning(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
|
||||
registerBootTokenProfileTestProvider()
|
||||
prov := testutil.NewMock("plan-mode-allowed-tools", testutil.Turn{Text: "done"})
|
||||
setBootTokenProfileTestProvider(t, prov)
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
plan_mode_allowed_tools = ["bash", "custom_reader"]
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "boot-token-profile-test"
|
||||
model = "x"
|
||||
`)
|
||||
|
||||
var notices []event.Event
|
||||
sink := event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.Notice {
|
||||
notices = append(notices, e)
|
||||
}
|
||||
})
|
||||
|
||||
ctrl, err := Build(context.Background(), Options{Sink: sink})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
|
||||
for _, notice := range notices {
|
||||
if strings.Contains(notice.Text, "plan-mode tool") || strings.Contains(notice.Detail, "plan_mode_allowed_tools") {
|
||||
t.Fatalf("legacy Plan setting emitted obsolete gate warning: %+v", notice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLegacyPlanModeReadOnlyCommandsDoesNotEmitGateWarning(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
@@ -3752,36 +3710,6 @@ func TestPartitionByTier(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginSpecsDeclareKnownCodeGraphReadTools(t *testing.T) {
|
||||
specs := PluginSpecs([]config.PluginEntry{{Name: "codegraph"}})
|
||||
if len(specs) != 1 {
|
||||
t.Fatalf("PluginSpecs returned %d specs, want 1", len(specs))
|
||||
}
|
||||
for _, name := range []string{"codegraph_context", "codegraph_search", "context", "search"} {
|
||||
if !specs[0].ReadOnlyToolNames[name] {
|
||||
t.Fatalf("codegraph spec missing read-only override for %q: %+v", name, specs[0].ReadOnlyToolNames)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginSpecsDeclareConfiguredReadOnlyTools(t *testing.T) {
|
||||
specs := PluginSpecs([]config.PluginEntry{{
|
||||
Name: "github",
|
||||
TrustedReadOnlyTools: []string{"issue_read", " pull_request_read ", ""},
|
||||
}})
|
||||
if len(specs) != 1 {
|
||||
t.Fatalf("PluginSpecs returned %d specs, want 1", len(specs))
|
||||
}
|
||||
for _, name := range []string{"issue_read", "pull_request_read"} {
|
||||
if !specs[0].ReadOnlyToolNames[name] {
|
||||
t.Fatalf("configured trusted read-only tool %q missing: %+v", name, specs[0].ReadOnlyToolNames)
|
||||
}
|
||||
}
|
||||
if specs[0].ReadOnlyToolNames[""] {
|
||||
t.Fatalf("empty trusted read-only tool name should be ignored: %+v", specs[0].ReadOnlyToolNames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginSpecsMapConfiguredCallTimeouts(t *testing.T) {
|
||||
specs := PluginSpecsForRootWithOptions([]config.PluginEntry{{
|
||||
Name: "maker",
|
||||
@@ -3813,34 +3741,41 @@ func TestPluginSpecsMapConfiguredCallTimeouts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginSpecsMapMCPApprovalPolicy(t *testing.T) {
|
||||
specs := PluginSpecs([]config.PluginEntry{{
|
||||
Name: "admin",
|
||||
DefaultToolsApprovalMode: "writes",
|
||||
Tools: map[string]config.MCPToolPolicy{
|
||||
"wipe": {ApprovalMode: "prompt"},
|
||||
},
|
||||
ApprovalsReviewer: "auto_review",
|
||||
}})
|
||||
if len(specs) != 1 || specs[0].DefaultToolsApprovalMode != "writes" ||
|
||||
specs[0].ToolApprovalModes["wipe"] != "prompt" || specs[0].ApprovalsReviewer != "auto_review" {
|
||||
t.Fatalf("mapped MCP approval policy = %+v", specs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginSpecsMapMCPSourceDefaults(t *testing.T) {
|
||||
specs := PluginSpecsForRootWithOptions([]config.PluginEntry{
|
||||
{Name: "user", Source: config.MCPSourceUserConfig},
|
||||
{Name: "project", Source: config.MCPSourceProjectConfig},
|
||||
}, "/workspace", PluginSpecOptions{ConfigSource: "workspace_config"})
|
||||
if len(specs) != 2 {
|
||||
t.Fatalf("spec count = %d", len(specs))
|
||||
tests := []struct {
|
||||
name string
|
||||
source config.MCPConfigSource
|
||||
wantAuthorized bool
|
||||
wantApproval bool
|
||||
}{
|
||||
{name: "user config", source: config.MCPSourceUserConfig, wantAuthorized: true},
|
||||
{name: "legacy user config", source: config.MCPSourceLegacyUser, wantAuthorized: true},
|
||||
{name: "plugin package", source: config.MCPSourcePluginPackage, wantAuthorized: true},
|
||||
{name: "project config", source: config.MCPSourceProjectConfig, wantApproval: true},
|
||||
{name: "project mcp json", source: config.MCPSourceProjectMCPJSON, wantApproval: true},
|
||||
{name: "unknown"},
|
||||
}
|
||||
if !specs[0].ImplicitApproval || specs[0].RequireLaunchApproval || specs[0].ConfigSource != string(config.MCPSourceUserConfig) {
|
||||
t.Fatalf("user source defaults = %+v", specs[0])
|
||||
}
|
||||
if specs[1].ImplicitApproval || !specs[1].RequireLaunchApproval || specs[1].ConfigSource != string(config.MCPSourceProjectConfig) {
|
||||
t.Fatalf("project source defaults = %+v", specs[1])
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
specs := PluginSpecsForRootWithOptions([]config.PluginEntry{{
|
||||
Name: "server",
|
||||
Source: tc.source,
|
||||
}}, "/workspace", PluginSpecOptions{ConfigSource: "workspace_config"})
|
||||
if len(specs) != 1 {
|
||||
t.Fatalf("spec count = %d", len(specs))
|
||||
}
|
||||
if specs[0].Authorized != tc.wantAuthorized || specs[0].RequireLaunchApproval != tc.wantApproval {
|
||||
t.Fatalf("source defaults = %+v, want authorized=%v approval=%v", specs[0], tc.wantAuthorized, tc.wantApproval)
|
||||
}
|
||||
wantSource := string(tc.source)
|
||||
if wantSource == "" {
|
||||
wantSource = "workspace_config"
|
||||
}
|
||||
if specs[0].ConfigSource != wantSource {
|
||||
t.Fatalf("ConfigSource = %q, want %q", specs[0].ConfigSource, wantSource)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3884,26 +3819,6 @@ func TestSkillMCPBindingsUseOnlyValidOwnedCache(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifiedMCPPackageRequiresMatchingTransport(t *testing.T) {
|
||||
entry := config.PluginEntry{Name: "official", Type: "http", URL: "https://example.com/mcp"}
|
||||
official := VerifiedMCPPackage{
|
||||
CatalogEntryID: "official@1", PackageDigest: "digest", Readers: []string{"read"}, Transport: "stdio",
|
||||
}
|
||||
specs := PluginSpecsForRootWithOptions([]config.PluginEntry{entry}, "/workspace", PluginSpecOptions{
|
||||
OfficialServers: map[string]VerifiedMCPPackage{"official": official},
|
||||
})
|
||||
if len(specs) != 1 || specs[0].OfficialCatalogEntryID != "" {
|
||||
t.Fatalf("mismatched transport received official trust: %+v", specs)
|
||||
}
|
||||
official.Transport = "streamable-http"
|
||||
specs = PluginSpecsForRootWithOptions([]config.PluginEntry{entry}, "/workspace", PluginSpecOptions{
|
||||
OfficialServers: map[string]VerifiedMCPPackage{"official": official},
|
||||
})
|
||||
if len(specs) != 1 || specs[0].OfficialCatalogEntryID != "official@1" {
|
||||
t.Fatalf("equivalent HTTP transport did not receive official trust: %+v", specs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDefaultMCPCallTimeoutPreservesConfiguredDefault(t *testing.T) {
|
||||
specs := applyDefaultMCPCallTimeout([]plugin.Spec{
|
||||
{Name: "configured", DefaultCallTimeout: 2 * time.Minute},
|
||||
@@ -3917,32 +3832,6 @@ func TestApplyDefaultMCPCallTimeoutPreservesConfiguredDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginSpecsApplyPlanModeAllowedMCPReaders(t *testing.T) {
|
||||
specs := PluginSpecsForRootWithPlanModeAllowedTools(
|
||||
[]config.PluginEntry{{Name: "github"}, {Name: "linear"}},
|
||||
"",
|
||||
[]string{
|
||||
"mcp__github__issue_read",
|
||||
"mcp__linear__issue_read",
|
||||
"mcp__github__",
|
||||
"read_file",
|
||||
"mcp__other__issue_read",
|
||||
},
|
||||
)
|
||||
if len(specs) != 2 {
|
||||
t.Fatalf("PluginSpecsForRootWithPlanModeAllowedTools returned %d specs, want 2", len(specs))
|
||||
}
|
||||
if !specs[0].ReadOnlyModelToolNames["mcp__github__issue_read"] {
|
||||
t.Fatalf("github allowed MCP tool missing from model reader map: %+v", specs[0].ReadOnlyModelToolNames)
|
||||
}
|
||||
if specs[0].ReadOnlyModelToolNames["mcp__github__"] || specs[0].ReadOnlyModelToolNames["mcp__other__issue_read"] {
|
||||
t.Fatalf("github trust map accepted non-concrete or other-server tools: %+v", specs[0].ReadOnlyModelToolNames)
|
||||
}
|
||||
if !specs[1].ReadOnlyModelToolNames["mcp__linear__issue_read"] {
|
||||
t.Fatalf("linear allowed MCP tool missing from model reader map: %+v", specs[1].ReadOnlyModelToolNames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginSpecsForRootPinsCodeGraphToWorkspace(t *testing.T) {
|
||||
specs := PluginSpecsForRoot([]config.PluginEntry{{Name: "codegraph"}}, "/workspace")
|
||||
if len(specs) != 1 {
|
||||
@@ -3951,6 +3840,9 @@ func TestPluginSpecsForRootPinsCodeGraphToWorkspace(t *testing.T) {
|
||||
if specs[0].Dir != "/workspace" {
|
||||
t.Fatalf("codegraph Dir = %q, want workspace root", specs[0].Dir)
|
||||
}
|
||||
if specs[0].WorkspaceRoot != "/workspace" {
|
||||
t.Fatalf("codegraph WorkspaceRoot = %q, want /workspace", specs[0].WorkspaceRoot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginSpecsForRootDoesNotPinHTTPCodeGraph(t *testing.T) {
|
||||
@@ -3961,15 +3853,8 @@ func TestPluginSpecsForRootDoesNotPinHTTPCodeGraph(t *testing.T) {
|
||||
if specs[0].Dir != "" {
|
||||
t.Fatalf("http codegraph Dir = %q, want empty", specs[0].Dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginSpecsDoNotTrustCodeGraphToolsForOtherServers(t *testing.T) {
|
||||
specs := PluginSpecs([]config.PluginEntry{{Name: "not-codegraph"}})
|
||||
if len(specs) != 1 {
|
||||
t.Fatalf("PluginSpecs returned %d specs, want 1", len(specs))
|
||||
}
|
||||
if specs[0].ReadOnlyToolNames["codegraph_context"] {
|
||||
t.Fatalf("non-codegraph spec should not receive codegraph read-only overrides: %+v", specs[0].ReadOnlyToolNames)
|
||||
if specs[0].WorkspaceRoot != "/workspace" {
|
||||
t.Fatalf("http codegraph WorkspaceRoot = %q, want /workspace", specs[0].WorkspaceRoot)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4452,7 +4337,7 @@ func TestHelperProcess(t *testing.T) {
|
||||
"capabilities": map[string]any{},
|
||||
}
|
||||
case "tools/list":
|
||||
result = map[string]any{"tools": []map[string]any{{
|
||||
echo := map[string]any{
|
||||
"name": "echo",
|
||||
"description": "Echo back the message.",
|
||||
"inputSchema": map[string]any{
|
||||
@@ -4460,7 +4345,11 @@ func TestHelperProcess(t *testing.T) {
|
||||
"properties": map[string]any{"msg": map[string]any{"type": "string"}},
|
||||
"required": []string{"msg"},
|
||||
},
|
||||
}}}
|
||||
}
|
||||
if os.Getenv("GO_WANT_HELPER_READ_ONLY") == "1" {
|
||||
echo["annotations"] = map[string]any{"readOnlyHint": true}
|
||||
}
|
||||
result = map[string]any{"tools": []map[string]any{echo}}
|
||||
}
|
||||
|
||||
resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID, "result": result}
|
||||
|
||||
@@ -39,7 +39,7 @@ type CatalogOptions struct {
|
||||
Failed map[string]string
|
||||
Disabled map[string]bool
|
||||
CachedTools map[string][]plugin.CachedTool // server → tools
|
||||
CacheHashOK map[string]bool // server → fingerprint match
|
||||
CacheKeyOK map[string]bool // server → schema-cache key match
|
||||
// ProxyTools carries host-observed live tools of servers connected through
|
||||
// the Delivery proxy: they are absent from Tools (never registered) yet
|
||||
// must stay routable after the server turns ready.
|
||||
@@ -47,27 +47,27 @@ type CatalogOptions struct {
|
||||
}
|
||||
|
||||
// LoadCachedToolsForSpecs loads the persisted MCP schema caches for the given
|
||||
// boot-converted specs, keyed by server name, plus the per-server fingerprint
|
||||
// match state. Fingerprint-mismatched caches are still returned (with
|
||||
// CacheHashOK=false) so MCPServerEntries can mark them stale instead of
|
||||
// boot-converted specs, keyed by server name, plus the per-server cache-key
|
||||
// match state. Mismatched caches are still returned (with
|
||||
// CacheKeyOK=false) so MCPServerEntries can mark them stale instead of
|
||||
// hiding them; servers without a usable cache are simply absent. Call once at
|
||||
// session start and reuse — the cache lives on disk.
|
||||
func LoadCachedToolsForSpecs(specs []plugin.Spec) (map[string][]plugin.CachedTool, map[string]bool) {
|
||||
cached := map[string][]plugin.CachedTool{}
|
||||
hashOK := map[string]bool{}
|
||||
keyOK := map[string]bool{}
|
||||
for _, s := range specs {
|
||||
name := strings.TrimSpace(s.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
cs, ok, match := plugin.LoadCachedSchemaAny(name, plugin.SpecFingerprint(s))
|
||||
cs, ok, match := plugin.LoadCachedSchemaAny(name, plugin.SchemaCacheKey(s))
|
||||
if !ok || len(cs.Tools) == 0 {
|
||||
continue
|
||||
}
|
||||
cached[name] = cs.Tools
|
||||
hashOK[name] = match
|
||||
keyOK[name] = match
|
||||
}
|
||||
return cached, hashOK
|
||||
return cached, keyOK
|
||||
}
|
||||
|
||||
// BuildCatalog assembles the unified capability directory.
|
||||
@@ -142,7 +142,7 @@ func MCPServerEntries(opts CatalogOptions) []Entry {
|
||||
status = StatusFailed
|
||||
} else if opts.Connected != nil && opts.Connected[name] {
|
||||
status = StatusReady
|
||||
} else if opts.CacheHashOK != nil && !opts.CacheHashOK[name] && opts.CachedTools != nil && len(opts.CachedTools[name]) > 0 {
|
||||
} else if opts.CacheKeyOK != nil && !opts.CacheKeyOK[name] && opts.CachedTools != nil && len(opts.CachedTools[name]) > 0 {
|
||||
status = StatusStale
|
||||
}
|
||||
e := Entry{
|
||||
@@ -180,7 +180,7 @@ func MCPServerEntries(opts CatalogOptions) []Entry {
|
||||
toolStatus = StatusReady
|
||||
case status != StatusReady:
|
||||
toolSrc = opts.CachedTools[name]
|
||||
// A fingerprint-mismatched cache marked the server stale; its
|
||||
// A schema-cache-key mismatch marked the server stale; its
|
||||
// tools carry the same staleness so routing prompts expose it.
|
||||
if status == StatusStale {
|
||||
toolStatus = StatusStale
|
||||
|
||||
@@ -11,29 +11,29 @@ import (
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
|
||||
func TestLoadCachedToolsForSpecsHonorsFingerprint(t *testing.T) {
|
||||
func TestLoadCachedToolsForSpecsHonorsSchemaCacheKey(t *testing.T) {
|
||||
t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
|
||||
fresh := plugin.Spec{Name: "gh", Type: "stdio", Command: "gh-mcp"}
|
||||
if err := plugin.SaveCachedSchema("gh", plugin.CachedSchema{
|
||||
SpecHash: plugin.SpecFingerprint(fresh),
|
||||
CacheKey: plugin.SchemaCacheKey(fresh),
|
||||
Tools: []plugin.CachedTool{{Name: "search_issues", Description: "search", ReadOnly: true}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := plugin.Spec{Name: "old", Type: "stdio", Command: "old-mcp"}
|
||||
if err := plugin.SaveCachedSchema("old", plugin.CachedSchema{
|
||||
SpecHash: "some-other-fingerprint",
|
||||
CacheKey: "some-other-cache-key",
|
||||
Tools: []plugin.CachedTool{{Name: "do_thing"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cached, hashOK := LoadCachedToolsForSpecs([]plugin.Spec{fresh, stale, {Name: "absent"}})
|
||||
if len(cached["gh"]) != 1 || !hashOK["gh"] {
|
||||
t.Fatalf("fresh cache: tools=%v hashOK=%v", cached["gh"], hashOK["gh"])
|
||||
cached, keyOK := LoadCachedToolsForSpecs([]plugin.Spec{fresh, stale, {Name: "absent"}})
|
||||
if len(cached["gh"]) != 1 || !keyOK["gh"] {
|
||||
t.Fatalf("fresh cache: tools=%v keyOK=%v", cached["gh"], keyOK["gh"])
|
||||
}
|
||||
if len(cached["old"]) != 1 || hashOK["old"] {
|
||||
t.Fatalf("stale cache must load with hashOK=false: tools=%v hashOK=%v", cached["old"], hashOK["old"])
|
||||
if len(cached["old"]) != 1 || keyOK["old"] {
|
||||
t.Fatalf("stale cache must load with keyOK=false: tools=%v keyOK=%v", cached["old"], keyOK["old"])
|
||||
}
|
||||
if _, ok := cached["absent"]; ok {
|
||||
t.Fatal("server without cache must be absent")
|
||||
@@ -45,7 +45,7 @@ func TestBuildCatalogSurfacesCachedToolsForAutoStartFalse(t *testing.T) {
|
||||
"gh": {{Name: "search_issues", Description: "search", ReadOnly: true}},
|
||||
"old": {{Name: "do_thing"}},
|
||||
}
|
||||
hashOK := map[string]bool{"gh": true, "old": false}
|
||||
keyOK := map[string]bool{"gh": true, "old": false}
|
||||
cat := BuildCatalog(CatalogOptions{
|
||||
Plugins: []config.PluginEntry{
|
||||
{Name: "gh", AutoStart: boolPtr(false)},
|
||||
@@ -53,7 +53,7 @@ func TestBuildCatalogSurfacesCachedToolsForAutoStartFalse(t *testing.T) {
|
||||
},
|
||||
Profile: ProfileDelivery,
|
||||
CachedTools: cached,
|
||||
CacheHashOK: hashOK,
|
||||
CacheKeyOK: keyOK,
|
||||
})
|
||||
byID := map[string]Entry{}
|
||||
for _, e := range cat.Entries {
|
||||
@@ -67,7 +67,7 @@ func TestBuildCatalogSurfacesCachedToolsForAutoStartFalse(t *testing.T) {
|
||||
t.Fatalf("cached tool entry lost metadata: %+v", toolEntry)
|
||||
}
|
||||
if server := byID["mcp-server:old"]; server.Status != StatusStale {
|
||||
t.Fatalf("fingerprint-mismatched cache should mark the server stale, got %q", server.Status)
|
||||
t.Fatalf("cache-key-mismatched schema should mark the server stale, got %q", server.Status)
|
||||
}
|
||||
if staleTool, ok := byID["mcp-tool:old/do_thing"]; !ok {
|
||||
t.Fatal("stale cached tools should still appear as candidates")
|
||||
|
||||
+163
-5
@@ -1,12 +1,18 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"reasonix/internal/config"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/mcpregistry"
|
||||
)
|
||||
|
||||
// mcp.go holds the MCP server-management surface shared by the `reasonix mcp`
|
||||
@@ -148,9 +154,9 @@ func tokenizeArgs(s string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// mcpCommand implements `reasonix mcp <add|remove|list>`. It edits config only
|
||||
// (validate → UpsertPlugin/RemovePlugin → Save); the server connects on the next
|
||||
// session start. For a live connect inside an open chat, use `/mcp add`.
|
||||
// mcpCommand implements persisted server management plus explicit browse/install
|
||||
// access to the official MCP Registry. Config edits take effect on the next
|
||||
// session start; for a live manual connection inside an open chat, use `/mcp add`.
|
||||
func mcpCommand(args []string) int {
|
||||
if len(args) == 0 {
|
||||
mcpUsage()
|
||||
@@ -167,6 +173,10 @@ func mcpCommand(args []string) int {
|
||||
return mcpRemoveCLI(args[1:])
|
||||
case "import":
|
||||
return mcpImportCLI()
|
||||
case "browse", "search":
|
||||
return mcpBrowseCLI(args[1:])
|
||||
case "install":
|
||||
return mcpInstallCLI(args[1:])
|
||||
case "help", "-h", "--help":
|
||||
mcpUsage()
|
||||
return 0
|
||||
@@ -177,6 +187,150 @@ func mcpCommand(args []string) int {
|
||||
}
|
||||
}
|
||||
|
||||
func defaultMCPRegistryClient() *mcpregistry.Client {
|
||||
cachePath := ""
|
||||
if cacheDir := config.CacheDir(); cacheDir != "" {
|
||||
cachePath = filepath.Join(cacheDir, "mcp-registry-v0.1.json")
|
||||
}
|
||||
return mcpregistry.New(cachePath)
|
||||
}
|
||||
|
||||
func mcpBrowseCLI(args []string) int {
|
||||
return mcpBrowseWithClient(args, defaultMCPRegistryClient())
|
||||
}
|
||||
|
||||
func mcpBrowseWithClient(args []string, client *mcpregistry.Client) int {
|
||||
query := ""
|
||||
limit := 20
|
||||
jsonOutput := false
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--json":
|
||||
jsonOutput = true
|
||||
case "--limit":
|
||||
if i+1 >= len(args) {
|
||||
fmt.Fprintln(os.Stderr, "mcp browse: --limit needs a value")
|
||||
return 2
|
||||
}
|
||||
i++
|
||||
value, err := strconv.Atoi(args[i])
|
||||
if err != nil || value <= 0 || value > 100 {
|
||||
fmt.Fprintln(os.Stderr, "mcp browse: --limit must be between 1 and 100")
|
||||
return 2
|
||||
}
|
||||
limit = value
|
||||
default:
|
||||
if strings.HasPrefix(args[i], "-") {
|
||||
fmt.Fprintf(os.Stderr, "mcp browse: unknown flag %q\n", args[i])
|
||||
return 2
|
||||
}
|
||||
if query != "" {
|
||||
fmt.Fprintln(os.Stderr, "mcp browse: provide at most one search query")
|
||||
return 2
|
||||
}
|
||||
query = args[i]
|
||||
}
|
||||
}
|
||||
result, err := client.Search(context.Background(), query, limit)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
if result.Warning != "" {
|
||||
fmt.Fprintf(os.Stderr, "MCP Registry unavailable; showing cached results: %s\n", result.Warning)
|
||||
}
|
||||
if jsonOutput {
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(result.Entries); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if len(result.Entries) == 0 {
|
||||
fmt.Println("no MCP Registry servers matched")
|
||||
return 0
|
||||
}
|
||||
for _, entry := range result.Entries {
|
||||
status := entry.Transport
|
||||
if !entry.Installable {
|
||||
status = "manual setup: " + entry.UnavailableReason
|
||||
}
|
||||
title := entry.Title
|
||||
if title == "" {
|
||||
title = entry.Name
|
||||
}
|
||||
fmt.Printf("%s\t%s\t%s\t%s\n", entry.Name, entry.Version, status, title)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func mcpInstallCLI(args []string) int {
|
||||
return mcpInstallWithClient(args, defaultMCPRegistryClient())
|
||||
}
|
||||
|
||||
func mcpInstallWithClient(args []string, client *mcpregistry.Client) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "usage: reasonix mcp install <registry-name> [--as <local-name>]")
|
||||
return 2
|
||||
}
|
||||
registryName := strings.TrimSpace(args[0])
|
||||
if registryName == "" || strings.HasPrefix(registryName, "-") {
|
||||
fmt.Fprintln(os.Stderr, "mcp install: registry server name is required")
|
||||
return 2
|
||||
}
|
||||
localName := ""
|
||||
for i := 1; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--as":
|
||||
if i+1 >= len(args) || strings.TrimSpace(args[i+1]) == "" {
|
||||
fmt.Fprintln(os.Stderr, "mcp install: --as needs a local name")
|
||||
return 2
|
||||
}
|
||||
i++
|
||||
localName = strings.TrimSpace(args[i])
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "mcp install: unknown argument %q\n", args[i])
|
||||
return 2
|
||||
}
|
||||
}
|
||||
entry, result, err := client.Resolve(context.Background(), registryName)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
if result.Warning != "" {
|
||||
fmt.Fprintf(os.Stderr, "MCP Registry unavailable; using cached result: %s\n", result.Warning)
|
||||
}
|
||||
pluginEntry, err := entry.PluginEntry(localName)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
for _, configured := range cfg.Plugins {
|
||||
if configured.Name == pluginEntry.Name {
|
||||
fmt.Fprintf(os.Stderr, "MCP server %q is already configured; choose another name with --as or remove it first\n", pluginEntry.Name)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if err := cfg.UpsertPlugin(pluginEntry); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
if err := cfg.Save(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("installed MCP Registry server %q as %q — loads on the next session\n", entry.Name, pluginEntry.Name)
|
||||
return 0
|
||||
}
|
||||
|
||||
func mcpImportCLI() int {
|
||||
total, added, updated, err := config.ImportCCSwitchMCP()
|
||||
if err != nil {
|
||||
@@ -393,6 +547,8 @@ Usage:
|
||||
reasonix mcp add <name> <command> [args...] stdio server
|
||||
reasonix mcp add <name> --http <url> [--header K=V] remote (Streamable HTTP)
|
||||
reasonix mcp add <name> --sse <url> remote (legacy SSE)
|
||||
reasonix mcp browse [query] [--limit N] [--json] browse the official MCP Registry
|
||||
reasonix mcp install <registry-name> [--as <name>] install a registry server
|
||||
reasonix mcp import import MCP servers from cc-switch
|
||||
reasonix mcp remove <name>
|
||||
|
||||
@@ -411,5 +567,7 @@ its authorization; there is no separate trust step.
|
||||
|
||||
Servers merely discovered in project configuration ask once for confirmation of
|
||||
the exact command or endpoint, then reconnect automatically while it is unchanged.
|
||||
Explicit tool policies and destructive calls can still require approval.`)
|
||||
After authorization, writer or destructive annotations never trigger per-call
|
||||
approval. Explicit deny rules still win; Plan Mode and strict read-only subagents
|
||||
may filter which tools are available.`)
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
@@ -11,6 +14,7 @@ import (
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/mcpregistry"
|
||||
"reasonix/internal/plugin"
|
||||
)
|
||||
|
||||
@@ -153,6 +157,46 @@ func TestMCPGetMissingServerFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPBrowseAndInstallOfficialRegistryEntry(t *testing.T) {
|
||||
isolateCLIConfigHome(t)
|
||||
registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"servers": []any{map[string]any{"server": map[string]any{
|
||||
"name": "io.example/demo", "title": "Demo MCP", "version": "1.0.0",
|
||||
"remotes": []any{map[string]any{"type": "streamable-http", "url": "https://mcp.example.test/mcp"}},
|
||||
}}}})
|
||||
}))
|
||||
defer registry.Close()
|
||||
client := mcpregistry.New("")
|
||||
client.BaseURL = registry.URL
|
||||
|
||||
browseOut := captureStdout(t, func() {
|
||||
if rc := mcpBrowseWithClient([]string{"demo", "--limit", "5"}, client); rc != 0 {
|
||||
t.Fatalf("mcp browse rc = %d", rc)
|
||||
}
|
||||
})
|
||||
for _, want := range []string{"io.example/demo", "1.0.0", "http", "Demo MCP"} {
|
||||
if !strings.Contains(browseOut, want) {
|
||||
t.Fatalf("mcp browse output missing %q: %s", want, browseOut)
|
||||
}
|
||||
}
|
||||
|
||||
installOut := captureStdout(t, func() {
|
||||
if rc := mcpInstallWithClient([]string{"io.example/demo", "--as", "demo-market"}, client); rc != 0 {
|
||||
t.Fatalf("mcp install rc = %d", rc)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(installOut, `installed MCP Registry server "io.example/demo" as "demo-market"`) {
|
||||
t.Fatalf("mcp install output = %q", installOut)
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.Plugins) != 1 || cfg.Plugins[0].Name != "demo-market" || cfg.Plugins[0].Type != "http" || cfg.Plugins[0].URL != "https://mcp.example.test/mcp" {
|
||||
t.Fatalf("installed plugins = %+v", cfg.Plugins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPGetRedactsRemoteAuthMaterial(t *testing.T) {
|
||||
isolateCLIConfigHome(t)
|
||||
|
||||
|
||||
@@ -1096,10 +1096,6 @@ type AgentConfig struct {
|
||||
// ColdResumePrune elides stale tool results when a session reopens past the
|
||||
// provider cache window. nil = default enabled.
|
||||
ColdResumePrune *bool `toml:"cold_resume_prune"`
|
||||
// PlanModeAllowedTools is a legacy compatibility field. Concrete MCP names may
|
||||
// still become local read-only trust aliases, but this field does not control
|
||||
// main Plan workflow availability.
|
||||
PlanModeAllowedTools []string `toml:"plan_mode_allowed_tools"`
|
||||
// PlanModeReadOnlyCommands is retained for old config/session round trips. Main
|
||||
// Plan bash calls now use the ordinary Permissions classifier and Sandbox.
|
||||
PlanModeReadOnlyCommands []string `toml:"plan_mode_read_only_commands"`
|
||||
@@ -1445,12 +1441,6 @@ type PermissionsConfig struct {
|
||||
Deny []string `toml:"deny"`
|
||||
}
|
||||
|
||||
// MCPToolPolicy is local execution policy for one raw server tool name. It is
|
||||
// intentionally absent from provider-visible tool schemas.
|
||||
type MCPToolPolicy struct {
|
||||
ApprovalMode string `toml:"approval_mode" json:"approval_mode"`
|
||||
}
|
||||
|
||||
// MCPConfigSource records where a merged MCP entry came from. It is runtime
|
||||
// provenance only and is never serialized back into TOML or .mcp.json.
|
||||
type MCPConfigSource string
|
||||
@@ -1494,18 +1484,6 @@ type PluginEntry struct {
|
||||
// from this server. Keys are server-local tool names, not model-visible
|
||||
// mcp__server__tool names.
|
||||
ToolTimeoutSeconds map[string]int `toml:"tool_timeout_seconds"`
|
||||
// TrustedReadOnlyTools is a local trust and compatibility override for
|
||||
// audited readers. Third-party readOnlyHint alone is not a Plan-mode trust
|
||||
// boundary.
|
||||
TrustedReadOnlyTools []string `toml:"trusted_read_only_tools"`
|
||||
// DefaultToolsApprovalMode is auto|prompt|writes|approve. Empty uses the
|
||||
// source-aware runtime default (direct for user-authorized servers, auto for
|
||||
// project-provided servers).
|
||||
DefaultToolsApprovalMode string `toml:"default_tools_approval_mode"`
|
||||
// Tools overrides approval policy by raw server-local tool name.
|
||||
Tools map[string]MCPToolPolicy `toml:"tools"`
|
||||
// ApprovalsReviewer is user|auto_review. Empty preserves legacy routing.
|
||||
ApprovalsReviewer string `toml:"approvals_reviewer"`
|
||||
// AutoStart controls whether the server connects during session startup.
|
||||
// Nil preserves historical behavior: configured servers start automatically.
|
||||
AutoStart *bool `toml:"auto_start"`
|
||||
|
||||
@@ -1105,22 +1105,6 @@ func validatePlugin(e PluginEntry) error {
|
||||
return fmt.Errorf("plugin %q: tool_timeout_seconds[%q] must be >= 0", e.Name, name)
|
||||
}
|
||||
}
|
||||
if !validMCPApprovalMode(e.DefaultToolsApprovalMode, true) {
|
||||
return fmt.Errorf("plugin %q: unknown default_tools_approval_mode %q (want auto|prompt|writes|approve)", e.Name, e.DefaultToolsApprovalMode)
|
||||
}
|
||||
for name, policy := range e.Tools {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return fmt.Errorf("plugin %q: tools contains an empty tool name", e.Name)
|
||||
}
|
||||
if !validMCPApprovalMode(policy.ApprovalMode, false) {
|
||||
return fmt.Errorf("plugin %q: tools[%q].approval_mode must be auto|prompt|writes|approve", e.Name, name)
|
||||
}
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(e.ApprovalsReviewer)) {
|
||||
case "", "user", "auto_review":
|
||||
default:
|
||||
return fmt.Errorf("plugin %q: unknown approvals_reviewer %q (want user|auto_review)", e.Name, e.ApprovalsReviewer)
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(e.Type)) {
|
||||
case "", "stdio":
|
||||
if strings.TrimSpace(e.Command) == "" {
|
||||
@@ -1136,17 +1120,6 @@ func validatePlugin(e PluginEntry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validMCPApprovalMode(mode string, allowEmpty bool) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "auto", "prompt", "writes", "approve":
|
||||
return true
|
||||
case "":
|
||||
return allowEmpty
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// SaveTo writes the configuration to path as annotated TOML, atomically: it
|
||||
// writes a sibling temp file then renames, so a crash mid-write can't leave a
|
||||
// half-written reasonix.toml that fails to parse on next load. Parent directories
|
||||
|
||||
@@ -936,16 +936,6 @@ func TestPluginMutators(t *testing.T) {
|
||||
if err := c.UpsertPlugin(PluginEntry{Name: "bad", Command: "x", ToolTimeoutSeconds: map[string]int{" ": 1}}); err == nil {
|
||||
t.Error("empty tool_timeout_seconds key should error")
|
||||
}
|
||||
if err := c.UpsertPlugin(PluginEntry{Name: "bad", Command: "x", DefaultToolsApprovalMode: "always"}); err == nil {
|
||||
t.Error("invalid MCP approval mode should error")
|
||||
}
|
||||
if err := c.UpsertPlugin(PluginEntry{Name: "bad", Command: "x", Tools: map[string]MCPToolPolicy{"wipe": {ApprovalMode: "sometimes"}}}); err == nil {
|
||||
t.Error("invalid per-tool MCP approval mode should error")
|
||||
}
|
||||
if err := c.UpsertPlugin(PluginEntry{Name: "bad", Command: "x", ApprovalsReviewer: "nobody"}); err == nil {
|
||||
t.Error("invalid MCP approvals reviewer should error")
|
||||
}
|
||||
|
||||
// Replace in place.
|
||||
if err := c.UpsertPlugin(PluginEntry{Name: "ex", Command: "other-cmd"}); err != nil {
|
||||
t.Fatalf("replace: %v", err)
|
||||
|
||||
@@ -120,9 +120,6 @@ func loadForRoot(root string, migrateOnDisk bool) (*Config, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range entries {
|
||||
entries[i] = restrictProjectMCPApprovalPolicy(entries[i])
|
||||
}
|
||||
cfg.mergeMCPJSON(entries)
|
||||
|
||||
// Lowest priority before the one-time v1.9.1 MCP migration: the v0.x
|
||||
@@ -368,7 +365,6 @@ func normalizeLegacyEffort(c *Config) {
|
||||
func mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {
|
||||
var merged []PluginEntry
|
||||
index := map[string]int{}
|
||||
userApprovalPolicies := map[string]PluginEntry{}
|
||||
for _, path := range paths {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
continue
|
||||
@@ -381,13 +377,8 @@ func mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {
|
||||
p, _ = NormalizePluginCommandLine(p)
|
||||
if isUserConfigPath(path) {
|
||||
p.Source = MCPSourceUserConfig
|
||||
userApprovalPolicies[p.Name] = p
|
||||
} else {
|
||||
p.Source = MCPSourceProjectConfig
|
||||
p = restrictProjectMCPApprovalPolicy(p)
|
||||
if userPolicy, ok := userApprovalPolicies[p.Name]; ok {
|
||||
p = inheritUserMCPApprovalPolicy(p, userPolicy)
|
||||
}
|
||||
}
|
||||
if i, ok := index[p.Name]; ok {
|
||||
merged[i] = p
|
||||
@@ -400,47 +391,6 @@ func mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func inheritUserMCPApprovalPolicy(project, user PluginEntry) PluginEntry {
|
||||
project.DefaultToolsApprovalMode = user.DefaultToolsApprovalMode
|
||||
project.Tools = cloneMCPToolPolicies(user.Tools)
|
||||
project.ApprovalsReviewer = user.ApprovalsReviewer
|
||||
return project
|
||||
}
|
||||
|
||||
// restrictProjectMCPApprovalPolicy keeps repository-controlled configuration
|
||||
// monotonic with the user's global permission posture. Project files may make
|
||||
// MCP calls stricter, but cannot silently auto-approve writers or route a human
|
||||
// prompt to an automated reviewer.
|
||||
func restrictProjectMCPApprovalPolicy(p PluginEntry) PluginEntry {
|
||||
if strings.EqualFold(strings.TrimSpace(p.DefaultToolsApprovalMode), "approve") {
|
||||
p.DefaultToolsApprovalMode = "auto"
|
||||
}
|
||||
if len(p.Tools) > 0 {
|
||||
p.Tools = cloneMCPToolPolicies(p.Tools)
|
||||
for name, policy := range p.Tools {
|
||||
if strings.EqualFold(strings.TrimSpace(policy.ApprovalMode), "approve") {
|
||||
policy.ApprovalMode = "auto"
|
||||
p.Tools[name] = policy
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(p.ApprovalsReviewer), "auto_review") {
|
||||
p.ApprovalsReviewer = "user"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func cloneMCPToolPolicies(in map[string]MCPToolPolicy) map[string]MCPToolPolicy {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]MCPToolPolicy, len(in))
|
||||
for name, policy := range in {
|
||||
out[name] = policy
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.
|
||||
// User-global providers win over same-named project providers; project providers
|
||||
// only fill names the global config does not define. Keep official legacy aliases
|
||||
|
||||
+30
-72
@@ -24,19 +24,15 @@ const mcpJSONFile = ".mcp.json"
|
||||
// stdio server; type/url/headers describe a remote one. Reasonix also accepts
|
||||
// timeout fields as MCP call policy extensions.
|
||||
type mcpServerSpec struct {
|
||||
Type string `json:"type"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Env map[string]string `json:"env"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
CallTimeoutSeconds int `json:"call_timeout_seconds"`
|
||||
ToolTimeoutSeconds map[string]int `json:"tool_timeout_seconds"`
|
||||
TrustedReadOnlyTools []string `json:"trusted_read_only_tools"`
|
||||
AutoStart *bool `json:"auto_start"`
|
||||
DefaultToolsApprovalMode string `json:"default_tools_approval_mode"`
|
||||
Tools map[string]MCPToolPolicy `json:"tools"`
|
||||
ApprovalsReviewer string `json:"approvals_reviewer"`
|
||||
Type string `json:"type"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Env map[string]string `json:"env"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
CallTimeoutSeconds int `json:"call_timeout_seconds"`
|
||||
ToolTimeoutSeconds map[string]int `json:"tool_timeout_seconds"`
|
||||
AutoStart *bool `json:"auto_start"`
|
||||
}
|
||||
|
||||
// loadMCPJSON reads path (Claude Code's .mcp.json) and returns its servers as
|
||||
@@ -202,20 +198,16 @@ func anonymousMCPName(i int) string {
|
||||
|
||||
func pluginEntryFromMCPSpec(name string, s mcpServerSpec) PluginEntry {
|
||||
e := PluginEntry{
|
||||
Name: name,
|
||||
Type: s.Type,
|
||||
Command: s.Command,
|
||||
Args: s.Args,
|
||||
Env: s.Env,
|
||||
URL: s.URL,
|
||||
Headers: s.Headers,
|
||||
CallTimeoutSeconds: s.CallTimeoutSeconds,
|
||||
ToolTimeoutSeconds: s.ToolTimeoutSeconds,
|
||||
TrustedReadOnlyTools: s.TrustedReadOnlyTools,
|
||||
AutoStart: s.AutoStart,
|
||||
DefaultToolsApprovalMode: s.DefaultToolsApprovalMode,
|
||||
Tools: mcpToolPoliciesWithApprovalMode(s.Tools),
|
||||
ApprovalsReviewer: s.ApprovalsReviewer,
|
||||
Name: name,
|
||||
Type: s.Type,
|
||||
Command: s.Command,
|
||||
Args: s.Args,
|
||||
Env: s.Env,
|
||||
URL: s.URL,
|
||||
Headers: s.Headers,
|
||||
CallTimeoutSeconds: s.CallTimeoutSeconds,
|
||||
ToolTimeoutSeconds: s.ToolTimeoutSeconds,
|
||||
AutoStart: s.AutoStart,
|
||||
}
|
||||
e, _ = NormalizePluginCommandLine(e)
|
||||
return e
|
||||
@@ -336,33 +328,21 @@ func applyPluginEntryToMCPJSONServer(server map[string]json.RawMessage, entry Pl
|
||||
}
|
||||
setMCPJSONInt(server, "call_timeout_seconds", entry.CallTimeoutSeconds)
|
||||
setMCPJSONIntMap(server, "tool_timeout_seconds", entry.ToolTimeoutSeconds)
|
||||
setMCPJSONStringArray(server, "trusted_read_only_tools", entry.TrustedReadOnlyTools)
|
||||
// The removed per-tool reader list is accepted on load for compatibility but
|
||||
// never persisted. Explicitly delete it when updating an existing shared
|
||||
// .mcp.json entry so the obsolete setting disappears naturally.
|
||||
delete(server, "trusted_read_only_tools")
|
||||
delete(server, "default_tools_approval_mode")
|
||||
delete(server, "approvals_reviewer")
|
||||
setMCPJSONBool(server, "auto_start", entry.AutoStart)
|
||||
setMCPJSONString(server, "default_tools_approval_mode", strings.TrimSpace(entry.DefaultToolsApprovalMode))
|
||||
if err := setMCPJSONToolPolicies(server, "tools", entry.Tools); err != nil {
|
||||
if err := removeMCPJSONApprovalModes(server); err != nil {
|
||||
return err
|
||||
}
|
||||
setMCPJSONString(server, "approvals_reviewer", strings.TrimSpace(entry.ApprovalsReviewer))
|
||||
return nil
|
||||
}
|
||||
|
||||
func mcpToolPoliciesWithApprovalMode(values map[string]MCPToolPolicy) map[string]MCPToolPolicy {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]MCPToolPolicy, len(values))
|
||||
for name, policy := range values {
|
||||
if strings.TrimSpace(policy.ApprovalMode) != "" {
|
||||
out[name] = policy
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func setMCPJSONToolPolicies(server map[string]json.RawMessage, key string, values map[string]MCPToolPolicy) error {
|
||||
func removeMCPJSONApprovalModes(server map[string]json.RawMessage) error {
|
||||
const key = "tools"
|
||||
tools := map[string]json.RawMessage{}
|
||||
if raw, ok := server[key]; ok && len(raw) > 0 && strings.TrimSpace(string(raw)) != "null" {
|
||||
if err := json.Unmarshal(raw, &tools); err != nil || tools == nil {
|
||||
@@ -370,12 +350,9 @@ func setMCPJSONToolPolicies(server map[string]json.RawMessage, key string, value
|
||||
}
|
||||
}
|
||||
|
||||
// An omitted Reasonix policy means remove only approval_mode. Other clients
|
||||
// may own additional fields on the same tool entry, so keep those intact.
|
||||
// Remove Reasonix's retired approval_mode while preserving tool fields owned
|
||||
// by other MCP clients.
|
||||
for name, raw := range tools {
|
||||
if _, keep := values[name]; keep {
|
||||
continue
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &fields); err != nil || fields == nil {
|
||||
continue
|
||||
@@ -392,25 +369,6 @@ func setMCPJSONToolPolicies(server map[string]json.RawMessage, key string, value
|
||||
tools[name] = updated
|
||||
}
|
||||
|
||||
for name, policy := range values {
|
||||
fields := map[string]json.RawMessage{}
|
||||
if raw, ok := tools[name]; ok {
|
||||
if err := json.Unmarshal(raw, &fields); err != nil || fields == nil {
|
||||
return fmt.Errorf("%s[%q] must be an object to update approval_mode", key, name)
|
||||
}
|
||||
}
|
||||
mode, err := json.Marshal(strings.TrimSpace(policy.ApprovalMode))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s[%q].approval_mode: %w", key, name, err)
|
||||
}
|
||||
fields["approval_mode"] = mode
|
||||
updated, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s[%q]: %w", key, name, err)
|
||||
}
|
||||
tools[name] = updated
|
||||
}
|
||||
|
||||
if len(tools) == 0 {
|
||||
delete(server, key)
|
||||
return nil
|
||||
|
||||
+18
-157
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -73,17 +72,26 @@ func TestLoadMCPJSONDecodesGB18030(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPJSONTrustedReadOnlyToolsRoundTrip(t *testing.T) {
|
||||
func TestMCPJSONDropsRemovedTrustedReadOnlyToolsSetting(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, mcpJSONFile)
|
||||
if err := os.WriteFile(path, []byte(`{"mcpServers":{"github":{"command":"old","trusted_read_only_tools":["issue_read"]}}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := UpsertMCPJSONPlugin(path, PluginEntry{
|
||||
Name: "github",
|
||||
Command: "npx",
|
||||
Args: []string{"-y", "@modelcontextprotocol/server-github"},
|
||||
TrustedReadOnlyTools: []string{"issue_read", "pull_request_read"},
|
||||
Name: "github",
|
||||
Command: "npx",
|
||||
Args: []string{"-y", "@modelcontextprotocol/server-github"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(body), "trusted_read_only_tools") {
|
||||
t.Fatalf("updated .mcp.json retained removed reader setting:\n%s", body)
|
||||
}
|
||||
got, err := loadMCPJSON(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -91,10 +99,6 @@ func TestMCPJSONTrustedReadOnlyToolsRoundTrip(t *testing.T) {
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("entries = %+v, want one github entry", got)
|
||||
}
|
||||
tools := got[0].TrustedReadOnlyTools
|
||||
if len(tools) != 2 || tools[0] != "issue_read" || tools[1] != "pull_request_read" {
|
||||
t.Fatalf("trusted read-only tools = %+v", tools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPJSONCallTimeoutsRoundTrip(t *testing.T) {
|
||||
@@ -155,32 +159,7 @@ func TestMCPJSONCallTimeoutsRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPJSONApprovalPolicyRoundTrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), mcpJSONFile)
|
||||
want := PluginEntry{
|
||||
Name: "admin",
|
||||
Command: "admin-mcp",
|
||||
DefaultToolsApprovalMode: "writes",
|
||||
Tools: map[string]MCPToolPolicy{
|
||||
"delete/all": {ApprovalMode: "prompt"},
|
||||
"status": {ApprovalMode: "approve"},
|
||||
},
|
||||
ApprovalsReviewer: "auto_review",
|
||||
}
|
||||
if _, err := UpsertMCPJSONPlugin(path, want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := loadMCPJSON(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].DefaultToolsApprovalMode != want.DefaultToolsApprovalMode ||
|
||||
got[0].ApprovalsReviewer != want.ApprovalsReviewer || !reflect.DeepEqual(got[0].Tools, want.Tools) {
|
||||
t.Fatalf("approval policy round trip = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPJSONApprovalPolicyUpdatePreservesNestedUnknownFields(t *testing.T) {
|
||||
func TestMCPJSONUpdateRemovesRetiredApprovalFieldsAndPreservesUnknownFields(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), mcpJSONFile)
|
||||
if err := os.WriteFile(path, []byte(`{
|
||||
"mcpServers": {
|
||||
@@ -199,26 +178,10 @@ func TestMCPJSONApprovalPolicyUpdatePreservesNestedUnknownFields(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := UpsertMCPJSONPlugin(path, PluginEntry{
|
||||
Name: "admin",
|
||||
Command: "admin-mcp",
|
||||
Tools: map[string]MCPToolPolicy{
|
||||
"wipe": {ApprovalMode: "approve"},
|
||||
"new": {ApprovalMode: "prompt"},
|
||||
},
|
||||
}); err != nil {
|
||||
if _, err := UpsertMCPJSONPlugin(path, PluginEntry{Name: "admin", Command: "admin-mcp"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entries, err := loadMCPJSON(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 1 || len(entries[0].Tools) != 2 ||
|
||||
entries[0].Tools["wipe"].ApprovalMode != "approve" || entries[0].Tools["new"].ApprovalMode != "prompt" {
|
||||
t.Fatalf("Reasonix tool policies = %+v, want only updated wipe and new", entries)
|
||||
}
|
||||
|
||||
root, servers, err := readMCPJSONRaw(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -237,8 +200,8 @@ func TestMCPJSONApprovalPolicyUpdatePreservesNestedUnknownFields(t *testing.T) {
|
||||
if err := json.Unmarshal(server["tools"], &tools); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tools) != 4 {
|
||||
t.Fatalf("raw tools = %+v, want wipe, new, external_only, and remove_keep", tools)
|
||||
if len(tools) != 3 {
|
||||
t.Fatalf("raw tools = %+v, want wipe, external_only, and remove_keep", tools)
|
||||
}
|
||||
if _, ok := tools["wipe"]["enabled"]; !ok {
|
||||
t.Fatal("known tool lost external enabled field")
|
||||
@@ -260,26 +223,6 @@ func TestMCPJSONApprovalPolicyUpdatePreservesNestedUnknownFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPJSONApprovalPolicyRejectsNonObjectToolUpdate(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), mcpJSONFile)
|
||||
if err := os.WriteFile(path, []byte(`{
|
||||
"mcpServers": {
|
||||
"admin": {"command": "admin-mcp", "tools": {"wipe": false}}
|
||||
}
|
||||
}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := UpsertMCPJSONPlugin(path, PluginEntry{
|
||||
Name: "admin",
|
||||
Command: "admin-mcp",
|
||||
Tools: map[string]MCPToolPolicy{"wipe": {ApprovalMode: "prompt"}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `tools["wipe"] must be an object`) {
|
||||
t.Fatalf("non-object tool update error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePluginCommandLine(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -445,88 +388,6 @@ command = "local-bin"
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadForRootRestrictsProjectMCPApprovalEscalation(t *testing.T) {
|
||||
isolateUserConfigHome(t)
|
||||
writeConfigTestFile(t, UserConfigPath(), `
|
||||
[[plugins]]
|
||||
name = "user-global"
|
||||
command = "global-server"
|
||||
default_tools_approval_mode = "approve"
|
||||
approvals_reviewer = "auto_review"
|
||||
|
||||
[plugins.tools.wipe]
|
||||
approval_mode = "approve"
|
||||
|
||||
[[plugins]]
|
||||
name = "shared"
|
||||
command = "global-shared"
|
||||
default_tools_approval_mode = "prompt"
|
||||
approvals_reviewer = "user"
|
||||
|
||||
[plugins.tools.wipe]
|
||||
approval_mode = "prompt"
|
||||
`)
|
||||
root := t.TempDir()
|
||||
writeConfigTestFile(t, filepath.Join(root, "reasonix.toml"), `
|
||||
[[plugins]]
|
||||
name = "project-toml"
|
||||
command = "project-server"
|
||||
default_tools_approval_mode = "approve"
|
||||
approvals_reviewer = "auto_review"
|
||||
|
||||
[plugins.tools.wipe]
|
||||
approval_mode = "approve"
|
||||
|
||||
[[plugins]]
|
||||
name = "shared"
|
||||
command = "project-shared"
|
||||
default_tools_approval_mode = "auto"
|
||||
approvals_reviewer = "auto_review"
|
||||
|
||||
[plugins.tools.wipe]
|
||||
approval_mode = "approve"
|
||||
`)
|
||||
writeConfigTestFile(t, filepath.Join(root, ".mcp.json"), `{
|
||||
"mcpServers": {
|
||||
"project-json": {
|
||||
"command": "json-server",
|
||||
"default_tools_approval_mode": "approve",
|
||||
"approvals_reviewer": "auto_review",
|
||||
"tools": {"wipe": {"approval_mode": "approve"}}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
cfg, err := LoadForRootReadOnly(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byName := make(map[string]PluginEntry, len(cfg.Plugins))
|
||||
for _, entry := range cfg.Plugins {
|
||||
byName[entry.Name] = entry
|
||||
}
|
||||
for _, name := range []string{"project-toml", "project-json"} {
|
||||
entry, ok := byName[name]
|
||||
if !ok {
|
||||
t.Fatalf("plugin %q missing from %+v", name, cfg.Plugins)
|
||||
}
|
||||
if entry.DefaultToolsApprovalMode != "auto" || entry.ApprovalsReviewer != "user" || entry.Tools["wipe"].ApprovalMode != "auto" {
|
||||
t.Fatalf("project plugin %q retained approval escalation: %+v", name, entry)
|
||||
}
|
||||
}
|
||||
global := byName["user-global"]
|
||||
if global.DefaultToolsApprovalMode != "approve" || global.ApprovalsReviewer != "auto_review" || global.Tools["wipe"].ApprovalMode != "approve" {
|
||||
t.Fatalf("user-global approval policy was restricted: %+v", global)
|
||||
}
|
||||
shared := byName["shared"]
|
||||
if shared.Command != "project-shared" {
|
||||
t.Fatalf("project connection settings did not win for shared plugin: %+v", shared)
|
||||
}
|
||||
if shared.DefaultToolsApprovalMode != "prompt" || shared.ApprovalsReviewer != "user" || shared.Tools["wipe"].ApprovalMode != "prompt" {
|
||||
t.Fatalf("project downgraded user-global approval policy for shared plugin: %+v", shared)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMergesPluginsAcrossTOMLSources(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("HOME", root)
|
||||
|
||||
@@ -225,11 +225,6 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
|
||||
b.WriteString("# recent_keep = 2 # minimum recent messages kept verbatim\n")
|
||||
}
|
||||
fmt.Fprintf(&b, "cold_resume_prune = %v # elide stale tool results when reopening a session past the provider cache window\n", c.ColdResumePruneEnabled())
|
||||
if len(c.Agent.PlanModeAllowedTools) > 0 {
|
||||
fmt.Fprintf(&b, "plan_mode_allowed_tools = %s # legacy MCP read-only aliases; does not change Plan availability\n", renderStringArray(c.Agent.PlanModeAllowedTools))
|
||||
} else {
|
||||
b.WriteString("# plan_mode_allowed_tools = [\"mcp__legacy__reader\"] # legacy MCP read-only alias; does not change Plan availability\n")
|
||||
}
|
||||
if len(c.Agent.PlanModeReadOnlyCommands) > 0 {
|
||||
fmt.Fprintf(&b, "plan_mode_read_only_commands = %s # legacy compatibility only; Plan bash uses Permissions\n", renderStringArray(c.Agent.PlanModeReadOnlyCommands))
|
||||
} else {
|
||||
@@ -705,9 +700,6 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
|
||||
b.WriteString("# command = \"reasonix-plugin-example\"\n")
|
||||
b.WriteString("# call_timeout_seconds = 600 # optional per-server MCP call timeout\n")
|
||||
b.WriteString("# tool_timeout_seconds = { \"generate_video\" = 1800 } # raw MCP tool names\n")
|
||||
b.WriteString("# default_tools_approval_mode = \"auto\" # auto|prompt|writes|approve\n")
|
||||
b.WriteString("# tools = { \"delete_all\" = { approval_mode = \"prompt\" } }\n")
|
||||
b.WriteString("# approvals_reviewer = \"user\" # user|auto_review\n")
|
||||
b.WriteString("# [[plugins]] # a remote server over Streamable HTTP\n")
|
||||
b.WriteString("# name = \"stripe\"\n")
|
||||
b.WriteString("# type = \"http\"\n")
|
||||
@@ -743,19 +735,6 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
|
||||
b.WriteString("# Raw MCP tool names with per-tool call timeouts.\n")
|
||||
fmt.Fprintf(&b, "tool_timeout_seconds = %s\n", renderIntMap(pl.ToolTimeoutSeconds))
|
||||
}
|
||||
if len(pl.TrustedReadOnlyTools) > 0 {
|
||||
b.WriteString("# explicit Plan/read-only-research declaration for audited raw MCP reader names\n")
|
||||
fmt.Fprintf(&b, "trusted_read_only_tools = %s\n", renderStringArray(pl.TrustedReadOnlyTools))
|
||||
}
|
||||
if strings.TrimSpace(pl.DefaultToolsApprovalMode) != "" {
|
||||
fmt.Fprintf(&b, "default_tools_approval_mode = %q\n", pl.DefaultToolsApprovalMode)
|
||||
}
|
||||
if len(pl.Tools) > 0 {
|
||||
fmt.Fprintf(&b, "tools = %s\n", renderMCPToolPolicies(pl.Tools))
|
||||
}
|
||||
if strings.TrimSpace(pl.ApprovalsReviewer) != "" {
|
||||
fmt.Fprintf(&b, "approvals_reviewer = %q\n", pl.ApprovalsReviewer)
|
||||
}
|
||||
if pl.AutoStart != nil {
|
||||
fmt.Fprintf(&b, "auto_start = %v\n", *pl.AutoStart)
|
||||
}
|
||||
@@ -898,10 +877,6 @@ func RenderTOMLProjectDelta(c *Config) string {
|
||||
fmt.Fprintf(&agentBuf, "cold_resume_prune = %v\n", c.ColdResumePruneEnabled())
|
||||
anyAgent = true
|
||||
}
|
||||
if len(c.Agent.PlanModeAllowedTools) > 0 && !reflect.DeepEqual(c.Agent.PlanModeAllowedTools, d.Agent.PlanModeAllowedTools) {
|
||||
fmt.Fprintf(&agentBuf, "plan_mode_allowed_tools = %s\n", renderStringArray(c.Agent.PlanModeAllowedTools))
|
||||
anyAgent = true
|
||||
}
|
||||
if len(c.Agent.PlanModeReadOnlyCommands) > 0 && !reflect.DeepEqual(c.Agent.PlanModeReadOnlyCommands, d.Agent.PlanModeReadOnlyCommands) {
|
||||
fmt.Fprintf(&agentBuf, "plan_mode_read_only_commands = %s\n", renderStringArray(c.Agent.PlanModeReadOnlyCommands))
|
||||
anyAgent = true
|
||||
@@ -1171,19 +1146,6 @@ func RenderTOMLProjectDelta(c *Config) string {
|
||||
b.WriteString("# Raw MCP tool names with per-tool call timeouts.\n")
|
||||
fmt.Fprintf(&b, "tool_timeout_seconds = %s\n", renderIntMap(pl.ToolTimeoutSeconds))
|
||||
}
|
||||
if len(pl.TrustedReadOnlyTools) > 0 {
|
||||
b.WriteString("# explicit Plan/read-only-research declaration for audited raw MCP reader names\n")
|
||||
fmt.Fprintf(&b, "trusted_read_only_tools = %s\n", renderStringArray(pl.TrustedReadOnlyTools))
|
||||
}
|
||||
if strings.TrimSpace(pl.DefaultToolsApprovalMode) != "" {
|
||||
fmt.Fprintf(&b, "default_tools_approval_mode = %q\n", pl.DefaultToolsApprovalMode)
|
||||
}
|
||||
if len(pl.Tools) > 0 {
|
||||
fmt.Fprintf(&b, "tools = %s\n", renderMCPToolPolicies(pl.Tools))
|
||||
}
|
||||
if strings.TrimSpace(pl.ApprovalsReviewer) != "" {
|
||||
fmt.Fprintf(&b, "approvals_reviewer = %q\n", pl.ApprovalsReviewer)
|
||||
}
|
||||
if pl.AutoStart != nil {
|
||||
fmt.Fprintf(&b, "auto_start = %v\n", *pl.AutoStart)
|
||||
}
|
||||
@@ -1420,26 +1382,6 @@ func renderStringMap(m map[string]string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderMCPToolPolicies(policies map[string]MCPToolPolicy) string {
|
||||
keys := make([]string, 0, len(policies))
|
||||
for name := range policies {
|
||||
if strings.TrimSpace(name) != "" {
|
||||
keys = append(keys, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var b strings.Builder
|
||||
b.WriteString("{ ")
|
||||
for i, name := range keys {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
fmt.Fprintf(&b, "%s = { approval_mode = %q }", renderTOMLKeyPart(name), policies[name].ApprovalMode)
|
||||
}
|
||||
b.WriteString(" }")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderAnyMap(m map[string]any) string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k, v := range m {
|
||||
|
||||
@@ -297,7 +297,7 @@ func TestRenderTOMLRoundTrips(t *testing.T) {
|
||||
}
|
||||
orig.Plugins = []PluginEntry{
|
||||
{Name: "example", Command: "reasonix-plugin-example"},
|
||||
{Name: "stripe", Type: "http", URL: "https://mcp.stripe.com", Headers: map[string]string{"Authorization": "Bearer x"}, TrustedReadOnlyTools: []string{"customer_read"}, AutoStart: boolPtr(false), Tier: "background"},
|
||||
{Name: "stripe", Type: "http", URL: "https://mcp.stripe.com", Headers: map[string]string{"Authorization": "Bearer x"}, AutoStart: boolPtr(false), Tier: "background"},
|
||||
}
|
||||
mm, _ := orig.Provider("mimo-pro")
|
||||
mm.BaseURL = "http://localhost:8000/v1"
|
||||
@@ -513,8 +513,8 @@ func TestRenderTOMLRoundTrips(t *testing.T) {
|
||||
if stripe.Headers["Authorization"] != "Bearer x" {
|
||||
t.Errorf("plugin headers not preserved: %v", stripe.Headers)
|
||||
}
|
||||
if len(stripe.TrustedReadOnlyTools) != 1 || stripe.TrustedReadOnlyTools[0] != "customer_read" {
|
||||
t.Errorf("plugin trusted_read_only_tools not preserved: %+v", stripe.TrustedReadOnlyTools)
|
||||
if strings.Contains(rendered, "trusted_read_only_tools") {
|
||||
t.Errorf("removed plugin reader setting survived render: entry=%+v\n%s", stripe, rendered)
|
||||
}
|
||||
if stripe.AutoStart == nil || *stripe.AutoStart {
|
||||
t.Errorf("auto_start should render and parse as false, got %+v", stripe.AutoStart)
|
||||
@@ -527,26 +527,15 @@ func TestRenderTOMLRoundTrips(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTOMLDocumentsPlanModeAllowedTools(t *testing.T) {
|
||||
func TestRenderTOMLDocumentsPlanModeReadOnlyCommands(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.Agent.PlanModeAllowedTools = []string{"custom_reader"}
|
||||
cfg.Agent.PlanModeReadOnlyCommands = []string{"gh issue view"}
|
||||
|
||||
rendered := RenderTOML(cfg)
|
||||
if !strings.Contains(rendered, `plan_mode_allowed_tools = ["custom_reader"]`) {
|
||||
t.Fatalf("rendered config should preserve plan_mode_allowed_tools:\n%s", rendered)
|
||||
}
|
||||
if !strings.Contains(rendered, "legacy MCP read-only aliases") || !strings.Contains(rendered, "does not change Plan availability") {
|
||||
t.Fatalf("rendered config should document legacy plan_mode_allowed_tools semantics:\n%s", rendered)
|
||||
}
|
||||
|
||||
var got Config
|
||||
if _, err := toml.Decode(rendered, &got); err != nil {
|
||||
t.Fatalf("rendered TOML does not parse: %v\n%s", err, rendered)
|
||||
}
|
||||
if !reflect.DeepEqual(got.Agent.PlanModeAllowedTools, cfg.Agent.PlanModeAllowedTools) {
|
||||
t.Fatalf("PlanModeAllowedTools round trip = %v, want %v", got.Agent.PlanModeAllowedTools, cfg.Agent.PlanModeAllowedTools)
|
||||
}
|
||||
if !strings.Contains(rendered, `plan_mode_read_only_commands = ["gh issue view"]`) {
|
||||
t.Fatalf("rendered config should preserve plan_mode_read_only_commands:\n%s", rendered)
|
||||
}
|
||||
@@ -558,29 +547,32 @@ func TestRenderTOMLDocumentsPlanModeAllowedTools(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTOMLPreservesLegacyPluginReadOnlyOverrides(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.Plugins = []PluginEntry{{
|
||||
Name: "github",
|
||||
Command: "github-mcp",
|
||||
TrustedReadOnlyTools: []string{"issue_read", "pull_request_read"},
|
||||
}}
|
||||
func TestRenderTOMLDropsRetiredMCPPolicyFields(t *testing.T) {
|
||||
var cfg Config
|
||||
if _, err := toml.Decode(`[[plugins]]
|
||||
name = "github"
|
||||
command = "github-mcp"
|
||||
trusted_read_only_tools = ["issue_read", "pull_request_read"]
|
||||
default_tools_approval_mode = "writes"
|
||||
approvals_reviewer = "auto_review"
|
||||
|
||||
rendered := RenderTOML(cfg)
|
||||
if !strings.Contains(rendered, `trusted_read_only_tools = ["issue_read", "pull_request_read"]`) {
|
||||
t.Fatalf("rendered config should preserve trusted_read_only_tools:\n%s", rendered)
|
||||
[plugins.tools.wipe]
|
||||
approval_mode = "prompt"
|
||||
`, &cfg); err != nil {
|
||||
t.Fatalf("legacy config should still decode: %v", err)
|
||||
}
|
||||
if !strings.Contains(rendered, "explicit Plan/read-only-research declaration for audited raw MCP reader names") {
|
||||
t.Fatalf("rendered config should document the legacy trusted_read_only_tools semantics:\n%s", rendered)
|
||||
|
||||
rendered := RenderTOML(&cfg)
|
||||
for _, retired := range []string{"trusted_read_only_tools", "default_tools_approval_mode", "approvals_reviewer", "\napproval_mode ="} {
|
||||
if strings.Contains(rendered, retired) {
|
||||
t.Fatalf("rendered config retained retired MCP field %q:\n%s", retired, rendered)
|
||||
}
|
||||
}
|
||||
|
||||
var got Config
|
||||
if _, err := toml.Decode(rendered, &got); err != nil {
|
||||
t.Fatalf("rendered TOML does not parse: %v\n%s", err, rendered)
|
||||
}
|
||||
if !reflect.DeepEqual(got.Plugins[0].TrustedReadOnlyTools, cfg.Plugins[0].TrustedReadOnlyTools) {
|
||||
t.Fatalf("TrustedReadOnlyTools round trip = %v, want %v", got.Plugins[0].TrustedReadOnlyTools, cfg.Plugins[0].TrustedReadOnlyTools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTOMLPreservesMCPCallTimeouts(t *testing.T) {
|
||||
@@ -623,39 +615,6 @@ func TestRenderTOMLPreservesMCPCallTimeouts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTOMLPreservesMCPApprovalPolicy(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.Plugins = []PluginEntry{{
|
||||
Name: "admin",
|
||||
Command: "admin-mcp",
|
||||
DefaultToolsApprovalMode: "writes",
|
||||
Tools: map[string]MCPToolPolicy{
|
||||
"delete/all": {ApprovalMode: "prompt"},
|
||||
"status": {ApprovalMode: "approve"},
|
||||
},
|
||||
ApprovalsReviewer: "auto_review",
|
||||
}}
|
||||
|
||||
rendered := RenderTOML(cfg)
|
||||
for _, want := range []string{
|
||||
`default_tools_approval_mode = "writes"`,
|
||||
`tools = { "delete/all" = { approval_mode = "prompt" }, status = { approval_mode = "approve" } }`,
|
||||
`approvals_reviewer = "auto_review"`,
|
||||
} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("rendered config missing %q:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
var got Config
|
||||
if _, err := toml.Decode(rendered, &got); err != nil {
|
||||
t.Fatalf("rendered TOML does not parse: %v\n%s", err, rendered)
|
||||
}
|
||||
if got.Plugins[0].DefaultToolsApprovalMode != "writes" || got.Plugins[0].ApprovalsReviewer != "auto_review" ||
|
||||
!reflect.DeepEqual(got.Plugins[0].Tools, cfg.Plugins[0].Tools) {
|
||||
t.Fatalf("approval policy round trip = %+v", got.Plugins[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTOMLCreationLayoutStyle(t *testing.T) {
|
||||
c := Default()
|
||||
if err := c.SetDesktopLayoutStyle("creation"); err != nil {
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/i18n"
|
||||
"reasonix/internal/permission"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
// approvalManager owns the approval/ask prompt bookkeeping and the runtime
|
||||
@@ -93,9 +92,7 @@ func BuildHeadlessApprovalGate(policy permission.Policy, mode string) *freshHuma
|
||||
switch normalizeToolApprovalMode(mode) {
|
||||
case ToolApprovalYolo:
|
||||
policy.Mode = permission.Allow
|
||||
gate := NewHeadlessPermissionGate(policy)
|
||||
gate.bypassMCPAuto = true
|
||||
return gate
|
||||
return NewHeadlessPermissionGate(policy)
|
||||
case ToolApprovalAuto:
|
||||
policy.Mode = permission.Allow
|
||||
return &freshHumanHeadlessGate{gate: permission.NewGate(policy, denyPermissionApprover{})}
|
||||
@@ -151,23 +148,15 @@ func (g *SharedHeadlessGate) Check(ctx context.Context, toolName string, args js
|
||||
return gate.Check(ctx, toolName, args, readOnly)
|
||||
}
|
||||
|
||||
func (g *SharedHeadlessGate) CheckFresh(ctx context.Context, toolName, subject string, args json.RawMessage, readOnly bool) (bool, string, error) {
|
||||
func (g *SharedHeadlessGate) ExplicitlyDenies(toolName string, args json.RawMessage) bool {
|
||||
g.mu.RLock()
|
||||
gate := g.gate
|
||||
g.mu.RUnlock()
|
||||
return gate.CheckFresh(ctx, toolName, subject, args, readOnly)
|
||||
}
|
||||
|
||||
func (g *SharedHeadlessGate) CheckMCP(ctx context.Context, toolName, subject string, args json.RawMessage, readOnly, destructive bool, mode, reviewer string) (bool, string, error) {
|
||||
g.mu.RLock()
|
||||
gate := g.gate
|
||||
g.mu.RUnlock()
|
||||
return gate.CheckMCP(ctx, toolName, subject, args, readOnly, destructive, mode, reviewer)
|
||||
return gate.ExplicitlyDenies(toolName, args)
|
||||
}
|
||||
|
||||
type freshHumanHeadlessGate struct {
|
||||
gate *permission.Gate
|
||||
bypassMCPAuto bool
|
||||
gate *permission.Gate
|
||||
}
|
||||
|
||||
func (g *freshHumanHeadlessGate) Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (bool, string, error) {
|
||||
@@ -177,15 +166,8 @@ func (g *freshHumanHeadlessGate) Check(ctx context.Context, toolName string, arg
|
||||
return g.gate.Check(ctx, toolName, args, readOnly)
|
||||
}
|
||||
|
||||
func (g *freshHumanHeadlessGate) CheckFresh(context.Context, string, string, json.RawMessage, bool) (bool, string, error) {
|
||||
return false, "this tool requires fresh human approval and cannot run in a non-interactive session.", nil
|
||||
}
|
||||
|
||||
func (g *freshHumanHeadlessGate) CheckMCP(ctx context.Context, toolName, subject string, args json.RawMessage, readOnly, destructive bool, mode, reviewer string) (bool, string, error) {
|
||||
if g.bypassMCPAuto && !destructive && tool.NormalizeMCPApprovalMode(mode) == tool.MCPApprovalAuto {
|
||||
reviewer = ""
|
||||
}
|
||||
return g.gate.CheckMCP(ctx, toolName, subject, args, readOnly, destructive, mode, reviewer)
|
||||
func (g *freshHumanHeadlessGate) ExplicitlyDenies(toolName string, args json.RawMessage) bool {
|
||||
return g.gate.Policy.ExplicitlyDenies(toolName, args)
|
||||
}
|
||||
|
||||
// preApproved reports whether a tool call can skip the prompt — either the
|
||||
|
||||
@@ -62,7 +62,7 @@ func (c *Controller) routeCapabilities(routeInput string) capability.RouteDecisi
|
||||
// auto_start=false servers contribute concrete mcp-tool candidates to
|
||||
// deterministic and semantic routing before any connection exists.
|
||||
opts.CachedTools = c.capCachedTools
|
||||
opts.CacheHashOK = c.capCacheHashOK
|
||||
opts.CacheKeyOK = c.capCacheKeyOK
|
||||
opts.ProxyTools = proxyTools
|
||||
if h := c.Host(); h != nil {
|
||||
opts.Connected = map[string]bool{}
|
||||
@@ -119,7 +119,7 @@ func (c *Controller) WireCapabilityRouting(plugins []config.PluginEntry, specs [
|
||||
return
|
||||
}
|
||||
c.pluginCfg = append([]config.PluginEntry(nil), plugins...)
|
||||
c.capCachedTools, c.capCacheHashOK = capability.LoadCachedToolsForSpecs(specs)
|
||||
c.capCachedTools, c.capCacheKeyOK = capability.LoadCachedToolsForSpecs(specs)
|
||||
c.semanticRouter = router
|
||||
c.capabilityAudit = audit
|
||||
}
|
||||
+17
-173
@@ -147,7 +147,7 @@ type Controller struct {
|
||||
// prefix; only seeds the turn-scoped ledger and optional semantic router.
|
||||
pluginCfg []config.PluginEntry
|
||||
capCachedTools map[string][]plugin.CachedTool
|
||||
capCacheHashOK map[string]bool
|
||||
capCacheKeyOK map[string]bool
|
||||
semanticRouter *capability.SemanticRouter
|
||||
capabilityAudit *capability.Audit
|
||||
runtimeProfile capability.Profile
|
||||
@@ -423,10 +423,6 @@ type Options struct {
|
||||
// OnSessionRecovered is called after a stale runtime's transcript has been
|
||||
// saved as a recovery branch, before the controller commits to that branch.
|
||||
OnSessionRecovered func(SessionRecoveryInfo) error
|
||||
// PlanModeAllowedTools is retained for legacy config/controller data. MCP
|
||||
// assembly may translate concrete entries into local read-only trust, but the
|
||||
// field does not grant or revoke calls in the main Plan workflow.
|
||||
PlanModeAllowedTools []string
|
||||
// ApprovalTimeout bounds how long a tool-approval or ask prompt blocks waiting
|
||||
// for a user decision. Zero (default) waits forever — right for an interactive
|
||||
// terminal. Bot/headless frontends set a positive value so an unanswered
|
||||
@@ -4541,23 +4537,20 @@ func (c *Controller) connectMCPServer(e config.PluginEntry) (int, error) {
|
||||
exp := e.ExpandedPlugin()
|
||||
configSource := strings.TrimSpace(string(exp.Source))
|
||||
spec := plugin.ApplyKnownOverrides(plugin.Spec{
|
||||
Name: exp.Name,
|
||||
Type: exp.Type,
|
||||
Command: exp.Command,
|
||||
Args: exp.Args,
|
||||
Env: exp.Env,
|
||||
URL: exp.URL,
|
||||
Headers: exp.Headers,
|
||||
DefaultCallTimeout: c.mcpDefaultCallTimeout,
|
||||
CallTimeout: controllerMCPTimeout(exp.CallTimeoutSeconds),
|
||||
ToolTimeouts: controllerMCPToolTimeouts(exp.ToolTimeoutSeconds),
|
||||
ReadOnlyToolNames: trustedReadOnlyToolNames(exp.TrustedReadOnlyTools),
|
||||
DefaultToolsApprovalMode: exp.DefaultToolsApprovalMode,
|
||||
ToolApprovalModes: controllerMCPToolApprovalModes(exp.Tools),
|
||||
ApprovalsReviewer: exp.ApprovalsReviewer,
|
||||
ConfigSource: configSource,
|
||||
ImplicitApproval: exp.Source.UserAuthorized(),
|
||||
RequireLaunchApproval: exp.Source.RequiresLaunchApproval(),
|
||||
Name: exp.Name,
|
||||
Type: exp.Type,
|
||||
Command: exp.Command,
|
||||
Args: exp.Args,
|
||||
Env: exp.Env,
|
||||
URL: exp.URL,
|
||||
Headers: exp.Headers,
|
||||
DefaultCallTimeout: c.mcpDefaultCallTimeout,
|
||||
CallTimeout: controllerMCPTimeout(exp.CallTimeoutSeconds),
|
||||
ToolTimeouts: controllerMCPToolTimeouts(exp.ToolTimeoutSeconds),
|
||||
WorkspaceRoot: c.WorkspaceRoot(),
|
||||
ConfigSource: configSource,
|
||||
Authorized: exp.Source.UserAuthorized(),
|
||||
RequireLaunchApproval: exp.Source.RequiresLaunchApproval(),
|
||||
}, c.WorkspaceRoot())
|
||||
if c.mcpConfigureSpec != nil {
|
||||
c.mcpConfigureSpec(&spec)
|
||||
@@ -4588,39 +4581,6 @@ func controllerMCPToolTimeouts(values map[string]int) map[string]time.Duration {
|
||||
return out
|
||||
}
|
||||
|
||||
func controllerMCPToolApprovalModes(policies map[string]config.MCPToolPolicy) map[string]string {
|
||||
if len(policies) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(policies))
|
||||
for name, policy := range policies {
|
||||
if name = strings.TrimSpace(name); name != "" {
|
||||
out[name] = policy.ApprovalMode
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func trustedReadOnlyToolNames(names []string) map[string]bool {
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for _, name := range names {
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" {
|
||||
out[name] = true
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ImportMCPEntries persists selected MCP entries and attempts to connect them
|
||||
// live. A connection failure does not roll back the config import: the user can
|
||||
// fix local dependencies and reconnect in a later session.
|
||||
@@ -5067,112 +5027,6 @@ func (g gateApprover) ApproveWithReason(ctx context.Context, tool, subject strin
|
||||
return allow, remember, "", err
|
||||
}
|
||||
|
||||
func (g gateApprover) ApproveFresh(ctx context.Context, toolName, subject string, args json.RawMessage) (bool, string, error) {
|
||||
target := strings.TrimSpace(subject)
|
||||
if target == "" {
|
||||
target = toolName
|
||||
}
|
||||
reply, err := g.c.requestStrictFreshApprovalDecision(
|
||||
ctx,
|
||||
toolName,
|
||||
fmt.Sprintf(i18n.M.MCPDestructiveSubjectFmt, target),
|
||||
args,
|
||||
i18n.M.MCPDestructiveReason,
|
||||
)
|
||||
if err != nil {
|
||||
return false, "approval aborted", err
|
||||
}
|
||||
if !reply.allow {
|
||||
return false, i18n.M.MCPDestructiveDeclined, nil
|
||||
}
|
||||
return true, "", nil
|
||||
}
|
||||
|
||||
func (g gateApprover) ApproveMCP(ctx context.Context, toolName, subject string, args json.RawMessage, destructive, forced bool, reviewer string) (bool, string, error) {
|
||||
reviewer = tool.NormalizeMCPApprovalReviewer(reviewer)
|
||||
subject = approvalDisplaySubject(toolName, subject, args)
|
||||
if !forced && g.c.approval.preApproved(toolName, subject, args) {
|
||||
return true, "", nil
|
||||
}
|
||||
// A destructive MCP call routed here by the permission gate requires a fresh
|
||||
// user decision. This check deliberately precedes Guardian/auto_review so no
|
||||
// reviewer, session grant, Auto, or YOLO posture can authorize it.
|
||||
if destructive {
|
||||
return g.ApproveFresh(ctx, toolName, subject, args)
|
||||
}
|
||||
if reviewer == tool.MCPApprovalReviewerAutoReview {
|
||||
if g.c.guardianSess != nil && g.c.executor != nil {
|
||||
allow, reason, err := g.c.guardianSess.ReviewVerdict(ctx, toolName, args, g.c.executor.Session())
|
||||
if err == nil {
|
||||
// A successful verdict — allow or deny — is final for auto_review
|
||||
// and never falls back.
|
||||
return allow, reason, nil
|
||||
}
|
||||
slog.Warn("automatic MCP approval review unavailable; falling back to fresh human approval", "tool", toolName, "err", err)
|
||||
}
|
||||
// Missing reviewer/parent, timeout, transport failure, or indeterminate
|
||||
// review degrades to a decision only a present human can make: Auto/YOLO,
|
||||
// the approved-plan window, and session grants must not answer it.
|
||||
// Non-interactive sessions never reach here — their gates fail closed
|
||||
// before an approver is consulted.
|
||||
target := strings.TrimSpace(subject)
|
||||
if target == "" {
|
||||
target = toolName
|
||||
}
|
||||
reply, err := g.c.requestStrictFreshApprovalDecision(ctx, toolName, target, args, i18n.M.MCPReviewerUnavailableReason)
|
||||
if err != nil {
|
||||
return false, "approval aborted", err
|
||||
}
|
||||
if !reply.allow {
|
||||
return false, i18n.M.MCPReviewerUnavailableDeclined, nil
|
||||
}
|
||||
return true, "", nil
|
||||
}
|
||||
|
||||
// An explicit MCP prompt/write policy outranks global Auto/YOLO. The legacy
|
||||
// empty-reviewer path still lets Guardian approve low-risk calls or provide
|
||||
// context for a final human decision.
|
||||
var reason string
|
||||
if reviewer == "" && g.c.guardianSess != nil {
|
||||
if g.c.executor == nil {
|
||||
return false, "automatic MCP approval review is configured, but no parent session is available", nil
|
||||
}
|
||||
allow, reviewReason, err := g.c.guardianSess.Review(ctx, toolName, args, g.c.executor.Session())
|
||||
if err != nil {
|
||||
return false, "automatic MCP approval review failed", err
|
||||
}
|
||||
if allow {
|
||||
return true, "", nil
|
||||
}
|
||||
reason = reviewReason
|
||||
}
|
||||
target := strings.TrimSpace(subject)
|
||||
if target == "" {
|
||||
target = toolName
|
||||
}
|
||||
if !forced {
|
||||
allow, _, err := g.c.requestApprovalWithReason(ctx, toolName, target, args, reason)
|
||||
if err != nil {
|
||||
return false, "approval aborted", err
|
||||
}
|
||||
if !allow && strings.TrimSpace(reason) == "" {
|
||||
reason = "the user declined this MCP tool call"
|
||||
}
|
||||
return allow, reason, nil
|
||||
}
|
||||
reply, err := g.c.requestStrictFreshApprovalDecision(ctx, toolName, target, args, reason)
|
||||
if err != nil {
|
||||
return false, "approval aborted", err
|
||||
}
|
||||
if !reply.allow {
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
reason = "the user declined this MCP tool call"
|
||||
}
|
||||
return false, reason, nil
|
||||
}
|
||||
return true, "", nil
|
||||
}
|
||||
|
||||
type planModeReadOnlyTrustApprover struct{ c *Controller }
|
||||
|
||||
type sandboxEscapeApprover struct{ c *Controller }
|
||||
@@ -5720,28 +5574,18 @@ func (c *Controller) requestFreshApprovalDecision(ctx context.Context, tool, sub
|
||||
return c.requestApprovalDecisionWithOptions(ctx, tool, subject, args, reason, approvalDecisionOptions{fresh: true})
|
||||
}
|
||||
|
||||
// requestStrictFreshApprovalDecision requires a new click for this invocation.
|
||||
// Unlike other fresh business decisions, it cannot reuse a session grant.
|
||||
func (c *Controller) requestStrictFreshApprovalDecision(ctx context.Context, tool, subject string, args json.RawMessage, reason string) (approvalReply, error) {
|
||||
return c.requestApprovalDecisionWithOptions(ctx, tool, subject, args, reason, approvalDecisionOptions{fresh: true, alwaysPrompt: true})
|
||||
}
|
||||
|
||||
type approvalDecisionOptions struct {
|
||||
// fresh marks a user trust/business decision rather than an ordinary tool
|
||||
// permission. It may reuse an explicit session grant, but YOLO/auto approval
|
||||
// must not answer or drain the prompt.
|
||||
fresh bool
|
||||
// alwaysPrompt prevents even an explicit session grant from satisfying the
|
||||
// decision. Destructive MCP calls use this because every invocation needs a
|
||||
// current human approval.
|
||||
alwaysPrompt bool
|
||||
}
|
||||
|
||||
func (c *Controller) requestApprovalDecisionWithOptions(ctx context.Context, tool, subject string, args json.RawMessage, reason string, opts approvalDecisionOptions) (approvalReply, error) {
|
||||
// YOLO/full access and the just-approved-plan execution window auto-allow
|
||||
// approval-gated tools without prompting. Plan approval is a user decision,
|
||||
// not a tool permission, so it deliberately stays interactive.
|
||||
if !opts.alwaysPrompt && c.approval.preApprovedForDecision(tool, subject, args, opts.fresh) {
|
||||
if c.approval.preApprovedForDecision(tool, subject, args, opts.fresh) {
|
||||
return approvalReply{allow: true}, nil
|
||||
}
|
||||
|
||||
@@ -5750,7 +5594,7 @@ func (c *Controller) requestApprovalDecisionWithOptions(ctx context.Context, too
|
||||
|
||||
// Re-check: a session grant may have landed while we queued behind another
|
||||
// prompt for the same subject.
|
||||
if !opts.alwaysPrompt && c.approval.preApprovedForDecision(tool, subject, args, opts.fresh) {
|
||||
if c.approval.preApprovedForDecision(tool, subject, args, opts.fresh) {
|
||||
return approvalReply{allow: true}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2473,6 +2473,7 @@ func TestDisconnectMCPServerRemovesLazyPlaceholder(t *testing.T) {
|
||||
func TestAddMCPServerAuthorizesExplicitUserAddBeforeConnecting(t *testing.T) {
|
||||
var configured plugin.Spec
|
||||
c := New(Options{
|
||||
WorkspaceRoot: "/workspace",
|
||||
MCPConfigureSpec: func(spec *plugin.Spec) { configured = *spec },
|
||||
})
|
||||
|
||||
@@ -2480,7 +2481,7 @@ func TestAddMCPServerAuthorizesExplicitUserAddBeforeConnecting(t *testing.T) {
|
||||
t.Fatal("AddMCPServer without a command unexpectedly succeeded")
|
||||
}
|
||||
if configured.ConfigSource != string(config.MCPSourceUserConfig) ||
|
||||
!configured.ImplicitApproval || configured.RequireLaunchApproval {
|
||||
!configured.Authorized || configured.RequireLaunchApproval || configured.WorkspaceRoot != "/workspace" {
|
||||
t.Fatalf("configured spec = %+v, want user-authorized add-and-use policy", configured)
|
||||
}
|
||||
}
|
||||
@@ -3135,140 +3136,6 @@ func TestSessionGrantShortCircuitsGuardianReview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPAutoReviewerIsFinalAndDoesNotPromptHuman(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
outcome string
|
||||
risk string
|
||||
allow bool
|
||||
}{
|
||||
{name: "allow", outcome: "allow", risk: "low", allow: true},
|
||||
{name: "deny", outcome: "deny", risk: "high", allow: false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
guardianProv := &recordingProvider{
|
||||
name: "guardian",
|
||||
streams: [][]provider.Chunk{textTurn(fmt.Sprintf(`{"risk_level":%q,"user_authorization":"unknown","outcome":%q,"rationale":"reviewed"}`, tc.risk, tc.outcome))},
|
||||
}
|
||||
guardianSess := guardian.NewSession(guardianProv, tool.NewRegistry(), guardian.PolicyPrompt(), "guardian-test", 0, nil, event.Discard)
|
||||
exec := agent.New(&recordingProvider{name: "executor"}, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
|
||||
prompts := 0
|
||||
c := New(Options{
|
||||
Executor: exec,
|
||||
Guardian: guardianSess,
|
||||
Sink: event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.ApprovalRequest {
|
||||
prompts++
|
||||
}
|
||||
}),
|
||||
})
|
||||
gate := permission.NewGate(permission.New("allow", nil, nil, nil), gateApprover{c})
|
||||
allow, _, err := gate.CheckMCP(context.Background(), "mcp__srv__write", "srv/write", json.RawMessage(`{"target":"one"}`), false, false, "prompt", "auto_review")
|
||||
if err != nil || allow != tc.allow || prompts != 0 || len(guardianProv.requests) != 1 {
|
||||
t.Fatalf("auto reviewer result allow=%v err=%v prompts=%d reviews=%d", allow, err, prompts, len(guardianProv.requests))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPAutoModeGlobalAllowSkipsMissingReviewer: approval_mode=auto inherits
|
||||
// the global posture. When the global policy already allows the call, no
|
||||
// reviewer runs at all — this is mode inheritance, not a reviewer fallback.
|
||||
func TestMCPAutoModeGlobalAllowSkipsMissingReviewer(t *testing.T) {
|
||||
prompts := 0
|
||||
c := New(Options{Sink: event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.ApprovalRequest {
|
||||
prompts++
|
||||
}
|
||||
})})
|
||||
c.SetToolApprovalMode(ToolApprovalAuto)
|
||||
gate := permission.NewGate(permission.New("allow", nil, nil, nil), gateApprover{c})
|
||||
allow, reason, err := gate.CheckMCP(context.Background(), "mcp__srv__write", "srv/write", nil, false, false, "auto", "auto_review")
|
||||
if err != nil || !allow || reason != "" || prompts != 0 {
|
||||
t.Fatalf("globally allowed auto call = (%v,%q,%v), prompts=%d", allow, reason, err, prompts)
|
||||
}
|
||||
}
|
||||
|
||||
type erroringProvider struct{ requests int }
|
||||
|
||||
func (p *erroringProvider) Name() string { return "guardian-down" }
|
||||
|
||||
func (p *erroringProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
|
||||
p.requests++
|
||||
return nil, fmt.Errorf("guardian transport down")
|
||||
}
|
||||
|
||||
// TestMCPAutoReviewerUnavailableDegradesToStrictFreshHuman: when auto_review is
|
||||
// configured but the reviewer errors out, the call degrades to a fresh human
|
||||
// decision. Session grants must neither answer it nor be minted by it, so a
|
||||
// second identical call prompts again.
|
||||
func TestMCPAutoReviewerUnavailableDegradesToStrictFreshHuman(t *testing.T) {
|
||||
guardianProv := &erroringProvider{}
|
||||
guardianSess := guardian.NewSession(guardianProv, tool.NewRegistry(), guardian.PolicyPrompt(), "guardian-test", 0, nil, event.Discard)
|
||||
exec := agent.New(&recordingProvider{name: "executor"}, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
|
||||
approvals := make(chan event.Approval, 2)
|
||||
c := New(Options{
|
||||
Executor: exec,
|
||||
Guardian: guardianSess,
|
||||
Sink: event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.ApprovalRequest {
|
||||
approvals <- e.Approval
|
||||
}
|
||||
}),
|
||||
})
|
||||
gate := permission.NewGate(permission.New("ask", nil, nil, nil), gateApprover{c})
|
||||
type result struct {
|
||||
allow bool
|
||||
reason string
|
||||
err error
|
||||
}
|
||||
run := func() chan result {
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
allow, reason, err := gate.CheckMCP(context.Background(), "mcp__srv__write", "srv/write", json.RawMessage(`{"target":"one"}`), false, false, "auto", "auto_review")
|
||||
done <- result{allow: allow, reason: reason, err: err}
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
done := run()
|
||||
var approval event.Approval
|
||||
select {
|
||||
case approval = <-approvals:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("reviewer failure did not degrade to a human prompt")
|
||||
}
|
||||
// Answer with a session grant: the strict decision must not persist it.
|
||||
c.Approve(approval.ID, true, true, false)
|
||||
select {
|
||||
case got := <-done:
|
||||
if got.err != nil || !got.allow {
|
||||
t.Fatalf("approved degraded call = %+v", got)
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("degraded approval never completed")
|
||||
}
|
||||
|
||||
done = run()
|
||||
select {
|
||||
case approval = <-approvals:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("second call reused a grant; strict fresh approval must prompt every time")
|
||||
}
|
||||
c.Approve(approval.ID, false, false, false)
|
||||
select {
|
||||
case got := <-done:
|
||||
if got.err != nil || got.allow || !strings.Contains(got.reason, "reviewer was unavailable") {
|
||||
t.Fatalf("declined degraded call = %+v", got)
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("degraded decline never completed")
|
||||
}
|
||||
if guardianProv.requests != 2 {
|
||||
t.Fatalf("guardian consulted %d times, want 2", guardianProv.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessGateRefusesFreshHumanApprovalTools(t *testing.T) {
|
||||
gate := NewHeadlessPermissionGate(permission.New("ask", nil, nil, nil))
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package control
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -97,31 +96,6 @@ func TestApplyHeadlessApprovalModeYoloBypassesAskRule(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessYoloOnlyBypassesAutoMCPPolicy(t *testing.T) {
|
||||
policy := permission.New("ask", nil, []string{"mcp__srv__write"}, nil)
|
||||
yolo := BuildHeadlessApprovalGate(policy, ToolApprovalYolo)
|
||||
allow, _, err := yolo.CheckMCP(context.Background(), "mcp__srv__write", "srv/write", nil, false, false, "auto", "auto_review")
|
||||
if err != nil || !allow {
|
||||
t.Fatalf("YOLO auto MCP result = (%v,%v), want bypass", allow, err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
mode string
|
||||
destructive bool
|
||||
}{
|
||||
{mode: "prompt"},
|
||||
} {
|
||||
allow, reason, err := yolo.CheckMCP(context.Background(), "mcp__srv__write", "srv/write", nil, false, tc.destructive, tc.mode, "auto_review")
|
||||
if err != nil || allow || !strings.Contains(reason, "non-interactive") {
|
||||
t.Fatalf("mode=%s destructive=%v result=(%v,%q,%v), want fail closed", tc.mode, tc.destructive, allow, reason, err)
|
||||
}
|
||||
}
|
||||
|
||||
allow, reason, err := yolo.CheckMCP(context.Background(), "mcp__srv__write", "srv/write", nil, false, true, "approve", "auto_review")
|
||||
if err != nil || !allow || reason != "" {
|
||||
t.Fatalf("approve destructive result=(%v,%q,%v), want allow without reviewer", allow, reason, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyHeadlessApprovalModeDontAskDeniesWithoutPrompting checks that dontAsk
|
||||
// denies a would-ask tool through the non-blocking deny approver rather than
|
||||
// emitting a prompt or hanging.
|
||||
|
||||
@@ -41,34 +41,6 @@ func TestReplayPendingPromptsReEmitsBlockedApproval(t *testing.T) {
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestReplayPendingPromptsPreservesFreshApproval(t *testing.T) {
|
||||
reqs := make(chan event.Approval, 8)
|
||||
c := New(Options{Sink: event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.ApprovalRequest {
|
||||
reqs <- e.Approval
|
||||
}
|
||||
})})
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
_, _, _ = (gateApprover{c}).ApproveFresh(context.Background(), "mcp__srv__wipe", "srv/wipe", nil)
|
||||
}()
|
||||
|
||||
first := <-reqs
|
||||
if !first.Fresh {
|
||||
t.Fatalf("first request = %+v, want fresh approval", first)
|
||||
}
|
||||
c.ReplayPendingPrompts()
|
||||
replayed := <-reqs
|
||||
if replayed != first || !replayed.Fresh {
|
||||
t.Fatalf("replayed = %+v, want identical fresh re-emit of %+v", replayed, first)
|
||||
}
|
||||
|
||||
c.Approve(first.ID, false, false, false)
|
||||
<-done
|
||||
}
|
||||
|
||||
// TestReplayPendingPromptsReEmitsBlockedAsk proves the same for a blocked `ask`
|
||||
// question, including its question payload (which the controller now retains).
|
||||
func TestReplayPendingPromptsReEmitsBlockedAsk(t *testing.T) {
|
||||
|
||||
@@ -529,110 +529,6 @@ func TestSandboxEscapeApprovalIgnoresAutoApproveTools(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDestructiveMCPApprovalAlwaysRequiresCurrentHumanDecision(t *testing.T) {
|
||||
for _, mode := range []string{ToolApprovalAuto, ToolApprovalYolo} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
approvals := make(chan event.Approval, 2)
|
||||
remembered := 0
|
||||
c := New(Options{
|
||||
Sink: event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.ApprovalRequest {
|
||||
approvals <- e.Approval
|
||||
}
|
||||
}),
|
||||
OnRemember: func(string) RememberResult {
|
||||
remembered++
|
||||
return RememberResult{Saved: true}
|
||||
},
|
||||
})
|
||||
c.SetToolApprovalMode(mode)
|
||||
|
||||
call := func() <-chan struct {
|
||||
allow bool
|
||||
reason string
|
||||
err error
|
||||
} {
|
||||
done := make(chan struct {
|
||||
allow bool
|
||||
reason string
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
allow, reason, err := (gateApprover{c}).ApproveFresh(context.Background(), "mcp__srv__wipe", "srv/wipe", json.RawMessage(`{"target":"all"}`))
|
||||
done <- struct {
|
||||
allow bool
|
||||
reason string
|
||||
err error
|
||||
}{allow: allow, reason: reason, err: err}
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
for invocation := 1; invocation <= 2; invocation++ {
|
||||
done := call()
|
||||
var approval event.Approval
|
||||
select {
|
||||
case approval = <-approvals:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatalf("invocation %d did not request approval in %s mode", invocation, mode)
|
||||
}
|
||||
if !approval.Fresh || approval.Tool != "mcp__srv__wipe" {
|
||||
t.Fatalf("approval = %+v, want fresh destructive MCP request", approval)
|
||||
}
|
||||
select {
|
||||
case got := <-done:
|
||||
t.Fatalf("%s mode auto-answered destructive MCP approval: %+v", mode, got)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
// Session/persistent flags from an old frontend must be ignored.
|
||||
c.Approve(approval.ID, true, true, true)
|
||||
select {
|
||||
case got := <-done:
|
||||
if got.err != nil || !got.allow || got.reason != "" {
|
||||
t.Fatalf("destructive MCP approval = %+v, want one-shot allow", got)
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("destructive MCP approval stayed blocked after manual approval")
|
||||
}
|
||||
}
|
||||
if remembered != 0 {
|
||||
t.Fatalf("persistent authorization callbacks = %d, want 0", remembered)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDestructiveMCPExplicitDenySkipsFreshPrompt(t *testing.T) {
|
||||
approvals := make(chan event.Approval, 1)
|
||||
c := New(Options{Sink: event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.ApprovalRequest {
|
||||
approvals <- e.Approval
|
||||
}
|
||||
})})
|
||||
gate := permission.NewGate(permission.New("allow", nil, nil, []string{"mcp__srv__wipe"}), gateApprover{c})
|
||||
|
||||
allow, reason, err := gate.CheckFresh(context.Background(), "mcp__srv__wipe", "srv/wipe", nil, false)
|
||||
if err != nil || allow || !strings.Contains(reason, "deny list") {
|
||||
t.Fatalf("explicit deny result = (%v,%q,%v), want policy denial", allow, reason, err)
|
||||
}
|
||||
select {
|
||||
case approval := <-approvals:
|
||||
t.Fatalf("explicit deny emitted approval prompt: %+v", approval)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessAutoAndYoloRefuseDestructiveMCPFreshApproval(t *testing.T) {
|
||||
for _, mode := range []string{ToolApprovalAuto, ToolApprovalYolo} {
|
||||
gate := BuildHeadlessApprovalGate(permission.New("allow", nil, nil, nil), mode)
|
||||
allow, reason, err := gate.CheckFresh(context.Background(), "mcp__srv__wipe", "srv/wipe", nil, false)
|
||||
if err != nil || allow || !strings.Contains(reason, "fresh human approval") {
|
||||
t.Fatalf("%s headless fresh check = (%v,%q,%v), want refusal", mode, allow, reason, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAutoApproveToolsDoesNotDrainPendingPlanApproval(t *testing.T) {
|
||||
approvalRequests := make(chan event.Approval, 1)
|
||||
c := New(Options{
|
||||
|
||||
@@ -118,11 +118,6 @@ type Messages struct {
|
||||
MemoryApprovalSaveUpdate string // subject prefix for remember approval
|
||||
MemoryApprovalBodyLabel string // label before the body excerpt in remember approval
|
||||
MemoryApprovalArchiveFmt string // subject for forget approval, %q = memory name
|
||||
MCPDestructiveSubjectFmt string // subject for destructive MCP approval, target
|
||||
MCPDestructiveReason string // reason for destructive MCP approval
|
||||
MCPDestructiveDeclined string // model-facing denial after destructive MCP rejection
|
||||
MCPReviewerUnavailableReason string // reason for fresh approval when auto_review is unavailable
|
||||
MCPReviewerUnavailableDeclined string // model-facing denial after reviewer-unavailable rejection
|
||||
PlanModeBashTrustSubjectFmt string // subject for bash read-only prefix trust approval, prefix + command
|
||||
PlanModeBashTrustReason string // reason for bash read-only prefix trust approval
|
||||
PlanModeBashTrustDeclined string // model-facing denial after bash read-only prefix rejection
|
||||
|
||||
@@ -100,11 +100,6 @@ var English = Messages{
|
||||
MemoryApprovalSaveUpdate: "Save/update memory",
|
||||
MemoryApprovalBodyLabel: "body",
|
||||
MemoryApprovalArchiveFmt: "Archive memory %q",
|
||||
MCPDestructiveSubjectFmt: "MCP %s declares destructive side effects",
|
||||
MCPDestructiveReason: "This installed MCP tool declares destructive side effects. Review the target and arguments before allowing this call. Auto/YOLO approval cannot answer this decision.",
|
||||
MCPDestructiveDeclined: "the user declined this destructive MCP tool call - do not retry it; ask how they would like to proceed.",
|
||||
MCPReviewerUnavailableReason: "The configured automatic approval reviewer is unavailable or returned no verdict. This call needs a fresh human decision; Auto/YOLO approval and session grants cannot answer it.",
|
||||
MCPReviewerUnavailableDeclined: "the user declined this MCP tool call after the automatic reviewer was unavailable - do not retry it; ask how they would like to proceed.",
|
||||
PlanModeBashTrustSubjectFmt: "Trust %q as a read-only command prefix while planning\nCommand: %s",
|
||||
PlanModeBashTrustReason: "This bash command is not in Reasonix's built-in read-only set. Confirm only if this exact prefix is read-only for planning and research. Auto/YOLO approval cannot answer this trust prompt.",
|
||||
PlanModeBashTrustDeclined: "the user declined to trust this bash command as read-only for plan mode - do not retry it; continue with other trusted read-only tools or ask how to proceed.",
|
||||
|
||||
@@ -101,11 +101,6 @@ var Chinese = Messages{
|
||||
MemoryApprovalSaveUpdate: "保存/更新记忆",
|
||||
MemoryApprovalBodyLabel: "正文",
|
||||
MemoryApprovalArchiveFmt: "归档记忆 %q",
|
||||
MCPDestructiveSubjectFmt: "MCP %s 声明包含破坏性副作用",
|
||||
MCPDestructiveReason: "这个已安装的 MCP 工具声明包含破坏性副作用。允许前请核对目标和参数;自动/YOLO 审批不能代答这个决定。",
|
||||
MCPDestructiveDeclined: "用户拒绝了这个破坏性 MCP 工具调用;不要重试,请询问用户希望如何继续。",
|
||||
MCPReviewerUnavailableReason: "配置的自动审批 reviewer 不可用或未给出结论。本次调用需要用户当场决定;自动/YOLO 审批与会话授权不能代答。",
|
||||
MCPReviewerUnavailableDeclined: "自动 reviewer 不可用后,用户拒绝了这个 MCP 工具调用;不要重试,请询问用户希望如何继续。",
|
||||
PlanModeBashTrustSubjectFmt: "在计划模式中信任 %q 为只读命令前缀\n命令:%s",
|
||||
PlanModeBashTrustReason: "这条 bash 命令不在 Reasonix 内置只读集合中。只有在确认这个精确前缀用于计划和研究时是只读的,才应批准。自动/YOLO 审批不能回答这个信任提示。",
|
||||
PlanModeBashTrustDeclined: "用户拒绝将这条 bash 命令信任为计划模式只读命令;不要重试它,请继续使用其它已信任的只读工具,或询问用户希望如何继续。",
|
||||
|
||||
@@ -97,11 +97,6 @@ var ChineseTraditional = Messages{
|
||||
MemoryApprovalSaveUpdate: "儲存/更新記憶",
|
||||
MemoryApprovalBodyLabel: "正文",
|
||||
MemoryApprovalArchiveFmt: "封存記憶 %q",
|
||||
MCPDestructiveSubjectFmt: "MCP %s 宣告包含破壞性副作用",
|
||||
MCPDestructiveReason: "這個已安裝的 MCP 工具宣告包含破壞性副作用。允許前請核對目標和參數;自動/YOLO 核准不能代答這個決定。",
|
||||
MCPDestructiveDeclined: "使用者拒絕了這個破壞性 MCP 工具呼叫;不要重試,請詢問使用者希望如何繼續。",
|
||||
MCPReviewerUnavailableReason: "設定的自動核准 reviewer 不可用或未給出結論。這次呼叫需要使用者當場決定;自動/YOLO 核准與工作階段授權不能代答。",
|
||||
MCPReviewerUnavailableDeclined: "自動 reviewer 不可用後,使用者拒絕了這個 MCP 工具呼叫;不要重試,請詢問使用者希望如何繼續。",
|
||||
PlanModeBashTrustSubjectFmt: "在計劃模式中信任 %q 為唯讀命令前綴\n命令:%s",
|
||||
PlanModeBashTrustReason: "這條 bash 命令不在 Reasonix 內建唯讀集合中。只有在確認這個精確前綴用於計劃和研究時是唯讀的,才應核准。自動/YOLO 核准不能回答這個信任提示。",
|
||||
PlanModeBashTrustDeclined: "使用者拒絕將這條 bash 命令信任為計劃模式唯讀命令;不要重試它,請繼續使用其他已信任的唯讀工具,或詢問使用者希望如何繼續。",
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestPluginGitCommandDisablesLineEndingConversion(t *testing.T) {
|
||||
cmd := pluginGitCommand(context.Background(), "clone", "https://example.test/repo.git")
|
||||
joined := strings.Join(cmd.Args, " ")
|
||||
if !strings.Contains(joined, "-c core.autocrlf=false clone") {
|
||||
t.Fatalf("plugin git command does not preserve signed package bytes: %v", cmd.Args)
|
||||
t.Fatalf("plugin git command does not preserve approved source bytes: %v", cmd.Args)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,11 +632,6 @@ func TestPlanLocalMCPJSON(t *testing.T) {
|
||||
if resp.Actions[1].Name != "remote" || resp.Actions[1].Transport != "http" {
|
||||
t.Fatalf("second action = %+v", resp.Actions[1])
|
||||
}
|
||||
if resp.Actions[1].DefaultToolsApprovalMode != "writes" ||
|
||||
resp.Actions[1].ToolPolicies["wipe"].ApprovalMode != "prompt" ||
|
||||
resp.Actions[1].ApprovalsReviewer != "auto_review" {
|
||||
t.Fatalf("MCP approval policy missing from planned action: %+v", resp.Actions[1])
|
||||
}
|
||||
for _, action := range resp.Actions {
|
||||
if action.Scope != "global" || action.ConfigPath != config.UserConfigPath() {
|
||||
t.Fatalf("local .mcp.json outside project action scope/path = %q %q, want global %q", action.Scope, action.ConfigPath, config.UserConfigPath())
|
||||
@@ -712,7 +707,7 @@ func TestPlanMCPJSONDefaultTierIsBackground(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanMCPJSONPreservesApprovalPolicy(t *testing.T) {
|
||||
func TestPlanMCPJSONIgnoresRetiredApprovalPolicy(t *testing.T) {
|
||||
entries, warnings, err := parseMCPJSON([]byte(`{
|
||||
"mcpServers": {
|
||||
"admin": {
|
||||
@@ -730,11 +725,8 @@ func TestPlanMCPJSONPreservesApprovalPolicy(t *testing.T) {
|
||||
t.Fatalf("parseMCPJSON: entries=%+v warnings=%v err=%v", entries, warnings, err)
|
||||
}
|
||||
got := entries[0]
|
||||
if got.CallTimeoutSeconds != 45 || got.ToolTimeoutSeconds["wipe"] != 120 ||
|
||||
len(got.TrustedReadOnlyTools) != 1 || got.TrustedReadOnlyTools[0] != "status" ||
|
||||
got.DefaultToolsApprovalMode != "writes" || len(got.Tools) != 1 || got.Tools["wipe"].ApprovalMode != "prompt" ||
|
||||
got.ApprovalsReviewer != "auto_review" {
|
||||
t.Fatalf("advanced MCP config was dropped: %+v", got)
|
||||
if got.CallTimeoutSeconds != 45 || got.ToolTimeoutSeconds["wipe"] != 120 {
|
||||
t.Fatalf("MCP timeout config was dropped: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1567,21 +1559,6 @@ func TestPlanIDIncludesActionDetails(t *testing.T) {
|
||||
if computePlanID(req, []action{a}) == computePlanID(req, []action{b}) {
|
||||
t.Fatal("planId should change when action URL changes")
|
||||
}
|
||||
b = a
|
||||
b.DefaultToolsApprovalMode = "approve"
|
||||
if computePlanID(req, []action{a}) == computePlanID(req, []action{b}) {
|
||||
t.Fatal("planId should change when default MCP approval policy changes")
|
||||
}
|
||||
b = a
|
||||
b.ToolPolicies = map[string]config.MCPToolPolicy{"wipe": {ApprovalMode: "approve"}}
|
||||
if computePlanID(req, []action{a}) == computePlanID(req, []action{b}) {
|
||||
t.Fatal("planId should change when per-tool MCP approval policy changes")
|
||||
}
|
||||
b = a
|
||||
b.ApprovalsReviewer = "auto_review"
|
||||
if computePlanID(req, []action{a}) == computePlanID(req, []action{b}) {
|
||||
t.Fatal("planId should change when MCP approval reviewer changes")
|
||||
}
|
||||
}
|
||||
|
||||
// --- sanitizers / parsers ---------------------------------------------------
|
||||
@@ -1637,23 +1614,6 @@ func TestValidateMCPEntry(t *testing.T) {
|
||||
if err := validateMCPEntry(config.PluginEntry{Name: "x", Type: "carrier-pigeon", Command: "c"}); err == nil {
|
||||
t.Error("unknown transport should fail")
|
||||
}
|
||||
if err := validateMCPEntry(config.PluginEntry{
|
||||
Name: "x", Command: "y",
|
||||
DefaultToolsApprovalMode: "writes",
|
||||
Tools: map[string]config.MCPToolPolicy{"wipe": {ApprovalMode: "prompt"}},
|
||||
ApprovalsReviewer: "auto_review",
|
||||
}); err != nil {
|
||||
t.Errorf("valid approval policy should validate at plan time, got %v", err)
|
||||
}
|
||||
if err := validateMCPEntry(config.PluginEntry{Name: "x", Command: "y", DefaultToolsApprovalMode: "banana"}); err == nil {
|
||||
t.Error("unknown default_tools_approval_mode should fail at plan time, before the server is connected")
|
||||
}
|
||||
if err := validateMCPEntry(config.PluginEntry{Name: "x", Command: "y", Tools: map[string]config.MCPToolPolicy{"wipe": {ApprovalMode: "sometimes"}}}); err == nil {
|
||||
t.Error("unknown per-tool approval_mode should fail at plan time")
|
||||
}
|
||||
if err := validateMCPEntry(config.PluginEntry{Name: "x", Command: "y", ApprovalsReviewer: "robot"}); err == nil {
|
||||
t.Error("unknown approvals_reviewer should fail at plan time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputePlanIDStable(t *testing.T) {
|
||||
|
||||
@@ -34,22 +34,19 @@ func (t *installSourceTool) mcpEntryAction(req request, e config.PluginEntry, so
|
||||
norm, ok := normalizeTier(desired)
|
||||
e.Tier = norm
|
||||
a := action{
|
||||
Kind: "mcp",
|
||||
Action: "install_mcp_server",
|
||||
Name: e.Name,
|
||||
Source: source,
|
||||
ConfigPath: t.configPath(scope),
|
||||
Scope: scope,
|
||||
Transport: pluginTransport(e),
|
||||
URL: e.URL,
|
||||
Command: e.Command,
|
||||
Args: e.Args,
|
||||
Env: e.Env,
|
||||
Headers: e.Headers,
|
||||
DefaultToolsApprovalMode: e.DefaultToolsApprovalMode,
|
||||
ToolPolicies: mcpToolPoliciesWithApprovalMode(e.Tools),
|
||||
ApprovalsReviewer: e.ApprovalsReviewer,
|
||||
entry: e,
|
||||
Kind: "mcp",
|
||||
Action: "install_mcp_server",
|
||||
Name: e.Name,
|
||||
Source: source,
|
||||
ConfigPath: t.configPath(scope),
|
||||
Scope: scope,
|
||||
Transport: pluginTransport(e),
|
||||
URL: e.URL,
|
||||
Command: e.Command,
|
||||
Args: e.Args,
|
||||
Env: e.Env,
|
||||
Headers: e.Headers,
|
||||
entry: e,
|
||||
}
|
||||
if !ok && strings.TrimSpace(desired) != "" {
|
||||
a.RiskReasons = append(a.RiskReasons, fmt.Sprintf("tier %q is unknown; treating as background", desired))
|
||||
@@ -177,20 +174,16 @@ func readMCPJSON(path string) ([]config.PluginEntry, []string, error) {
|
||||
func parseMCPJSON(b []byte) ([]config.PluginEntry, []string, error) {
|
||||
var raw struct {
|
||||
MCPServers map[string]struct {
|
||||
Type string `json:"type"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Env map[string]string `json:"env"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
AutoStart *bool `json:"auto_start"`
|
||||
CallTimeoutSeconds int `json:"call_timeout_seconds"`
|
||||
ToolTimeoutSeconds map[string]int `json:"tool_timeout_seconds"`
|
||||
TrustedReadOnlyTools []string `json:"trusted_read_only_tools"`
|
||||
DefaultToolsApprovalMode string `json:"default_tools_approval_mode"`
|
||||
Tools map[string]config.MCPToolPolicy `json:"tools"`
|
||||
ApprovalsReviewer string `json:"approvals_reviewer"`
|
||||
Tier string `json:"tier"`
|
||||
Type string `json:"type"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Env map[string]string `json:"env"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
AutoStart *bool `json:"auto_start"`
|
||||
CallTimeoutSeconds int `json:"call_timeout_seconds"`
|
||||
ToolTimeoutSeconds map[string]int `json:"tool_timeout_seconds"`
|
||||
Tier string `json:"tier"`
|
||||
} `json:"mcpServers"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
@@ -227,21 +220,17 @@ func parseMCPJSON(b []byte) ([]config.PluginEntry, []string, error) {
|
||||
warnings = append(warnings, fmt.Sprintf("%s: tier %q is unknown; treating as background", name, s.Tier))
|
||||
}
|
||||
e := config.PluginEntry{
|
||||
Name: name,
|
||||
Type: typ,
|
||||
Command: strings.TrimSpace(s.Command),
|
||||
Args: append([]string(nil), s.Args...),
|
||||
Env: cleanMap(s.Env),
|
||||
URL: strings.TrimSpace(s.URL),
|
||||
Headers: cleanMap(s.Headers),
|
||||
AutoStart: s.AutoStart,
|
||||
CallTimeoutSeconds: s.CallTimeoutSeconds,
|
||||
ToolTimeoutSeconds: s.ToolTimeoutSeconds,
|
||||
TrustedReadOnlyTools: append([]string(nil), s.TrustedReadOnlyTools...),
|
||||
DefaultToolsApprovalMode: s.DefaultToolsApprovalMode,
|
||||
Tools: mcpToolPoliciesWithApprovalMode(s.Tools),
|
||||
ApprovalsReviewer: s.ApprovalsReviewer,
|
||||
Tier: tier,
|
||||
Name: name,
|
||||
Type: typ,
|
||||
Command: strings.TrimSpace(s.Command),
|
||||
Args: append([]string(nil), s.Args...),
|
||||
Env: cleanMap(s.Env),
|
||||
URL: strings.TrimSpace(s.URL),
|
||||
Headers: cleanMap(s.Headers),
|
||||
AutoStart: s.AutoStart,
|
||||
CallTimeoutSeconds: s.CallTimeoutSeconds,
|
||||
ToolTimeoutSeconds: s.ToolTimeoutSeconds,
|
||||
Tier: tier,
|
||||
}
|
||||
// An empty Type is the canonical "stdio" form; keep it that way so
|
||||
// the rest of the config layer (UpsertPlugin / ShouldAutoStart)
|
||||
@@ -262,22 +251,6 @@ func parseMCPJSON(b []byte) ([]config.PluginEntry, []string, error) {
|
||||
return out, warnings, nil
|
||||
}
|
||||
|
||||
func mcpToolPoliciesWithApprovalMode(values map[string]config.MCPToolPolicy) map[string]config.MCPToolPolicy {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]config.MCPToolPolicy, len(values))
|
||||
for name, policy := range values {
|
||||
if strings.TrimSpace(policy.ApprovalMode) != "" {
|
||||
out[name] = policy
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// validateMCPEntry enforces the per-transport required fields. It is the
|
||||
// last line of defense before a server entry is persisted.
|
||||
func validateMCPEntry(e config.PluginEntry) error {
|
||||
@@ -307,32 +280,5 @@ func validateMCPEntry(e config.PluginEntry) error {
|
||||
default:
|
||||
return newErr(ErrInvalidManifest, "MCP server %q has unknown transport %q", e.Name, e.Type)
|
||||
}
|
||||
// Reject invalid approval policy at plan time. Persisting is validated again
|
||||
// by config, but that happens only after the server was already connected.
|
||||
if !validApprovalModeValue(e.DefaultToolsApprovalMode) {
|
||||
return newErr(ErrInvalidManifest, "MCP server %q has unknown default_tools_approval_mode %q (want auto|prompt|writes|approve)", e.Name, e.DefaultToolsApprovalMode)
|
||||
}
|
||||
for tool, policy := range e.Tools {
|
||||
if strings.TrimSpace(tool) == "" {
|
||||
return newErr(ErrInvalidManifest, "MCP server %q tools contains an empty tool name", e.Name)
|
||||
}
|
||||
if !validApprovalModeValue(policy.ApprovalMode) {
|
||||
return newErr(ErrInvalidManifest, "MCP server %q tools[%q].approval_mode must be auto|prompt|writes|approve", e.Name, tool)
|
||||
}
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(e.ApprovalsReviewer)) {
|
||||
case "", "user", "auto_review":
|
||||
default:
|
||||
return newErr(ErrInvalidManifest, "MCP server %q has unknown approvals_reviewer %q (want user|auto_review)", e.Name, e.ApprovalsReviewer)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validApprovalModeValue(mode string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "", "auto", "prompt", "writes", "approve":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@ package installsource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -14,10 +12,7 @@ import (
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/mcpcatalog"
|
||||
"reasonix/internal/pluginpkg"
|
||||
"reasonix/internal/proc"
|
||||
"reasonix/internal/secrets"
|
||||
@@ -464,8 +459,6 @@ func (t *installSourceTool) applyInstallPluginPackage(ctx context.Context, req r
|
||||
}
|
||||
if act.Mode == "link" {
|
||||
installed.Root = sourceRoot
|
||||
} else if verification, ok := verifyInstalledPluginCatalog(ctx, installed, target, pkg.ManifestKind); ok {
|
||||
installed.Verification = verification
|
||||
}
|
||||
if err := pluginpkg.Upsert(t.reasonixHome, installed); err != nil {
|
||||
return err
|
||||
@@ -481,37 +474,6 @@ func (t *installSourceTool) applyInstallPluginPackage(ctx context.Context, req r
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyInstalledPluginCatalog(ctx context.Context, installed pluginpkg.InstalledPlugin, root, manifestKind string) (*pluginpkg.Verification, bool) {
|
||||
packageDigest, err := mcpcatalog.TreeSHA256(root)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
manifestPath := filepath.Join(root, pluginpkg.NativeManifest)
|
||||
switch manifestKind {
|
||||
case "codex":
|
||||
manifestPath = filepath.Join(root, pluginpkg.CodexManifest)
|
||||
case "claude":
|
||||
manifestPath = filepath.Join(root, pluginpkg.ClaudeManifest)
|
||||
}
|
||||
manifestBody, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
manifestSum := sha256.Sum256(manifestBody)
|
||||
result, err := (mcpcatalog.Loader{CacheDir: config.CacheDir()}).Load(ctx, false)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
entry, ok := result.Index.Match(installed.Name, installed.Version, installed.Source, installed.Commit, packageDigest)
|
||||
if !ok || !strings.EqualFold(entry.ManifestSHA256, hex.EncodeToString(manifestSum[:])) {
|
||||
return nil, false
|
||||
}
|
||||
return &pluginpkg.Verification{
|
||||
CatalogEntryID: entry.ID, Commit: installed.Commit, PackageSHA256: packageDigest,
|
||||
VerifiedAt: time.Now().UTC(), CatalogSequence: result.Index.Sequence,
|
||||
}, true
|
||||
}
|
||||
|
||||
func (t *installSourceTool) preparePluginSource(ctx context.Context, source, mode string) (string, string, func(), error) {
|
||||
source = strings.TrimSpace(source)
|
||||
if strings.HasPrefix(source, "git:github.com/") {
|
||||
@@ -594,8 +556,8 @@ func checkoutPluginCommit(ctx context.Context, cloneRoot, commit string) error {
|
||||
|
||||
func pluginGitCommand(ctx context.Context, args ...string) *exec.Cmd {
|
||||
// Preserve repository bytes across platforms. A user's global autocrlf
|
||||
// setting must not rewrite JSON/scripts on Windows and make the installed
|
||||
// tree differ from the catalog's signed package digest.
|
||||
// setting must not rewrite JSON/scripts on Windows after the user approved
|
||||
// the exact source commit.
|
||||
gitArgs := append([]string{"-c", "core.autocrlf=false"}, args...)
|
||||
cmd := exec.CommandContext(ctx, "git", gitArgs...)
|
||||
cmd.Env = secrets.ProcessEnv()
|
||||
|
||||
@@ -70,48 +70,45 @@ type kindTally struct {
|
||||
// dispatcher; RiskLevel/RiskReasons help the calling skill decide whether to
|
||||
// ask the user before apply=true.
|
||||
type action struct {
|
||||
Kind string `json:"kind"` // "skill" | "mcp" | "plugin"
|
||||
Action string `json:"action"` // copy_skill|link_skill|register_skill_root|install_mcp_server|remove_skill|remove_skill_root|remove_mcp_server
|
||||
Status string `json:"status"` // planned|done|failed
|
||||
RiskLevel RiskLevel `json:"riskLevel"` // low|medium|high
|
||||
RiskReasons []string `json:"riskReasons,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
ConfigPath string `json:"configPath,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
DefaultToolsApprovalMode string `json:"defaultToolsApprovalMode,omitempty"`
|
||||
ToolPolicies map[string]config.MCPToolPolicy `json:"tools,omitempty"`
|
||||
ApprovalsReviewer string `json:"approvalsReviewer,omitempty"`
|
||||
Skills []string `json:"skills,omitempty"`
|
||||
SkillCount int `json:"skillCount,omitempty"`
|
||||
Agents []string `json:"agents,omitempty"`
|
||||
AgentCount int `json:"agentCount,omitempty"`
|
||||
Commands []string `json:"commands,omitempty"`
|
||||
CommandCount int `json:"commandCount,omitempty"`
|
||||
Commit string `json:"commit,omitempty"` // resolved git snapshot the plan describes; apply pins to it
|
||||
Layout string `json:"layout,omitempty"` // canonical_dir|flat_compat|registered_root
|
||||
InstallRoot string `json:"installRoot,omitempty"` // skills root or registered custom root
|
||||
CanonicalPath string `json:"canonicalPath,omitempty"` // <skill-name>/SKILL.md when applicable
|
||||
Discoverable bool `json:"discoverable,omitempty"` // Store.Read can load it after apply/register
|
||||
Indexed bool `json:"indexed,omitempty"` // Store.List includes it for the skills index
|
||||
ToolCount int `json:"toolCount,omitempty"`
|
||||
Compatibility string `json:"compatibility,omitempty"`
|
||||
MappedCapabilities []string `json:"mappedCapabilities,omitempty"`
|
||||
SkippedCapabilities []pluginpkg.CompatibilityIssue `json:"skippedCapabilities,omitempty"`
|
||||
HookCount int `json:"hookCount,omitempty"`
|
||||
ManifestKind string `json:"manifestKind,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Next string `json:"next,omitempty"`
|
||||
Kind string `json:"kind"` // "skill" | "mcp" | "plugin"
|
||||
Action string `json:"action"` // copy_skill|link_skill|register_skill_root|install_mcp_server|remove_skill|remove_skill_root|remove_mcp_server
|
||||
Status string `json:"status"` // planned|done|failed
|
||||
RiskLevel RiskLevel `json:"riskLevel"` // low|medium|high
|
||||
RiskReasons []string `json:"riskReasons,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
ConfigPath string `json:"configPath,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Skills []string `json:"skills,omitempty"`
|
||||
SkillCount int `json:"skillCount,omitempty"`
|
||||
Agents []string `json:"agents,omitempty"`
|
||||
AgentCount int `json:"agentCount,omitempty"`
|
||||
Commands []string `json:"commands,omitempty"`
|
||||
CommandCount int `json:"commandCount,omitempty"`
|
||||
Commit string `json:"commit,omitempty"` // resolved git snapshot the plan describes; apply pins to it
|
||||
Layout string `json:"layout,omitempty"` // canonical_dir|flat_compat|registered_root
|
||||
InstallRoot string `json:"installRoot,omitempty"` // skills root or registered custom root
|
||||
CanonicalPath string `json:"canonicalPath,omitempty"` // <skill-name>/SKILL.md when applicable
|
||||
Discoverable bool `json:"discoverable,omitempty"` // Store.Read can load it after apply/register
|
||||
Indexed bool `json:"indexed,omitempty"` // Store.List includes it for the skills index
|
||||
ToolCount int `json:"toolCount,omitempty"`
|
||||
Compatibility string `json:"compatibility,omitempty"`
|
||||
MappedCapabilities []string `json:"mappedCapabilities,omitempty"`
|
||||
SkippedCapabilities []pluginpkg.CompatibilityIssue `json:"skippedCapabilities,omitempty"`
|
||||
HookCount int `json:"hookCount,omitempty"`
|
||||
ManifestKind string `json:"manifestKind,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Next string `json:"next,omitempty"`
|
||||
|
||||
// Internal state used by apply. Stripped by publicActions before
|
||||
// serializing to JSON.
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"sequence": 0,
|
||||
"generated_at": "2026-07-15T00:00:00Z",
|
||||
"entries": []
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
untrusted comment: signature from private key: 1AB246842CC91DE1
|
||||
RWThHckshEayGtiKcqbc+6GS3Pzm+qafl7S8UCVcl0htjRuQ8YYBjWzZ2nuy2jDNfIL03EfwQX2ma99S63vCTCg6jSK1YTuJuAA=
|
||||
trusted comment: timestamp:1784092277
|
||||
lvLtjYghN+WULAQl9zDFerRhxy/O/o2wUV6tn4u1plO0FgHDc9jUhh8dvJYCXc76Z+IaSmGt9JimqHK++ETGAg==
|
||||
@@ -1,528 +0,0 @@
|
||||
// Package mcpcatalog verifies and caches the signed Reasonix MCP plugin catalog.
|
||||
// Catalog data is host-local policy and never enters provider-visible prompts or
|
||||
// tool definitions.
|
||||
package mcpcatalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"aead.dev/minisign"
|
||||
|
||||
"reasonix/internal/fileutil"
|
||||
)
|
||||
|
||||
const (
|
||||
SchemaVersion = 1
|
||||
CatalogURL = "https://dl.reasonix.io/plugins/catalog/v1/index.json"
|
||||
SignatureURL = CatalogURL + ".minisig"
|
||||
maxCatalogSize = 4 << 20
|
||||
staleAfter = 30 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// Dedicated catalog key. This is intentionally distinct from the desktop
|
||||
// release-signing key. Additional keys may be appended during an app release
|
||||
// for rotation; remote catalog data can never add a trust root.
|
||||
var PublicKeys = []string{
|
||||
`untrusted comment: minisign public key: 1AB246842CC91DE1
|
||||
RWThHckshEayGlxrbXAAaeyiQeXLwmMPSzVyh11HwJ1VCKXWMWCyJCqo`,
|
||||
}
|
||||
|
||||
var validEntryID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._@+-]{0,191}$`)
|
||||
var validCommit = regexp.MustCompile(`^[A-Fa-f0-9]{40}([A-Fa-f0-9]{24})?$`)
|
||||
|
||||
var runtimeCatalog struct {
|
||||
sync.RWMutex
|
||||
revoked map[string]bool
|
||||
}
|
||||
|
||||
//go:embed catalog-v1.json catalog-v1.json.minisig
|
||||
var bundledFS embed.FS
|
||||
|
||||
type Index struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Sequence uint64 `json:"sequence"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
Entries []Entry `json:"entries"`
|
||||
Revocations []Revocation `json:"revocations,omitempty"`
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Source string `json:"source"`
|
||||
Commit string `json:"commit"`
|
||||
PackageSHA256 string `json:"package_sha256"`
|
||||
ManifestSHA256 string `json:"manifest_sha256"`
|
||||
Servers []Server `json:"mcp_servers,omitempty"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Name string `json:"name"`
|
||||
Transport string `json:"transport"`
|
||||
Network bool `json:"network"`
|
||||
Readers []string `json:"readers,omitempty"`
|
||||
}
|
||||
|
||||
type Revocation struct {
|
||||
EntryID string `json:"entry_id"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type Source string
|
||||
|
||||
const (
|
||||
SourceRemote Source = "remote"
|
||||
SourceCached Source = "cached"
|
||||
SourceBundled Source = "bundled"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
Index Index
|
||||
Source Source
|
||||
Offline bool
|
||||
Stale bool
|
||||
}
|
||||
|
||||
type Loader struct {
|
||||
CacheDir string
|
||||
Client *http.Client
|
||||
CatalogURL string
|
||||
SignatureURL string
|
||||
Keys []string
|
||||
}
|
||||
|
||||
func CachePaths(cacheDir string) (string, string) {
|
||||
return filepath.Join(cacheDir, "mcp-catalog-v1.json"), filepath.Join(cacheDir, "mcp-catalog-v1.json.minisig")
|
||||
}
|
||||
|
||||
func cacheEnvelopePath(cacheDir string) string {
|
||||
return filepath.Join(cacheDir, "mcp-catalog-v1.lkg.json")
|
||||
}
|
||||
|
||||
type cacheEnvelope struct {
|
||||
Data []byte `json:"data"`
|
||||
Signature []byte `json:"signature"`
|
||||
}
|
||||
|
||||
func (l Loader) Load(ctx context.Context, refresh bool) (Result, error) {
|
||||
if refresh {
|
||||
if result, err := l.Refresh(ctx); err == nil {
|
||||
rememberRuntimeIndex(result.Index)
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
result, err := l.loadNewestLocal()
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
result.Offline = refresh
|
||||
result.Stale = catalogStale(result.Index, time.Now())
|
||||
rememberRuntimeIndex(result.Index)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (l Loader) Refresh(ctx context.Context) (Result, error) {
|
||||
client := l.Client
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
catalogURL := strings.TrimSpace(l.CatalogURL)
|
||||
if catalogURL == "" {
|
||||
catalogURL = CatalogURL
|
||||
}
|
||||
sigURL := strings.TrimSpace(l.SignatureURL)
|
||||
if sigURL == "" {
|
||||
sigURL = SignatureURL
|
||||
}
|
||||
data, err := fetch(ctx, client, catalogURL)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
sig, err := fetch(ctx, client, sigURL)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
idx, err := Verify(data, sig, l.keys())
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if local, localErr := l.loadNewestLocal(); localErr == nil && idx.Sequence < local.Index.Sequence {
|
||||
return Result{}, fmt.Errorf("MCP catalog sequence rollback: remote=%d local=%d source=%s", idx.Sequence, local.Index.Sequence, local.Source)
|
||||
}
|
||||
if strings.TrimSpace(l.CacheDir) != "" {
|
||||
body, marshalErr := json.Marshal(cacheEnvelope{Data: data, Signature: sig})
|
||||
if marshalErr != nil {
|
||||
return Result{}, marshalErr
|
||||
}
|
||||
// Store the verified pair in one atomic envelope. Two independent file
|
||||
// renames can crash between JSON and signature and destroy the LKG.
|
||||
if err := fileutil.AtomicWriteFile(cacheEnvelopePath(l.CacheDir), body, 0o600); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
}
|
||||
rememberRuntimeIndex(idx)
|
||||
return Result{Index: idx, Source: SourceRemote, Stale: catalogStale(idx, time.Now())}, nil
|
||||
}
|
||||
|
||||
func (l Loader) loadNewestLocal() (Result, error) {
|
||||
cached, cachedErr := l.loadCached()
|
||||
bundled, bundledErr := loadBundled(l.keys())
|
||||
switch {
|
||||
case cachedErr == nil && bundledErr == nil:
|
||||
return newestCatalogResult(cached, bundled), nil
|
||||
case cachedErr == nil:
|
||||
return cached, nil
|
||||
case bundledErr == nil:
|
||||
return bundled, nil
|
||||
default:
|
||||
return Result{}, fmt.Errorf("load local MCP catalog: cached: %v; bundled: %v", cachedErr, bundledErr)
|
||||
}
|
||||
}
|
||||
|
||||
func newestCatalogResult(a, b Result) Result {
|
||||
if b.Index.Sequence >= a.Index.Sequence {
|
||||
return b
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func catalogStale(index Index, now time.Time) bool {
|
||||
return index.GeneratedAt.IsZero() || now.Sub(index.GeneratedAt) > staleAfter
|
||||
}
|
||||
|
||||
func rememberRuntimeIndex(index Index) {
|
||||
revoked := make(map[string]bool, len(index.Revocations))
|
||||
for _, revocation := range index.Revocations {
|
||||
if entryID := strings.TrimSpace(revocation.EntryID); entryID != "" {
|
||||
revoked[entryID] = true
|
||||
}
|
||||
}
|
||||
runtimeCatalog.Lock()
|
||||
runtimeCatalog.revoked = revoked
|
||||
runtimeCatalog.Unlock()
|
||||
}
|
||||
|
||||
// RuntimeEntryRevoked lets a lazy MCP startup observe a catalog revocation
|
||||
// fetched after its Spec was composed but before its process connects.
|
||||
func RuntimeEntryRevoked(entryID string) bool {
|
||||
runtimeCatalog.RLock()
|
||||
defer runtimeCatalog.RUnlock()
|
||||
return runtimeCatalog.revoked[strings.TrimSpace(entryID)]
|
||||
}
|
||||
|
||||
func (idx Index) RevokedEntryIDs() map[string]bool {
|
||||
out := make(map[string]bool, len(idx.Revocations))
|
||||
for _, revocation := range idx.Revocations {
|
||||
if entryID := strings.TrimSpace(revocation.EntryID); entryID != "" {
|
||||
out[entryID] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (l Loader) loadCached() (Result, error) {
|
||||
if strings.TrimSpace(l.CacheDir) == "" {
|
||||
return Result{}, os.ErrNotExist
|
||||
}
|
||||
body, envelopeErr := os.ReadFile(cacheEnvelopePath(l.CacheDir))
|
||||
var data, sig []byte
|
||||
if envelopeErr == nil {
|
||||
var envelope cacheEnvelope
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return Result{}, fmt.Errorf("parse cached MCP catalog envelope: %w", err)
|
||||
}
|
||||
data, sig = envelope.Data, envelope.Signature
|
||||
} else {
|
||||
// Two-file caches are accepted only as a migration fallback from early
|
||||
// v1 builds. Every successful refresh rewrites the atomic envelope.
|
||||
dataPath, sigPath := CachePaths(l.CacheDir)
|
||||
var err error
|
||||
data, err = os.ReadFile(dataPath)
|
||||
if err != nil {
|
||||
return Result{}, envelopeErr
|
||||
}
|
||||
sig, err = os.ReadFile(sigPath)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
}
|
||||
idx, err := Verify(data, sig, l.keys())
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return Result{Index: idx, Source: SourceCached}, nil
|
||||
}
|
||||
|
||||
func (l Loader) keys() []string {
|
||||
if len(l.Keys) > 0 {
|
||||
return l.Keys
|
||||
}
|
||||
return PublicKeys
|
||||
}
|
||||
|
||||
func loadBundled(keys []string) (Result, error) {
|
||||
data, err := bundledFS.ReadFile("catalog-v1.json")
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
sig, err := bundledFS.ReadFile("catalog-v1.json.minisig")
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
idx, err := Verify(data, sig, keys)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("verify bundled MCP catalog: %w", err)
|
||||
}
|
||||
return Result{Index: idx, Source: SourceBundled}, nil
|
||||
}
|
||||
|
||||
func Verify(data, sig []byte, keys []string) (Index, error) {
|
||||
if len(data) == 0 || len(data) > maxCatalogSize {
|
||||
return Index{}, fmt.Errorf("invalid MCP catalog size %d", len(data))
|
||||
}
|
||||
verified := false
|
||||
var parseErrors []string
|
||||
for _, text := range keys {
|
||||
var key minisign.PublicKey
|
||||
if err := key.UnmarshalText([]byte(text)); err != nil {
|
||||
parseErrors = append(parseErrors, err.Error())
|
||||
continue
|
||||
}
|
||||
if minisign.Verify(key, data, sig) {
|
||||
verified = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !verified {
|
||||
if len(parseErrors) == len(keys) && len(keys) > 0 {
|
||||
return Index{}, fmt.Errorf("parse MCP catalog public keys: %s", strings.Join(parseErrors, "; "))
|
||||
}
|
||||
return Index{}, errors.New("MCP catalog signature verification failed")
|
||||
}
|
||||
var idx Index
|
||||
if err := json.Unmarshal(data, &idx); err != nil {
|
||||
return Index{}, fmt.Errorf("parse MCP catalog: %w", err)
|
||||
}
|
||||
if err := Validate(idx); err != nil {
|
||||
return Index{}, err
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
func Validate(idx Index) error {
|
||||
if idx.SchemaVersion != SchemaVersion {
|
||||
return fmt.Errorf("unsupported MCP catalog schema version %d", idx.SchemaVersion)
|
||||
}
|
||||
seenIDs, seenVersions := map[string]bool{}, map[string]bool{}
|
||||
for _, entry := range idx.Entries {
|
||||
if strings.TrimSpace(entry.ID) == "" || strings.TrimSpace(entry.Name) == "" || strings.TrimSpace(entry.Version) == "" {
|
||||
return errors.New("MCP catalog entry requires id, name, and version")
|
||||
}
|
||||
if strings.TrimSpace(entry.Source) == "" || !validCommit.MatchString(strings.TrimSpace(entry.Commit)) {
|
||||
return fmt.Errorf("MCP catalog entry %q requires a source and exact 40- or 64-hex commit", entry.ID)
|
||||
}
|
||||
if !validEntryID.MatchString(entry.ID) {
|
||||
return fmt.Errorf("MCP catalog entry has invalid id %q", entry.ID)
|
||||
}
|
||||
if seenIDs[entry.ID] {
|
||||
return fmt.Errorf("duplicate MCP catalog entry id %q", entry.ID)
|
||||
}
|
||||
seenIDs[entry.ID] = true
|
||||
versionKey := entry.Name + "\x00" + entry.Version
|
||||
if seenVersions[versionKey] {
|
||||
return fmt.Errorf("duplicate MCP catalog plugin version %s@%s", entry.Name, entry.Version)
|
||||
}
|
||||
seenVersions[versionKey] = true
|
||||
if !validSHA256(entry.PackageSHA256) || !validSHA256(entry.ManifestSHA256) {
|
||||
return fmt.Errorf("MCP catalog entry %q has invalid package or manifest digest", entry.ID)
|
||||
}
|
||||
serverNames := map[string]bool{}
|
||||
for _, server := range entry.Servers {
|
||||
if strings.TrimSpace(server.Name) == "" {
|
||||
return fmt.Errorf("MCP catalog entry %q has unnamed server", entry.ID)
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(server.Transport)) {
|
||||
case "stdio", "http", "streamable-http", "sse":
|
||||
default:
|
||||
return fmt.Errorf("MCP catalog entry %q server %q has unsupported transport %q", entry.ID, server.Name, server.Transport)
|
||||
}
|
||||
if serverNames[server.Name] {
|
||||
return fmt.Errorf("MCP catalog entry %q has duplicate server %q", entry.ID, server.Name)
|
||||
}
|
||||
serverNames[server.Name] = true
|
||||
readers := append([]string(nil), server.Readers...)
|
||||
sort.Strings(readers)
|
||||
for i, reader := range readers {
|
||||
if strings.TrimSpace(reader) == "" {
|
||||
return fmt.Errorf("MCP catalog entry %q server %q has an empty reader name", entry.ID, server.Name)
|
||||
}
|
||||
if strings.TrimSpace(reader) == "" || (i > 0 && reader == readers[i-1]) {
|
||||
return fmt.Errorf("MCP catalog entry %q server %q has invalid reader declarations", entry.ID, server.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, rev := range idx.Revocations {
|
||||
if strings.TrimSpace(rev.EntryID) == "" {
|
||||
return errors.New("MCP catalog revocation requires entry_id")
|
||||
}
|
||||
}
|
||||
seenRevocations := map[string]bool{}
|
||||
for _, revocation := range idx.Revocations {
|
||||
entryID := strings.TrimSpace(revocation.EntryID)
|
||||
if entryID == "" || seenRevocations[entryID] {
|
||||
return fmt.Errorf("MCP catalog has invalid or duplicate revocation %q", entryID)
|
||||
}
|
||||
seenRevocations[entryID] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse validates unsigned catalog source before it is signed for publication.
|
||||
// Runtime callers must use Verify so untrusted remote bytes cannot bypass the
|
||||
// catalog signature.
|
||||
func Parse(data []byte) (Index, error) {
|
||||
if len(data) == 0 || len(data) > maxCatalogSize {
|
||||
return Index{}, fmt.Errorf("invalid MCP catalog size %d", len(data))
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(data)))
|
||||
dec.DisallowUnknownFields()
|
||||
var idx Index
|
||||
if err := dec.Decode(&idx); err != nil {
|
||||
return Index{}, fmt.Errorf("parse MCP catalog: %w", err)
|
||||
}
|
||||
if err := Validate(idx); err != nil {
|
||||
return Index{}, err
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
func (idx Index) Match(name, version, source, commit, packageDigest string) (Entry, bool) {
|
||||
for _, entry := range idx.Entries {
|
||||
if entry.Name != name || entry.Version != version || entry.Source != source || !strings.EqualFold(entry.Commit, commit) || !strings.EqualFold(entry.PackageSHA256, packageDigest) {
|
||||
continue
|
||||
}
|
||||
if idx.IsRevoked(entry.ID) {
|
||||
return Entry{}, false
|
||||
}
|
||||
return entry, true
|
||||
}
|
||||
return Entry{}, false
|
||||
}
|
||||
|
||||
func (idx Index) IsRevoked(entryID string) bool {
|
||||
for _, rev := range idx.Revocations {
|
||||
if rev.EntryID == entryID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TreeSHA256(root string) (string, error) {
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
type item struct {
|
||||
path string
|
||||
body []byte
|
||||
}
|
||||
var items []item
|
||||
err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
if entry.IsDir() && entry.Name() == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("MCP catalog package contains symlink %q", filepath.ToSlash(rel))
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("MCP catalog package contains non-regular file %q", filepath.ToSlash(rel))
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items = append(items, item{path: filepath.ToSlash(rel), body: body})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].path < items[j].path })
|
||||
h := sha256.New()
|
||||
for _, item := range items {
|
||||
// Permission bits are intentionally excluded: Windows checkouts do not
|
||||
// preserve POSIX modes, so including them makes a catalog package verify
|
||||
// on Linux but silently lose official status on Windows. Paths, lengths,
|
||||
// and bytes still bind the complete regular-file tree.
|
||||
_, _ = fmt.Fprintf(h, "%s\x00%d\x00", item.path, len(item.body))
|
||||
_, _ = h.Write(item.body)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func fetch(ctx context.Context, client *http.Client, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("fetch MCP catalog %s: %s", url, resp.Status)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxCatalogSize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) > maxCatalogSize {
|
||||
return nil, fmt.Errorf("MCP catalog response exceeds %d bytes", maxCatalogSize)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func validSHA256(value string) bool {
|
||||
if len(value) != sha256.Size*2 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(value)
|
||||
return err == nil
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
package mcpcatalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"aead.dev/minisign"
|
||||
)
|
||||
|
||||
func signedIndex(t *testing.T, idx Index) ([]byte, []byte, string) {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(idx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pub, priv, err := minisign.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pubText, err := pub.MarshalText()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return data, minisign.Sign(priv, data), string(pubText)
|
||||
}
|
||||
|
||||
func emptyIndex(sequence uint64) Index {
|
||||
return Index{SchemaVersion: SchemaVersion, Sequence: sequence}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsTamperingAndWrongKey(t *testing.T) {
|
||||
data, sig, key := signedIndex(t, emptyIndex(1))
|
||||
idx, err := Verify(data, sig, []string{key})
|
||||
if err != nil || idx.Sequence != 1 {
|
||||
t.Fatalf("verify = %+v, %v", idx, err)
|
||||
}
|
||||
data[0] ^= 1
|
||||
if _, err := Verify(data, sig, []string{key}); err == nil {
|
||||
t.Fatal("tampered catalog verified")
|
||||
}
|
||||
otherData, _, otherKey := signedIndex(t, emptyIndex(1))
|
||||
if _, err := Verify(otherData, sig, []string{otherKey}); err == nil {
|
||||
t.Fatal("wrong-key signature verified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsDuplicatesAndBadReaders(t *testing.T) {
|
||||
sha := strings.Repeat("a", 64)
|
||||
idx := emptyIndex(1)
|
||||
idx.Entries = []Entry{{ID: "x", Name: "p", Version: "1", Source: "https://example.test/p", Commit: strings.Repeat("b", 40), PackageSHA256: sha, ManifestSHA256: sha, Servers: []Server{{Name: "s", Transport: "stdio", Readers: []string{"r", "r"}}}}}
|
||||
if err := Validate(idx); err == nil {
|
||||
t.Fatal("duplicate readers accepted")
|
||||
}
|
||||
idx.Entries[0].Servers[0].Readers = []string{"r"}
|
||||
idx.Entries = append(idx.Entries, idx.Entries[0])
|
||||
if err := Validate(idx); err == nil {
|
||||
t.Fatal("duplicate entry accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshKeepsLastGoodAndRejectsRollback(t *testing.T) {
|
||||
cache := t.TempDir()
|
||||
data2, sig2, key := signedIndex(t, emptyIndex(2))
|
||||
var data, sig = data2, sig2
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, ".minisig") {
|
||||
_, _ = w.Write(sig)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
}))
|
||||
defer server.Close()
|
||||
loader := Loader{CacheDir: cache, CatalogURL: server.URL + "/index.json", SignatureURL: server.URL + "/index.json.minisig", Keys: []string{key}}
|
||||
result, err := loader.Refresh(context.Background())
|
||||
if err != nil || result.Index.Sequence != 2 || result.Source != SourceRemote {
|
||||
t.Fatalf("refresh = %+v, %v", result, err)
|
||||
}
|
||||
|
||||
data, sig, _ = signedIndex(t, emptyIndex(1))
|
||||
if _, err := loader.Refresh(context.Background()); err == nil {
|
||||
t.Fatal("catalog rollback accepted")
|
||||
}
|
||||
cached, err := loader.Load(context.Background(), false)
|
||||
if err != nil || cached.Index.Sequence != 2 || cached.Source != SourceCached {
|
||||
t.Fatalf("cached = %+v, %v", cached, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewestLocalCatalogUsesHighestSequence(t *testing.T) {
|
||||
cached := Result{Index: emptyIndex(1), Source: SourceCached}
|
||||
bundled := Result{Index: emptyIndex(2), Source: SourceBundled}
|
||||
if got := newestCatalogResult(cached, bundled); got.Source != SourceBundled || got.Index.Sequence != 2 {
|
||||
t.Fatalf("newestCatalogResult(cached, bundled) = %+v", got)
|
||||
}
|
||||
if got := newestCatalogResult(bundled, cached); got.Source != SourceBundled || got.Index.Sequence != 2 {
|
||||
t.Fatalf("newestCatalogResult(bundled, cached) = %+v", got)
|
||||
}
|
||||
// Equal sequences prefer the app-bundled snapshot so an upgraded binary's
|
||||
// signed baseline cannot be shadowed by an older on-disk representation.
|
||||
equalCached := Result{Index: emptyIndex(2), Source: SourceCached}
|
||||
if got := newestCatalogResult(equalCached, bundled); got.Source != SourceBundled {
|
||||
t.Fatalf("equal sequence selected %q, want bundled", got.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedCatalogUsesOneAtomicEnvelope(t *testing.T) {
|
||||
cache := t.TempDir()
|
||||
data, sig, key := signedIndex(t, emptyIndex(3))
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, ".minisig") {
|
||||
_, _ = w.Write(sig)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
}))
|
||||
defer server.Close()
|
||||
loader := Loader{CacheDir: cache, CatalogURL: server.URL + "/index.json", SignatureURL: server.URL + "/index.json.minisig", Keys: []string{key}}
|
||||
if _, err := loader.Refresh(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info, err := os.Stat(cacheEnvelopePath(cache)); err != nil || (runtime.GOOS != "windows" && info.Mode().Perm() != 0o600) {
|
||||
t.Fatalf("atomic LKG envelope = %v, %v", info, err)
|
||||
}
|
||||
dataPath, sigPath := CachePaths(cache)
|
||||
if _, err := os.Stat(dataPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("refresh wrote split data cache: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(sigPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("refresh wrote split signature cache: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreeSHA256StableAndRejectsSymlink(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "b.txt"), []byte("b"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(root, "a"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "a", "x.txt"), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := TreeSHA256(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := TreeSHA256(root)
|
||||
if err != nil || first != second {
|
||||
t.Fatalf("tree digest = %q %q, %v", first, second, err)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
if err := os.Chmod(filepath.Join(root, "b.txt"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
modeChanged, err := TreeSHA256(root)
|
||||
if err != nil || modeChanged != first {
|
||||
t.Fatalf("platform-specific mode changed portable tree digest: %q != %q, %v", modeChanged, first, err)
|
||||
}
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(root, "b.txt"), filepath.Join(root, "link")); err == nil {
|
||||
if _, err := TreeSHA256(root); err == nil {
|
||||
t.Fatal("symlink accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRevocationSnapshotReplacesPreviousState(t *testing.T) {
|
||||
rememberRuntimeIndex(Index{Revocations: []Revocation{{EntryID: "entry-a"}}})
|
||||
defer rememberRuntimeIndex(Index{})
|
||||
if !RuntimeEntryRevoked("entry-a") || RuntimeEntryRevoked("entry-b") {
|
||||
t.Fatal("runtime revocation snapshot was not applied")
|
||||
}
|
||||
rememberRuntimeIndex(Index{Revocations: []Revocation{{EntryID: "entry-b"}}})
|
||||
if RuntimeEntryRevoked("entry-a") || !RuntimeEntryRevoked("entry-b") {
|
||||
t.Fatal("runtime revocation snapshot was not replaced")
|
||||
}
|
||||
}
|
||||
@@ -28,27 +28,21 @@ const (
|
||||
workspaceScope = "workspace"
|
||||
)
|
||||
|
||||
// Identity is the secret-free canonical input to a server identity digest.
|
||||
// ProjectLaunchIdentity is the secret-free canonical input to an exact project
|
||||
// server launch identity digest.
|
||||
// Environment and header values are intentionally excluded so credential
|
||||
// rotation does not invalidate an otherwise identical authorization.
|
||||
type Identity struct {
|
||||
Server string `json:"server"`
|
||||
Transport string `json:"transport"`
|
||||
CommandPath string `json:"command_path,omitempty"`
|
||||
CommandSHA256 string `json:"command_sha256,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Dir string `json:"dir,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
EnvKeys []string `json:"env_keys,omitempty"`
|
||||
HeaderKeys []string `json:"header_keys,omitempty"`
|
||||
PackageDigest string `json:"package_digest,omitempty"`
|
||||
LauncherDigest string `json:"launcher_digest,omitempty"`
|
||||
ConfigSource string `json:"config_source,omitempty"`
|
||||
Network bool `json:"network"`
|
||||
WriteRoots []string `json:"write_roots,omitempty"`
|
||||
ReadRoots []string `json:"read_roots,omitempty"`
|
||||
ForbidReadRoots []string `json:"forbid_read_roots,omitempty"`
|
||||
IsolationPolicy string `json:"isolation_policy,omitempty"`
|
||||
type ProjectLaunchIdentity struct {
|
||||
Server string `json:"server"`
|
||||
Transport string `json:"transport"`
|
||||
CommandPath string `json:"command_path,omitempty"`
|
||||
CommandSHA256 string `json:"command_sha256,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Dir string `json:"dir,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
EnvKeys []string `json:"env_keys,omitempty"`
|
||||
HeaderKeys []string `json:"header_keys,omitempty"`
|
||||
LauncherDigest string `json:"launcher_digest,omitempty"`
|
||||
}
|
||||
|
||||
type LauncherLock struct {
|
||||
@@ -68,21 +62,17 @@ type LaunchGrant struct {
|
||||
WorkspaceFingerprint string `json:"workspace_fingerprint,omitempty"`
|
||||
Server string `json:"server"`
|
||||
ConfigSource string `json:"config_source"`
|
||||
IdentityFingerprint string `json:"identity_fingerprint"`
|
||||
IdentityDigest string `json:"identity_fingerprint"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// State preserves retired fields as raw JSON during the compatibility window.
|
||||
// They are never consulted for tool authority and are never modified by new
|
||||
// code. This prevents a new CLI and an older Desktop sharing one Reasonix home
|
||||
// from deleting each other's state during read-modify-write cycles.
|
||||
// State stores server-level launch grants and exact mutable-launcher locks.
|
||||
// Legacy per-tool reader receipts are deliberately not retained or consulted.
|
||||
type State struct {
|
||||
Version int `json:"version"`
|
||||
LegacyReceipts []json.RawMessage `json:"receipts,omitempty"`
|
||||
LaunchGrants []LaunchGrant `json:"launch_grants,omitempty"`
|
||||
LauncherLocks []LauncherLock `json:"launcher_locks,omitempty"`
|
||||
LegacyOfficialDenials json.RawMessage `json:"official_denials,omitempty"`
|
||||
LegacyImports json.RawMessage `json:"legacy_imports,omitempty"`
|
||||
Version int `json:"version"`
|
||||
LaunchGrants []LaunchGrant `json:"launch_grants,omitempty"`
|
||||
LauncherLocks []LauncherLock `json:"launcher_locks,omitempty"`
|
||||
LegacyImports json.RawMessage `json:"legacy_imports,omitempty"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
@@ -134,10 +124,11 @@ func WorkspaceFingerprint(workspace string) string {
|
||||
return digestBytes([]byte(workspace))
|
||||
}
|
||||
|
||||
func IdentityFingerprint(identity Identity) (string, error) {
|
||||
func ProjectLaunchIdentityDigest(identity ProjectLaunchIdentity) (string, error) {
|
||||
identity = normalizeIdentity(identity, runtime.GOOS == "windows")
|
||||
// Runtime confinement is deliberately excluded. Tightening Reasonix's own
|
||||
// sandbox must not invalidate authorization for unchanged server code.
|
||||
// PackageDigest was never populated, but its empty field was part of the
|
||||
// original canonical JSON. Keep that placeholder so existing project launch
|
||||
// grants remain byte-for-byte valid after the internal cleanup.
|
||||
payload := struct {
|
||||
Server, Transport, CommandPath, CommandSHA256, Dir, URL string
|
||||
Args, EnvKeys, HeaderKeys []string
|
||||
@@ -145,7 +136,7 @@ func IdentityFingerprint(identity Identity) (string, error) {
|
||||
}{
|
||||
identity.Server, identity.Transport, identity.CommandPath, identity.CommandSHA256,
|
||||
identity.Dir, identity.URL, identity.Args, identity.EnvKeys, identity.HeaderKeys,
|
||||
identity.PackageDigest, identity.LauncherDigest,
|
||||
"", identity.LauncherDigest,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
@@ -154,19 +145,15 @@ func IdentityFingerprint(identity Identity) (string, error) {
|
||||
return digestBytes(body), nil
|
||||
}
|
||||
|
||||
func normalizeIdentity(identity Identity, envCaseInsensitive bool) Identity {
|
||||
func normalizeIdentity(identity ProjectLaunchIdentity, envCaseInsensitive bool) ProjectLaunchIdentity {
|
||||
identity.Server = strings.TrimSpace(identity.Server)
|
||||
identity.Transport = normalizeTransport(identity.Transport)
|
||||
identity.CommandPath = canonicalPath(identity.CommandPath)
|
||||
identity.Dir = canonicalPath(identity.Dir)
|
||||
identity.URL = strings.TrimSpace(identity.URL)
|
||||
identity.ConfigSource = strings.TrimSpace(identity.ConfigSource)
|
||||
identity.Args = append([]string(nil), identity.Args...)
|
||||
identity.EnvKeys = cleanStrings(identity.EnvKeys, envCaseInsensitive)
|
||||
identity.HeaderKeys = cleanStrings(identity.HeaderKeys, true)
|
||||
identity.WriteRoots = canonicalPaths(identity.WriteRoots)
|
||||
identity.ReadRoots = canonicalPaths(identity.ReadRoots)
|
||||
identity.ForbidReadRoots = canonicalPaths(identity.ForbidReadRoots)
|
||||
return identity
|
||||
}
|
||||
|
||||
@@ -208,13 +195,13 @@ func (m *Manager) Load() (State, error) {
|
||||
}
|
||||
|
||||
// Authorize records durable workspace consent for one exact server identity.
|
||||
func (m *Manager) Authorize(server, configSource, identityFingerprint string) error {
|
||||
func (m *Manager) Authorize(server, configSource, identityDigest string) error {
|
||||
grant := LaunchGrant{
|
||||
Scope: workspaceScope, WorkspaceFingerprint: m.workspaceFingerprint,
|
||||
Server: strings.TrimSpace(server), ConfigSource: strings.TrimSpace(configSource),
|
||||
IdentityFingerprint: strings.TrimSpace(identityFingerprint), CreatedAt: time.Now().UTC(),
|
||||
IdentityDigest: strings.TrimSpace(identityDigest), CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if grant.Server == "" || grant.ConfigSource == "" || grant.IdentityFingerprint == "" {
|
||||
if grant.Server == "" || grant.ConfigSource == "" || grant.IdentityDigest == "" {
|
||||
return fmt.Errorf("MCP launch authorization requires server, config source, and identity")
|
||||
}
|
||||
m.mu.Lock()
|
||||
@@ -224,13 +211,11 @@ func (m *Manager) Authorize(server, configSource, identityFingerprint string) er
|
||||
})
|
||||
}
|
||||
|
||||
// LaunchAuthorized checks consent without starting the server. Exact legacy
|
||||
// receipts count only as a migration source for project launch authorization;
|
||||
// their tool snapshots and trust scopes are otherwise ignored.
|
||||
func (m *Manager) LaunchAuthorized(server, configSource, identityFingerprint string) (authorized, changed bool, err error) {
|
||||
// LaunchAuthorized checks exact server-level consent without starting the server.
|
||||
func (m *Manager) LaunchAuthorized(server, configSource, identityDigest string) (authorized, changed bool, err error) {
|
||||
server = strings.TrimSpace(server)
|
||||
configSource = strings.TrimSpace(configSource)
|
||||
identityFingerprint = strings.TrimSpace(identityFingerprint)
|
||||
identityDigest = strings.TrimSpace(identityDigest)
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
state, err := m.Load()
|
||||
@@ -241,41 +226,11 @@ func (m *Manager) LaunchAuthorized(server, configSource, identityFingerprint str
|
||||
if grant.Server != server || grant.ConfigSource != configSource || grant.WorkspaceFingerprint != m.workspaceFingerprint {
|
||||
continue
|
||||
}
|
||||
if grant.IdentityFingerprint == identityFingerprint {
|
||||
if grant.IdentityDigest == identityDigest {
|
||||
return true, false, nil
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
for _, raw := range state.LegacyReceipts {
|
||||
var receipt struct {
|
||||
Scope string `json:"scope"`
|
||||
WorkspaceFingerprint string `json:"workspace_fingerprint"`
|
||||
Server string `json:"server"`
|
||||
ConfigSource string `json:"config_source"`
|
||||
IdentityFingerprint string `json:"identity_fingerprint"`
|
||||
}
|
||||
if json.Unmarshal(raw, &receipt) != nil || receipt.Server != server || receipt.WorkspaceFingerprint != m.workspaceFingerprint {
|
||||
continue
|
||||
}
|
||||
if receipt.ConfigSource != configSource && receipt.ConfigSource != "workspace_config" {
|
||||
continue
|
||||
}
|
||||
if receipt.IdentityFingerprint != identityFingerprint {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
grant := LaunchGrant{
|
||||
Scope: workspaceScope, WorkspaceFingerprint: m.workspaceFingerprint,
|
||||
Server: server, ConfigSource: configSource, IdentityFingerprint: identityFingerprint,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := m.updatePersistent(func(latest *State) {
|
||||
latest.LaunchGrants = upsertLaunchGrant(latest.LaunchGrants, grant)
|
||||
}); err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return true, false, nil
|
||||
}
|
||||
return false, changed, nil
|
||||
}
|
||||
|
||||
@@ -430,17 +385,6 @@ func canonicalPath(path string) string {
|
||||
return filepath.Clean(path)
|
||||
}
|
||||
|
||||
func canonicalPaths(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if path := canonicalPath(value); path != "" {
|
||||
out = append(out, path)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return compactStrings(out)
|
||||
}
|
||||
|
||||
func cleanStrings(values []string, fold bool) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
|
||||
@@ -9,30 +9,46 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIdentityFingerprintIgnoresHostPolicyAndCredentialValues(t *testing.T) {
|
||||
base := Identity{
|
||||
func TestProjectLaunchIdentityDigestCanonicalizesStableIdentity(t *testing.T) {
|
||||
base := ProjectLaunchIdentity{
|
||||
Server: "reader", Transport: "stdio", CommandPath: "/bin/tool",
|
||||
CommandSHA256: "abc", Args: []string{"--serve"}, EnvKeys: []string{"TOKEN"},
|
||||
ConfigSource: "project_config", ReadRoots: []string{"/workspace"},
|
||||
IsolationPolicy: "enforced",
|
||||
CommandSHA256: "abc", Args: []string{"--serve"},
|
||||
EnvKeys: []string{"TOKEN", "PATH"}, HeaderKeys: []string{"Authorization", "X-Tenant"},
|
||||
}
|
||||
changedPolicy := base
|
||||
changedPolicy.ReadRoots = []string{"/different"}
|
||||
changedPolicy.IsolationPolicy = "unavailable_unconfined"
|
||||
a, err := IdentityFingerprint(base)
|
||||
reordered := base
|
||||
reordered.EnvKeys = []string{"PATH", "TOKEN"}
|
||||
reordered.HeaderKeys = []string{"X-Tenant", "Authorization"}
|
||||
a, err := ProjectLaunchIdentityDigest(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := IdentityFingerprint(changedPolicy)
|
||||
normalized := normalizeIdentity(base, runtime.GOOS == "windows")
|
||||
legacyPayload := struct {
|
||||
Server, Transport, CommandPath, CommandSHA256, Dir, URL string
|
||||
Args, EnvKeys, HeaderKeys []string
|
||||
PackageDigest, LauncherDigest string
|
||||
}{
|
||||
normalized.Server, normalized.Transport, normalized.CommandPath, normalized.CommandSHA256,
|
||||
normalized.Dir, normalized.URL, normalized.Args, normalized.EnvKeys,
|
||||
normalized.HeaderKeys, "", normalized.LauncherDigest,
|
||||
}
|
||||
body, err := json.Marshal(legacyPayload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := digestBytes(body); a != want {
|
||||
t.Fatalf("project launch identity digest changed: got %q want legacy %q", a, want)
|
||||
}
|
||||
b, err := ProjectLaunchIdentityDigest(reordered)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a != b {
|
||||
t.Fatal("host policy changed the exact server identity")
|
||||
t.Fatal("equivalent key ordering changed the project launch identity digest")
|
||||
}
|
||||
changedCommand := base
|
||||
changedCommand.Args = []string{"--serve", "--network"}
|
||||
c, err := IdentityFingerprint(changedCommand)
|
||||
c, err := ProjectLaunchIdentityDigest(changedCommand)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -47,6 +63,13 @@ func TestLaunchGrantIsExactDurableAndWorkspaceScoped(t *testing.T) {
|
||||
if err := a.Authorize("project", "project_config", "identity-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(body), `"identity_fingerprint": "identity-a"`) || strings.Contains(string(body), `"identity_digest"`) {
|
||||
t.Fatalf("launch grant JSON compatibility changed: %s", body)
|
||||
}
|
||||
if authorized, changed, err := a.LaunchAuthorized("project", "project_config", "identity-a"); err != nil || !authorized || changed {
|
||||
t.Fatalf("exact grant = (%v,%v,%v)", authorized, changed, err)
|
||||
}
|
||||
@@ -109,14 +132,13 @@ func TestLauncherLockRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetiredStateIsPreservedButIgnored(t *testing.T) {
|
||||
func TestRetiredToolTrustStateIsDropped(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, StateFilename)
|
||||
workspaceFP := WorkspaceFingerprint("/workspace")
|
||||
body := `{
|
||||
"version": 1,
|
||||
"receipts": [{"scope":"workspace","workspace_fingerprint":"` + workspaceFP + `","server":"legacy","config_source":"project_config","identity_fingerprint":"other","tools":[{"raw_name":"read","trusted_reader":true,"future":"keep"}]}],
|
||||
"official_denials": [{"server":"official","future":"keep"}],
|
||||
"legacy_imports": {"future":"keep"}
|
||||
}`
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
@@ -133,11 +155,16 @@ func TestRetiredStateIsPreservedButIgnored(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, marker := range []string{`"tools"`, `"trusted_reader"`, `"future": "keep"`, `"official_denials"`, `"legacy_imports"`} {
|
||||
for _, marker := range []string{`"legacy_imports"`, `"future": "keep"`} {
|
||||
if !strings.Contains(string(written), marker) {
|
||||
t.Fatalf("retired state marker %s was lost:\n%s", marker, written)
|
||||
}
|
||||
}
|
||||
for _, marker := range []string{`"tools"`, `"trusted_reader"`} {
|
||||
if strings.Contains(string(written), marker) {
|
||||
t.Fatalf("retired tool trust marker %s survived:\n%s", marker, written)
|
||||
}
|
||||
}
|
||||
var decoded map[string]json.RawMessage
|
||||
if err := json.Unmarshal(written, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -147,27 +174,6 @@ func TestRetiredStateIsPreservedButIgnored(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExactLegacyReceiptMigratesOnlyToLaunchGrant(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, StateFilename)
|
||||
workspaceFP := WorkspaceFingerprint("/workspace")
|
||||
body := `{"version":1,"receipts":[{"scope":"workspace","workspace_fingerprint":"` + workspaceFP + `","server":"project","config_source":"workspace_config","identity_fingerprint":"identity","tools":[{"raw_name":"read","trusted_reader":true}]}]}`
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager := NewManager(path, "/workspace")
|
||||
if authorized, changed, err := manager.LaunchAuthorized("project", "project_config", "identity"); err != nil || !authorized || changed {
|
||||
t.Fatalf("legacy launch migration = (%v,%v,%v)", authorized, changed, err)
|
||||
}
|
||||
state, err := manager.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(state.LaunchGrants) != 1 || len(state.LegacyReceipts) != 1 {
|
||||
t.Fatalf("migration state = %+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateFilePermissions(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), StateFilename)
|
||||
manager := NewManager(path, "/workspace")
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
// Package mcpregistry provides the explicit browse/install path for the
|
||||
// official Model Context Protocol Registry. It is never called during boot or
|
||||
// tool discovery, so a slow or unavailable registry cannot affect a session.
|
||||
package mcpregistry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/fileutil"
|
||||
)
|
||||
|
||||
const DefaultBaseURL = "https://registry.modelcontextprotocol.io"
|
||||
|
||||
const (
|
||||
defaultLimit = 20
|
||||
maxLimit = 100
|
||||
maxBody = 8 << 20
|
||||
maxCacheAge = 30 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTP *http.Client
|
||||
CachePath string
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func New(cachePath string) *Client {
|
||||
return &Client{
|
||||
BaseURL: DefaultBaseURL,
|
||||
HTTP: &http.Client{Timeout: 15 * time.Second},
|
||||
CachePath: cachePath,
|
||||
Now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Entry is one registry server reduced to the configuration Reasonix can
|
||||
// install without prompting for missing secrets or server-specific arguments.
|
||||
type Entry struct {
|
||||
Name string `json:"name"`
|
||||
SuggestedName string `json:"suggestedName"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
RepositoryURL string `json:"repositoryUrl,omitempty"`
|
||||
Installable bool `json:"installable"`
|
||||
UnavailableReason string `json:"unavailableReason,omitempty"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Entries []Entry
|
||||
Cached bool
|
||||
Warning string
|
||||
}
|
||||
|
||||
func (c *Client) Search(ctx context.Context, query string, limit int) (Result, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
if limit <= 0 {
|
||||
limit = defaultLimit
|
||||
}
|
||||
if limit > maxLimit {
|
||||
limit = maxLimit
|
||||
}
|
||||
key := cacheKey(query, limit)
|
||||
entries, err := c.fetch(ctx, query, limit)
|
||||
if err == nil {
|
||||
c.storeCache(key, entries)
|
||||
return Result{Entries: entries}, nil
|
||||
}
|
||||
if cached, ok := c.loadCache(key); ok {
|
||||
return Result{Entries: cached, Cached: true, Warning: err.Error()}, nil
|
||||
}
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
func (c *Client) Resolve(ctx context.Context, registryName string) (Entry, Result, error) {
|
||||
name := strings.TrimSpace(registryName)
|
||||
if name == "" {
|
||||
return Entry{}, Result{}, fmt.Errorf("registry server name is required")
|
||||
}
|
||||
// Installation must use current Registry metadata. Search deliberately falls
|
||||
// back to cache for offline browsing, but a cached package may have been
|
||||
// removed or marked unavailable since it was stored.
|
||||
entries, err := c.fetch(ctx, name, maxLimit)
|
||||
if err != nil {
|
||||
return Entry{}, Result{}, err
|
||||
}
|
||||
c.storeCache(cacheKey(name, maxLimit), entries)
|
||||
result := Result{Entries: entries}
|
||||
for _, entry := range result.Entries {
|
||||
if entry.Name == name || strings.EqualFold(entry.Name, name) {
|
||||
return entry, result, nil
|
||||
}
|
||||
}
|
||||
return Entry{}, result, fmt.Errorf("MCP Registry has no server named %q", name)
|
||||
}
|
||||
|
||||
func (e Entry) PluginEntry(localName string) (config.PluginEntry, error) {
|
||||
if !e.Installable {
|
||||
reason := e.UnavailableReason
|
||||
if reason == "" {
|
||||
reason = "the registry entry has no directly installable transport"
|
||||
}
|
||||
return config.PluginEntry{}, fmt.Errorf("registry server %q requires manual setup: %s", e.Name, reason)
|
||||
}
|
||||
name := strings.TrimSpace(localName)
|
||||
if name == "" {
|
||||
name = SuggestedName(e.Name)
|
||||
}
|
||||
entry := config.PluginEntry{Name: name}
|
||||
switch e.Transport {
|
||||
case "stdio":
|
||||
entry.Command = e.Command
|
||||
entry.Args = append([]string(nil), e.Args...)
|
||||
case "http", "sse":
|
||||
entry.Type = e.Transport
|
||||
entry.URL = e.URL
|
||||
default:
|
||||
return config.PluginEntry{}, fmt.Errorf("registry server %q has unsupported transport %q", e.Name, e.Transport)
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
var invalidLocalName = regexp.MustCompile(`[^a-z0-9._-]+`)
|
||||
|
||||
func SuggestedName(registryName string) string {
|
||||
name := strings.TrimSpace(registryName)
|
||||
if slash := strings.LastIndex(name, "/"); slash >= 0 {
|
||||
name = name[slash+1:]
|
||||
}
|
||||
name = strings.ToLower(name)
|
||||
name = invalidLocalName.ReplaceAllString(name, "-")
|
||||
name = strings.Trim(name, "-._")
|
||||
if name == "" {
|
||||
return "mcp-server"
|
||||
}
|
||||
if len(name) > 64 {
|
||||
name = strings.TrimRight(name[:64], "-._")
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
type apiResponse struct {
|
||||
Servers []struct {
|
||||
Server apiServer `json:"server"`
|
||||
} `json:"servers"`
|
||||
}
|
||||
|
||||
type apiServer struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version"`
|
||||
Repository *apiRepo `json:"repository"`
|
||||
Packages []apiPackage `json:"packages"`
|
||||
Remotes []apiRemote `json:"remotes"`
|
||||
}
|
||||
|
||||
type apiRepo struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type apiPackage struct {
|
||||
RegistryType string `json:"registryType"`
|
||||
Identifier string `json:"identifier"`
|
||||
Version string `json:"version"`
|
||||
Transport apiTransport `json:"transport"`
|
||||
EnvironmentVariables []json.RawMessage `json:"environmentVariables"`
|
||||
PackageArguments []json.RawMessage `json:"packageArguments"`
|
||||
RuntimeArguments []json.RawMessage `json:"runtimeArguments"`
|
||||
}
|
||||
|
||||
type apiRemote struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
Headers []json.RawMessage `json:"headers"`
|
||||
Variables map[string]json.RawMessage `json:"variables"`
|
||||
}
|
||||
|
||||
type apiTransport struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func (c *Client) fetch(ctx context.Context, query string, limit int) ([]Entry, error) {
|
||||
base := strings.TrimRight(strings.TrimSpace(c.BaseURL), "/")
|
||||
if base == "" {
|
||||
base = DefaultBaseURL
|
||||
}
|
||||
endpoint, err := url.Parse(base + "/v0.1/servers")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := endpoint.Query()
|
||||
values.Set("limit", fmt.Sprintf("%d", limit))
|
||||
values.Set("version", "latest")
|
||||
if query != "" {
|
||||
values.Set("search", query)
|
||||
}
|
||||
endpoint.RawQuery = values.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "reasonix-mcp-registry/dev")
|
||||
client := c.HTTP
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 15 * time.Second}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query MCP Registry: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("query MCP Registry: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var response apiResponse
|
||||
decoder := json.NewDecoder(io.LimitReader(resp.Body, maxBody))
|
||||
if err := decoder.Decode(&response); err != nil {
|
||||
return nil, fmt.Errorf("decode MCP Registry response: %w", err)
|
||||
}
|
||||
entries := make([]Entry, 0, len(response.Servers))
|
||||
for _, item := range response.Servers {
|
||||
if entry, ok := normalize(item.Server); ok {
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func normalize(server apiServer) (Entry, bool) {
|
||||
if strings.TrimSpace(server.Name) == "" {
|
||||
return Entry{}, false
|
||||
}
|
||||
entry := Entry{
|
||||
Name: server.Name,
|
||||
SuggestedName: SuggestedName(server.Name),
|
||||
Title: server.Title,
|
||||
Description: server.Description,
|
||||
Version: server.Version,
|
||||
}
|
||||
if server.Repository != nil {
|
||||
entry.RepositoryURL = server.Repository.URL
|
||||
}
|
||||
var reasons []string
|
||||
for _, remote := range server.Remotes {
|
||||
transport := strings.ToLower(strings.TrimSpace(remote.Type))
|
||||
if transport != "streamable-http" && transport != "http" && transport != "sse" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(remote.URL) == "" {
|
||||
continue
|
||||
}
|
||||
if len(remote.Headers) > 0 || len(remote.Variables) > 0 {
|
||||
reasons = append(reasons, "remote transport requires headers or URL variables")
|
||||
continue
|
||||
}
|
||||
entry.Installable = true
|
||||
entry.Transport = "http"
|
||||
if transport == "sse" {
|
||||
entry.Transport = "sse"
|
||||
}
|
||||
entry.URL = remote.URL
|
||||
return entry, true
|
||||
}
|
||||
for _, pkg := range server.Packages {
|
||||
transport := strings.ToLower(strings.TrimSpace(pkg.Transport.Type))
|
||||
if transport != "" && transport != "stdio" {
|
||||
continue
|
||||
}
|
||||
if len(pkg.EnvironmentVariables) > 0 || len(pkg.PackageArguments) > 0 || len(pkg.RuntimeArguments) > 0 {
|
||||
reasons = append(reasons, "package requires environment variables or arguments")
|
||||
continue
|
||||
}
|
||||
identifier := strings.TrimSpace(pkg.Identifier)
|
||||
if identifier == "" {
|
||||
continue
|
||||
}
|
||||
version := strings.TrimSpace(pkg.Version)
|
||||
if version == "" {
|
||||
version = strings.TrimSpace(server.Version)
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(pkg.RegistryType)) {
|
||||
case "npm":
|
||||
entry.Command = "npx"
|
||||
entry.Args = []string{"-y", npmPackageVersion(identifier, version)}
|
||||
case "pypi":
|
||||
entry.Command = "uvx"
|
||||
entry.Args = []string{pythonPackageVersion(identifier, version)}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
entry.Installable = true
|
||||
entry.Transport = "stdio"
|
||||
return entry, true
|
||||
}
|
||||
if len(reasons) > 0 {
|
||||
sort.Strings(reasons)
|
||||
entry.UnavailableReason = reasons[0]
|
||||
} else {
|
||||
entry.UnavailableReason = "no supported stdio, Streamable HTTP, or SSE transport"
|
||||
}
|
||||
return entry, true
|
||||
}
|
||||
|
||||
func npmPackageVersion(identifier, version string) string {
|
||||
if version == "" {
|
||||
return identifier
|
||||
}
|
||||
return identifier + "@" + version
|
||||
}
|
||||
|
||||
func pythonPackageVersion(identifier, version string) string {
|
||||
if version == "" {
|
||||
return identifier
|
||||
}
|
||||
return identifier + "==" + version
|
||||
}
|
||||
|
||||
type cacheFile struct {
|
||||
// FetchedAt is retained as a read-only fallback for caches written by older
|
||||
// Reasonix versions. New writes timestamp each query independently so a
|
||||
// successful lookup cannot keep unrelated stale results alive.
|
||||
FetchedAt time.Time `json:"fetchedAt,omitempty"`
|
||||
QueryFetchedAt map[string]time.Time `json:"queryFetchedAt,omitempty"`
|
||||
Queries map[string][]Entry `json:"queries"`
|
||||
}
|
||||
|
||||
func cacheKey(query string, limit int) string {
|
||||
return strings.ToLower(strings.TrimSpace(query)) + "\x00" + fmt.Sprintf("%d", limit)
|
||||
}
|
||||
|
||||
func (c *Client) now() time.Time {
|
||||
if c.Now != nil {
|
||||
return c.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (c *Client) loadCache(key string) ([]Entry, bool) {
|
||||
if strings.TrimSpace(c.CachePath) == "" {
|
||||
return nil, false
|
||||
}
|
||||
data, err := os.ReadFile(c.CachePath)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var cache cacheFile
|
||||
if json.Unmarshal(data, &cache) != nil {
|
||||
return nil, false
|
||||
}
|
||||
entries, ok := cache.Queries[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
fetchedAt := cache.QueryFetchedAt[key]
|
||||
if fetchedAt.IsZero() {
|
||||
fetchedAt = cache.FetchedAt
|
||||
}
|
||||
if fetchedAt.IsZero() || c.now().Sub(fetchedAt) > maxCacheAge {
|
||||
return nil, false
|
||||
}
|
||||
return append([]Entry(nil), entries...), ok
|
||||
}
|
||||
|
||||
func (c *Client) storeCache(key string, entries []Entry) {
|
||||
if strings.TrimSpace(c.CachePath) == "" {
|
||||
return
|
||||
}
|
||||
cache := cacheFile{
|
||||
QueryFetchedAt: map[string]time.Time{},
|
||||
Queries: map[string][]Entry{},
|
||||
}
|
||||
if data, err := os.ReadFile(c.CachePath); err == nil {
|
||||
_ = json.Unmarshal(data, &cache)
|
||||
if cache.Queries == nil {
|
||||
cache.Queries = map[string][]Entry{}
|
||||
}
|
||||
if cache.QueryFetchedAt == nil {
|
||||
cache.QueryFetchedAt = map[string]time.Time{}
|
||||
}
|
||||
}
|
||||
now := c.now().UTC()
|
||||
if cache.FetchedAt.IsZero() {
|
||||
cache.FetchedAt = now
|
||||
}
|
||||
cache.QueryFetchedAt[key] = now
|
||||
cache.Queries[key] = append([]Entry(nil), entries...)
|
||||
data, err := json.MarshalIndent(cache, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if os.MkdirAll(filepath.Dir(c.CachePath), 0o700) != nil {
|
||||
return
|
||||
}
|
||||
_ = fileutil.AtomicWriteFile(c.CachePath, data, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package mcpregistry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSearchNormalizesOfficialRegistryEntries(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v0.1/servers" || r.URL.Query().Get("version") != "latest" || r.URL.Query().Get("search") != "demo" {
|
||||
t.Fatalf("request = %s", r.URL)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"servers": []any{
|
||||
map[string]any{"server": map[string]any{
|
||||
"name": "io.example/remote", "title": "Remote", "version": "1.2.0",
|
||||
"remotes": []any{map[string]any{"type": "streamable-http", "url": "https://mcp.example/mcp"}},
|
||||
}},
|
||||
map[string]any{"server": map[string]any{
|
||||
"name": "io.example/package", "description": "Package server", "version": "2.0.0",
|
||||
"packages": []any{map[string]any{
|
||||
"registryType": "npm", "identifier": "@example/mcp", "version": "2.0.0",
|
||||
"transport": map[string]any{"type": "stdio"},
|
||||
}},
|
||||
}},
|
||||
map[string]any{"server": map[string]any{
|
||||
"name": "io.example/manual", "version": "1.0.0",
|
||||
"remotes": []any{map[string]any{
|
||||
"type": "streamable-http", "url": "https://mcp.example/{tenant}",
|
||||
"variables": map[string]any{"tenant": map[string]any{"isRequired": true}},
|
||||
}},
|
||||
}},
|
||||
}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := New(filepath.Join(t.TempDir(), "registry.json"))
|
||||
client.BaseURL = server.URL
|
||||
result, err := client.Search(context.Background(), "demo", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Cached || len(result.Entries) != 3 {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
remote := result.Entries[0]
|
||||
if !remote.Installable || remote.Transport != "http" || remote.URL != "https://mcp.example/mcp" {
|
||||
t.Fatalf("remote = %+v", remote)
|
||||
}
|
||||
pkg := result.Entries[1]
|
||||
if !pkg.Installable || pkg.Transport != "stdio" || pkg.Command != "npx" || len(pkg.Args) != 2 || pkg.Args[1] != "@example/[email protected]" {
|
||||
t.Fatalf("package = %+v", pkg)
|
||||
}
|
||||
if result.Entries[2].Installable || result.Entries[2].UnavailableReason == "" {
|
||||
t.Fatalf("manual entry = %+v", result.Entries[2])
|
||||
}
|
||||
entry, err := pkg.PluginEntry("")
|
||||
if err != nil || entry.Name != "package" || entry.Command != "npx" {
|
||||
t.Fatalf("PluginEntry = %+v, %v", entry, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchFallsBackToMatchingCache(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"servers": []any{map[string]any{"server": map[string]any{
|
||||
"name": "io.example/cached", "version": "1", "remotes": []any{map[string]any{"type": "sse", "url": "https://mcp.example/sse"}},
|
||||
}}}})
|
||||
}))
|
||||
cachePath := filepath.Join(t.TempDir(), "registry.json")
|
||||
client := New(cachePath)
|
||||
client.BaseURL = server.URL
|
||||
client.Now = func() time.Time { return time.Unix(1_000_000, 0) }
|
||||
if _, err := client.Search(context.Background(), "cached", 5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server.Close()
|
||||
client.HTTP = &http.Client{Timeout: 100 * time.Millisecond}
|
||||
result, err := client.Search(context.Background(), "cached", 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Cached || result.Warning == "" || len(result.Entries) != 1 || result.Entries[0].Transport != "sse" {
|
||||
t.Fatalf("cached result = %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchExpiresEachCachedQueryIndependently(t *testing.T) {
|
||||
now := time.Unix(1_000_000, 0)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("search")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"servers": []any{map[string]any{"server": map[string]any{
|
||||
"name": "io.example/" + query,
|
||||
"remotes": []any{map[string]any{"type": "sse", "url": "https://mcp.example/" + query}},
|
||||
}}}})
|
||||
}))
|
||||
cachePath := filepath.Join(t.TempDir(), "registry.json")
|
||||
client := New(cachePath)
|
||||
client.BaseURL = server.URL
|
||||
client.Now = func() time.Time { return now }
|
||||
if _, err := client.Search(context.Background(), "old", 5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now = now.Add(maxCacheAge + time.Hour)
|
||||
if _, err := client.Search(context.Background(), "fresh", 5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server.Close()
|
||||
client.HTTP = &http.Client{Timeout: 100 * time.Millisecond}
|
||||
|
||||
if _, err := client.Search(context.Background(), "old", 5); err == nil {
|
||||
t.Fatal("expired query reused after an unrelated query refreshed the cache")
|
||||
}
|
||||
result, err := client.Search(context.Background(), "fresh", 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Cached || len(result.Entries) != 1 || result.Entries[0].Name != "io.example/fresh" {
|
||||
t.Fatalf("fresh cached result = %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchReadsLegacyGlobalTimestampCache(t *testing.T) {
|
||||
now := time.Unix(1_000_000, 0)
|
||||
key := cacheKey("legacy", 5)
|
||||
data, err := json.Marshal(cacheFile{
|
||||
FetchedAt: now,
|
||||
Queries: map[string][]Entry{
|
||||
key: {{Name: "io.example/legacy", Transport: "sse", URL: "https://mcp.example/legacy"}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cachePath := filepath.Join(t.TempDir(), "registry.json")
|
||||
if err := os.WriteFile(cachePath, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := New(cachePath)
|
||||
client.BaseURL = "http://127.0.0.1:1"
|
||||
client.HTTP = &http.Client{Timeout: 100 * time.Millisecond}
|
||||
client.Now = func() time.Time { return now.Add(time.Hour) }
|
||||
result, err := client.Search(context.Background(), "legacy", 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Cached || len(result.Entries) != 1 || result.Entries[0].Name != "io.example/legacy" {
|
||||
t.Fatalf("legacy cached result = %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRequiresLiveRegistryMetadata(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"servers": []any{map[string]any{"server": map[string]any{
|
||||
"name": "io.example/demo",
|
||||
"remotes": []any{map[string]any{"type": "streamable-http", "url": "https://mcp.example/demo"}},
|
||||
}}}})
|
||||
}))
|
||||
client := New(filepath.Join(t.TempDir(), "registry.json"))
|
||||
client.BaseURL = server.URL
|
||||
if _, err := client.Search(context.Background(), "io.example/demo", maxLimit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server.Close()
|
||||
client.HTTP = &http.Client{Timeout: 100 * time.Millisecond}
|
||||
|
||||
if _, result, err := client.Resolve(context.Background(), "io.example/demo"); err == nil {
|
||||
t.Fatalf("Resolve used cached install metadata: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestedName(t *testing.T) {
|
||||
if got := SuggestedName("io.github.Example/My MCP Server"); got != "my-mcp-server" {
|
||||
t.Fatalf("SuggestedName = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -149,8 +149,8 @@ func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Dec
|
||||
}
|
||||
|
||||
// ExplicitlyDenies reports only configured deny-rule matches. It deliberately
|
||||
// excludes the fallback Mode so higher-priority local MCP policy can be applied
|
||||
// before the global posture.
|
||||
// excludes the fallback Mode so installing or explicitly authorizing an MCP
|
||||
// server remains the final allow decision.
|
||||
func (p Policy) ExplicitlyDenies(toolName string, args json.RawMessage) bool {
|
||||
subjects := Subjects(args)
|
||||
if len(subjects) == 0 {
|
||||
@@ -444,20 +444,6 @@ type ReasonedApprover interface {
|
||||
ApproveWithReason(ctx context.Context, toolName, subject string, args json.RawMessage) (allow, remember bool, reason string, err error)
|
||||
}
|
||||
|
||||
// FreshApprover handles safety decisions that must come from a current human
|
||||
// and cannot be satisfied by an allow rule, Auto/YOLO, or a remembered choice.
|
||||
type FreshApprover interface {
|
||||
ApproveFresh(ctx context.Context, toolName, subject string, args json.RawMessage) (allow bool, reason string, err error)
|
||||
}
|
||||
|
||||
// MCPApprover resolves an MCP approval through the configured reviewer. It is
|
||||
// separate from Approver because prompt/writes modes outrank the controller's
|
||||
// global Auto/YOLO posture, and destructive calls must never reuse a remembered
|
||||
// grant. reviewer is "user", "auto_review", or empty for legacy routing.
|
||||
type MCPApprover interface {
|
||||
ApproveMCP(ctx context.Context, toolName, subject string, args json.RawMessage, destructive, forced bool, reviewer string) (allow bool, reason string, err error)
|
||||
}
|
||||
|
||||
// Gate is what the agent consults at execute time: a Policy plus an optional
|
||||
// Approver. It satisfies the agent's Gate interface structurally.
|
||||
type Gate struct {
|
||||
@@ -520,101 +506,11 @@ func (g *Gate) Check(ctx context.Context, toolName string, args json.RawMessage,
|
||||
}
|
||||
}
|
||||
|
||||
// CheckFresh preserves explicit deny precedence, then requires a fresh approver
|
||||
// regardless of ordinary allow/ask/fallback posture. It deliberately never
|
||||
// persists or installs an in-memory allow rule.
|
||||
func (g *Gate) CheckFresh(ctx context.Context, toolName, subject string, args json.RawMessage, readOnly bool) (bool, string, error) {
|
||||
if g.Policy.Decide(toolName, readOnly, args) == Deny {
|
||||
return false, "denied by permission policy — this tool/command is on the deny list. Do not retry it; choose another approach or stop and explain.", nil
|
||||
}
|
||||
approver, ok := g.Approver.(FreshApprover)
|
||||
if !ok {
|
||||
return false, "this tool requires fresh human approval and cannot run in a non-interactive session.", nil
|
||||
}
|
||||
allow, reason, err := approver.ApproveFresh(ctx, toolName, subject, args)
|
||||
if err != nil {
|
||||
return false, "approval aborted", err
|
||||
}
|
||||
if !allow {
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
reason = "the user declined this tool call"
|
||||
}
|
||||
return false, reason, nil
|
||||
}
|
||||
return true, "", nil
|
||||
}
|
||||
|
||||
// CheckMCP applies MCP-local approval policy. Precedence is explicit deny,
|
||||
// explicit approval, destructive fresh review, the remaining per-tool/server
|
||||
// modes, then the ordinary global permission posture. Local prompt/writes
|
||||
// decisions require an MCPApprover and therefore fail closed in headless and
|
||||
// sub-agent sessions.
|
||||
func (g *Gate) CheckMCP(ctx context.Context, toolName, subject string, args json.RawMessage, readOnly, destructive bool, mode, reviewer string) (bool, string, error) {
|
||||
if g.Policy.ExplicitlyDenies(toolName, args) {
|
||||
return false, "denied by permission policy — this tool/command is on the deny list. Do not retry it; choose another approach or stop and explain.", nil
|
||||
}
|
||||
|
||||
effectiveMode := normalizeMCPApprovalMode(mode)
|
||||
if effectiveMode == "approve" {
|
||||
return true, "", nil
|
||||
}
|
||||
if destructive {
|
||||
return g.approveMCP(ctx, toolName, subject, args, true, true, reviewer)
|
||||
}
|
||||
|
||||
switch effectiveMode {
|
||||
case "prompt":
|
||||
return g.approveMCP(ctx, toolName, subject, args, false, true, reviewer)
|
||||
case "writes":
|
||||
if readOnly {
|
||||
return true, "", nil
|
||||
}
|
||||
return g.approveMCP(ctx, toolName, subject, args, false, true, reviewer)
|
||||
default: // auto preserves the existing global policy and posture.
|
||||
switch g.Policy.Decide(toolName, readOnly, args) {
|
||||
case Deny:
|
||||
return g.Check(ctx, toolName, args, readOnly)
|
||||
case Ask:
|
||||
if strings.TrimSpace(reviewer) != "" {
|
||||
return g.approveMCP(ctx, toolName, subject, args, false, false, reviewer)
|
||||
}
|
||||
return g.Check(ctx, toolName, args, readOnly)
|
||||
default:
|
||||
return true, "", nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gate) approveMCP(ctx context.Context, toolName, subject string, args json.RawMessage, destructive, forced bool, reviewer string) (bool, string, error) {
|
||||
approver, ok := g.Approver.(MCPApprover)
|
||||
if !ok {
|
||||
return false, "this MCP tool requires an approval reviewer and cannot run in a non-interactive session.", nil
|
||||
}
|
||||
allow, reason, err := approver.ApproveMCP(ctx, toolName, subject, args, destructive, forced, reviewer)
|
||||
if err != nil {
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
reason = "approval review aborted"
|
||||
}
|
||||
return false, reason, err
|
||||
}
|
||||
if !allow {
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
reason = "the MCP approval reviewer declined this tool call"
|
||||
}
|
||||
return false, reason, nil
|
||||
}
|
||||
return true, "", nil
|
||||
}
|
||||
|
||||
func normalizeMCPApprovalMode(mode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "", "auto":
|
||||
return "auto"
|
||||
case "approve", "prompt", "writes":
|
||||
return strings.ToLower(strings.TrimSpace(mode))
|
||||
default:
|
||||
return "prompt"
|
||||
}
|
||||
// ExplicitlyDenies reports whether an explicit deny rule matches. Authorized
|
||||
// MCP servers use this narrow view so install-time authorization is not
|
||||
// followed by redundant per-call approval prompts.
|
||||
func (g *Gate) ExplicitlyDenies(toolName string, args json.RawMessage) bool {
|
||||
return g.Policy.ExplicitlyDenies(toolName, args)
|
||||
}
|
||||
|
||||
func (g *Gate) approve(ctx context.Context, toolName, subject string, args json.RawMessage) (bool, bool, string, error) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -216,41 +215,6 @@ type stubApprover struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
type freshStubApprover struct {
|
||||
allow bool
|
||||
reason string
|
||||
calls int
|
||||
subject string
|
||||
}
|
||||
|
||||
type mcpStubApprover struct {
|
||||
stubApprover
|
||||
mcpCalls int
|
||||
destructive bool
|
||||
forced bool
|
||||
reviewer string
|
||||
subject string
|
||||
}
|
||||
|
||||
func (s *mcpStubApprover) ApproveMCP(_ context.Context, _ string, subject string, _ json.RawMessage, destructive, forced bool, reviewer string) (bool, string, error) {
|
||||
s.mcpCalls++
|
||||
s.destructive = destructive
|
||||
s.forced = forced
|
||||
s.reviewer = reviewer
|
||||
s.subject = subject
|
||||
return s.allow, "reviewed", s.err
|
||||
}
|
||||
|
||||
func (s *freshStubApprover) Approve(context.Context, string, string, json.RawMessage) (bool, bool, error) {
|
||||
return true, false, nil
|
||||
}
|
||||
|
||||
func (s *freshStubApprover) ApproveFresh(_ context.Context, _ string, subject string, _ json.RawMessage) (bool, string, error) {
|
||||
s.calls++
|
||||
s.subject = subject
|
||||
return s.allow, s.reason, nil
|
||||
}
|
||||
|
||||
func (s *stubApprover) Approve(ctx context.Context, tool, subject string, args json.RawMessage) (bool, bool, error) {
|
||||
s.calls++
|
||||
return s.allow, s.remember, s.err
|
||||
@@ -313,141 +277,6 @@ func TestGateInteractive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateFreshApprovalIgnoresAllowButHonorsDeny(t *testing.T) {
|
||||
ap := &freshStubApprover{allow: true}
|
||||
g := NewGate(New("allow", []string{"mcp__srv__danger"}, nil, nil), ap)
|
||||
allow, reason, err := g.CheckFresh(context.Background(), "mcp__srv__danger", "srv/danger", json.RawMessage(`{"target":"x"}`), true)
|
||||
if err != nil || !allow || reason != "" || ap.calls != 1 || ap.subject != "srv/danger" {
|
||||
t.Fatalf("fresh allow = (%v,%q,%v), calls=%d subject=%q", allow, reason, err, ap.calls, ap.subject)
|
||||
}
|
||||
|
||||
deniedApprover := &freshStubApprover{allow: true}
|
||||
denied := NewGate(New("allow", nil, nil, []string{"mcp__srv__danger"}), deniedApprover)
|
||||
allow, reason, err = denied.CheckFresh(context.Background(), "mcp__srv__danger", "srv/danger", nil, false)
|
||||
if err != nil || allow || reason == "" || deniedApprover.calls != 0 {
|
||||
t.Fatalf("fresh explicit deny = (%v,%q,%v), approver calls=%d", allow, reason, err, deniedApprover.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateFreshApprovalFailsClosedWithoutFreshApprover(t *testing.T) {
|
||||
g := NewGate(New("allow", nil, nil, nil), nil)
|
||||
allow, reason, err := g.CheckFresh(context.Background(), "mcp__srv__danger", "srv/danger", nil, false)
|
||||
if err != nil || allow || !strings.Contains(reason, "fresh human approval") {
|
||||
t.Fatalf("headless fresh approval = (%v,%q,%v), want fail closed", allow, reason, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateMCPApprovalPrecedence(t *testing.T) {
|
||||
args := json.RawMessage(`{"path":"target"}`)
|
||||
t.Run("explicit deny beats approve", func(t *testing.T) {
|
||||
ap := &mcpStubApprover{stubApprover: stubApprover{allow: true}}
|
||||
g := NewGate(New("allow", nil, nil, []string{"mcp__srv__tool"}), ap)
|
||||
allow, _, err := g.CheckMCP(context.Background(), "mcp__srv__tool", "srv/tool", args, false, false, "approve", "auto_review")
|
||||
if err != nil || allow || ap.mcpCalls != 0 {
|
||||
t.Fatalf("deny result allow=%v err=%v reviewer calls=%d", allow, err, ap.mcpCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("approve bypasses destructive review", func(t *testing.T) {
|
||||
ap := &mcpStubApprover{stubApprover: stubApprover{allow: true}}
|
||||
g := NewGate(New("allow", []string{"mcp__srv__tool"}, nil, nil), ap)
|
||||
allow, _, err := g.CheckMCP(context.Background(), "mcp__srv__tool", "srv/tool", args, true, true, "approve", "auto_review")
|
||||
if err != nil || !allow || ap.calls != 0 || ap.mcpCalls != 0 {
|
||||
t.Fatalf("approve destructive result allow=%v err=%v approver=%+v", allow, err, ap)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("destructive prompt remains fresh and forwards reviewer", func(t *testing.T) {
|
||||
ap := &mcpStubApprover{stubApprover: stubApprover{allow: true}}
|
||||
g := NewGate(New("allow", []string{"mcp__srv__tool"}, nil, nil), ap)
|
||||
allow, _, err := g.CheckMCP(context.Background(), "mcp__srv__tool", "srv/tool", args, true, true, "prompt", "auto_review")
|
||||
if err != nil || !allow || ap.mcpCalls != 1 || !ap.destructive || !ap.forced || ap.reviewer != "auto_review" || ap.subject != "srv/tool" {
|
||||
t.Fatalf("destructive prompt result allow=%v err=%v approver=%+v", allow, err, ap)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prompt reviews readers and writers", func(t *testing.T) {
|
||||
for _, readOnly := range []bool{true, false} {
|
||||
ap := &mcpStubApprover{stubApprover: stubApprover{allow: true}}
|
||||
g := NewGate(New("allow", []string{"mcp__srv__tool"}, nil, nil), ap)
|
||||
allow, _, err := g.CheckMCP(context.Background(), "mcp__srv__tool", "srv/tool", args, readOnly, false, "prompt", "user")
|
||||
if err != nil || !allow || ap.mcpCalls != 1 {
|
||||
t.Fatalf("readOnly=%v allow=%v err=%v calls=%d", readOnly, allow, err, ap.mcpCalls)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("writes reviews only writers", func(t *testing.T) {
|
||||
ap := &mcpStubApprover{stubApprover: stubApprover{allow: true}}
|
||||
g := NewGate(New("allow", nil, nil, nil), ap)
|
||||
allow, _, _ := g.CheckMCP(context.Background(), "mcp__srv__tool", "srv/tool", args, true, false, "writes", "user")
|
||||
if !allow || ap.mcpCalls != 0 {
|
||||
t.Fatalf("reader allow=%v calls=%d", allow, ap.mcpCalls)
|
||||
}
|
||||
allow, _, _ = g.CheckMCP(context.Background(), "mcp__srv__tool", "srv/tool", args, false, false, "writes", "user")
|
||||
if !allow || ap.mcpCalls != 1 {
|
||||
t.Fatalf("writer allow=%v calls=%d", allow, ap.mcpCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("approve overrides ordinary ask", func(t *testing.T) {
|
||||
ap := &mcpStubApprover{stubApprover: stubApprover{allow: false}}
|
||||
g := NewGate(New("ask", nil, []string{"mcp__srv__tool"}, nil), ap)
|
||||
allow, _, err := g.CheckMCP(context.Background(), "mcp__srv__tool", "srv/tool", args, false, false, "approve", "user")
|
||||
if err != nil || !allow || ap.calls != 0 || ap.mcpCalls != 0 {
|
||||
t.Fatalf("approve allow=%v err=%v normal=%d mcp=%d", allow, err, ap.calls, ap.mcpCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("approve overrides global deny fallback", func(t *testing.T) {
|
||||
ap := &mcpStubApprover{stubApprover: stubApprover{allow: false}}
|
||||
g := NewGate(New("deny", nil, nil, nil), ap)
|
||||
allow, _, err := g.CheckMCP(context.Background(), "mcp__srv__tool", "srv/tool", args, false, false, "approve", "user")
|
||||
if err != nil || !allow || ap.mcpCalls != 0 {
|
||||
t.Fatalf("approve allow=%v err=%v mcp=%d", allow, err, ap.mcpCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("auto routes an ask to the configured reviewer", func(t *testing.T) {
|
||||
ap := &mcpStubApprover{stubApprover: stubApprover{allow: true}}
|
||||
g := NewGate(New("ask", nil, []string{"mcp__srv__tool"}, nil), ap)
|
||||
allow, _, err := g.CheckMCP(context.Background(), "mcp__srv__tool", "srv/tool", args, false, false, "auto", "auto_review")
|
||||
if err != nil || !allow || ap.calls != 0 || ap.mcpCalls != 1 || ap.forced {
|
||||
t.Fatalf("auto allow=%v err=%v normal=%d mcp=%d", allow, err, ap.calls, ap.mcpCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("auto without reviewer preserves legacy approver", func(t *testing.T) {
|
||||
ap := &mcpStubApprover{stubApprover: stubApprover{allow: true}}
|
||||
g := NewGate(New("ask", nil, []string{"mcp__srv__tool"}, nil), ap)
|
||||
allow, _, err := g.CheckMCP(context.Background(), "mcp__srv__tool", "srv/tool", args, false, false, "auto", "")
|
||||
if err != nil || !allow || ap.calls != 1 || ap.mcpCalls != 0 {
|
||||
t.Fatalf("legacy auto allow=%v err=%v normal=%d mcp=%d", allow, err, ap.calls, ap.mcpCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGateMCPConfiguredReviewFailsClosedWithoutReviewer(t *testing.T) {
|
||||
g := NewGate(New("allow", nil, nil, nil), nil)
|
||||
for _, tc := range []struct {
|
||||
mode string
|
||||
destructive bool
|
||||
}{
|
||||
{mode: "prompt"},
|
||||
{mode: "writes"},
|
||||
} {
|
||||
allow, reason, err := g.CheckMCP(context.Background(), "mcp__srv__tool", "srv/tool", nil, false, tc.destructive, tc.mode, "user")
|
||||
if err != nil || allow || !strings.Contains(reason, "non-interactive") {
|
||||
t.Fatalf("mode=%s destructive=%v result=(%v,%q,%v)", tc.mode, tc.destructive, allow, reason, err)
|
||||
}
|
||||
}
|
||||
|
||||
allow, reason, err := g.CheckMCP(context.Background(), "mcp__srv__tool", "srv/tool", nil, false, true, "approve", "user")
|
||||
if err != nil || !allow || reason != "" {
|
||||
t.Fatalf("approve destructive result=(%v,%q,%v), want allow without reviewer", allow, reason, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeStyleRuleMatchesExactCommandWithoutWildcard(t *testing.T) {
|
||||
p := New("ask", []string{"Bash(go build)"}, nil, nil)
|
||||
|
||||
|
||||
@@ -25,15 +25,14 @@ const (
|
||||
PlanSafetyUnsafe
|
||||
)
|
||||
|
||||
// Call is the plan-mode view of one tool invocation. ReadOnly,
|
||||
// UntrustedReadOnly, and Args remain for source compatibility with older callers;
|
||||
// they do not decide phase availability because Permissions/Sandbox own safety.
|
||||
// Call is the plan-mode view of one tool invocation. ReadOnly and Args remain
|
||||
// for source compatibility with older callers; they do not decide phase
|
||||
// availability because Permissions/Sandbox own safety.
|
||||
type Call struct {
|
||||
Name string
|
||||
ReadOnly bool
|
||||
UntrustedReadOnly bool
|
||||
Safety PlanSafety
|
||||
Args json.RawMessage
|
||||
Name string
|
||||
ReadOnly bool
|
||||
Safety PlanSafety
|
||||
Args json.RawMessage
|
||||
}
|
||||
|
||||
// Decision reports whether phase semantics refuse a call and why.
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestDecideLeavesSafetyToPermissionsAndSandbox(t *testing.T) {
|
||||
{Name: "read_file", ReadOnly: true},
|
||||
{Name: "write_file", ReadOnly: false},
|
||||
{Name: "bash", ReadOnly: false},
|
||||
{Name: "mcp__srv__query", ReadOnly: true, UntrustedReadOnly: true},
|
||||
{Name: "mcp__srv__query", ReadOnly: true},
|
||||
{Name: "mcp__srv__write", ReadOnly: false},
|
||||
{Name: "self_reported_writer", ReadOnly: false, Safety: PlanSafetySafe},
|
||||
} {
|
||||
|
||||
+32
-53
@@ -1,10 +1,10 @@
|
||||
// Package-internal cache for MCP handshake results. The handshake (initialize +
|
||||
// listTools, plus optional listPrompts/listResources) costs hundreds of ms to a
|
||||
// few seconds per server on cold start. We persist the tool schema +
|
||||
// capabilities under the user cache dir keyed by a fingerprint of the load-
|
||||
// bearing Spec fields, so the next launch can register tools optimistically
|
||||
// capabilities under the user cache dir keyed by load-bearing, non-secret Spec
|
||||
// fields, so the next launch can register tools optimistically
|
||||
// without waiting for the network/subprocess. Caching is purely an
|
||||
// optimisation: any failure (missing dir, bad JSON, hash mismatch) silently
|
||||
// optimisation: any failure (missing dir, bad JSON, key mismatch) silently
|
||||
// degrades to a fresh handshake.
|
||||
package plugin
|
||||
|
||||
@@ -36,7 +36,7 @@ func cacheableToolsOf(tools []tool.Tool) []CachedTool {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
declaredReadOnly, _, _, destructive, _ := rt.securitySnapshot()
|
||||
declaredReadOnly, _, destructive := rt.securitySnapshot()
|
||||
out = append(out, CachedTool{
|
||||
Name: rt.rawName,
|
||||
Description: rt.desc,
|
||||
@@ -55,12 +55,14 @@ func cacheableToolsOf(tools []tool.Tool) []CachedTool {
|
||||
const cacheVersion = 2
|
||||
|
||||
// CachedSchema is the persisted snapshot of one server's handshake result.
|
||||
// SpecHash gates reuse — Capabilities/Tools are only trusted when the
|
||||
// caller's expectedHash (from SpecFingerprint of the current Spec) matches,
|
||||
// CacheKey gates reuse — Capabilities/Tools are only trusted when the
|
||||
// caller's expectedKey (from SchemaCacheKey of the current Spec) matches,
|
||||
// so renaming env vars or swapping a command never serves stale tools.
|
||||
type CachedSchema struct {
|
||||
Version int `json:"version"`
|
||||
SpecHash string `json:"spec_hash"`
|
||||
Version int `json:"version"`
|
||||
// Keep the historical JSON key so older versions can reuse the same
|
||||
// best-effort cache during rolling upgrades.
|
||||
CacheKey string `json:"spec_hash"`
|
||||
Capabilities map[string]bool `json:"capabilities"`
|
||||
Tools []CachedTool `json:"tools"`
|
||||
LastValidated time.Time `json:"last_validated"`
|
||||
@@ -69,7 +71,7 @@ type CachedSchema struct {
|
||||
// CachedTool mirrors the subset of an MCP tool definition we need to register
|
||||
// a placeholder before the real handshake completes: Name (raw, server-local),
|
||||
// Description (model-visible), Schema (raw JSON for input validation),
|
||||
// ReadOnly and Destructive (drive local approval policy).
|
||||
// ReadOnly and Destructive (drive Plan/read-only safety classification).
|
||||
type CachedTool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
@@ -79,28 +81,28 @@ type CachedTool struct {
|
||||
Destructive bool `json:"destructive,omitempty"`
|
||||
}
|
||||
|
||||
// SpecFingerprint hashes the load-bearing, non-secret parts of a Spec. Secret
|
||||
// SchemaCacheKey hashes the load-bearing, non-secret parts of a Spec. Secret
|
||||
// values in env and headers are intentionally excluded: credential rotation
|
||||
// must not leak through a stable digest or force an unrelated trust review.
|
||||
// Their sorted key names remain identity-bearing, so adding/removing a runtime
|
||||
// input still invalidates the cached schema.
|
||||
func SpecFingerprint(s Spec) string {
|
||||
return specFingerprintForURL(s, normalizeIdentityURL(s.URL))
|
||||
func SchemaCacheKey(s Spec) string {
|
||||
return schemaCacheKeyForURL(s, normalizeIdentityURL(s.URL))
|
||||
}
|
||||
|
||||
// legacySpecFingerprint recomputes the cache fingerprint with the
|
||||
// legacySchemaCacheKey recomputes the cache key with the
|
||||
// pre-credential-aware URL normalization, or ("", false) when it cannot
|
||||
// differ. LoadCachedSchemaForSpec uses it to upgrade old cache entries in
|
||||
// place; remove together with legacyNormalizeIdentityURL.
|
||||
func legacySpecFingerprint(s Spec) (string, bool) {
|
||||
func legacySchemaCacheKey(s Spec) (string, bool) {
|
||||
legacyURL := legacyNormalizeIdentityURL(s.URL)
|
||||
if strings.TrimSpace(s.URL) == "" || legacyURL == normalizeIdentityURL(s.URL) {
|
||||
return "", false
|
||||
}
|
||||
return specFingerprintForURL(s, legacyURL), true
|
||||
return schemaCacheKeyForURL(s, legacyURL), true
|
||||
}
|
||||
|
||||
func specFingerprintForURL(s Spec, urlValue string) string {
|
||||
func schemaCacheKeyForURL(s Spec, urlValue string) string {
|
||||
h := sha256.New()
|
||||
writeField(h, "name", s.Name)
|
||||
writeField(h, "type", s.Type)
|
||||
@@ -112,38 +114,32 @@ func specFingerprintForURL(s Spec, urlValue string) string {
|
||||
}
|
||||
writeKeys(h, "env", s.Env)
|
||||
writeKeys(h, "headers", s.Headers)
|
||||
if len(s.ReadOnlyToolNames) > 0 {
|
||||
writeBoolKV(h, "read_only_tool", s.ReadOnlyToolNames)
|
||||
}
|
||||
if len(s.ReadOnlyModelToolNames) > 0 {
|
||||
writeBoolKV(h, "read_only_model_tool", s.ReadOnlyModelToolNames)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// LoadCachedSchema returns the cached schema for name iff it exists, parses,
|
||||
// and matches expectedHash. Any error → (nil, false): cache is best-effort,
|
||||
// and matches expectedKey. Any error → (nil, false): cache is best-effort,
|
||||
// a corrupt file just means we re-handshake. Returning an error here would
|
||||
// only invite callers to log it on every launch — silence is intentional.
|
||||
func LoadCachedSchema(name, expectedHash string) (*CachedSchema, bool) {
|
||||
cs, ok, hashOK := LoadCachedSchemaAny(name, expectedHash)
|
||||
if !ok || !hashOK {
|
||||
func LoadCachedSchema(name, expectedKey string) (*CachedSchema, bool) {
|
||||
cs, ok, keyOK := LoadCachedSchemaAny(name, expectedKey)
|
||||
if !ok || !keyOK {
|
||||
return nil, false
|
||||
}
|
||||
return cs, true
|
||||
}
|
||||
|
||||
// LoadCachedSchemaForSpec returns the cached schema matching the spec's
|
||||
// current fingerprint, transparently rewriting an entry still saved under the
|
||||
// legacy URL fingerprint. Without the in-place upgrade, credential rotation or
|
||||
// current key, transparently rewriting an entry still saved under the legacy
|
||||
// URL key. Without the in-place upgrade, credential rotation or
|
||||
// the credential-aware normalization rollout would force a pointless
|
||||
// re-handshake even though nothing observable changed.
|
||||
func LoadCachedSchemaForSpec(s Spec) (*CachedSchema, bool) {
|
||||
current := SpecFingerprint(s)
|
||||
current := SchemaCacheKey(s)
|
||||
if cs, ok := LoadCachedSchema(s.Name, current); ok {
|
||||
return cs, true
|
||||
}
|
||||
legacy, ok := legacySpecFingerprint(s)
|
||||
legacy, ok := legacySchemaCacheKey(s)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
@@ -151,16 +147,16 @@ func LoadCachedSchemaForSpec(s Spec) (*CachedSchema, bool) {
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
cs.SpecHash = current
|
||||
cs.CacheKey = current
|
||||
_ = SaveCachedSchema(s.Name, *cs)
|
||||
return cs, true
|
||||
}
|
||||
|
||||
// LoadCachedSchemaAny returns the cached schema regardless of spec-hash match,
|
||||
// plus whether the hash matched expectedHash. Catalog building uses it so a
|
||||
// fingerprint-mismatched cache can still surface tools as stale candidates;
|
||||
// LoadCachedSchemaAny returns the cached schema regardless of cache-key match,
|
||||
// plus whether the key matched expectedKey. Catalog building uses it so a
|
||||
// mismatched cache can still surface tools as stale candidates;
|
||||
// execution paths must keep using LoadCachedSchema, which refuses mismatches.
|
||||
func LoadCachedSchemaAny(name, expectedHash string) (cs *CachedSchema, ok bool, hashOK bool) {
|
||||
func LoadCachedSchemaAny(name, expectedKey string) (cs *CachedSchema, ok bool, keyOK bool) {
|
||||
p := cachePath(name)
|
||||
if p == "" {
|
||||
return nil, false, false
|
||||
@@ -177,7 +173,7 @@ func LoadCachedSchemaAny(name, expectedHash string) (cs *CachedSchema, ok bool,
|
||||
return nil, false, false
|
||||
}
|
||||
out.Tools = filterValidCachedTools(out.Tools)
|
||||
return &out, true, out.SpecHash == expectedHash
|
||||
return &out, true, out.CacheKey == expectedKey
|
||||
}
|
||||
|
||||
func filterValidCachedTools(tools []CachedTool) []CachedTool {
|
||||
@@ -286,20 +282,3 @@ func writeKeys(h io.Writer, key string, m map[string]string) {
|
||||
writeField(h, key+"."+k, "present")
|
||||
}
|
||||
}
|
||||
|
||||
func writeBoolKV(h io.Writer, key string, m map[string]bool) {
|
||||
if len(m) == 0 {
|
||||
writeField(h, key, "")
|
||||
return
|
||||
}
|
||||
keys := make([]string, 0, len(m))
|
||||
for k, v := range m {
|
||||
if v {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
writeField(h, key+"."+k, "true")
|
||||
}
|
||||
}
|
||||
+83
-102
@@ -34,9 +34,9 @@ func sampleSpec() Spec {
|
||||
}
|
||||
}
|
||||
|
||||
func sampleCachedSchema(hash string) CachedSchema {
|
||||
func sampleCachedSchema(key string) CachedSchema {
|
||||
return CachedSchema{
|
||||
SpecHash: hash,
|
||||
CacheKey: key,
|
||||
Capabilities: map[string]bool{"prompts": true, "resources": false},
|
||||
Tools: []CachedTool{{
|
||||
Name: "do_thing",
|
||||
@@ -51,18 +51,25 @@ func sampleCachedSchema(hash string) CachedSchema {
|
||||
func TestCacheRoundTrip(t *testing.T) {
|
||||
redirectCache(t)
|
||||
spec := sampleSpec()
|
||||
hash := SpecFingerprint(spec)
|
||||
cs := sampleCachedSchema(hash)
|
||||
key := SchemaCacheKey(spec)
|
||||
cs := sampleCachedSchema(key)
|
||||
|
||||
if err := SaveCachedSchema(spec.Name, cs); err != nil {
|
||||
t.Fatalf("SaveCachedSchema: %v", err)
|
||||
}
|
||||
got, ok := LoadCachedSchema(spec.Name, hash)
|
||||
body, err := os.ReadFile(cachePath(spec.Name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(body), `"spec_hash"`) || strings.Contains(string(body), `"cache_key"`) {
|
||||
t.Fatalf("schema cache JSON compatibility changed: %s", body)
|
||||
}
|
||||
got, ok := LoadCachedSchema(spec.Name, key)
|
||||
if !ok {
|
||||
t.Fatal("LoadCachedSchema: miss after save")
|
||||
}
|
||||
if got.SpecHash != hash {
|
||||
t.Errorf("SpecHash: got %q want %q", got.SpecHash, hash)
|
||||
if got.CacheKey != key {
|
||||
t.Errorf("CacheKey: got %q want %q", got.CacheKey, key)
|
||||
}
|
||||
if len(got.Tools) != 1 || got.Tools[0].Name != "do_thing" {
|
||||
t.Errorf("Tools: %+v", got.Tools)
|
||||
@@ -84,17 +91,17 @@ func TestCacheRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachePersistsDeclaredReaderIndependentlyOfLocalTrust(t *testing.T) {
|
||||
func TestCachePersistsDeclaredReaderIndependentlyOfServerAuthorization(t *testing.T) {
|
||||
cached := cacheableToolsOf([]tool.Tool{&remoteTool{
|
||||
rawName: "search", schema: json.RawMessage(`{"type":"object"}`),
|
||||
declaredReadOnly: true, readOnly: false, readOnlyTrusted: false,
|
||||
declaredReadOnly: true, readOnly: false,
|
||||
}})
|
||||
if len(cached) != 1 || !cached[0].ReadOnly {
|
||||
t.Fatalf("cached tool = %+v, want the server-declared reader snapshot", cached)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedToolSafetySeparatesServerHintFromExplicitReaderAuthority(t *testing.T) {
|
||||
func TestCachedToolSafetyTracksReadOnlyAndDestructiveHintsOnly(t *testing.T) {
|
||||
redirectCache(t)
|
||||
spec := Spec{
|
||||
Name: "cached-reader", Type: "http", URL: "https://example.com/mcp",
|
||||
@@ -102,40 +109,36 @@ func TestCachedToolSafetySeparatesServerHintFromExplicitReaderAuthority(t *testi
|
||||
reader := CachedTool{
|
||||
Name: "search", Schema: json.RawMessage(`{"type":"object","properties":{"q":{"type":"string"}}}`), ReadOnly: true,
|
||||
}
|
||||
if err := SaveCachedSchema(spec.Name, CachedSchema{SpecHash: SpecFingerprint(spec), Tools: []CachedTool{reader}}); err != nil {
|
||||
if err := SaveCachedSchema(spec.Name, CachedSchema{CacheKey: SchemaCacheKey(spec), Tools: []CachedTool{reader}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, found := CachedToolSafetyForSpec(spec, "search")
|
||||
if !found || !before.ReadOnly || before.TrustedReader {
|
||||
t.Fatalf("server hint = (%+v,%v), want ordinary reader without strict authority", before, found)
|
||||
}
|
||||
spec.ReadOnlyToolNames = map[string]bool{"search": true}
|
||||
if _, found := CachedToolSafetyForSpec(spec, "search"); found {
|
||||
t.Fatal("classification change reused a cache from a different policy")
|
||||
}
|
||||
if err := SaveCachedSchema(spec.Name, CachedSchema{SpecHash: SpecFingerprint(spec), Tools: []CachedTool{reader}}); err != nil {
|
||||
t.Fatal(err)
|
||||
if !found || !before.ReadOnly {
|
||||
t.Fatalf("server hint = (%+v,%v), want reader metadata", before, found)
|
||||
}
|
||||
after, found := CachedToolSafetyForSpec(spec, "search")
|
||||
if !found || !after.ReadOnly || !after.TrustedReader {
|
||||
t.Fatalf("explicit reader = (%+v,%v), want strict reader authority", after, found)
|
||||
if !found || !after.ReadOnly {
|
||||
t.Fatalf("explicit reader = (%+v,%v), want reader metadata", after, found)
|
||||
}
|
||||
|
||||
oldFingerprint := after.CapabilityFingerprint
|
||||
// Input/output schema changes are compatibility facts, not authorization or
|
||||
// execution-safety decisions. The live server validates the current call and
|
||||
// the refreshed schema cache becomes provider-visible next session.
|
||||
reader.Schema = json.RawMessage(`{"type":"object","properties":{"q":{"type":"number"}}}`)
|
||||
if err := SaveCachedSchema(spec.Name, CachedSchema{SpecHash: SpecFingerprint(spec), Tools: []CachedTool{reader}}); err != nil {
|
||||
reader.Destructive = true
|
||||
if err := SaveCachedSchema(spec.Name, CachedSchema{CacheKey: SchemaCacheKey(spec), Tools: []CachedTool{reader}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
drifted, found := CachedToolSafetyForSpec(spec, "search")
|
||||
if !found || !drifted.TrustedReader || drifted.CapabilityFingerprint == oldFingerprint {
|
||||
t.Fatalf("schema update = (%+v,%v), want current fingerprint with explicit authority", drifted, found)
|
||||
updated, found := CachedToolSafetyForSpec(spec, "search")
|
||||
if !found || !updated.ReadOnly || !updated.Destructive {
|
||||
t.Fatalf("safety update = (%+v,%v), want read-only/destructive hints", updated, found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheLoadsLegacyToolWithoutDestructiveField(t *testing.T) {
|
||||
redirectCache(t)
|
||||
spec := sampleSpec()
|
||||
hash := SpecFingerprint(spec)
|
||||
hash := SchemaCacheKey(spec)
|
||||
p := cachePath(spec.Name)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -157,7 +160,7 @@ func TestCacheLoadsLegacyToolWithoutDestructiveField(t *testing.T) {
|
||||
func TestCacheLoadQuarantinesMalformedToolSchema(t *testing.T) {
|
||||
redirectCache(t)
|
||||
spec := sampleSpec()
|
||||
hash := SpecFingerprint(spec)
|
||||
hash := SchemaCacheKey(spec)
|
||||
cs := sampleCachedSchema(hash)
|
||||
cs.Tools = append(cs.Tools, CachedTool{
|
||||
Name: "generate_yso_bytes",
|
||||
@@ -182,31 +185,15 @@ func TestCacheLoadQuarantinesMalformedToolSchema(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheInvalidatesOnSpecHashMismatch(t *testing.T) {
|
||||
func TestCacheInvalidatesOnSchemaCacheKeyMismatch(t *testing.T) {
|
||||
redirectCache(t)
|
||||
spec := sampleSpec()
|
||||
hash := SpecFingerprint(spec)
|
||||
if err := SaveCachedSchema(spec.Name, sampleCachedSchema(hash)); err != nil {
|
||||
key := SchemaCacheKey(spec)
|
||||
if err := SaveCachedSchema(spec.Name, sampleCachedSchema(key)); err != nil {
|
||||
t.Fatalf("SaveCachedSchema: %v", err)
|
||||
}
|
||||
if _, ok := LoadCachedSchema(spec.Name, "different-hash"); ok {
|
||||
t.Fatal("LoadCachedSchema: hit despite mismatching expectedHash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheInvalidatesWhenReadOnlyTrustChanges(t *testing.T) {
|
||||
redirectCache(t)
|
||||
spec := sampleSpec()
|
||||
spec.ReadOnlyToolNames = map[string]bool{"echo": true}
|
||||
spec.ReadOnlyModelToolNames = map[string]bool{"mcp__my-server__search": true}
|
||||
hash := SpecFingerprint(spec)
|
||||
if err := SaveCachedSchema(spec.Name, sampleCachedSchema(hash)); err != nil {
|
||||
t.Fatalf("SaveCachedSchema: %v", err)
|
||||
}
|
||||
|
||||
withoutTrust := sampleSpec()
|
||||
if _, ok := LoadCachedSchema(spec.Name, SpecFingerprint(withoutTrust)); ok {
|
||||
t.Fatal("LoadCachedSchema: hit after trusted read-only config changed")
|
||||
if _, ok := LoadCachedSchema(spec.Name, "different-cache-key"); ok {
|
||||
t.Fatal("LoadCachedSchema: hit despite mismatching expectedKey")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +224,7 @@ func TestCacheVersionMismatchReturnsFalse(t *testing.T) {
|
||||
// not poison an older binary's cache reads.
|
||||
redirectCache(t)
|
||||
spec := sampleSpec()
|
||||
hash := SpecFingerprint(spec)
|
||||
hash := SchemaCacheKey(spec)
|
||||
cs := sampleCachedSchema(hash)
|
||||
cs.Version = cacheVersion + 99
|
||||
p := cachePath(spec.Name)
|
||||
@@ -256,12 +243,12 @@ func TestCacheVersionMismatchReturnsFalse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecFingerprintStable(t *testing.T) {
|
||||
func TestSchemaCacheKeyStable(t *testing.T) {
|
||||
spec := sampleSpec()
|
||||
h1 := SpecFingerprint(spec)
|
||||
h2 := SpecFingerprint(spec)
|
||||
h1 := SchemaCacheKey(spec)
|
||||
h2 := SchemaCacheKey(spec)
|
||||
if h1 != h2 {
|
||||
t.Fatalf("SpecFingerprint not stable: %q vs %q", h1, h2)
|
||||
t.Fatalf("SchemaCacheKey not stable: %q vs %q", h1, h2)
|
||||
}
|
||||
|
||||
// Reorder the env (build a new map; Go map iteration order is randomised
|
||||
@@ -270,74 +257,68 @@ func TestSpecFingerprintStable(t *testing.T) {
|
||||
reordered := spec
|
||||
for i := 0; i < 32; i++ {
|
||||
reordered.Env = map[string]string{"BAR": "2", "FOO": "1"}
|
||||
if got := SpecFingerprint(reordered); got != h1 {
|
||||
t.Fatalf("SpecFingerprint changed when env was rebuilt: %q vs %q", got, h1)
|
||||
if got := SchemaCacheKey(reordered); got != h1 {
|
||||
t.Fatalf("SchemaCacheKey changed when env was rebuilt: %q vs %q", got, h1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecFingerprintIgnoresHostLocalTrustAndIsolation(t *testing.T) {
|
||||
func TestSchemaCacheKeyIgnoresHostLocalAuthorizationAndIsolation(t *testing.T) {
|
||||
base := sampleSpec()
|
||||
changed := base
|
||||
changed.LaunchManager = mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), "/workspace")
|
||||
changed.ConfigSource = "project:.mcp.json"
|
||||
changed.Package = "figma"
|
||||
changed.OfficialCatalogEntryID = "[email protected]@1.0.0"
|
||||
changed.OfficialReaderNames = []string{"search"}
|
||||
changed.PackageDigest = "sha256:package"
|
||||
changed.VerifiedVersion = "1.0.0"
|
||||
changed.CatalogSequence = 42
|
||||
changed.ReaderSandbox = sandbox.Spec{Mode: "enforce", Network: true, WriteRoots: []string{"/host/state"}, MinimalWrites: true}
|
||||
changed.WriterSandbox = sandbox.Spec{Mode: "enforce", WriteRoots: []string{"/workspace"}, MinimalWrites: true}
|
||||
changed.Sandbox = sandbox.Spec{Mode: "enforce", Network: true, WriteRoots: []string{"/workspace"}, MinimalWrites: true}
|
||||
changed.StateDir = "/host/state"
|
||||
if got, want := SpecFingerprint(changed), SpecFingerprint(base); got != want {
|
||||
t.Fatalf("host-local security state changed provider cache fingerprint: %q != %q", got, want)
|
||||
if got, want := SchemaCacheKey(changed), SchemaCacheKey(base); got != want {
|
||||
t.Fatalf("host-local security state changed schema cache key: %q != %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecFingerprintTracksNonSecretIdentityOnly(t *testing.T) {
|
||||
func TestSchemaCacheKeyTracksNonSecretSpecIdentityOnly(t *testing.T) {
|
||||
a := sampleSpec()
|
||||
renamed := a
|
||||
renamed.Name = "other-server"
|
||||
if SpecFingerprint(a) == SpecFingerprint(renamed) {
|
||||
t.Fatal("SpecFingerprint did not change when server name changed")
|
||||
if SchemaCacheKey(a) == SchemaCacheKey(renamed) {
|
||||
t.Fatal("SchemaCacheKey did not change when server name changed")
|
||||
}
|
||||
|
||||
b := a
|
||||
b.Command = "/usr/local/bin/other"
|
||||
if SpecFingerprint(a) == SpecFingerprint(b) {
|
||||
t.Fatal("SpecFingerprint did not change when Command changed")
|
||||
if SchemaCacheKey(a) == SchemaCacheKey(b) {
|
||||
t.Fatal("SchemaCacheKey did not change when Command changed")
|
||||
}
|
||||
|
||||
c := a
|
||||
c.Args = append([]string{}, a.Args...)
|
||||
c.Args[0] = "--different"
|
||||
if SpecFingerprint(a) == SpecFingerprint(c) {
|
||||
t.Fatal("SpecFingerprint did not change when Args changed")
|
||||
if SchemaCacheKey(a) == SchemaCacheKey(c) {
|
||||
t.Fatal("SchemaCacheKey did not change when Args changed")
|
||||
}
|
||||
|
||||
d := a
|
||||
d.Env = map[string]string{"FOO": "1", "BAR": "different"}
|
||||
if SpecFingerprint(a) != SpecFingerprint(d) {
|
||||
t.Fatal("SpecFingerprint changed when an environment credential value rotated")
|
||||
if SchemaCacheKey(a) != SchemaCacheKey(d) {
|
||||
t.Fatal("SchemaCacheKey changed when an environment credential value rotated")
|
||||
}
|
||||
|
||||
e := a
|
||||
e.Env = map[string]string{"FOO": "1", "NEW_KEY": "2"}
|
||||
if SpecFingerprint(a) == SpecFingerprint(e) {
|
||||
t.Fatal("SpecFingerprint did not change when environment key names changed")
|
||||
if SchemaCacheKey(a) == SchemaCacheKey(e) {
|
||||
t.Fatal("SchemaCacheKey did not change when environment key names changed")
|
||||
}
|
||||
|
||||
f := a
|
||||
f.Headers = map[string]string{"X-Custom": "rotated-secret"}
|
||||
if SpecFingerprint(a) != SpecFingerprint(f) {
|
||||
t.Fatal("SpecFingerprint changed when a header credential value rotated")
|
||||
if SchemaCacheKey(a) != SchemaCacheKey(f) {
|
||||
t.Fatal("SchemaCacheKey changed when a header credential value rotated")
|
||||
}
|
||||
|
||||
g := a
|
||||
g.Headers = map[string]string{"Authorization": "secret"}
|
||||
if SpecFingerprint(a) == SpecFingerprint(g) {
|
||||
t.Fatal("SpecFingerprint did not change when header key names changed")
|
||||
if SchemaCacheKey(a) == SchemaCacheKey(g) {
|
||||
t.Fatal("SchemaCacheKey did not change when header key names changed")
|
||||
}
|
||||
|
||||
h := a
|
||||
@@ -345,8 +326,8 @@ func TestSpecFingerprintTracksNonSecretIdentityOnly(t *testing.T) {
|
||||
h.URL = "https://user:[email protected]/mcp?access_token=first&workspace=one"
|
||||
i := h
|
||||
i.URL = "https://other:[email protected]/mcp?access_token=second&workspace=two"
|
||||
if SpecFingerprint(h) == SpecFingerprint(i) {
|
||||
t.Fatal("SpecFingerprint did not bind URL credential/query values")
|
||||
if SchemaCacheKey(h) == SchemaCacheKey(i) {
|
||||
t.Fatal("SchemaCacheKey did not bind URL credential/query values")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,54 +389,54 @@ func TestSlugAppendsHashForWindowsReservedDeviceNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecFingerprintRedactsURLCredentialsButKeepsResourceScope(t *testing.T) {
|
||||
func TestSchemaCacheKeyRedactsURLCredentialsButKeepsResourceScope(t *testing.T) {
|
||||
base := sampleSpec()
|
||||
base.Type = "http"
|
||||
base.URL = "https://user:[email protected]/mcp?access_token=one&workspace=alpha&tenant=t1"
|
||||
|
||||
rotated := base
|
||||
rotated.URL = "https://user:[email protected]/mcp?access_token=two&workspace=alpha&tenant=t1"
|
||||
if SpecFingerprint(base) != SpecFingerprint(rotated) {
|
||||
t.Fatal("credential rotation changed the cache fingerprint")
|
||||
if SchemaCacheKey(base) != SchemaCacheKey(rotated) {
|
||||
t.Fatal("credential rotation changed the schema cache key")
|
||||
}
|
||||
|
||||
reordered := base
|
||||
reordered.URL = "https://user:[email protected]/mcp?tenant=t1&workspace=alpha&access_token=one"
|
||||
if SpecFingerprint(base) != SpecFingerprint(reordered) {
|
||||
t.Fatal("query parameter order changed the cache fingerprint")
|
||||
if SchemaCacheKey(base) != SchemaCacheKey(reordered) {
|
||||
t.Fatal("query parameter order changed the schema cache key")
|
||||
}
|
||||
|
||||
movedWorkspace := base
|
||||
movedWorkspace.URL = "https://user:[email protected]/mcp?access_token=one&workspace=beta&tenant=t1"
|
||||
if SpecFingerprint(base) == SpecFingerprint(movedWorkspace) {
|
||||
t.Fatal("workspace scope change did not change the cache fingerprint")
|
||||
if SchemaCacheKey(base) == SchemaCacheKey(movedWorkspace) {
|
||||
t.Fatal("workspace scope change did not change the schema cache key")
|
||||
}
|
||||
|
||||
// Case/separator variants of credential keys are still recognized: their
|
||||
// values redact, so rotating them never moves the fingerprint. The key
|
||||
// values redact, so rotating them never moves the cache key. The key
|
||||
// spelling itself remains identity-bearing.
|
||||
variant := base
|
||||
variant.URL = "https://user:[email protected]/mcp?ACCESS-TOKEN=three&workspace=alpha&tenant=t1"
|
||||
variantRotated := base
|
||||
variantRotated.URL = "https://user:[email protected]/mcp?ACCESS-TOKEN=four&workspace=alpha&tenant=t1"
|
||||
if SpecFingerprint(variant) != SpecFingerprint(variantRotated) {
|
||||
t.Fatal("variant-spelled credential key leaked its value into the fingerprint")
|
||||
if SchemaCacheKey(variant) != SchemaCacheKey(variantRotated) {
|
||||
t.Fatal("variant-spelled credential key leaked its value into the schema cache key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCachedSchemaForSpecMigratesLegacyURLFingerprint(t *testing.T) {
|
||||
func TestLoadCachedSchemaForSpecMigratesLegacyURLCacheKey(t *testing.T) {
|
||||
redirectCache(t)
|
||||
spec := sampleSpec()
|
||||
spec.Name = "legacy-url"
|
||||
spec.Type = "http"
|
||||
spec.URL = "https://example.com/mcp?access_token=secret&workspace=alpha"
|
||||
|
||||
legacy, ok := legacySpecFingerprint(spec)
|
||||
legacy, ok := legacySchemaCacheKey(spec)
|
||||
if !ok {
|
||||
t.Fatal("legacy fingerprint unavailable for credential-bearing URL")
|
||||
t.Fatal("legacy cache key unavailable for credential-bearing URL")
|
||||
}
|
||||
if err := SaveCachedSchema(spec.Name, CachedSchema{
|
||||
SpecHash: legacy,
|
||||
CacheKey: legacy,
|
||||
Capabilities: map[string]bool{"tools": true},
|
||||
Tools: []CachedTool{{Name: "echo", Schema: json.RawMessage(`{"type":"object"}`)}},
|
||||
}); err != nil {
|
||||
@@ -466,11 +447,11 @@ func TestLoadCachedSchemaForSpecMigratesLegacyURLFingerprint(t *testing.T) {
|
||||
if !ok || len(cs.Tools) != 1 || cs.Tools[0].Name != "echo" {
|
||||
t.Fatalf("legacy cache entry did not load: ok=%v cs=%+v", ok, cs)
|
||||
}
|
||||
if cs.SpecHash != SpecFingerprint(spec) {
|
||||
t.Fatalf("loaded cache kept legacy hash %q", cs.SpecHash)
|
||||
if cs.CacheKey != SchemaCacheKey(spec) {
|
||||
t.Fatalf("loaded cache kept legacy key %q", cs.CacheKey)
|
||||
}
|
||||
// The upgrade persists: a plain current-hash load now succeeds.
|
||||
if _, ok := LoadCachedSchema(spec.Name, SpecFingerprint(spec)); !ok {
|
||||
// The upgrade persists: a plain current-key load now succeeds.
|
||||
if _, ok := LoadCachedSchema(spec.Name, SchemaCacheKey(spec)); !ok {
|
||||
t.Fatal("legacy cache entry was not rewritten in place")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ const (
|
||||
// startup behavior.
|
||||
func ApplyKnownOverrides(s Spec, workspaceRoot string) Spec {
|
||||
if isCodeGraphSpecName(s.Name) {
|
||||
s.ReadOnlyToolNames = mergeReadOnlyToolNames(s.ReadOnlyToolNames, codeGraphReadOnlyToolNames())
|
||||
if isStdioSpecType(s.Type) {
|
||||
if s.Dir == "" {
|
||||
s.Dir = strings.TrimSpace(workspaceRoot)
|
||||
@@ -43,13 +42,6 @@ func ApplyKnownOverrides(s Spec, workspaceRoot string) Spec {
|
||||
return s
|
||||
}
|
||||
|
||||
// ApplyKnownReadOnlyOverrides fills compatibility read-only hints for MCP
|
||||
// servers whose read surfaces are stable but older runtimes may omit MCP
|
||||
// annotations. It does not make the server built-in or change startup behavior.
|
||||
func ApplyKnownReadOnlyOverrides(s Spec) Spec {
|
||||
return ApplyKnownOverrides(s, "")
|
||||
}
|
||||
|
||||
func isCodeGraphSpecName(name string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(name), "codegraph")
|
||||
}
|
||||
@@ -95,19 +87,6 @@ func isStdioSpecType(typ string) bool {
|
||||
return typ == "" || typ == "stdio"
|
||||
}
|
||||
|
||||
func mergeReadOnlyToolNames(existing map[string]bool, extra map[string]bool) map[string]bool {
|
||||
out := make(map[string]bool, len(existing)+len(extra))
|
||||
for name, ok := range existing {
|
||||
out[name] = ok
|
||||
}
|
||||
for name, ok := range extra {
|
||||
if ok {
|
||||
out[name] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeDefaultEnv(existing map[string]string, key, value string) map[string]string {
|
||||
out := make(map[string]string, len(existing)+1)
|
||||
for name, v := range existing {
|
||||
@@ -118,24 +97,3 @@ func mergeDefaultEnv(existing map[string]string, key, value string) map[string]s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func codeGraphReadOnlyToolNames() map[string]bool {
|
||||
base := []string{
|
||||
"callees",
|
||||
"callers",
|
||||
"context",
|
||||
"explore",
|
||||
"files",
|
||||
"impact",
|
||||
"node",
|
||||
"search",
|
||||
"status",
|
||||
"trace",
|
||||
}
|
||||
out := make(map[string]bool, len(base)*2)
|
||||
for _, name := range base {
|
||||
out[name] = true
|
||||
out["codegraph_"+name] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -15,8 +15,6 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/mod/semver"
|
||||
|
||||
"reasonix/internal/mcplaunch"
|
||||
"reasonix/internal/secrets"
|
||||
)
|
||||
@@ -34,8 +32,7 @@ var (
|
||||
// fullGitCommit accepts exactly a complete SHA-1 (40 hex) or SHA-256
|
||||
// (64 hex) object name. Intermediate lengths are abbreviations or custom
|
||||
// refs, never a verified commit: they must resolve through git ls-remote.
|
||||
// Official catalog validation and mutable launcher locks share this single
|
||||
// exact-commit predicate.
|
||||
// Mutable launcher locks require a complete immutable commit predicate.
|
||||
fullGitCommit = regexp.MustCompile(`^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$`)
|
||||
pypiBaseURL = "https://pypi.org/pypi"
|
||||
)
|
||||
@@ -56,9 +53,6 @@ func effectiveLaunchArgs(spec Spec) []string {
|
||||
}
|
||||
|
||||
func mutableLauncherLocator(spec Spec) (launcherLocator, bool) {
|
||||
if strings.TrimSpace(spec.OfficialCatalogEntryID) != "" {
|
||||
return launcherLocator{}, false
|
||||
}
|
||||
return launcherLocatorForSpec(spec)
|
||||
}
|
||||
|
||||
@@ -99,41 +93,6 @@ func launcherLocatorForSpec(spec Spec) (launcherLocator, bool) {
|
||||
return launcherLocator{kind: kind, command: command}, true
|
||||
}
|
||||
|
||||
func validateOfficialLauncher(spec Spec) error {
|
||||
if strings.TrimSpace(spec.OfficialCatalogEntryID) == "" {
|
||||
return nil
|
||||
}
|
||||
locator, launcher := launcherLocatorForSpec(spec)
|
||||
if !launcher {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(locator.value) == "" || !immutableLauncherLocator(locator) {
|
||||
return fmt.Errorf("official MCP server %q uses mutable %s package locator %q; catalog packages must pin an exact version or Git commit", spec.Name, locator.kind, locator.value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func immutableLauncherLocator(locator launcherLocator) bool {
|
||||
value := strings.TrimSpace(locator.value)
|
||||
if strings.HasPrefix(value, "git+") {
|
||||
at := strings.LastIndex(value, "@")
|
||||
return at > len("git+https://") && fullGitCommit.MatchString(value[at+1:])
|
||||
}
|
||||
switch locator.kind {
|
||||
case "npx", "bunx":
|
||||
name := npmPackageName(value)
|
||||
if name == "" || len(value) <= len(name)+1 || value[len(name)] != '@' {
|
||||
return false
|
||||
}
|
||||
return semver.IsValid("v" + value[len(name)+1:])
|
||||
case "uvx":
|
||||
match := pep508Package.FindStringSubmatch(value)
|
||||
return match != nil && exactPEP440Version(match[3])
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func safeLauncherFlag(kind, flag string) bool {
|
||||
switch kind {
|
||||
case "npx":
|
||||
|
||||
@@ -37,8 +37,8 @@ func TestStoredNPXLauncherLockUsesExactOfflinePackage(t *testing.T) {
|
||||
if locked.LauncherDigest == "" {
|
||||
t.Fatal("launcher digest is empty")
|
||||
}
|
||||
if SpecFingerprint(locked) != SpecFingerprint(spec) {
|
||||
t.Fatal("host-local launcher lock changed provider cache fingerprint")
|
||||
if SchemaCacheKey(locked) != SchemaCacheKey(spec) {
|
||||
t.Fatal("host-local launcher lock changed the schema cache key")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ func TestStoredLauncherEnforcementFlagPreservesAuthorizedIdentity(t *testing.T)
|
||||
}
|
||||
preflight := spec
|
||||
applyLauncherResolution(&preflight, locator, lock, false)
|
||||
approvedIdentity, err := specIdentityFingerprint(context.Background(), preflight)
|
||||
approvedIdentity, err := projectLaunchIdentityDigest(context.Background(), preflight)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func TestStoredLauncherEnforcementFlagPreservesAuthorizedIdentity(t *testing.T)
|
||||
if !stringSliceContains(locked.LaunchArgs, tc.enforcementFlag) || stringSliceContains(locked.LauncherIdentityArgs, tc.enforcementFlag) {
|
||||
t.Fatalf("launch args = %v, identity args = %v, enforcement flag = %q", locked.LaunchArgs, locked.LauncherIdentityArgs, tc.enforcementFlag)
|
||||
}
|
||||
restartIdentity, err := specIdentityFingerprint(context.Background(), locked)
|
||||
restartIdentity, err := projectLaunchIdentityDigest(context.Background(), locked)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -164,36 +164,6 @@ func TestMutableLauncherRejectsAmbiguousFlagValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialLauncherRequiresImmutablePackageLocator(t *testing.T) {
|
||||
commit := "0123456789abcdef0123456789abcdef01234567"
|
||||
cases := []struct {
|
||||
name string
|
||||
spec Spec
|
||||
ok bool
|
||||
}{
|
||||
{name: "exact npm", spec: Spec{Command: "npx", Args: []string{"-y", "@scope/[email protected]"}}, ok: true},
|
||||
{name: "npm tag", spec: Spec{Command: "npx", Args: []string{"server@latest"}}},
|
||||
{name: "npm range", spec: Spec{Command: "bunx", Args: []string{"server@^1.2.3"}}},
|
||||
{name: "exact pypi", spec: Spec{Command: "uvx", Args: []string{"server==2.4.1"}}, ok: true},
|
||||
{name: "floating pypi", spec: Spec{Command: "uvx", Args: []string{"server"}}},
|
||||
{name: "exact git", spec: Spec{Command: "npx", Args: []string{"git+https://example.test/server.git@" + commit}}, ok: true},
|
||||
{name: "git branch", spec: Spec{Command: "npx", Args: []string{"git+https://example.test/server.git@main"}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tc.spec.Name = "official"
|
||||
tc.spec.OfficialCatalogEntryID = "official@1"
|
||||
err := validateOfficialLauncher(tc.spec)
|
||||
if tc.ok && err != nil {
|
||||
t.Fatalf("immutable official launcher rejected: %v", err)
|
||||
}
|
||||
if !tc.ok && err == nil {
|
||||
t.Fatal("mutable official launcher accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePyPIPackagePinsVersionAndFileDigests(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/demo/json" {
|
||||
@@ -289,47 +259,6 @@ func TestFullGitCommitAcceptsOnlyCompleteObjectNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Official catalog validation shares the predicate: intermediate-length
|
||||
// hex refs are mutable and must be rejected as pinned git locators.
|
||||
for _, tc := range []struct {
|
||||
ref string
|
||||
ok bool
|
||||
}{
|
||||
{ref: sha1Commit, ok: true},
|
||||
{ref: sha256Commit, ok: true},
|
||||
{ref: sha1Commit + "a", ok: false},
|
||||
{ref: sha256Commit[:63], ok: false},
|
||||
} {
|
||||
spec := Spec{Name: "official", OfficialCatalogEntryID: "official@1", Command: "npx", Args: []string{"git+https://example.test/server.git@" + tc.ref}}
|
||||
err := validateOfficialLauncher(spec)
|
||||
if tc.ok && err != nil {
|
||||
t.Errorf("complete commit %d hex rejected: %v", len(tc.ref), err)
|
||||
}
|
||||
if !tc.ok && err == nil {
|
||||
t.Errorf("incomplete commit ref %d hex accepted", len(tc.ref))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialLauncherRejectsPEP440WildcardVersions(t *testing.T) {
|
||||
for value, ok := range map[string]bool{
|
||||
"server==2.4.1": true,
|
||||
"server==1.0.0rc1": true,
|
||||
"server==2.4.1.post1": true,
|
||||
"server==1.2.3.dev4": true,
|
||||
"server==1.2.3+local1": true,
|
||||
"server==2.4.*": false,
|
||||
"server==*": false,
|
||||
} {
|
||||
spec := Spec{Name: "official", OfficialCatalogEntryID: "official@1", Command: "uvx", Args: []string{value}}
|
||||
err := validateOfficialLauncher(spec)
|
||||
if ok && err != nil {
|
||||
t.Errorf("exact pin %q rejected: %v", value, err)
|
||||
}
|
||||
if !ok && err == nil {
|
||||
t.Errorf("wildcard pin %q accepted", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePyPIPackageRejectsWildcardBeforeNetwork(t *testing.T) {
|
||||
|
||||
+33
-121
@@ -153,7 +153,7 @@ func (s *lazySpawn) run() {
|
||||
|
||||
func saveLazyCachedSchema(spec Spec, real []tool.Tool) {
|
||||
_ = SaveCachedSchema(spec.Name, CachedSchema{
|
||||
SpecHash: SpecFingerprint(spec),
|
||||
CacheKey: SchemaCacheKey(spec),
|
||||
Capabilities: map[string]bool{"tools": len(real) > 0},
|
||||
Tools: cacheableToolsOf(real),
|
||||
})
|
||||
@@ -194,17 +194,15 @@ func (s *lazySpawn) trySwap() {
|
||||
// sees cached metadata (or a stub when no cache exists); Execute consults the
|
||||
// state machine, kicking off the handshake on first call.
|
||||
type lazyTool struct {
|
||||
shared *lazySpawn
|
||||
name string // namespaced "mcp__<server>__<tool>"
|
||||
rawName string // original server-local tool name, when cached
|
||||
visibleName string // raw name after configured prefix stripping
|
||||
desc string
|
||||
schema json.RawMessage
|
||||
capabilityFingerprint string
|
||||
// readOnly/readOnlyTrusted are guarded by shared.mu because a live handshake
|
||||
// can demote a stale cached reader before asking the model to retry.
|
||||
readOnly bool
|
||||
readOnlyTrusted bool
|
||||
shared *lazySpawn
|
||||
name string // namespaced "mcp__<server>__<tool>"
|
||||
rawName string // original server-local tool name, when cached
|
||||
visibleName string // raw name after configured prefix stripping
|
||||
desc string
|
||||
schema json.RawMessage
|
||||
// readOnly is guarded by shared.mu because a live handshake can demote a
|
||||
// stale cached reader before asking the model to retry.
|
||||
readOnly bool
|
||||
// destructive is guarded by shared.mu because a live handshake may promote
|
||||
// a stale cached false value before asking the model to retry.
|
||||
destructive bool
|
||||
@@ -241,28 +239,10 @@ func (lt *lazyTool) MCPPackageName() string {
|
||||
return lt.shared.spec.Package
|
||||
}
|
||||
|
||||
// ReadOnlyExecutionAuthority mirrors remoteTool: reader classification
|
||||
// counts for strict read-only execution only when launch state is available.
|
||||
func (lt *lazyTool) ReadOnlyExecutionAuthority() bool {
|
||||
return lt.shared != nil && lt.shared.spec.LaunchManager != nil
|
||||
func (lt *lazyTool) MCPServerAuthorized() bool {
|
||||
return lt.shared != nil && lt.shared.spec.ServerAuthorized()
|
||||
}
|
||||
|
||||
func (lt *lazyTool) MCPCapabilityFingerprint() string {
|
||||
if lt.shared == nil {
|
||||
return lt.capabilityFingerprint
|
||||
}
|
||||
lt.shared.mu.Lock()
|
||||
defer lt.shared.mu.Unlock()
|
||||
return lt.capabilityFingerprint
|
||||
}
|
||||
func (lt *lazyTool) PlanModeUntrustedReadOnly() bool {
|
||||
if lt.shared == nil {
|
||||
return lt.readOnly && !lt.readOnlyTrusted
|
||||
}
|
||||
lt.shared.mu.Lock()
|
||||
defer lt.shared.mu.Unlock()
|
||||
return lt.readOnly && !lt.readOnlyTrusted
|
||||
}
|
||||
func (lt *lazyTool) MCPDestructiveHint() bool {
|
||||
if lt.shared == nil {
|
||||
return lt.destructive
|
||||
@@ -271,18 +251,6 @@ func (lt *lazyTool) MCPDestructiveHint() bool {
|
||||
defer lt.shared.mu.Unlock()
|
||||
return lt.destructive
|
||||
}
|
||||
func (lt *lazyTool) MCPApprovalMode() string {
|
||||
if lt.shared == nil {
|
||||
return tool.MCPApprovalAuto
|
||||
}
|
||||
return lt.shared.spec.ToolApprovalMode(lt.rawName)
|
||||
}
|
||||
func (lt *lazyTool) MCPApprovalReviewer() string {
|
||||
if lt.shared == nil {
|
||||
return ""
|
||||
}
|
||||
return lt.shared.spec.approvalReviewer()
|
||||
}
|
||||
func (lt *lazyTool) Schema() json.RawMessage {
|
||||
if len(lt.schema) == 0 {
|
||||
return json.RawMessage(`{"type":"object"}`)
|
||||
@@ -431,43 +399,18 @@ func (lt *lazyTool) Execute(ctx context.Context, args json.RawMessage) (string,
|
||||
// reconcileLiveSafety updates a pinned cache-hit placeholder when the live
|
||||
// server becomes stricter. Caller must hold shared.mu. The current call always
|
||||
// stops on a reader-to-writer demotion or destructive promotion so the next
|
||||
// attempt re-enters the agent's approval policy with current metadata.
|
||||
// attempt re-enters the agent's Plan/read-only safety checks with current metadata.
|
||||
func (lt *lazyTool) reconcileLiveSafety(real tool.Tool) error {
|
||||
if real == nil {
|
||||
return nil
|
||||
}
|
||||
if remote, ok := real.(*remoteTool); ok {
|
||||
_, readOnly, trusted, destructive, fingerprint := remote.securitySnapshot()
|
||||
if lt.capabilityFingerprint != "" && fingerprint != "" && lt.capabilityFingerprint != fingerprint {
|
||||
lt.readOnly = readOnly
|
||||
lt.readOnlyTrusted = trusted
|
||||
lt.destructive = destructive
|
||||
lt.capabilityFingerprint = fingerprint
|
||||
return fmt.Errorf("MCP server %q changed the security schema for tool %q; the current call was blocked before execution, retry after the parent session reviews the change", lt.shared.spec.Name, lt.rawName)
|
||||
}
|
||||
if lt.readOnlyTrusted && !trusted {
|
||||
lt.readOnlyTrusted = false
|
||||
return fmt.Errorf("MCP server %q no longer exposes tool %q as an explicitly allowed reader; retry from a parent session or update the read-only policy", lt.shared.spec.Name, lt.rawName)
|
||||
}
|
||||
}
|
||||
if lt.readOnly && !real.ReadOnly() {
|
||||
lt.readOnly = false
|
||||
lt.readOnlyTrusted = false
|
||||
return fmt.Errorf("MCP server %q no longer marks tool %q as read-only; retry so Reasonix can apply writer approval before execution", lt.shared.spec.Name, lt.rawName)
|
||||
}
|
||||
if lt.destructive {
|
||||
return nil
|
||||
}
|
||||
annotations, ok := real.(tool.MCPAnnotations)
|
||||
if !ok || !annotations.MCPDestructiveHint() {
|
||||
return nil
|
||||
}
|
||||
lt.destructive = true
|
||||
return destructiveHintChangedError(lt.shared.spec.Name, lt.rawName)
|
||||
}
|
||||
|
||||
func destructiveHintChangedError(server, rawTool string) error {
|
||||
return fmt.Errorf("MCP server %q now marks tool %q as destructive; retry so Reasonix can apply the current approval policy before execution", server, rawTool)
|
||||
live, err := ReconcileCachedToolSafety(lt.shared.spec.Name, lt.rawName, CachedToolSafety{
|
||||
ReadOnly: lt.readOnly,
|
||||
Destructive: lt.destructive,
|
||||
}, real)
|
||||
lt.readOnly = live.ReadOnly
|
||||
lt.destructive = live.Destructive
|
||||
return err
|
||||
}
|
||||
|
||||
// LazyToolset returns the placeholder tools to register for one background spec.
|
||||
@@ -486,19 +429,10 @@ func destructiveHintChangedError(server, rawTool string) error {
|
||||
// single Execute (use the controller's PluginCtx) — a turn-scoped ctx would
|
||||
// kill the stdio child between turns.
|
||||
func LazyToolset(spec Spec, cs *CachedSchema, host *Host, reg *tool.Registry, sessionCtx context.Context, kick bool) []tool.Tool {
|
||||
// Resolve an already-authorized project server before constructing cached
|
||||
// placeholders. Their approval policy is consulted before the background
|
||||
// handshake finishes, so leaving the original project Spec here would cause
|
||||
// one redundant approval even though the exact launch grant already exists.
|
||||
if spec.RequireLaunchApproval {
|
||||
if locked, err := applyStoredLauncherLock(spec); err == nil {
|
||||
if identity, err := specIdentityFingerprint(sessionCtx, locked); err == nil {
|
||||
if authorized, err := applyEstablishedLaunchGrant(locked, identity); err == nil {
|
||||
spec = authorized
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Resolve an existing exact project grant before constructing cached
|
||||
// placeholders. This is read-only host preparation; no MCP process or network
|
||||
// connection starts here.
|
||||
spec = ResolveStoredAuthorization(sessionCtx, spec)
|
||||
spawnCtx, cancel := context.WithCancel(sessionCtx)
|
||||
shared := &lazySpawn{
|
||||
spec: spec,
|
||||
@@ -508,23 +442,6 @@ func LazyToolset(spec Spec, cs *CachedSchema, host *Host, reg *tool.Registry, se
|
||||
}
|
||||
shared.generation = host.registerDeferredCancel(spec.Name, cancel)
|
||||
|
||||
cachedCapabilities := map[string]toolCapability{}
|
||||
if cs != nil && len(cs.Tools) > 0 {
|
||||
for _, ct := range cs.Tools {
|
||||
visible := ct.Name
|
||||
if spec.StripRawPrefix != "" {
|
||||
visible = strings.TrimPrefix(visible, spec.StripRawPrefix)
|
||||
}
|
||||
cap := toolCapability{
|
||||
RawName: ct.Name, ModelName: toolName(spec.Name, visible), VisibleName: visible,
|
||||
InputSchema: ct.Schema, OutputSchema: ct.OutputSchema,
|
||||
ReadOnly: ct.ReadOnly || spec.toolReadOnlyOverride(ct.Name, visible),
|
||||
Destructive: ct.Destructive,
|
||||
}
|
||||
cachedCapabilities[ct.Name] = cap
|
||||
}
|
||||
}
|
||||
|
||||
var out []tool.Tool
|
||||
// A snapshot with zero tools presents nothing the model could call, so it
|
||||
// gets the same connect stub as a cache miss — otherwise the live tools
|
||||
@@ -545,21 +462,16 @@ func LazyToolset(spec Spec, cs *CachedSchema, host *Host, reg *tool.Registry, se
|
||||
if spec.StripRawPrefix != "" {
|
||||
visibleName = strings.TrimPrefix(visibleName, spec.StripRawPrefix)
|
||||
}
|
||||
capability := cachedCapabilities[ct.Name]
|
||||
readOnly := capability.ReadOnly
|
||||
trusted := trustedReaderForSpec(spec, capability)
|
||||
out = append(out, &lazyTool{
|
||||
shared: shared,
|
||||
name: toolName(spec.Name, visibleName),
|
||||
rawName: ct.Name,
|
||||
visibleName: visibleName,
|
||||
desc: ct.Description,
|
||||
schema: ct.Schema,
|
||||
capabilityFingerprint: capabilityFingerprint(cachedCapabilities[ct.Name]),
|
||||
readOnly: readOnly,
|
||||
readOnlyTrusted: trusted,
|
||||
destructive: ct.Destructive,
|
||||
hasCache: true,
|
||||
shared: shared,
|
||||
name: toolName(spec.Name, visibleName),
|
||||
rawName: ct.Name,
|
||||
visibleName: visibleName,
|
||||
desc: ct.Description,
|
||||
schema: ct.Schema,
|
||||
readOnly: ct.ReadOnly,
|
||||
destructive: ct.Destructive,
|
||||
hasCache: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,35 +25,6 @@ type mutableLazyTarget struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func TestLazyAuthorizedProjectPlaceholderUsesDirectApproval(t *testing.T) {
|
||||
manager := mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), t.TempDir())
|
||||
spec := Spec{
|
||||
Name: "project-http", Type: "http", URL: "https://mcp.example.com/mcp",
|
||||
ConfigSource: "project_config", LaunchManager: manager, RequireLaunchApproval: true,
|
||||
}
|
||||
if err := AuthorizeSpecLaunch(context.Background(), spec); err != nil {
|
||||
t.Fatalf("AuthorizeSpecLaunch: %v", err)
|
||||
}
|
||||
cs := &CachedSchema{Tools: []CachedTool{{
|
||||
Name: "write", Description: "Write data.", Schema: json.RawMessage(`{"type":"object"}`),
|
||||
}}}
|
||||
host := NewHost()
|
||||
defer host.Close()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
tools := LazyToolset(spec, cs, host, tool.NewRegistry(), ctx, false)
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("lazy tools = %d, want 1", len(tools))
|
||||
}
|
||||
policy, ok := tools[0].(tool.MCPApprovalPolicy)
|
||||
if !ok {
|
||||
t.Fatalf("lazy project tool %T does not expose MCP approval policy", tools[0])
|
||||
}
|
||||
if got := policy.MCPApprovalMode(); got != tool.MCPApprovalApprove {
|
||||
t.Fatalf("lazy authorized project approval = %q, want direct approval", got)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *mutableLazyTarget) Name() string { return t.name }
|
||||
func (t *mutableLazyTarget) Description() string { return "writer test target" }
|
||||
func (t *mutableLazyTarget) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||||
@@ -94,7 +65,7 @@ func helperSpec() Spec {
|
||||
func writeMockCache(t *testing.T, spec Spec) {
|
||||
t.Helper()
|
||||
cs := CachedSchema{
|
||||
SpecHash: SpecFingerprint(spec),
|
||||
CacheKey: SchemaCacheKey(spec),
|
||||
Capabilities: map[string]bool{"prompts": false, "resources": false},
|
||||
Tools: []CachedTool{
|
||||
{
|
||||
@@ -136,7 +107,7 @@ func waitForCachedSchema(t *testing.T, spec Spec, timeout time.Duration) *Cached
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if cs, ok := LoadCachedSchema(spec.Name, SpecFingerprint(spec)); ok {
|
||||
if cs, ok := LoadCachedSchema(spec.Name, SchemaCacheKey(spec)); ok {
|
||||
return cs
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
@@ -156,7 +127,7 @@ func TestLazyCacheHitSyncSpawn(t *testing.T) {
|
||||
spec := helperSpec()
|
||||
writeMockCache(t, spec)
|
||||
|
||||
cs, ok := LoadCachedSchema(spec.Name, SpecFingerprint(spec))
|
||||
cs, ok := LoadCachedSchema(spec.Name, SchemaCacheKey(spec))
|
||||
if !ok {
|
||||
t.Fatal("LoadCachedSchema: miss right after save (sanity)")
|
||||
}
|
||||
@@ -233,7 +204,7 @@ func TestLazyCacheHitReusesExistingSharedHostClient(t *testing.T) {
|
||||
spec := helperSpec()
|
||||
writeMockCache(t, spec)
|
||||
|
||||
cs, ok := LoadCachedSchema(spec.Name, SpecFingerprint(spec))
|
||||
cs, ok := LoadCachedSchema(spec.Name, SchemaCacheKey(spec))
|
||||
if !ok {
|
||||
t.Fatal("LoadCachedSchema: miss right after save (sanity)")
|
||||
}
|
||||
@@ -317,7 +288,7 @@ func TestLazyRemoveCancelsInFlightGenerationWithoutResurrection(t *testing.T) {
|
||||
if _, ok := reg.Get(ToolPrefix(spec.Name) + "connect"); ok {
|
||||
t.Fatal("removed lazy placeholder was re-registered")
|
||||
}
|
||||
if _, ok := LoadCachedSchema(spec.Name, SpecFingerprint(spec)); ok {
|
||||
if _, ok := LoadCachedSchema(spec.Name, SchemaCacheKey(spec)); ok {
|
||||
t.Fatal("cancelled lazy generation wrote a new schema cache")
|
||||
}
|
||||
tools, err := host.Add(ctx, spec)
|
||||
@@ -376,7 +347,7 @@ func TestLazyCacheHitStartupTimeoutCanRetry(t *testing.T) {
|
||||
spec.Env["GO_WANT_HELPER_INIT_MS"] = fmt.Sprint(int(defaultStartTimeout/time.Millisecond) + 200)
|
||||
writeMockCache(t, spec)
|
||||
|
||||
cs, ok := LoadCachedSchema(spec.Name, SpecFingerprint(spec))
|
||||
cs, ok := LoadCachedSchema(spec.Name, SchemaCacheKey(spec))
|
||||
if !ok {
|
||||
t.Fatal("LoadCachedSchema: miss right after save (sanity)")
|
||||
}
|
||||
@@ -414,41 +385,42 @@ func TestLazyCacheHitStartupTimeoutCanRetry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLazyToolsetAppliesSpecReadOnlyOverrideToCachedTools(t *testing.T) {
|
||||
func TestLazyToolsetInheritsInstalledServerReaderAuthorization(t *testing.T) {
|
||||
redirectCache(t)
|
||||
spec := helperSpec()
|
||||
spec.ReadOnlyToolNames = map[string]bool{"echo": true}
|
||||
writeMockCache(t, spec)
|
||||
|
||||
cs, ok := LoadCachedSchema(spec.Name, SpecFingerprint(spec))
|
||||
spec.LaunchManager = mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), t.TempDir())
|
||||
spec.Authorized = true
|
||||
if err := SaveCachedSchema(spec.Name, CachedSchema{
|
||||
CacheKey: SchemaCacheKey(spec),
|
||||
Tools: []CachedTool{{
|
||||
Name: "echo", Description: "Echo back the message.",
|
||||
Schema: json.RawMessage(`{"type":"object","properties":{"msg":{"type":"string"}}}`), ReadOnly: true,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cs, ok := LoadCachedSchema(spec.Name, SchemaCacheKey(spec))
|
||||
if !ok {
|
||||
t.Fatal("LoadCachedSchema: miss right after save (sanity)")
|
||||
t.Fatal("LoadCachedSchema: miss right after save")
|
||||
}
|
||||
|
||||
host := NewHost()
|
||||
defer host.Close()
|
||||
reg := tool.NewRegistry()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tools := LazyToolset(spec, cs, host, reg, ctx, false)
|
||||
byName := map[string]tool.Tool{}
|
||||
for _, tl := range tools {
|
||||
byName[tl.Name()] = tl
|
||||
tools := LazyToolset(spec, cs, host, tool.NewRegistry(), ctx, false)
|
||||
var echo tool.Tool
|
||||
for _, candidate := range tools {
|
||||
if candidate.Name() == "mcp__mock__echo" {
|
||||
echo = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
echo := byName["mcp__mock__echo"]
|
||||
if echo == nil {
|
||||
t.Fatalf("mcp__mock__echo missing from %v", byName)
|
||||
if echo == nil || !echo.ReadOnly() {
|
||||
t.Fatalf("installed cached reader missing or not read-only: %T", echo)
|
||||
}
|
||||
if !echo.ReadOnly() {
|
||||
t.Fatal("lazy cached echo should use the spec read-only override")
|
||||
}
|
||||
zed := byName["mcp__mock__zed"]
|
||||
if zed == nil {
|
||||
t.Fatalf("mcp__mock__zed missing from %v", byName)
|
||||
}
|
||||
if zed.ReadOnly() {
|
||||
t.Fatal("lazy cached zed should keep cached non-read-only status")
|
||||
if authority, ok := echo.(tool.MCPServerAuthorization); !ok || !authority.MCPServerAuthorized() {
|
||||
t.Fatalf("lazy installed reader did not inherit authorization: %T", echo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,7 +486,7 @@ func TestLazySwapDoesNotRaceRegistrySchemas(t *testing.T) {
|
||||
spec := helperSpec()
|
||||
spec.Env["GO_WANT_HELPER_INIT_MS"] = "50"
|
||||
writeMockCache(t, spec)
|
||||
cs, _ := LoadCachedSchema(spec.Name, SpecFingerprint(spec))
|
||||
cs, _ := LoadCachedSchema(spec.Name, SchemaCacheKey(spec))
|
||||
|
||||
host := NewHost()
|
||||
defer host.Close()
|
||||
@@ -566,7 +538,7 @@ func TestLazyBackgroundKick(t *testing.T) {
|
||||
redirectCache(t)
|
||||
spec := helperSpec()
|
||||
writeMockCache(t, spec)
|
||||
cs, _ := LoadCachedSchema(spec.Name, SpecFingerprint(spec))
|
||||
cs, _ := LoadCachedSchema(spec.Name, SchemaCacheKey(spec))
|
||||
|
||||
host := NewHost()
|
||||
defer host.Close()
|
||||
@@ -639,7 +611,7 @@ func TestLazyBackgroundCloseCancelsInFlightKick(t *testing.T) {
|
||||
spec.Name = "slow"
|
||||
spec.Env["GO_WANT_HELPER_INIT_MS"] = "5000"
|
||||
writeMockCache(t, spec)
|
||||
cs, _ := LoadCachedSchema(spec.Name, SpecFingerprint(spec))
|
||||
cs, _ := LoadCachedSchema(spec.Name, SchemaCacheKey(spec))
|
||||
|
||||
host := NewHost()
|
||||
reg := tool.NewRegistry()
|
||||
@@ -683,7 +655,7 @@ func TestLazyConcurrentExecuteOnlyOneSpawn(t *testing.T) {
|
||||
redirectCache(t)
|
||||
spec := helperSpec()
|
||||
writeMockCache(t, spec)
|
||||
cs, _ := LoadCachedSchema(spec.Name, SpecFingerprint(spec))
|
||||
cs, _ := LoadCachedSchema(spec.Name, SchemaCacheKey(spec))
|
||||
|
||||
host := NewHost()
|
||||
defer host.Close()
|
||||
@@ -766,10 +738,10 @@ func TestLazyHandshakeFailureSurfaced(t *testing.T) {
|
||||
|
||||
// Hand-craft a cache so the cache-HIT branch runs (synchronous spawn,
|
||||
// failure surfaces directly to the first caller rather than via a retry
|
||||
// hint). The SpecHash must match — otherwise LoadCachedSchema would miss
|
||||
// hint). The CacheKey must match — otherwise LoadCachedSchema would miss
|
||||
// and we'd be exercising the async path.
|
||||
cs := &CachedSchema{
|
||||
SpecHash: SpecFingerprint(spec),
|
||||
CacheKey: SchemaCacheKey(spec),
|
||||
Capabilities: map[string]bool{},
|
||||
Tools: []CachedTool{{
|
||||
Name: "doit",
|
||||
@@ -825,7 +797,7 @@ func TestLazyToolsetCacheHitSchemaVisible(t *testing.T) {
|
||||
|
||||
rawSchema := json.RawMessage(`{"properties":{"msg":{"type":"string"}},"type":"object","required":["msg"]}`)
|
||||
cs := &CachedSchema{
|
||||
SpecHash: SpecFingerprint(spec),
|
||||
CacheKey: SchemaCacheKey(spec),
|
||||
Capabilities: map[string]bool{},
|
||||
Tools: []CachedTool{{
|
||||
Name: "echo",
|
||||
@@ -879,7 +851,7 @@ func TestLazyCacheHitPinsToolBytesAcrossDivergentHandshake(t *testing.T) {
|
||||
redirectCache(t)
|
||||
spec := helperSpec()
|
||||
stale := CachedSchema{
|
||||
SpecHash: SpecFingerprint(spec),
|
||||
CacheKey: SchemaCacheKey(spec),
|
||||
Capabilities: map[string]bool{},
|
||||
Tools: []CachedTool{{
|
||||
Name: "echo",
|
||||
@@ -891,7 +863,7 @@ func TestLazyCacheHitPinsToolBytesAcrossDivergentHandshake(t *testing.T) {
|
||||
if err := SaveCachedSchema(spec.Name, stale); err != nil {
|
||||
t.Fatalf("SaveCachedSchema: %v", err)
|
||||
}
|
||||
cs, ok := LoadCachedSchema(spec.Name, SpecFingerprint(spec))
|
||||
cs, ok := LoadCachedSchema(spec.Name, SchemaCacheKey(spec))
|
||||
if !ok {
|
||||
t.Fatal("LoadCachedSchema miss after save")
|
||||
}
|
||||
@@ -910,8 +882,8 @@ func TestLazyCacheHitPinsToolBytesAcrossDivergentHandshake(t *testing.T) {
|
||||
// Let the background handshake finish and give trySwap every chance to run.
|
||||
waitForServer(t, host, "mock", 5*time.Second)
|
||||
echo, _ := reg.Get("mcp__mock__echo")
|
||||
if out, err := echo.Execute(ctx, json.RawMessage(`{"msg":"pin"}`)); err == nil || out != "" || !strings.Contains(err.Error(), "changed the security schema") {
|
||||
t.Fatalf("Execute after schema drift = %q, %v; want a pre-execution drift block", out, err)
|
||||
if out, err := echo.Execute(ctx, json.RawMessage(`{"msg":"pin"}`)); err != nil || out != "echo: pin" {
|
||||
t.Fatalf("Execute after schema drift = %q, %v; want live execution", out, err)
|
||||
}
|
||||
|
||||
if got := registrySchemaBytes(t, reg); got != bootBytes {
|
||||
@@ -927,7 +899,7 @@ func TestLazyCacheHitPinsToolBytesAcrossDivergentHandshake(t *testing.T) {
|
||||
// slow machines) rather than accepting the first loadable snapshot.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
refreshed, ok := LoadCachedSchema(spec.Name, SpecFingerprint(spec))
|
||||
refreshed, ok := LoadCachedSchema(spec.Name, SchemaCacheKey(spec))
|
||||
if ok {
|
||||
names := map[string]bool{}
|
||||
for _, ct := range refreshed.Tools {
|
||||
@@ -972,7 +944,7 @@ func TestLazyToolPromotesLiveDestructiveHintBeforeExecution(t *testing.T) {
|
||||
|
||||
out, err := lazy.Execute(context.Background(), nil)
|
||||
if err != nil || out != "executed" || target.calls != 1 {
|
||||
t.Fatalf("second Execute = (%q,%v), calls=%d, want execution after approval retry", out, err, target.calls)
|
||||
t.Fatalf("second Execute = (%q,%v), calls=%d, want execution after metadata refresh retry", out, err, target.calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -989,7 +961,7 @@ func TestLazyToolDemotesStaleReaderBeforeExecution(t *testing.T) {
|
||||
shared: shared, name: name, rawName: "mutate", readOnly: true, hasCache: true,
|
||||
}
|
||||
|
||||
if out, err := lazy.Execute(context.Background(), nil); err == nil || !strings.Contains(err.Error(), "writer approval") || out != "" {
|
||||
if out, err := lazy.Execute(context.Background(), nil); err == nil || !strings.Contains(err.Error(), "Plan/read-only safety boundary") || out != "" {
|
||||
t.Fatalf("first Execute = (%q,%v), want retry before writer execution", out, err)
|
||||
}
|
||||
if target.calls != 0 || lazy.ReadOnly() {
|
||||
@@ -1008,7 +980,7 @@ func TestLazyToolDemotesStaleReaderBeforeExecution(t *testing.T) {
|
||||
func TestLazyEmptyCachedToolsFallsBackToConnectStub(t *testing.T) {
|
||||
redirectCache(t)
|
||||
spec := helperSpec()
|
||||
cs := &CachedSchema{SpecHash: SpecFingerprint(spec), Tools: nil}
|
||||
cs := &CachedSchema{CacheKey: SchemaCacheKey(spec), Tools: nil}
|
||||
|
||||
host := NewHost()
|
||||
defer host.Close()
|
||||
|
||||
+156
-184
@@ -19,6 +19,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/event"
|
||||
@@ -65,59 +66,42 @@ type Spec struct {
|
||||
// for cwd-aware servers like CodeGraph, which detect the project from the
|
||||
// directory they are launched in — they must be pinned to the project root.
|
||||
Dir string
|
||||
// WorkspaceRoot is the project root exposed through the MCP roots capability.
|
||||
// It is runtime-only and intentionally separate from Dir: user-installed
|
||||
// stdio servers keep inheriting Reasonix's cwd while still receiving the
|
||||
// explicit workspace root when they ask for roots/list.
|
||||
WorkspaceRoot string
|
||||
// Stderr optionally mirrors plugin subprocess stderr output. Stderr is always
|
||||
// captured in a bounded buffer for failure diagnostics; nil keeps it out of
|
||||
// the terminal so child logs cannot corrupt interactive UIs.
|
||||
Stderr io.Writer
|
||||
// ReadOnlyToolNames marks raw MCP tool names as read-only when the server omits
|
||||
// annotations.readOnlyHint. It preserves known and legacy compatibility
|
||||
// overrides; normal MCP classification should rely on server metadata.
|
||||
ReadOnlyToolNames map[string]bool
|
||||
// ReadOnlyModelToolNames marks explicitly declared model-visible MCP tool names
|
||||
// ("mcp__<server>__<tool>") as read-only. This supports user-level
|
||||
// declarations such as agent.plan_mode_allowed_tools without reverse-parsing
|
||||
// normalized MCP tool names back into raw server-local names.
|
||||
ReadOnlyModelToolNames map[string]bool
|
||||
// DefaultToolsApprovalMode controls MCP approval when no raw-tool override
|
||||
// exists: auto|prompt|writes|approve. Empty uses auto unless the composition
|
||||
// root marks this explicitly user-authorized server for implicit approval.
|
||||
DefaultToolsApprovalMode string
|
||||
// ToolApprovalModes overrides approval behavior by raw server-local tool name.
|
||||
ToolApprovalModes map[string]string
|
||||
// ApprovalsReviewer selects user|auto_review for this server. Empty preserves
|
||||
// legacy behavior: Guardian reviews ordinary Ask decisions, while destructive
|
||||
// calls still require the user.
|
||||
ApprovalsReviewer string
|
||||
// LaunchManager owns exact project launch grants and mutable launcher locks.
|
||||
// It never contributes to SpecFingerprint or provider-visible tool schemas.
|
||||
// It never contributes to SchemaCacheKey or provider-visible tool schemas.
|
||||
LaunchManager *mcplaunch.Manager
|
||||
// ConfigSource disambiguates otherwise identical server names coming from
|
||||
// workspace config, a host transport, or a verified plugin package.
|
||||
ConfigSource string
|
||||
ImplicitApproval bool
|
||||
RequireLaunchApproval bool
|
||||
OfficialCatalogEntryID string
|
||||
OfficialReaderNames []string
|
||||
PackageDigest string
|
||||
PackageRoot string
|
||||
VerifiedVersion string
|
||||
CatalogSequence uint64
|
||||
// workspace config, a host transport, or a user-installed plugin package.
|
||||
ConfigSource string
|
||||
// Authorized is the single runtime authorization result for this server.
|
||||
// User-installed and explicit host-session servers set it directly; project
|
||||
// servers set it only after an exact launch grant is resolved.
|
||||
Authorized bool
|
||||
RequireLaunchApproval bool
|
||||
// LaunchArgs and launcher metadata are host-local immutable resolutions for
|
||||
// mutable package launchers. LauncherIdentityArgs is the same exact package
|
||||
// resolution without an automatically injected offline/no-install flag: that
|
||||
// enforcement-only flag changes process invocation but not the server identity
|
||||
// the user approved. These fields never contribute to SpecFingerprint or the
|
||||
// the user approved. These fields never contribute to SchemaCacheKey or the
|
||||
// provider-visible tool surface; Args remains the user's stable config.
|
||||
LaunchArgs []string
|
||||
LauncherIdentityArgs []string
|
||||
LauncherLocator string
|
||||
LauncherResolvedVersion string
|
||||
LauncherDigest string
|
||||
// ReaderSandbox and WriterSandbox describe host isolation. The persistent stdio process runs with WriterSandbox
|
||||
// so one stateful session is preserved across reads and writes.
|
||||
ReaderSandbox sandbox.Spec
|
||||
WriterSandbox sandbox.Spec
|
||||
StateDir string
|
||||
// Sandbox isolates the persistent MCP process. One process serves both reads
|
||||
// and writes, so separate reader/writer specs created configuration without
|
||||
// changing the actual runtime boundary.
|
||||
Sandbox sandbox.Spec
|
||||
StateDir string
|
||||
// StripRawPrefix, when non-empty, removes this prefix from each MCP tool's
|
||||
// raw name before namespacing. For example, StripRawPrefix="server_" turns
|
||||
// "server_search" into "search", yielding "mcp__search__search" instead of
|
||||
@@ -131,9 +115,9 @@ type Spec struct {
|
||||
|
||||
// transport carries JSON-RPC messages to and from one MCP server. call sends a
|
||||
// request and returns its result (correlating by id internally); notify sends a
|
||||
// fire-and-forget notification; close releases resources. Server notifications
|
||||
// are ignored. Transports answer ping requests and reject unsupported client
|
||||
// capabilities such as roots or sampling with JSON-RPC method-not-found.
|
||||
// fire-and-forget notification; close releases resources. Transports route MCP
|
||||
// progress notifications to the active tool call and answer the client
|
||||
// capabilities Reasonix advertises (currently ping and roots/list).
|
||||
type transport interface {
|
||||
call(ctx context.Context, method string, params any) (json.RawMessage, error)
|
||||
notify(ctx context.Context, method string, params any) error
|
||||
@@ -400,7 +384,7 @@ func Start(ctx context.Context, specs []Spec, p StartPolicy) (*Host, []tool.Tool
|
||||
defer h.bgWrites.Done()
|
||||
_ = RecordStartup(spec.Name, phaseADur)
|
||||
_ = SaveCachedSchema(spec.Name, CachedSchema{
|
||||
SpecHash: SpecFingerprint(spec),
|
||||
CacheKey: SchemaCacheKey(spec),
|
||||
Capabilities: map[string]bool{
|
||||
"tools": c.hasTools,
|
||||
"prompts": c.hasPrompts,
|
||||
@@ -574,8 +558,6 @@ type Client struct {
|
||||
toolCount int // tools discovered, for /mcp status
|
||||
transport string // declared transport type, for /mcp status ("stdio"/"http")
|
||||
|
||||
identityFingerprint string
|
||||
|
||||
// Prompts and resources discovered during StartAll, stored here so the
|
||||
// parallel startup can collect them per-client before merging into Host.
|
||||
prompts []Prompt
|
||||
@@ -589,6 +571,7 @@ type Client struct {
|
||||
// MCP servers just to rebuild identical schemas.
|
||||
toolsListed bool
|
||||
toolAdapters []tool.Tool
|
||||
progressID atomic.Uint64
|
||||
}
|
||||
|
||||
func (c *Client) auxiliaryClient(ctx context.Context) (*Client, context.Context, context.CancelFunc, error) {
|
||||
@@ -659,7 +642,7 @@ func authorizeSpecLaunch(ctx context.Context, spec Spec, lockMutableLauncher boo
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
identity, err := specIdentityFingerprint(ctx, prepared)
|
||||
identityDigest, err := projectLaunchIdentityDigest(ctx, prepared)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -670,7 +653,7 @@ func authorizeSpecLaunch(ctx context.Context, spec Spec, lockMutableLauncher boo
|
||||
return err
|
||||
}
|
||||
}
|
||||
return manager.Authorize(prepared.Name, launchConfigSource(prepared), identity)
|
||||
return manager.Authorize(prepared.Name, launchConfigSource(prepared), identityDigest)
|
||||
}
|
||||
|
||||
// Failure records one MCP server that was configured but could not connect.
|
||||
@@ -728,31 +711,6 @@ func (h *Host) Servers() []ServerStatus {
|
||||
return out
|
||||
}
|
||||
|
||||
// ApplyCatalogRevocations immediately removes official reader authority from
|
||||
// already-connected servers. Future lazy starts consult the same in-memory
|
||||
// revocation set before accepting signed reader declarations.
|
||||
func (h *Host) ApplyCatalogRevocations(revoked map[string]bool) {
|
||||
if len(revoked) == 0 {
|
||||
return
|
||||
}
|
||||
h.mu.RLock()
|
||||
clients := append([]*Client(nil), h.clients...)
|
||||
h.mu.RUnlock()
|
||||
for _, client := range clients {
|
||||
entryID := strings.TrimSpace(client.spec.OfficialCatalogEntryID)
|
||||
if entryID == "" || !revoked[entryID] {
|
||||
continue
|
||||
}
|
||||
client.toolsMu.Lock()
|
||||
for _, adapter := range client.toolAdapters {
|
||||
if remote, ok := adapter.(*remoteTool); ok {
|
||||
remote.readOnlyTrusted = false
|
||||
}
|
||||
}
|
||||
client.toolsMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Failures returns configured MCP servers that failed to connect.
|
||||
func (h *Host) Failures() []Failure {
|
||||
h.mu.RLock()
|
||||
@@ -1155,11 +1113,7 @@ func start(lifeCtx, callCtx context.Context, s Spec) (*Client, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
identity, err := specIdentityFingerprint(callCtx, s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s, err = applyEstablishedLaunchGrant(s, identity)
|
||||
s, err = resolveProjectLaunchAuthorization(callCtx, s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1171,7 +1125,7 @@ func start(lifeCtx, callCtx context.Context, s Spec) (*Client, error) {
|
||||
if tt == "" {
|
||||
tt = "stdio"
|
||||
}
|
||||
c := &Client{name: s.Name, t: t, spec: s, transport: tt, identityFingerprint: identity}
|
||||
c := &Client{name: s.Name, t: t, spec: s, transport: tt}
|
||||
if err := c.initialize(callCtx); err != nil {
|
||||
c.close()
|
||||
return nil, err
|
||||
@@ -1179,14 +1133,29 @@ func start(lifeCtx, callCtx context.Context, s Spec) (*Client, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func applyEstablishedLaunchGrant(s Spec, identity string) (Spec, error) {
|
||||
// resolveProjectLaunchAuthorization deliberately skips identity resolution for
|
||||
// installed and host-session servers. Their explicit installation is already
|
||||
// the authorization decision; only repository-declared servers need an exact
|
||||
// executable or endpoint digest before startup.
|
||||
func resolveProjectLaunchAuthorization(ctx context.Context, s Spec) (Spec, error) {
|
||||
if !s.RequireLaunchApproval {
|
||||
return s, nil
|
||||
}
|
||||
identityDigest, err := projectLaunchIdentityDigest(ctx, s)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
return applyEstablishedLaunchGrant(s, identityDigest)
|
||||
}
|
||||
|
||||
func applyEstablishedLaunchGrant(s Spec, identityDigest string) (Spec, error) {
|
||||
if !s.RequireLaunchApproval {
|
||||
return s, nil
|
||||
}
|
||||
if s.LaunchManager == nil {
|
||||
return s, fmt.Errorf("MCP launch authorization store is unavailable")
|
||||
}
|
||||
authorized, changed, err := s.LaunchManager.LaunchAuthorized(s.Name, launchConfigSource(s), identity)
|
||||
authorized, changed, err := s.LaunchManager.LaunchAuthorized(s.Name, launchConfigSource(s), identityDigest)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
@@ -1194,12 +1163,40 @@ func applyEstablishedLaunchGrant(s Spec, identity string) (Spec, error) {
|
||||
return s, &launchApprovalError{server: s.Name, changed: changed}
|
||||
}
|
||||
// A matching exact-identity launch grant is the user's authorization for
|
||||
// this project server. Calls proceed like an explicit install;
|
||||
// explicit policies still override the source-aware approval default.
|
||||
s.ImplicitApproval = true
|
||||
// this project server. Calls proceed like an explicit install, while global
|
||||
// deny rules and execution safety boundaries remain authoritative.
|
||||
s.Authorized = true
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ResolveStoredAuthorization applies an existing exact project grant without
|
||||
// starting a process or opening a network connection. Cached lazy/on-demand
|
||||
// tools use it before strict read-only filtering so every execution path sees
|
||||
// the same server-level authorization. Errors fail closed by returning the
|
||||
// original unauthorized Spec; a parent connection surfaces the detailed error.
|
||||
func ResolveStoredAuthorization(ctx context.Context, s Spec) Spec {
|
||||
if !s.RequireLaunchApproval {
|
||||
return s
|
||||
}
|
||||
locked, err := applyStoredLauncherLock(s)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
authorized, err := resolveProjectLaunchAuthorization(ctx, locked)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
return authorized
|
||||
}
|
||||
|
||||
// ServerAuthorized is the single MCP authorization source. Tools do not carry
|
||||
// an independent trust bit: installation or an exact project launch grant
|
||||
// authorizes the server, while read-only/destructive classification remains a
|
||||
// live per-tool safety fact.
|
||||
func (s Spec) ServerAuthorized() bool {
|
||||
return s.Authorized
|
||||
}
|
||||
|
||||
// newTransport builds the transport for a spec's declared type. Empty / unknown
|
||||
// defaults to stdio.
|
||||
func newTransport(ctx context.Context, s Spec) (transport, error) {
|
||||
@@ -1209,17 +1206,16 @@ func newTransport(ctx context.Context, s Spec) (transport, error) {
|
||||
case "http", "streamable-http", "streamable_http":
|
||||
return newHTTPTransport(s)
|
||||
case "sse":
|
||||
// The legacy 2024-11-05 HTTP+SSE transport needs a persistent GET stream
|
||||
// with a background dispatcher — deprecated upstream ("avoid for new
|
||||
// work"). Use type="http" (Streamable HTTP), which most remote servers
|
||||
// now speak. Tracked for later (SPEC §9).
|
||||
return nil, fmt.Errorf("plugin %q: legacy sse transport not yet supported — use type=\"http\" (Streamable HTTP)", s.Name)
|
||||
return newSSETransport(ctx, s)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown transport type %q (want stdio|http|sse)", s.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
|
||||
params, unregisterProgress := c.withProgress(ctx, method, params)
|
||||
defer unregisterProgress()
|
||||
|
||||
callCtx, cancel, timeout := c.contextWithCallTimeout(ctx, method, params)
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
@@ -1234,6 +1230,40 @@ func (c *Client) call(ctx context.Context, method string, params any) (json.RawM
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (c *Client) withProgress(ctx context.Context, method string, params any) (any, func()) {
|
||||
if method != "tools/call" {
|
||||
return params, func() {}
|
||||
}
|
||||
sink, ok := tool.ProgressFrom(ctx)
|
||||
if !ok {
|
||||
return params, func() {}
|
||||
}
|
||||
router, ok := c.t.(progressTransport)
|
||||
if !ok {
|
||||
return params, func() {}
|
||||
}
|
||||
callParams, ok := params.(map[string]any)
|
||||
if !ok {
|
||||
return params, func() {}
|
||||
}
|
||||
|
||||
token := fmt.Sprintf("reasonix-%d", c.progressID.Add(1))
|
||||
copyParams := make(map[string]any, len(callParams))
|
||||
for key, value := range callParams {
|
||||
copyParams[key] = value
|
||||
}
|
||||
meta := map[string]any{}
|
||||
if existing, ok := callParams["_meta"].(map[string]any); ok {
|
||||
for key, value := range existing {
|
||||
meta[key] = value
|
||||
}
|
||||
}
|
||||
meta["progressToken"] = token
|
||||
copyParams["_meta"] = meta
|
||||
unregister := router.registerProgress(token, sink)
|
||||
return copyParams, unregister
|
||||
}
|
||||
|
||||
func (c *Client) callTransport(ctx context.Context, method string, params any) (json.RawMessage, error) {
|
||||
res, err := c.t.call(ctx, method, params)
|
||||
if err == nil || method == "initialize" || !isHTTPSessionExpired(err) {
|
||||
@@ -1317,9 +1347,13 @@ func (c *Client) initialize(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (c *Client) initializeSession(ctx context.Context, recordCapabilities bool) error {
|
||||
capabilities := map[string]any{}
|
||||
if len(mcpRoots(c.spec.WorkspaceRoot)) > 0 {
|
||||
capabilities["roots"] = map[string]any{"listChanged": false}
|
||||
}
|
||||
res, err := c.call(ctx, "initialize", map[string]any{
|
||||
"protocolVersion": protocolVersion,
|
||||
"capabilities": map[string]any{},
|
||||
"capabilities": capabilities,
|
||||
"clientInfo": map[string]any{"name": "reasonix", "version": "dev"},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1358,32 +1392,6 @@ type mcpTool struct {
|
||||
} `json:"annotations"`
|
||||
}
|
||||
|
||||
// toolReadOnlyOverride keeps legacy trusted_read_only_tools and first-party
|
||||
// overrides useful when an older MCP server omits readOnlyHint.
|
||||
func (s Spec) toolReadOnlyOverride(rawName, visibleName string) bool {
|
||||
return s.ReadOnlyToolNames[rawName] || s.ReadOnlyModelToolNames[toolName(s.Name, visibleName)]
|
||||
}
|
||||
|
||||
// ToolApprovalMode resolves the effective approval mode for one raw tool
|
||||
// name: an explicit per-tool override wins, an unset server default becomes
|
||||
// direct approval for user-authorized (implicit approval) sources, and the
|
||||
// normalized server default applies otherwise. Every approval surface,
|
||||
// including proxies outside this package, must use this resolution instead of
|
||||
// re-deriving the policy from Spec fields.
|
||||
func (s Spec) ToolApprovalMode(rawName string) string {
|
||||
if mode := strings.TrimSpace(s.ToolApprovalModes[rawName]); mode != "" {
|
||||
return tool.NormalizeMCPApprovalMode(mode)
|
||||
}
|
||||
if strings.TrimSpace(s.DefaultToolsApprovalMode) == "" && s.ImplicitApproval {
|
||||
return tool.MCPApprovalApprove
|
||||
}
|
||||
return tool.NormalizeMCPApprovalMode(s.DefaultToolsApprovalMode)
|
||||
}
|
||||
|
||||
func (s Spec) approvalReviewer() string {
|
||||
return tool.NormalizeMCPApprovalReviewer(s.ApprovalsReviewer)
|
||||
}
|
||||
|
||||
func (c *Client) listTools(ctx context.Context) ([]tool.Tool, error) {
|
||||
c.toolsMu.Lock()
|
||||
defer c.toolsMu.Unlock()
|
||||
@@ -1425,23 +1433,19 @@ func (c *Client) listTools(ctx context.Context) ([]tool.Tool, error) {
|
||||
if c.spec.StripRawPrefix != "" {
|
||||
visibleName = strings.TrimPrefix(visibleName, c.spec.StripRawPrefix)
|
||||
}
|
||||
capability := capabilityOf(c.spec, t, schema)
|
||||
readOnly := capability.ReadOnly
|
||||
trusted := trustedReaderForSpec(c.spec, capability)
|
||||
readOnly := readOnlyHint
|
||||
toolInfos = append(toolInfos, info)
|
||||
tools = append(tools, &remoteTool{
|
||||
client: c,
|
||||
name: toolName(c.name, visibleName),
|
||||
rawName: t.Name,
|
||||
visibleName: visibleName,
|
||||
desc: t.Description,
|
||||
schema: schema,
|
||||
outputSchema: t.OutputSchema,
|
||||
capabilityFingerprint: capabilityFingerprint(capability),
|
||||
declaredReadOnly: capability.ReadOnly,
|
||||
readOnly: readOnly,
|
||||
readOnlyTrusted: trusted,
|
||||
destructive: destructiveHint,
|
||||
client: c,
|
||||
name: toolName(c.name, visibleName),
|
||||
rawName: t.Name,
|
||||
visibleName: visibleName,
|
||||
desc: t.Description,
|
||||
schema: schema,
|
||||
outputSchema: t.OutputSchema,
|
||||
declaredReadOnly: readOnlyHint,
|
||||
readOnly: readOnly,
|
||||
destructive: destructiveHint,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(toolInfos, func(i, j int) bool { return toolInfos[i].Name < toolInfos[j].Name })
|
||||
@@ -1625,22 +1629,17 @@ func (e *rpcError) Error() string { return fmt.Sprintf("rpc error %d: %s", e.Cod
|
||||
// --- remote tool adapter ---
|
||||
|
||||
type remoteTool struct {
|
||||
client *Client
|
||||
name string // namespaced "mcp__<server>__<tool>"
|
||||
rawName string // original name for tools/call
|
||||
visibleName string // raw name after configured prefix stripping
|
||||
desc string
|
||||
schema json.RawMessage
|
||||
outputSchema json.RawMessage
|
||||
capabilityFingerprint string
|
||||
declaredReadOnly bool // server hint/legacy override, independent of local trust
|
||||
readOnly bool // true only when the host accepts this reader snapshot
|
||||
// readOnlyTrusted is true only when local configuration, not the server hint,
|
||||
// classified the tool as read-only.
|
||||
readOnlyTrusted bool
|
||||
client *Client
|
||||
name string // namespaced "mcp__<server>__<tool>"
|
||||
rawName string // original name for tools/call
|
||||
visibleName string // raw name after configured prefix stripping
|
||||
desc string
|
||||
schema json.RawMessage
|
||||
outputSchema json.RawMessage
|
||||
declaredReadOnly bool // server hint, independent of server authorization
|
||||
readOnly bool // effective reader classification for this live snapshot
|
||||
// destructive is the MCP destructiveHint. It takes precedence over a
|
||||
// conflicting readOnlyHint; the effective MCP approval mode decides whether
|
||||
// it needs a fresh review.
|
||||
// conflicting readOnlyHint in Plan and strict read-only execution.
|
||||
destructive bool
|
||||
}
|
||||
|
||||
@@ -1661,59 +1660,32 @@ func (t *remoteTool) MCPPackageName() string {
|
||||
return t.client.spec.Package
|
||||
}
|
||||
|
||||
// ReadOnlyExecutionAuthority reports whether this adapter's reader
|
||||
// classification comes from explicit local or signed package policy. Without a LaunchManager the
|
||||
// compatibility path derives readers from server hints, which must never
|
||||
// satisfy the strict read-only boundary.
|
||||
func (t *remoteTool) ReadOnlyExecutionAuthority() bool {
|
||||
return t.client != nil && t.client.spec.LaunchManager != nil
|
||||
}
|
||||
|
||||
func (t *remoteTool) MCPCapabilityFingerprint() string {
|
||||
return t.capabilityFingerprint
|
||||
func (t *remoteTool) MCPServerAuthorized() bool {
|
||||
return t.client != nil && t.client.spec.ServerAuthorized()
|
||||
}
|
||||
|
||||
// ReadOnly reflects MCP readOnlyHint plus backward-compatible Spec overrides.
|
||||
// It defaults to false, so opaque tools remain write-capable unless the server
|
||||
// or local configuration explicitly classifies them as read-only.
|
||||
func (t *remoteTool) securitySnapshot() (declaredReadOnly, readOnly, trusted, destructive bool, fingerprint string) {
|
||||
func (t *remoteTool) securitySnapshot() (declaredReadOnly, readOnly, destructive bool) {
|
||||
if t.client == nil {
|
||||
return t.declaredReadOnly, t.readOnly, t.readOnlyTrusted, t.destructive, t.capabilityFingerprint
|
||||
return t.declaredReadOnly, t.readOnly, t.destructive
|
||||
}
|
||||
t.client.toolsMu.Lock()
|
||||
defer t.client.toolsMu.Unlock()
|
||||
return t.declaredReadOnly, t.readOnly, t.readOnlyTrusted, t.destructive, t.capabilityFingerprint
|
||||
return t.declaredReadOnly, t.readOnly, t.destructive
|
||||
}
|
||||
|
||||
func (t *remoteTool) ReadOnly() bool {
|
||||
_, readOnly, _, _, _ := t.securitySnapshot()
|
||||
_, readOnly, _ := t.securitySnapshot()
|
||||
return readOnly
|
||||
}
|
||||
|
||||
func (t *remoteTool) PlanModeUntrustedReadOnly() bool {
|
||||
_, readOnly, trusted, _, _ := t.securitySnapshot()
|
||||
return readOnly && !trusted
|
||||
}
|
||||
|
||||
func (t *remoteTool) MCPDestructiveHint() bool {
|
||||
_, _, _, destructive, _ := t.securitySnapshot()
|
||||
_, _, destructive := t.securitySnapshot()
|
||||
return destructive
|
||||
}
|
||||
|
||||
func (t *remoteTool) MCPApprovalMode() string {
|
||||
if t.client == nil {
|
||||
return tool.MCPApprovalAuto
|
||||
}
|
||||
return t.client.spec.ToolApprovalMode(t.rawName)
|
||||
}
|
||||
|
||||
func (t *remoteTool) MCPApprovalReviewer() string {
|
||||
if t.client == nil {
|
||||
return ""
|
||||
}
|
||||
return t.client.spec.approvalReviewer()
|
||||
}
|
||||
|
||||
func (t *remoteTool) Schema() json.RawMessage {
|
||||
if len(t.schema) == 0 {
|
||||
return json.RawMessage(`{"type":"object"}`)
|
||||
@@ -1736,16 +1708,16 @@ func (t *remoteTool) ExecuteWithImages(ctx context.Context, args json.RawMessage
|
||||
return "", nil, fmt.Errorf("invalid args: %w", err)
|
||||
}
|
||||
}
|
||||
_, readOnly, trusted, destructive, fingerprint := t.securitySnapshot()
|
||||
if intent, ok := tool.ReaderExecutionIntentFrom(ctx); ok {
|
||||
_, readOnly, destructive := t.securitySnapshot()
|
||||
if tool.HasReaderExecutionIntent(ctx) {
|
||||
// Final, linearizable check for a reader-authorized call: the snapshot
|
||||
// above and every authority mutation (catalog revocations, live security
|
||||
// reconciliation) serialize on the owning client's toolsMu. A call that
|
||||
// was approved as a non-destructive reader must never execute after authority
|
||||
// changed — state drift here returns an actionable error instead of
|
||||
// above and every live security reconciliation serialize on the owning
|
||||
// client's toolsMu. A call approved as a non-destructive reader must never
|
||||
// execute after authorization or safety metadata changed — state drift
|
||||
// here returns an actionable error instead of
|
||||
// dispatching.
|
||||
if !readOnly || !trusted || destructive || (intent.CapabilityFingerprint != "" && fingerprint != "" && fingerprint != intent.CapabilityFingerprint) {
|
||||
return "", nil, fmt.Errorf("MCP server %q no longer classifies tool %q as an allowed reader; the call was blocked before dispatch — retry from a parent session or update the explicit read-only policy", t.client.name, t.rawName)
|
||||
if !t.MCPServerAuthorized() || !readOnly || destructive {
|
||||
return "", nil, fmt.Errorf("MCP server %q changed the authorization or security metadata for tool %q; the call was blocked before dispatch — refresh the server from a parent session before retrying", t.client.name, t.rawName)
|
||||
}
|
||||
}
|
||||
res, err := t.client.call(ctx, "tools/call", map[string]any{
|
||||
|
||||
+64
-265
@@ -17,7 +17,6 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/mcpcatalog"
|
||||
"reasonix/internal/mcplaunch"
|
||||
"reasonix/internal/sandbox"
|
||||
"reasonix/internal/tool"
|
||||
@@ -492,139 +491,49 @@ func TestClientListToolsPropagatesReadOnlyAndDestructiveHints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPApprovalPolicyDoesNotChangeProviderSchemas(t *testing.T) {
|
||||
schema := json.RawMessage(`{"type":"object","properties":{"target":{"type":"string"}}}`)
|
||||
makeSchemas := func(spec Spec) []byte {
|
||||
client := &Client{name: "admin", spec: spec}
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(&remoteTool{
|
||||
client: client, name: "mcp__admin__wipe", rawName: "wipe",
|
||||
desc: "wipe target", schema: schema,
|
||||
})
|
||||
out, err := json.Marshal(reg.Schemas())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
func TestUserAuthorizedMCPHintedReaderIsAuthorizedForSubagents(t *testing.T) {
|
||||
client := &Client{
|
||||
name: "mock", t: &countingToolsTransport{},
|
||||
spec: Spec{Name: "mock", Authorized: true},
|
||||
}
|
||||
baseline := makeSchemas(Spec{Name: "admin"})
|
||||
configured := makeSchemas(Spec{
|
||||
Name: "admin", DefaultToolsApprovalMode: "writes",
|
||||
ToolApprovalModes: map[string]string{"wipe": "prompt"},
|
||||
ApprovalsReviewer: "auto_review", ImplicitApproval: true,
|
||||
})
|
||||
if !bytes.Equal(baseline, configured) {
|
||||
t.Fatalf("provider schemas changed with local approval policy:\nbaseline=%s\nconfigured=%s", baseline, configured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserAuthorizedMCPDefaultsToDirectApprovalWithoutChangingOverrides(t *testing.T) {
|
||||
spec := Spec{Name: "user-server", ImplicitApproval: true}
|
||||
if got := spec.ToolApprovalMode("write"); got != tool.MCPApprovalApprove {
|
||||
t.Fatalf("implicit user approval mode = %q, want approve", got)
|
||||
}
|
||||
spec.ToolApprovalModes = map[string]string{"write": "prompt"}
|
||||
if got := spec.ToolApprovalMode("write"); got != tool.MCPApprovalPrompt {
|
||||
t.Fatalf("explicit per-tool policy = %q, want prompt", got)
|
||||
}
|
||||
spec.ToolApprovalModes = nil
|
||||
spec.DefaultToolsApprovalMode = "writes"
|
||||
if got := spec.ToolApprovalMode("write"); got != tool.MCPApprovalWrites {
|
||||
t.Fatalf("explicit server policy = %q, want writes", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecReadOnlyToolNamesMarksUnhintedToolsReadOnly(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
spec := Spec{
|
||||
Name: "mock",
|
||||
Command: os.Args[0],
|
||||
Args: []string{"-test.run=TestHelperProcess", "--"},
|
||||
Env: map[string]string{"GO_WANT_HELPER_PROCESS": "1"},
|
||||
ReadOnlyToolNames: map[string]bool{
|
||||
"echo": true,
|
||||
},
|
||||
}
|
||||
|
||||
host, tools, err := StartAll(ctx, []Spec{spec})
|
||||
tools, err := client.listTools(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("StartAll: %v", err)
|
||||
t.Fatalf("listTools: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
byName := map[string]tool.Tool{}
|
||||
for _, tl := range tools {
|
||||
byName[tl.Name()] = tl
|
||||
echo := findToolByName(tools, "mcp__mock__echo")
|
||||
if echo == nil || !echo.ReadOnly() {
|
||||
t.Fatalf("installed hinted reader missing or not read-only: %T", echo)
|
||||
}
|
||||
echo := byName["mcp__mock__echo"]
|
||||
if echo == nil {
|
||||
t.Fatalf("mcp__mock__echo missing from %v", byName)
|
||||
if authority, ok := echo.(tool.MCPServerAuthorization); !ok || !authority.MCPServerAuthorized() {
|
||||
t.Fatalf("installed hinted reader lacks server authorization: %T", echo)
|
||||
}
|
||||
if !echo.ReadOnly() {
|
||||
t.Fatal("read-only override did not mark unhinted echo tool read-only")
|
||||
}
|
||||
zed := byName["mcp__mock__zed"]
|
||||
if zed == nil {
|
||||
t.Fatalf("mcp__mock__zed missing from %v", byName)
|
||||
}
|
||||
if zed.ReadOnly() {
|
||||
t.Fatal("read-only override should not mark non-listed tools read-only")
|
||||
if _, err := echo.Execute(tool.WithReaderExecutionIntent(context.Background()), json.RawMessage(`{"msg":"ok","z":"ok"}`)); err != nil {
|
||||
t.Fatalf("installed hinted reader dispatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecReadOnlyModelToolNamesMarksVisibleToolsTrusted(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
spec := Spec{
|
||||
Name: "mock",
|
||||
Command: os.Args[0],
|
||||
Args: []string{"-test.run=TestHelperProcess", "--"},
|
||||
Env: map[string]string{"GO_WANT_HELPER_PROCESS": "1"},
|
||||
ReadOnlyModelToolNames: map[string]bool{
|
||||
"mcp__mock__echo": true,
|
||||
},
|
||||
func TestServerAuthorizedUsesResolvedBooleanOnly(t *testing.T) {
|
||||
if !(Spec{Authorized: true}).ServerAuthorized() {
|
||||
t.Fatal("an explicitly authorized server should not require a launch manager")
|
||||
}
|
||||
|
||||
host, tools, err := StartAll(ctx, []Spec{spec})
|
||||
if err != nil {
|
||||
t.Fatalf("StartAll: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
byName := map[string]tool.Tool{}
|
||||
for _, tl := range tools {
|
||||
byName[tl.Name()] = tl
|
||||
}
|
||||
echo := byName["mcp__mock__echo"]
|
||||
if echo == nil {
|
||||
t.Fatalf("mcp__mock__echo missing from %v", byName)
|
||||
}
|
||||
if !echo.ReadOnly() {
|
||||
t.Fatal("model-visible read-only override did not mark echo tool read-only")
|
||||
}
|
||||
zed := byName["mcp__mock__zed"]
|
||||
if zed == nil {
|
||||
t.Fatalf("mcp__mock__zed missing from %v", byName)
|
||||
}
|
||||
if zed.ReadOnly() {
|
||||
t.Fatal("model-visible read-only override should not mark non-listed tools read-only")
|
||||
if (Spec{}).ServerAuthorized() {
|
||||
t.Fatal("an unresolved server should remain unauthorized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyKnownReadOnlyOverridesMarksCodeGraphReadTools(t *testing.T) {
|
||||
got := ApplyKnownReadOnlyOverrides(Spec{Name: "codegraph", ReadOnlyToolNames: map[string]bool{"custom": true}})
|
||||
for _, name := range []string{"custom", "codegraph_context", "codegraph_search", "context", "search"} {
|
||||
if !got.ReadOnlyToolNames[name] {
|
||||
t.Fatalf("codegraph read-only override missing %q: %+v", name, got.ReadOnlyToolNames)
|
||||
}
|
||||
func TestInstalledServerAuthorizationSkipsProjectIdentityDigest(t *testing.T) {
|
||||
installed := Spec{Name: "installed", Authorized: true}
|
||||
resolved, err := resolveProjectLaunchAuthorization(context.Background(), installed)
|
||||
if err != nil || !resolved.ServerAuthorized() {
|
||||
t.Fatalf("installed authorization = (%+v, %v), want authorized without identity resolution", resolved, err)
|
||||
}
|
||||
|
||||
other := ApplyKnownReadOnlyOverrides(Spec{Name: "not-codegraph"})
|
||||
if other.ReadOnlyToolNames["codegraph_context"] {
|
||||
t.Fatalf("non-codegraph spec should not receive codegraph overrides: %+v", other.ReadOnlyToolNames)
|
||||
project := Spec{
|
||||
Name: "project", RequireLaunchApproval: true,
|
||||
LaunchManager: mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), t.TempDir()),
|
||||
}
|
||||
if _, err := resolveProjectLaunchAuthorization(context.Background(), project); err == nil || !strings.Contains(err.Error(), "command is required") {
|
||||
t.Fatalf("project authorization did not resolve its exact launch identity: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,9 +542,6 @@ func TestApplyKnownOverridesPinsCodeGraphStdioToWorkspace(t *testing.T) {
|
||||
if got.Dir != "/workspace" {
|
||||
t.Fatalf("codegraph stdio Dir = %q, want workspace root", got.Dir)
|
||||
}
|
||||
if !got.ReadOnlyToolNames["codegraph_search"] {
|
||||
t.Fatalf("codegraph read-only override missing: %+v", got.ReadOnlyToolNames)
|
||||
}
|
||||
if got.Env[codeGraphDaemonIdleTimeoutEnv] != codeGraphDaemonIdleTimeoutDefaultMS {
|
||||
t.Fatalf("codegraph daemon idle timeout env = %q, want %s; env=%v", got.Env[codeGraphDaemonIdleTimeoutEnv], codeGraphDaemonIdleTimeoutDefaultMS, got.Env)
|
||||
}
|
||||
@@ -1368,62 +1274,18 @@ func TestNormalizeIdentityURLPreservesEndpointSemantics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialIdentityIsStableAcrossWorkspaceIsolationRoots(t *testing.T) {
|
||||
packageRoot := t.TempDir()
|
||||
packageFile := filepath.Join(packageRoot, "server.js")
|
||||
if err := os.WriteFile(packageFile, []byte("verified"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packageDigest, err := mcpcatalog.TreeSHA256(packageRoot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := Spec{
|
||||
Name: "official", Command: os.Args[0], OfficialCatalogEntryID: "official@1",
|
||||
PackageDigest: packageDigest, PackageRoot: packageRoot, ConfigSource: "workspace_config",
|
||||
ReaderSandbox: sandbox.Spec{
|
||||
Mode: "enforce", ReadRoots: []string{"/workspace/a", "/home/user"},
|
||||
WriteRoots: []string{"/state/a"}, ForbidReadRoots: []string{"/workspace/a/private"},
|
||||
},
|
||||
WriterSandbox: sandbox.Spec{Mode: "enforce", ReadRoots: []string{"/workspace/a"}, WriteRoots: []string{"/workspace/a"}},
|
||||
}
|
||||
other := base
|
||||
other.ReaderSandbox.ReadRoots = []string{"/workspace/b", "/home/user"}
|
||||
other.ReaderSandbox.WriteRoots = []string{"/state/b"}
|
||||
other.ReaderSandbox.ForbidReadRoots = []string{"/workspace/b/private"}
|
||||
other.WriterSandbox.ReadRoots = []string{"/workspace/b"}
|
||||
other.WriterSandbox.WriteRoots = []string{"/workspace/b"}
|
||||
a, err := specIdentityFingerprint(context.Background(), base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := specIdentityFingerprint(context.Background(), other)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a != b {
|
||||
t.Fatalf("official global identity changed across workspaces: %s != %s", a, b)
|
||||
}
|
||||
if err := os.WriteFile(packageFile, []byte("tampered"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := specIdentityFingerprint(context.Background(), base); err == nil || !strings.Contains(err.Error(), "changed after verification") {
|
||||
t.Fatalf("tampered official package identity error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceIdentityIgnoresHostPolicyChanges(t *testing.T) {
|
||||
base := Spec{
|
||||
Name: "custom", Command: os.Args[0], ConfigSource: "workspace_config",
|
||||
ReaderSandbox: sandbox.Spec{Mode: "enforce", ForbidReadRoots: []string{"/secret/a"}},
|
||||
Sandbox: sandbox.Spec{Mode: "enforce", ForbidReadRoots: []string{"/secret/a"}},
|
||||
}
|
||||
changed := base
|
||||
changed.ReaderSandbox.ForbidReadRoots = []string{"/secret/b"}
|
||||
a, err := specIdentityFingerprint(context.Background(), base)
|
||||
changed.Sandbox.ForbidReadRoots = []string{"/secret/b"}
|
||||
a, err := projectLaunchIdentityDigest(context.Background(), base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := specIdentityFingerprint(context.Background(), changed)
|
||||
b, err := projectLaunchIdentityDigest(context.Background(), changed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1466,13 +1328,6 @@ func TestProjectLaunchApprovalBlocksBeforeProcessStart(t *testing.T) {
|
||||
if len(tools) == 0 {
|
||||
t.Fatal("authorized project server returned no tools")
|
||||
}
|
||||
policy, ok := tools[0].(tool.MCPApprovalPolicy)
|
||||
if !ok {
|
||||
t.Fatalf("authorized project tool %T does not expose MCP approval policy", tools[0])
|
||||
}
|
||||
if got := policy.MCPApprovalMode(); got != tool.MCPApprovalApprove {
|
||||
t.Fatalf("authorized project tool approval = %q, want direct approval", got)
|
||||
}
|
||||
host.Close()
|
||||
if got := readHelperCounter(t, startCount); got != 1 {
|
||||
t.Fatalf("post-authorization starts = %d, want 1", got)
|
||||
@@ -1497,10 +1352,13 @@ func TestAuthorizeSpecLaunchRecordsInstallConsentWithoutStartingServer(t *testin
|
||||
if err := AuthorizeSpecLaunch(ctx, spec); err != nil {
|
||||
t.Fatalf("AuthorizeSpecLaunch: %v", err)
|
||||
}
|
||||
if resolved := ResolveStoredAuthorization(ctx, spec); !resolved.ServerAuthorized() {
|
||||
t.Fatal("stored project launch grant did not resolve server authorization")
|
||||
}
|
||||
if got := readHelperCounter(t, startCount); got != 0 {
|
||||
t.Fatalf("install authorization started server %d times, want 0", got)
|
||||
}
|
||||
identity, err := specIdentityFingerprint(ctx, spec)
|
||||
identity, err := projectLaunchIdentityDigest(ctx, spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1516,13 +1374,6 @@ func TestAuthorizeSpecLaunchRecordsInstallConsentWithoutStartingServer(t *testin
|
||||
if len(tools) == 0 {
|
||||
t.Fatal("installed project server returned no tools")
|
||||
}
|
||||
policy, ok := tools[0].(tool.MCPApprovalPolicy)
|
||||
if !ok {
|
||||
t.Fatalf("installed project tool %T does not expose MCP approval policy", tools[0])
|
||||
}
|
||||
if got := policy.MCPApprovalMode(); got != tool.MCPApprovalApprove {
|
||||
t.Fatalf("installed project tool approval = %q, want direct approval", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizeSpecLaunchDoesNotAddPersistentTransportRestrictions(t *testing.T) {
|
||||
@@ -1535,7 +1386,7 @@ func TestAuthorizeSpecLaunchDoesNotAddPersistentTransportRestrictions(t *testing
|
||||
if err := AuthorizeSpecLaunch(ctx, spec); err != nil {
|
||||
t.Fatalf("explicit install authorization: %v", err)
|
||||
}
|
||||
identity, err := specIdentityFingerprint(ctx, spec)
|
||||
identity, err := projectLaunchIdentityDigest(ctx, spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1571,7 +1422,7 @@ func TestAuthorizeProjectSpecLaunchLocksMutableLauncherWithoutStartingServer(t *
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity, err := specIdentityFingerprint(context.Background(), locked)
|
||||
identity, err := projectLaunchIdentityDigest(context.Background(), locked)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1581,60 +1432,7 @@ func TestAuthorizeProjectSpecLaunchLocksMutableLauncherWithoutStartingServer(t *
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadOnlyOverrideDoesNotChangeModelVisibleSchema locks the cache invariant
|
||||
// behind the backward-compatible MCP read-only override: classification may
|
||||
// change ReadOnly, but must not alter the provider-visible name or input schema.
|
||||
func TestReadOnlyOverrideDoesNotChangeModelVisibleSchema(t *testing.T) {
|
||||
startMockEcho := func(spec Spec) (*Host, map[string]tool.Tool) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
spec.Name = "mock"
|
||||
spec.Command = os.Args[0]
|
||||
spec.Args = []string{"-test.run=TestHelperProcess", "--"}
|
||||
spec.Env = map[string]string{"GO_WANT_HELPER_PROCESS": "1"}
|
||||
host, tools, err := StartAll(ctx, []Spec{spec})
|
||||
if err != nil {
|
||||
t.Fatalf("StartAll: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { host.Close() })
|
||||
byName := map[string]tool.Tool{}
|
||||
for _, tl := range tools {
|
||||
byName[tl.Name()] = tl
|
||||
}
|
||||
return host, byName
|
||||
}
|
||||
|
||||
_, baseTools := startMockEcho(Spec{})
|
||||
_, overriddenTools := startMockEcho(Spec{ReadOnlyModelToolNames: map[string]bool{"mcp__mock__echo": true}})
|
||||
|
||||
base, ok := baseTools["mcp__mock__echo"]
|
||||
if !ok {
|
||||
t.Fatalf("mcp__mock__echo missing from base tools %v", baseTools)
|
||||
}
|
||||
overriddenEcho, ok := overriddenTools["mcp__mock__echo"]
|
||||
if !ok {
|
||||
t.Fatalf("mcp__mock__echo missing from overridden tools %v", overriddenTools)
|
||||
}
|
||||
|
||||
// The model-visible surface (name + schema bytes) must be byte-identical.
|
||||
if base.Name() != overriddenEcho.Name() {
|
||||
t.Fatalf("override changed model-visible tool name: %q vs %q", base.Name(), overriddenEcho.Name())
|
||||
}
|
||||
if got, want := string(overriddenEcho.Schema()), string(base.Schema()); got != want {
|
||||
t.Fatalf("override changed model-visible schema bytes:\n override=%s\n base=%s", got, want)
|
||||
}
|
||||
|
||||
// The legacy override only flips the read-only classification.
|
||||
if base.ReadOnly() {
|
||||
t.Fatal("base echo should not be read-only without a hint")
|
||||
}
|
||||
if !overriddenEcho.ReadOnly() {
|
||||
t.Fatal("overridden echo should be marked read-only")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderIntentRefusesDispatchAfterRevocation(t *testing.T) {
|
||||
func TestReaderIntentRefusesDispatchAfterSafetyDrift(t *testing.T) {
|
||||
stateDir := t.TempDir()
|
||||
startCount := filepath.Join(t.TempDir(), "starts")
|
||||
callCount := filepath.Join(t.TempDir(), "calls")
|
||||
@@ -1645,7 +1443,8 @@ func TestReaderIntentRefusesDispatchAfterRevocation(t *testing.T) {
|
||||
"GO_WANT_HELPER_START_COUNT": startCount,
|
||||
"GO_WANT_HELPER_CALL_COUNT": callCount,
|
||||
},
|
||||
StateDir: stateDir,
|
||||
StateDir: stateDir, Authorized: true,
|
||||
LaunchManager: mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), t.TempDir()),
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -1663,13 +1462,13 @@ func TestReaderIntentRefusesDispatchAfterRevocation(t *testing.T) {
|
||||
t.Fatalf("expected remoteTool adapter, got %T", target)
|
||||
}
|
||||
|
||||
// The tool is authorized as a trusted reader.
|
||||
// The installed server is authorized and currently advertises a reader.
|
||||
rt.client.toolsMu.Lock()
|
||||
rt.readOnly, rt.readOnlyTrusted = true, true
|
||||
rt.readOnly = true
|
||||
rt.client.toolsMu.Unlock()
|
||||
readerCtx := tool.WithReaderExecutionIntent(ctx, rt.MCPCapabilityFingerprint())
|
||||
readerCtx := tool.WithReaderExecutionIntent(ctx)
|
||||
if _, _, err := rt.ExecuteWithImages(readerCtx, json.RawMessage(`{"msg":"ok","z":"ok"}`)); err != nil {
|
||||
t.Fatalf("trusted reader call failed: %v", err)
|
||||
t.Fatalf("authorized reader call failed: %v", err)
|
||||
}
|
||||
if got := readHelperCounter(t, startCount); got != 1 {
|
||||
t.Fatalf("reader call spawned extra processes: starts=%d", got)
|
||||
@@ -1678,14 +1477,13 @@ func TestReaderIntentRefusesDispatchAfterRevocation(t *testing.T) {
|
||||
t.Fatalf("reader call count = %d, want 1", got)
|
||||
}
|
||||
|
||||
// A concurrent revocation (catalog refresh, trust re-evaluation) lands
|
||||
// after the authorization: the reader-authorized call must refuse instead
|
||||
// of issuing tools/call.
|
||||
// A concurrent read-to-write classification change lands after authorization:
|
||||
// the reader-authorized call must refuse instead of issuing tools/call.
|
||||
rt.client.toolsMu.Lock()
|
||||
rt.readOnly, rt.readOnlyTrusted = false, false
|
||||
rt.readOnly = false
|
||||
rt.client.toolsMu.Unlock()
|
||||
if _, _, err := rt.ExecuteWithImages(readerCtx, json.RawMessage(`{"msg":"blocked","z":"ok"}`)); err == nil || !strings.Contains(err.Error(), "no longer classifies") {
|
||||
t.Fatalf("revoked reader call = %v, want trusted-reader refusal", err)
|
||||
if _, _, err := rt.ExecuteWithImages(readerCtx, json.RawMessage(`{"msg":"blocked","z":"ok"}`)); err == nil || !strings.Contains(err.Error(), "changed the authorization or security metadata") {
|
||||
t.Fatalf("changed reader call = %v, want reader refusal", err)
|
||||
}
|
||||
if got := readHelperCounter(t, startCount); got != 1 {
|
||||
t.Fatalf("revoked reader call started a writer process: starts=%d", got)
|
||||
@@ -1694,31 +1492,32 @@ func TestReaderIntentRefusesDispatchAfterRevocation(t *testing.T) {
|
||||
t.Fatalf("revoked reader call reached tools/call: calls=%d", got)
|
||||
}
|
||||
|
||||
// A stale capability fingerprint pinned at authorization time is refused
|
||||
// even when the tool is still a reader.
|
||||
// Schema-only changes do not revoke an installed server or its reader lane.
|
||||
// The live server owns argument validation; refreshed provider-visible schema
|
||||
// bytes land in the next session rather than interrupting this call.
|
||||
rt.client.toolsMu.Lock()
|
||||
rt.readOnly, rt.readOnlyTrusted = true, true
|
||||
rt.readOnly = true
|
||||
rt.client.toolsMu.Unlock()
|
||||
staleCtx := tool.WithReaderExecutionIntent(ctx, "stale-fingerprint")
|
||||
if _, _, err := rt.ExecuteWithImages(staleCtx, json.RawMessage(`{"msg":"stale","z":"ok"}`)); err == nil || !strings.Contains(err.Error(), "no longer classifies") {
|
||||
t.Fatalf("stale fingerprint call = %v, want refusal", err)
|
||||
rt.schema = json.RawMessage(`{"type":"object","properties":{"msg":{"type":"number"}}}`)
|
||||
if _, _, err := rt.ExecuteWithImages(readerCtx, json.RawMessage(`{"msg":"schema-changed","z":"ok"}`)); err != nil {
|
||||
t.Fatalf("schema-only reader change should execute: %v", err)
|
||||
}
|
||||
if got := readHelperCounter(t, callCount); got != 1 {
|
||||
t.Fatalf("stale fingerprint call reached tools/call: calls=%d", got)
|
||||
if got := readHelperCounter(t, callCount); got != 2 {
|
||||
t.Fatalf("schema-only reader call count = %d, want 2", got)
|
||||
}
|
||||
|
||||
// Without reader intent the ordinary writer path remains on the persistent
|
||||
// connection.
|
||||
rt.client.toolsMu.Lock()
|
||||
rt.readOnly, rt.readOnlyTrusted = false, false
|
||||
rt.readOnly = false
|
||||
rt.client.toolsMu.Unlock()
|
||||
if _, _, err := rt.ExecuteWithImages(ctx, json.RawMessage(`{"msg":"writer","z":"ok"}`)); err != nil {
|
||||
t.Fatalf("approved writer call failed: %v", err)
|
||||
t.Fatalf("authorized writer call failed: %v", err)
|
||||
}
|
||||
if got := readHelperCounter(t, startCount); got != 1 {
|
||||
t.Fatalf("writer call starts = %d, want one persistent process", got)
|
||||
}
|
||||
if got := readHelperCounter(t, callCount); got != 2 {
|
||||
t.Fatalf("writer call count = %d, want 2", got)
|
||||
if got := readHelperCounter(t, callCount); got != 3 {
|
||||
t.Fatalf("writer call count = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
// progressTransport is optional so lightweight transports used by embedders and
|
||||
// tests remain valid. Native transports implement it and route a server's
|
||||
// notifications/progress message to the matching tools/call context.
|
||||
type progressTransport interface {
|
||||
registerProgress(token string, sink tool.ProgressFunc) func()
|
||||
}
|
||||
|
||||
type progressRouter struct {
|
||||
mu sync.Mutex
|
||||
sinks map[string]tool.ProgressFunc
|
||||
}
|
||||
|
||||
func (r *progressRouter) registerProgress(token string, sink tool.ProgressFunc) func() {
|
||||
if token == "" || sink == nil {
|
||||
return func() {}
|
||||
}
|
||||
r.mu.Lock()
|
||||
if r.sinks == nil {
|
||||
r.sinks = map[string]tool.ProgressFunc{}
|
||||
}
|
||||
r.sinks[token] = sink
|
||||
r.mu.Unlock()
|
||||
return func() {
|
||||
r.mu.Lock()
|
||||
delete(r.sinks, token)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *progressRouter) dispatchProgress(params json.RawMessage) bool {
|
||||
var p struct {
|
||||
ProgressToken any `json:"progressToken"`
|
||||
Progress *float64 `json:"progress"`
|
||||
Total *float64 `json:"total"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return false
|
||||
}
|
||||
token := progressTokenKey(p.ProgressToken)
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
r.mu.Lock()
|
||||
sink := r.sinks[token]
|
||||
r.mu.Unlock()
|
||||
if sink == nil {
|
||||
return false
|
||||
}
|
||||
sink(formatMCPProgress(p.Message, p.Progress, p.Total))
|
||||
return true
|
||||
}
|
||||
|
||||
func progressTokenKey(token any) string {
|
||||
switch value := token.(type) {
|
||||
case string:
|
||||
return value
|
||||
case float64:
|
||||
return strconv.FormatFloat(value, 'f', -1, 64)
|
||||
case json.Number:
|
||||
return value.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func formatMCPProgress(message string, progress, total *float64) string {
|
||||
label := strings.TrimSpace(message)
|
||||
if label == "" {
|
||||
label = "MCP progress"
|
||||
}
|
||||
formatNumber := func(value float64) string {
|
||||
return strconv.FormatFloat(value, 'f', -1, 64)
|
||||
}
|
||||
switch {
|
||||
case progress != nil && total != nil:
|
||||
return fmt.Sprintf("%s (%s/%s)\n", label, formatNumber(*progress), formatNumber(*total))
|
||||
case progress != nil:
|
||||
return fmt.Sprintf("%s (%s)\n", label, formatNumber(*progress))
|
||||
default:
|
||||
return label + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
type mcpRoot struct {
|
||||
URI string `json:"uri"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
func mcpRoots(workspaceRoot string) []mcpRoot {
|
||||
root := strings.TrimSpace(workspaceRoot)
|
||||
if root == "" {
|
||||
return nil
|
||||
}
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
clean := filepath.Clean(abs)
|
||||
path := filepath.ToSlash(clean)
|
||||
fileURL := &url.URL{Scheme: "file"}
|
||||
if strings.HasPrefix(path, "//") {
|
||||
parts := strings.SplitN(strings.TrimPrefix(path, "//"), "/", 2)
|
||||
fileURL.Host = parts[0]
|
||||
if len(parts) == 2 {
|
||||
fileURL.Path = "/" + parts[1]
|
||||
} else {
|
||||
fileURL.Path = "/"
|
||||
}
|
||||
} else {
|
||||
if volume := filepath.VolumeName(clean); volume != "" && !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
fileURL.Path = path
|
||||
}
|
||||
name := filepath.Base(clean)
|
||||
if name == "." {
|
||||
name = clean
|
||||
}
|
||||
return []mcpRoot{{URI: fileURL.String(), Name: name}}
|
||||
}
|
||||
|
||||
type inboundMessage struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
|
||||
func decodeInboundMessage(payload []byte) (inboundMessage, bool) {
|
||||
var message inboundMessage
|
||||
if err := json.Unmarshal(payload, &message); err != nil {
|
||||
return inboundMessage{}, false
|
||||
}
|
||||
return message, true
|
||||
}
|
||||
|
||||
func isNotificationID(id json.RawMessage) bool {
|
||||
id = bytes.TrimSpace(id)
|
||||
return len(id) == 0 || bytes.Equal(id, []byte("null"))
|
||||
}
|
||||
|
||||
func serverRequestReply(id json.RawMessage, method string, roots []mcpRoot) any {
|
||||
response := struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
}{JSONRPC: "2.0", ID: append(json.RawMessage(nil), id...)}
|
||||
switch method {
|
||||
case "ping":
|
||||
response.Result = map[string]any{}
|
||||
case "roots/list":
|
||||
response.Result = map[string]any{"roots": roots}
|
||||
default:
|
||||
response.Error = &rpcError{Code: -32601, Message: "Method not found"}
|
||||
}
|
||||
return response
|
||||
}
|
||||
+63
-203
@@ -2,9 +2,6 @@ package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
@@ -12,81 +9,47 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/mcpcatalog"
|
||||
"reasonix/internal/mcplaunch"
|
||||
"reasonix/internal/sandbox"
|
||||
"reasonix/internal/secrets"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
// specIdentityFingerprint resolves a secret-free identity before a server is
|
||||
// trusted. For stdio this pins the real executable path and file content; for
|
||||
// HTTP it normalizes the endpoint while retaining only header key names.
|
||||
func specIdentityFingerprint(ctx context.Context, s Spec) (string, error) {
|
||||
identity, err := buildSpecIdentity(ctx, s)
|
||||
// projectLaunchIdentityDigest resolves a secret-free identity before a project
|
||||
// server is authorized. For stdio this pins the real executable path and file
|
||||
// content; for HTTP it normalizes the endpoint while retaining only header key
|
||||
// names. Installed and host-session servers never call this path.
|
||||
func projectLaunchIdentityDigest(ctx context.Context, s Spec) (string, error) {
|
||||
identity, err := buildProjectLaunchIdentity(ctx, s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return mcplaunch.IdentityFingerprint(identity)
|
||||
return mcplaunch.ProjectLaunchIdentityDigest(identity)
|
||||
}
|
||||
|
||||
func buildSpecIdentity(ctx context.Context, s Spec) (mcplaunch.Identity, error) {
|
||||
func buildProjectLaunchIdentity(ctx context.Context, s Spec) (mcplaunch.ProjectLaunchIdentity, error) {
|
||||
transport := strings.ToLower(strings.TrimSpace(s.Type))
|
||||
if transport == "" {
|
||||
transport = "stdio"
|
||||
}
|
||||
if strings.TrimSpace(s.OfficialCatalogEntryID) != "" {
|
||||
if err := validateOfficialLauncher(s); err != nil {
|
||||
return mcplaunch.Identity{}, err
|
||||
}
|
||||
if strings.TrimSpace(s.PackageRoot) == "" || strings.TrimSpace(s.PackageDigest) == "" {
|
||||
return mcplaunch.Identity{}, fmt.Errorf("official MCP server %q is missing its verified package root or digest", s.Name)
|
||||
}
|
||||
liveDigest, err := mcpcatalog.TreeSHA256(s.PackageRoot)
|
||||
if err != nil {
|
||||
return mcplaunch.Identity{}, fmt.Errorf("verify official MCP package for %q: %w", s.Name, err)
|
||||
}
|
||||
if !strings.EqualFold(liveDigest, s.PackageDigest) {
|
||||
return mcplaunch.Identity{}, fmt.Errorf("official MCP package for %q changed after verification; blocked before process or network startup", s.Name)
|
||||
}
|
||||
}
|
||||
launchArgs := effectiveLaunchArgs(s)
|
||||
if s.LauncherIdentityArgs != nil {
|
||||
launchArgs = s.LauncherIdentityArgs
|
||||
}
|
||||
identity := mcplaunch.Identity{
|
||||
Server: s.Name, Transport: transport, ConfigSource: s.ConfigSource,
|
||||
identity := mcplaunch.ProjectLaunchIdentity{
|
||||
Server: s.Name, Transport: transport,
|
||||
Dir: s.Dir, Args: append([]string(nil), launchArgs...),
|
||||
EnvKeys: sortedMapKeys(s.Env), HeaderKeys: sortedMapKeys(s.Headers),
|
||||
Network: s.ReaderSandbox.Network || s.WriterSandbox.Network,
|
||||
WriteRoots: append(append(append([]string(nil), s.ReaderSandbox.WriteRoots...),
|
||||
s.ReaderSandbox.AppContainerWriteRoots...), s.WriterSandbox.WriteRoots...),
|
||||
ReadRoots: append(append([]string(nil), s.ReaderSandbox.ReadRoots...), s.WriterSandbox.ReadRoots...),
|
||||
ForbidReadRoots: append(append([]string(nil), s.ReaderSandbox.ForbidReadRoots...),
|
||||
s.WriterSandbox.ForbidReadRoots...),
|
||||
IsolationPolicy: isolationPolicy(s),
|
||||
PackageDigest: s.PackageDigest,
|
||||
LauncherDigest: s.LauncherDigest,
|
||||
}
|
||||
if strings.TrimSpace(s.OfficialCatalogEntryID) != "" {
|
||||
// Verified official package identity is global across workspaces. The signed package digest
|
||||
// pins executable code and the catalog pins the server definition, so
|
||||
// workspace-expanded args and write-root paths are excluded here.
|
||||
identity.Args = nil
|
||||
identity.Dir = ""
|
||||
identity.WriteRoots = nil
|
||||
identity.ReadRoots = nil
|
||||
identity.ForbidReadRoots = nil
|
||||
identity.ConfigSource = "official_catalog:" + s.OfficialCatalogEntryID
|
||||
LauncherDigest: s.LauncherDigest,
|
||||
}
|
||||
switch transport {
|
||||
case "stdio":
|
||||
if strings.TrimSpace(s.Command) == "" {
|
||||
return mcplaunch.Identity{}, fmt.Errorf("stdio plugin %q: command is required", s.Name)
|
||||
return mcplaunch.ProjectLaunchIdentity{}, fmt.Errorf("stdio plugin %q: command is required", s.Name)
|
||||
}
|
||||
env := mergeEnv(secrets.ProcessEnv(), s.Env)
|
||||
exe, _, err := resolveStdioExecutable(ctx, s, env)
|
||||
if err != nil {
|
||||
return mcplaunch.Identity{}, err
|
||||
return mcplaunch.ProjectLaunchIdentity{}, err
|
||||
}
|
||||
if abs, err := filepath.Abs(exe); err == nil {
|
||||
exe = abs
|
||||
@@ -94,7 +57,7 @@ func buildSpecIdentity(ctx context.Context, s Spec) (mcplaunch.Identity, error)
|
||||
identity.CommandPath = exe
|
||||
identity.CommandSHA256, err = mcplaunch.FileSHA256(exe)
|
||||
if err != nil {
|
||||
return mcplaunch.Identity{}, fmt.Errorf("hash MCP executable %q: %w", exe, err)
|
||||
return mcplaunch.ProjectLaunchIdentity{}, fmt.Errorf("hash MCP executable %q: %w", exe, err)
|
||||
}
|
||||
case "http", "streamable-http", "streamable_http":
|
||||
identity.Transport = "http"
|
||||
@@ -121,20 +84,6 @@ func MCPStateDir(reasonixHome, workspace, server string) string {
|
||||
return filepath.Join(reasonixHome, "mcp-state", workspaceID, slug(server))
|
||||
}
|
||||
|
||||
func isolationPolicy(s Spec) string {
|
||||
transport := strings.ToLower(strings.TrimSpace(s.Type))
|
||||
if transport == "http" || transport == "streamable-http" || transport == "streamable_http" {
|
||||
return "not_applicable"
|
||||
}
|
||||
if !s.ReaderSandbox.Enforce() && !s.WriterSandbox.Enforce() {
|
||||
return "off"
|
||||
}
|
||||
if sandbox.Available() {
|
||||
return "enforced"
|
||||
}
|
||||
return "unavailable_unconfined"
|
||||
}
|
||||
|
||||
// identityURLRedacted replaces credential material inside identity and cache
|
||||
// URLs. Only the structure survives: whether userinfo/a password exists and
|
||||
// how many values a credential parameter carries, never their contents.
|
||||
@@ -178,7 +127,7 @@ func credentialURLQueryKey(key string) bool {
|
||||
}
|
||||
|
||||
// normalizeIdentityURL canonicalizes an MCP endpoint for host-local identity
|
||||
// and schema-cache fingerprints: scheme/host case and default ports fold,
|
||||
// and schema-cache keys: scheme/host case and default ports fold,
|
||||
// the fragment drops, query keys sort stably, and credential material
|
||||
// (userinfo, credential query values) is replaced by a fixed placeholder so
|
||||
// rotation never invalidates an exact project launch authorization.
|
||||
@@ -264,129 +213,54 @@ func sortedMapKeys[V any](values map[string]V) []string {
|
||||
}
|
||||
|
||||
func launchConfigSource(s Spec) string {
|
||||
if s.OfficialCatalogEntryID != "" {
|
||||
return "official_catalog:" + s.OfficialCatalogEntryID
|
||||
}
|
||||
return s.ConfigSource
|
||||
}
|
||||
|
||||
type toolCapability struct {
|
||||
RawName string
|
||||
ModelName string
|
||||
VisibleName string
|
||||
InputSchema json.RawMessage
|
||||
OutputSchema json.RawMessage
|
||||
ReadOnly bool
|
||||
Destructive bool
|
||||
}
|
||||
|
||||
func capabilityOf(s Spec, raw mcpTool, schema []byte) toolCapability {
|
||||
visible := raw.Name
|
||||
if s.StripRawPrefix != "" {
|
||||
visible = strings.TrimPrefix(visible, s.StripRawPrefix)
|
||||
}
|
||||
hinted := raw.Annotations != nil && raw.Annotations.ReadOnlyHint
|
||||
destructive := raw.Annotations != nil && raw.Annotations.DestructiveHint
|
||||
return toolCapability{
|
||||
RawName: raw.Name, ModelName: toolName(s.Name, visible), VisibleName: visible,
|
||||
InputSchema: schema, OutputSchema: raw.OutputSchema,
|
||||
ReadOnly: hinted || s.toolReadOnlyOverride(raw.Name, visible), Destructive: destructive,
|
||||
}
|
||||
}
|
||||
|
||||
func trustedReaderForSpec(s Spec, cap toolCapability) bool {
|
||||
if !cap.ReadOnly || cap.Destructive {
|
||||
return false
|
||||
}
|
||||
if s.toolReadOnlyOverride(cap.RawName, cap.VisibleName) {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(s.OfficialCatalogEntryID) == "" || mcpcatalog.RuntimeEntryRevoked(s.OfficialCatalogEntryID) {
|
||||
return false
|
||||
}
|
||||
for _, name := range s.OfficialReaderNames {
|
||||
if strings.TrimSpace(name) == cap.RawName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func capabilityFingerprint(cap toolCapability) string {
|
||||
in, err := canonicalSecuritySchema(cap.InputSchema)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
out, err := canonicalSecuritySchema(cap.OutputSchema)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
payload := struct {
|
||||
RawName string `json:"raw_name"`
|
||||
ModelName string `json:"model_name"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
Output json.RawMessage `json:"output,omitempty"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
Destructive bool `json:"destructive"`
|
||||
}{
|
||||
RawName: strings.TrimSpace(cap.RawName), ModelName: strings.TrimSpace(cap.ModelName),
|
||||
Input: in, Output: out, ReadOnly: cap.ReadOnly, Destructive: cap.Destructive,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func canonicalSecuritySchema(raw json.RawMessage) (json.RawMessage, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var value any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stripSchemaDisplayFields(value)
|
||||
body, err := json.Marshal(value)
|
||||
return json.RawMessage(body), err
|
||||
}
|
||||
|
||||
func stripSchemaDisplayFields(value any) {
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
for _, key := range []string{"description", "title", "examples", "$comment"} {
|
||||
delete(v, key)
|
||||
}
|
||||
for key, child := range v {
|
||||
switch key {
|
||||
case "properties", "patternProperties", "$defs", "definitions", "dependentSchemas", "dependentRequired", "dependencies":
|
||||
if named, ok := child.(map[string]any); ok {
|
||||
for _, schema := range named {
|
||||
stripSchemaDisplayFields(schema)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
stripSchemaDisplayFields(child)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range v {
|
||||
stripSchemaDisplayFields(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CachedToolSafety is the local safety classification for one tool in an
|
||||
// identity-matched schema cache. Server hints control ordinary read-only
|
||||
// policy; only explicit local declarations or signed catalog readers are
|
||||
// admitted into strict read-only execution.
|
||||
// identity-matched schema cache. Authorization belongs to the MCP server;
|
||||
// cached tools retain only safety facts that must match the live server.
|
||||
type CachedToolSafety struct {
|
||||
ReadOnly bool
|
||||
TrustedReader bool
|
||||
Destructive bool
|
||||
CapabilityFingerprint string
|
||||
ReadOnly bool
|
||||
Destructive bool
|
||||
}
|
||||
|
||||
// LiveToolSafety returns the host-local execution classification for a live MCP
|
||||
// target. remoteTool uses one locked snapshot; compatibility adapters fall back
|
||||
// to the public tool interfaces. The result never changes provider-visible
|
||||
// schemas.
|
||||
func LiveToolSafety(target tool.Tool) CachedToolSafety {
|
||||
if target == nil {
|
||||
return CachedToolSafety{}
|
||||
}
|
||||
if remote, ok := target.(*remoteTool); ok {
|
||||
_, readOnly, destructive := remote.securitySnapshot()
|
||||
return CachedToolSafety{
|
||||
ReadOnly: readOnly,
|
||||
Destructive: destructive,
|
||||
}
|
||||
}
|
||||
safety := CachedToolSafety{ReadOnly: target.ReadOnly()}
|
||||
if annotations, ok := target.(tool.MCPAnnotations); ok {
|
||||
safety.Destructive = annotations.MCPDestructiveHint()
|
||||
}
|
||||
return safety
|
||||
}
|
||||
|
||||
// ReconcileCachedToolSafety is the single cached-to-live boundary shared by
|
||||
// lazy and on-demand MCP adapters. Both adapters stop the current call when the
|
||||
// live server is stricter than the snapshot, before the direct tool executes.
|
||||
func ReconcileCachedToolSafety(server, rawName string, cached CachedToolSafety, target tool.Tool) (CachedToolSafety, error) {
|
||||
live := LiveToolSafety(target)
|
||||
if target == nil {
|
||||
return live, nil
|
||||
}
|
||||
if cached.ReadOnly && !live.ReadOnly {
|
||||
return live, fmt.Errorf("MCP server %q no longer marks tool %q as read-only; the current call was blocked before execution — retry so Reasonix can apply the current Plan/read-only safety boundary", server, rawName)
|
||||
}
|
||||
if !cached.Destructive && live.Destructive {
|
||||
return live, fmt.Errorf("MCP server %q now marks tool %q as destructive; retry so Reasonix can apply the current Plan/read-only safety boundary before execution", server, rawName)
|
||||
}
|
||||
return live, nil
|
||||
}
|
||||
|
||||
func CachedToolSafetyForSpec(s Spec, rawName string) (CachedToolSafety, bool) {
|
||||
@@ -394,30 +268,16 @@ func CachedToolSafetyForSpec(s Spec, rawName string) (CachedToolSafety, bool) {
|
||||
if !ok {
|
||||
return CachedToolSafety{}, false
|
||||
}
|
||||
var target *toolCapability
|
||||
var target *CachedToolSafety
|
||||
for _, cached := range cs.Tools {
|
||||
visible := cached.Name
|
||||
if s.StripRawPrefix != "" {
|
||||
visible = strings.TrimPrefix(visible, s.StripRawPrefix)
|
||||
}
|
||||
cap := toolCapability{
|
||||
RawName: cached.Name, ModelName: toolName(s.Name, visible), VisibleName: visible,
|
||||
InputSchema: cached.Schema, OutputSchema: cached.OutputSchema,
|
||||
ReadOnly: cached.ReadOnly || s.toolReadOnlyOverride(cached.Name, visible),
|
||||
Destructive: cached.Destructive,
|
||||
}
|
||||
if cached.Name == rawName {
|
||||
copy := cap
|
||||
copy := CachedToolSafety{ReadOnly: cached.ReadOnly, Destructive: cached.Destructive}
|
||||
target = ©
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return CachedToolSafety{}, false
|
||||
}
|
||||
return CachedToolSafety{
|
||||
ReadOnly: target.ReadOnly,
|
||||
TrustedReader: trustedReaderForSpec(s, *target),
|
||||
Destructive: target.Destructive,
|
||||
CapabilityFingerprint: capabilityFingerprint(*target),
|
||||
}, true
|
||||
return *target, true
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
type discardWriteCloser struct{}
|
||||
@@ -106,6 +108,7 @@ func TestStdioCallCancelReturnsContextCanceled(t *testing.T) {
|
||||
// before it can finish the handshake; dropping server requests deadlocks both
|
||||
// sides even though notifications themselves are harmless.
|
||||
func TestStdioInitializeHandlesNotificationsAndServerPing(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
serverReads, clientWrites := io.Pipe()
|
||||
clientReads, serverWrites := io.Pipe()
|
||||
t.Cleanup(func() {
|
||||
@@ -117,6 +120,7 @@ func TestStdioInitializeHandlesNotificationsAndServerPing(t *testing.T) {
|
||||
|
||||
tr := &stdioTransport{
|
||||
name: "matlab",
|
||||
roots: mcpRoots(workspaceRoot),
|
||||
stdin: clientWrites,
|
||||
stdout: bufio.NewReader(clientReads),
|
||||
stderr: &tailBuffer{limit: 1024},
|
||||
@@ -131,6 +135,9 @@ func TestStdioInitializeHandlesNotificationsAndServerPing(t *testing.T) {
|
||||
var initialize struct {
|
||||
ID int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
Capabilities map[string]json.RawMessage `json:"capabilities"`
|
||||
} `json:"params"`
|
||||
}
|
||||
if err := dec.Decode(&initialize); err != nil {
|
||||
serverDone <- fmt.Errorf("decode initialize: %w", err)
|
||||
@@ -140,12 +147,35 @@ func TestStdioInitializeHandlesNotificationsAndServerPing(t *testing.T) {
|
||||
serverDone <- fmt.Errorf("first method = %q, want initialize", initialize.Method)
|
||||
return
|
||||
}
|
||||
if _, ok := initialize.Params.Capabilities["roots"]; !ok {
|
||||
serverDone <- fmt.Errorf("initialize capabilities = %v, want roots", initialize.Params.Capabilities)
|
||||
return
|
||||
}
|
||||
for _, method := range []string{"notifications/tools/list_changed", "notifications/resources/list_changed"} {
|
||||
if err := enc.Encode(map[string]any{"jsonrpc": "2.0", "method": method}); err != nil {
|
||||
serverDone <- fmt.Errorf("encode %s: %w", method, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := enc.Encode(map[string]any{"jsonrpc": "2.0", "id": "server-roots", "method": "roots/list"}); err != nil {
|
||||
serverDone <- fmt.Errorf("encode roots/list: %w", err)
|
||||
return
|
||||
}
|
||||
var rootsResponse struct {
|
||||
ID string `json:"id"`
|
||||
Result struct {
|
||||
Roots []mcpRoot `json:"roots"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := dec.Decode(&rootsResponse); err != nil {
|
||||
serverDone <- fmt.Errorf("decode roots/list response: %w", err)
|
||||
return
|
||||
}
|
||||
wantRoots := mcpRoots(workspaceRoot)
|
||||
if rootsResponse.ID != "server-roots" || len(rootsResponse.Result.Roots) != 1 || rootsResponse.Result.Roots[0] != wantRoots[0] {
|
||||
serverDone <- fmt.Errorf("roots/list response = %+v, want %+v", rootsResponse, wantRoots)
|
||||
return
|
||||
}
|
||||
if err := enc.Encode(map[string]any{"jsonrpc": "2.0", "id": "server-ping", "method": "ping"}); err != nil {
|
||||
serverDone <- fmt.Errorf("encode ping: %w", err)
|
||||
return
|
||||
@@ -190,7 +220,7 @@ func TestStdioInitializeHandlesNotificationsAndServerPing(t *testing.T) {
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
client := &Client{name: "matlab", t: tr}
|
||||
client := &Client{name: "matlab", t: tr, spec: Spec{WorkspaceRoot: workspaceRoot}}
|
||||
if err := client.initialize(ctx); err != nil {
|
||||
t.Fatalf("initialize with server notifications and ping: %v", err)
|
||||
}
|
||||
@@ -204,6 +234,84 @@ func TestStdioInitializeHandlesNotificationsAndServerPing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioToolCallRoutesProgressNotification(t *testing.T) {
|
||||
serverReads, clientWrites := io.Pipe()
|
||||
clientReads, serverWrites := io.Pipe()
|
||||
t.Cleanup(func() {
|
||||
_ = clientWrites.Close()
|
||||
_ = serverReads.Close()
|
||||
_ = serverWrites.Close()
|
||||
_ = clientReads.Close()
|
||||
})
|
||||
|
||||
tr := &stdioTransport{
|
||||
name: "worker",
|
||||
stdin: clientWrites,
|
||||
stdout: bufio.NewReader(clientReads),
|
||||
stderr: &tailBuffer{limit: 1024},
|
||||
pending: map[int]chan rpcResponse{},
|
||||
}
|
||||
go tr.readLoop()
|
||||
|
||||
serverDone := make(chan error, 1)
|
||||
go func() {
|
||||
dec := json.NewDecoder(serverReads)
|
||||
enc := json.NewEncoder(serverWrites)
|
||||
var request struct {
|
||||
ID int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
Meta map[string]any `json:"_meta"`
|
||||
} `json:"params"`
|
||||
}
|
||||
if err := dec.Decode(&request); err != nil {
|
||||
serverDone <- err
|
||||
return
|
||||
}
|
||||
token, _ := request.Params.Meta["progressToken"].(string)
|
||||
if request.Method != "tools/call" || token == "" {
|
||||
serverDone <- fmt.Errorf("tools/call request = %+v, want progressToken", request)
|
||||
return
|
||||
}
|
||||
if err := enc.Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/progress",
|
||||
"params": map[string]any{
|
||||
"progressToken": token,
|
||||
"progress": 2,
|
||||
"total": 5,
|
||||
"message": "Indexing",
|
||||
},
|
||||
}); err != nil {
|
||||
serverDone <- err
|
||||
return
|
||||
}
|
||||
if err := enc.Encode(map[string]any{"jsonrpc": "2.0", "id": request.ID, "result": map[string]any{"content": []any{}}}); err != nil {
|
||||
serverDone <- err
|
||||
return
|
||||
}
|
||||
serverDone <- nil
|
||||
}()
|
||||
|
||||
progress := make(chan string, 1)
|
||||
ctx := tool.WithProgress(context.Background(), func(chunk string) { progress <- chunk })
|
||||
client := &Client{name: "worker", t: tr}
|
||||
if _, err := client.call(ctx, "tools/call", map[string]any{"name": "index", "arguments": map[string]any{}}); err != nil {
|
||||
t.Fatalf("tools/call: %v", err)
|
||||
}
|
||||
select {
|
||||
case got := <-progress:
|
||||
if got != "Indexing (2/5)\n" {
|
||||
t.Fatalf("progress = %q", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("progress notification was not routed")
|
||||
}
|
||||
if err := <-serverDone; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// readLoop is the only goroutine draining stdout, so it must never block on
|
||||
// the shared stdin pipe: with both pipe buffers full, waiting on writeMu would
|
||||
// deadlock against a client call whose own stdin write is jammed. Replies to
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
// maxHTTPBody caps how much of a JSON / SSE response body we read, so a
|
||||
@@ -28,10 +30,12 @@ const maxHTTPBody = 16 << 20 // 16 MiB
|
||||
// different transports and stay concurrent. Correctness over latency for P1 —
|
||||
// it also keeps nextID and the session id race-free.
|
||||
type httpTransport struct {
|
||||
name string
|
||||
url string
|
||||
headers map[string]string
|
||||
client *http.Client
|
||||
name string
|
||||
url string
|
||||
headers map[string]string
|
||||
client *http.Client
|
||||
roots []mcpRoot
|
||||
progress progressRouter
|
||||
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
@@ -50,6 +54,7 @@ func newHTTPTransport(s Spec) (*httpTransport, error) {
|
||||
name: s.Name,
|
||||
url: s.URL,
|
||||
headers: headers,
|
||||
roots: mcpRoots(s.WorkspaceRoot),
|
||||
client: &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) == 0 || sameHTTPOrigin(via[0].URL, req.URL) {
|
||||
return nil
|
||||
@@ -114,7 +119,7 @@ func (t *httpTransport) call(ctx context.Context, method string, params any) (js
|
||||
}
|
||||
|
||||
if strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream") {
|
||||
return t.readSSEResponse(resp.Body, id)
|
||||
return t.readSSEResponse(ctx, resp.Body, id)
|
||||
}
|
||||
return decodeRPCResult(resp.Body, t.name)
|
||||
}
|
||||
@@ -144,6 +149,10 @@ func (t *httpTransport) close() {
|
||||
t.client.CloseIdleConnections()
|
||||
}
|
||||
|
||||
func (t *httpTransport) registerProgress(token string, sink tool.ProgressFunc) func() {
|
||||
return t.progress.registerProgress(token, sink)
|
||||
}
|
||||
|
||||
// do POSTs one JSON-RPC body with the standard MCP headers, the configured
|
||||
// static headers, and the session id (once known). Caller holds t.mu.
|
||||
func (t *httpTransport) do(ctx context.Context, body []byte) (*http.Response, error) {
|
||||
@@ -195,7 +204,7 @@ func isHTTPSessionExpiredResponse(status int, body []byte) bool {
|
||||
// skipping server notifications and any other-id messages. Per the SSE spec,
|
||||
// consecutive data: lines within one event are joined with "\n" and an event is
|
||||
// dispatched on the blank line that terminates it.
|
||||
func (t *httpTransport) readSSEResponse(body io.Reader, id int) (json.RawMessage, error) {
|
||||
func (t *httpTransport) readSSEResponse(ctx context.Context, body io.Reader, id int) (json.RawMessage, error) {
|
||||
sc := bufio.NewScanner(io.LimitReader(body, maxHTTPBody))
|
||||
sc.Buffer(make([]byte, 0, 64*1024), maxHTTPBody)
|
||||
|
||||
@@ -208,9 +217,25 @@ func (t *httpTransport) readSSEResponse(body io.Reader, id int) (json.RawMessage
|
||||
}
|
||||
payload := data.String()
|
||||
data.Reset()
|
||||
message, ok := decodeInboundMessage([]byte(payload))
|
||||
if !ok {
|
||||
return nil, false, nil // not a JSON-RPC message we care about
|
||||
}
|
||||
if message.Method != "" {
|
||||
if isNotificationID(message.ID) {
|
||||
if message.Method == "notifications/progress" {
|
||||
t.progress.dispatchProgress(message.Params)
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
if err := t.replyServerRequest(ctx, message); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
var resp rpcResponse
|
||||
if err := json.Unmarshal([]byte(payload), &resp); err != nil {
|
||||
return nil, false, nil // not a JSON-RPC message we care about
|
||||
return nil, false, nil
|
||||
}
|
||||
if resp.ID != id {
|
||||
return nil, false, nil // a notification or another call's response
|
||||
@@ -246,6 +271,27 @@ func (t *httpTransport) readSSEResponse(body io.Reader, id int) (json.RawMessage
|
||||
return nil, fmt.Errorf("plugin %q: SSE stream ended without a response to id %d", t.name, id)
|
||||
}
|
||||
|
||||
// replyServerRequest sends a JSON-RPC response on a separate Streamable HTTP
|
||||
// POST while the original response stream remains open. call holds t.mu here,
|
||||
// so do can safely read the current session id without taking another lock.
|
||||
func (t *httpTransport) replyServerRequest(ctx context.Context, message inboundMessage) error {
|
||||
body, err := json.Marshal(serverRequestReply(message.ID, message.Method, t.roots))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := t.do(ctx, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plugin %q: reply to %s: %w", t.name, message.Method, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
t.captureSession(resp)
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxHTTPBody))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("plugin %q: reply to %s: http %d", t.name, message.Method, resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeRPCResult parses a single application/json JSON-RPC response body.
|
||||
func decodeRPCResult(body io.Reader, name string) (json.RawMessage, error) {
|
||||
b, err := io.ReadAll(io.LimitReader(body, maxHTTPBody))
|
||||
|
||||
@@ -51,6 +51,7 @@ func mcpHTTPServer(t *testing.T, sse bool) *httptest.Server {
|
||||
}
|
||||
|
||||
var result any
|
||||
progressToken := ""
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
result = map[string]any{"protocolVersion": protocolVersion, "serverInfo": map[string]any{"name": "h", "version": "0"}}
|
||||
@@ -63,11 +64,13 @@ func mcpHTTPServer(t *testing.T, sse bool) *httptest.Server {
|
||||
}}}
|
||||
case "tools/call":
|
||||
var p struct {
|
||||
Meta map[string]any `json:"_meta"`
|
||||
Arguments struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"arguments"`
|
||||
}
|
||||
_ = json.Unmarshal(req.Params, &p)
|
||||
progressToken, _ = p.Meta["progressToken"].(string)
|
||||
result = map[string]any{"content": []map[string]any{{"type": "text", "text": "hello " + p.Arguments.Name}}}
|
||||
}
|
||||
resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID, "result": result}
|
||||
@@ -78,6 +81,12 @@ func mcpHTTPServer(t *testing.T, sse bool) *httptest.Server {
|
||||
// A server notification first: the client must skip it and keep
|
||||
// reading for the id-matching response.
|
||||
fmt.Fprint(w, "event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/message\",\"params\":{}}\n\n")
|
||||
if progressToken != "" {
|
||||
progress, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "method": "notifications/progress", "params": map[string]any{
|
||||
"progressToken": progressToken, "progress": 3, "total": 4, "message": "Streaming",
|
||||
}})
|
||||
fmt.Fprintf(w, "event: message\ndata: %s\n\n", progress)
|
||||
}
|
||||
fmt.Fprintf(w, "event: message\ndata: %s\n\n", b)
|
||||
return
|
||||
}
|
||||
@@ -114,13 +123,65 @@ func runHTTPTransportTest(t *testing.T, sse bool) {
|
||||
if !ok || !annotations.MCPDestructiveHint() {
|
||||
t.Error("destructiveHint not honoured over HTTP")
|
||||
}
|
||||
got, err := tools[0].Execute(ctx, json.RawMessage(`{"name":"sam"}`))
|
||||
progress := make(chan string, 1)
|
||||
executeCtx := tool.WithProgress(ctx, func(chunk string) { progress <- chunk })
|
||||
got, err := tools[0].Execute(executeCtx, json.RawMessage(`{"name":"sam"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if got != "hello sam" {
|
||||
t.Errorf("Execute = %q, want %q", got, "hello sam")
|
||||
}
|
||||
if sse {
|
||||
select {
|
||||
case chunk := <-progress:
|
||||
if chunk != "Streaming (3/4)\n" {
|
||||
t.Fatalf("progress = %q", chunk)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Streamable HTTP progress notification was not routed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamableHTTPAnswersRootsRequestInSSEResponse(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
reply := make(chan struct {
|
||||
ID string `json:"id"`
|
||||
Result struct {
|
||||
Roots []mcpRoot `json:"roots"`
|
||||
} `json:"result"`
|
||||
}, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var response struct {
|
||||
ID string `json:"id"`
|
||||
Result struct {
|
||||
Roots []mcpRoot `json:"roots"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&response); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
reply <- response
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
defer server.Close()
|
||||
transport, err := newHTTPTransport(Spec{Name: "roots", URL: server.URL, WorkspaceRoot: workspaceRoot})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer transport.close()
|
||||
stream := strings.NewReader("data: {\"jsonrpc\":\"2.0\",\"id\":\"server-roots\",\"method\":\"roots/list\"}\n\n" +
|
||||
"data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}\n\n")
|
||||
if _, err := transport.readSSEResponse(context.Background(), stream, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := <-reply
|
||||
want := mcpRoots(workspaceRoot)
|
||||
if got.ID != "server-roots" || len(got.Result.Roots) != 1 || got.Result.Roots[0] != want[0] {
|
||||
t.Fatalf("roots response = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPTransportJSON(t *testing.T) { runHTTPTransportTest(t, false) }
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
// sseTransport implements MCP's legacy HTTP+SSE transport. The client keeps a
|
||||
// long-lived GET stream open; the server announces a POST endpoint through an
|
||||
// `event: endpoint` frame, and all JSON-RPC messages travel to that endpoint
|
||||
// while responses and server messages return on the GET stream.
|
||||
type sseTransport struct {
|
||||
name string
|
||||
getURL *url.URL
|
||||
headers map[string]string
|
||||
client *http.Client
|
||||
roots []mcpRoot
|
||||
progress progressRouter
|
||||
replies chan inboundMessage
|
||||
replyTimeout time.Duration
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
callMu sync.Mutex
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
pending map[int]chan rpcResponse
|
||||
readErr error
|
||||
endpoint *url.URL
|
||||
endpointErr error
|
||||
endpointReady chan struct{}
|
||||
endpointOnce sync.Once
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// sseReplyQueueBound keeps a server-request flood from creating an unbounded
|
||||
// number of blocked HTTP POST goroutines. Overflow is intentionally dropped so
|
||||
// the GET reader can continue routing client responses and notifications.
|
||||
const sseReplyQueueBound = 16
|
||||
|
||||
func newSSETransport(ctx context.Context, s Spec) (*sseTransport, error) {
|
||||
if strings.TrimSpace(s.URL) == "" {
|
||||
return nil, fmt.Errorf("sse plugin %q: url is required", s.Name)
|
||||
}
|
||||
getURL, err := url.Parse(s.URL)
|
||||
if err != nil || getURL.Scheme == "" || getURL.Host == "" {
|
||||
return nil, fmt.Errorf("sse plugin %q: invalid url %q", s.Name, s.URL)
|
||||
}
|
||||
headers := make(map[string]string, len(s.Headers))
|
||||
for key, value := range s.Headers {
|
||||
headers[key] = value
|
||||
}
|
||||
lifeCtx, cancel := context.WithCancel(ctx)
|
||||
t := &sseTransport{
|
||||
name: s.Name,
|
||||
getURL: getURL,
|
||||
headers: headers,
|
||||
roots: mcpRoots(s.WorkspaceRoot),
|
||||
replies: make(chan inboundMessage, sseReplyQueueBound),
|
||||
replyTimeout: s.CallTimeout,
|
||||
ctx: lifeCtx,
|
||||
cancel: cancel,
|
||||
pending: map[int]chan rpcResponse{},
|
||||
endpointReady: make(chan struct{}),
|
||||
}
|
||||
if t.replyTimeout <= 0 {
|
||||
t.replyTimeout = s.DefaultCallTimeout
|
||||
}
|
||||
if t.replyTimeout <= 0 {
|
||||
t.replyTimeout = defaultCallTimeout
|
||||
}
|
||||
t.client = &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) == 0 || sameHTTPOrigin(via[0].URL, req.URL) {
|
||||
return nil
|
||||
}
|
||||
return http.ErrUseLastResponse
|
||||
}}
|
||||
go t.replyLoop()
|
||||
go t.readLoop()
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *sseTransport) registerProgress(token string, sink tool.ProgressFunc) func() {
|
||||
return t.progress.registerProgress(token, sink)
|
||||
}
|
||||
|
||||
func (t *sseTransport) readLoop() {
|
||||
defer close(t.replies)
|
||||
req, err := http.NewRequestWithContext(t.ctx, http.MethodGet, t.getURL.String(), nil)
|
||||
if err != nil {
|
||||
t.fail(err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
for key, value := range t.headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
t.fail(err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
t.fail(fmt.Errorf("GET %s: http %d: %s", t.getURL, resp.StatusCode, strings.TrimSpace(string(body))))
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(resp.Header.Get("Content-Type")), "text/event-stream") {
|
||||
t.fail(fmt.Errorf("GET %s: expected text/event-stream, got %q", t.getURL, resp.Header.Get("Content-Type")))
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := resp.Request.URL
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), maxHTTPBody)
|
||||
eventName := "message"
|
||||
var data strings.Builder
|
||||
dispatch := func() {
|
||||
if data.Len() == 0 {
|
||||
eventName = "message"
|
||||
return
|
||||
}
|
||||
payload := data.String()
|
||||
data.Reset()
|
||||
t.handleEvent(eventName, payload, baseURL)
|
||||
eventName = "message"
|
||||
}
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
dispatch()
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, ":") {
|
||||
continue
|
||||
}
|
||||
if value, ok := strings.CutPrefix(line, "event:"); ok {
|
||||
eventName = strings.TrimSpace(value)
|
||||
continue
|
||||
}
|
||||
if value, ok := strings.CutPrefix(line, "data:"); ok {
|
||||
if data.Len() > 0 {
|
||||
data.WriteByte('\n')
|
||||
}
|
||||
data.WriteString(strings.TrimPrefix(value, " "))
|
||||
}
|
||||
}
|
||||
dispatch()
|
||||
if err := scanner.Err(); err != nil {
|
||||
t.fail(fmt.Errorf("read SSE: %w", err))
|
||||
return
|
||||
}
|
||||
t.fail(io.EOF)
|
||||
}
|
||||
|
||||
func (t *sseTransport) handleEvent(eventName, payload string, baseURL *url.URL) {
|
||||
switch eventName {
|
||||
case "endpoint":
|
||||
endpoint, err := url.Parse(strings.TrimSpace(payload))
|
||||
if err == nil {
|
||||
endpoint = baseURL.ResolveReference(endpoint)
|
||||
if !sameHTTPOrigin(baseURL, endpoint) {
|
||||
err = fmt.Errorf("server announced cross-origin endpoint %s", endpoint)
|
||||
}
|
||||
}
|
||||
t.setEndpoint(endpoint, err)
|
||||
case "message", "":
|
||||
t.handleMessage([]byte(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func (t *sseTransport) setEndpoint(endpoint *url.URL, err error) {
|
||||
set := false
|
||||
t.endpointOnce.Do(func() {
|
||||
t.mu.Lock()
|
||||
t.endpoint = endpoint
|
||||
t.endpointErr = err
|
||||
t.mu.Unlock()
|
||||
close(t.endpointReady)
|
||||
set = true
|
||||
})
|
||||
if set && err != nil {
|
||||
t.failPending(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *sseTransport) handleMessage(payload []byte) {
|
||||
message, ok := decodeInboundMessage(payload)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if message.Method != "" {
|
||||
if isNotificationID(message.ID) {
|
||||
if message.Method == "notifications/progress" {
|
||||
t.progress.dispatchProgress(message.Params)
|
||||
}
|
||||
return
|
||||
}
|
||||
select {
|
||||
case t.replies <- message:
|
||||
default:
|
||||
// Let the remote server time out excess requests. Blocking here could
|
||||
// prevent an unrelated client response from ever reaching its caller.
|
||||
}
|
||||
return
|
||||
}
|
||||
var response rpcResponse
|
||||
if err := json.Unmarshal(payload, &response); err != nil {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
ch := t.pending[response.ID]
|
||||
delete(t.pending, response.ID)
|
||||
t.mu.Unlock()
|
||||
if ch != nil {
|
||||
ch <- response
|
||||
}
|
||||
}
|
||||
|
||||
// replyLoop serializes server-request responses through one bounded worker.
|
||||
// Each POST has a finite deadline inherited from the server's call-timeout
|
||||
// configuration; after a transport error the worker keeps draining so the SSE
|
||||
// reader never waits on the queue.
|
||||
func (t *sseTransport) replyLoop() {
|
||||
dead := false
|
||||
for message := range t.replies {
|
||||
if dead {
|
||||
continue
|
||||
}
|
||||
body, err := json.Marshal(serverRequestReply(message.ID, message.Method, t.roots))
|
||||
if err == nil {
|
||||
ctx, cancel := context.WithTimeout(t.ctx, t.replyTimeout)
|
||||
err = t.post(ctx, body)
|
||||
cancel()
|
||||
}
|
||||
if err != nil && t.ctx.Err() == nil {
|
||||
t.fail(fmt.Errorf("reply to %s: %w", message.Method, err))
|
||||
dead = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *sseTransport) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
|
||||
t.callMu.Lock()
|
||||
defer t.callMu.Unlock()
|
||||
|
||||
if err := t.waitEndpoint(ctx); err != nil {
|
||||
return nil, fmt.Errorf("plugin %q: %s: %w", t.name, method, err)
|
||||
}
|
||||
t.mu.Lock()
|
||||
if t.readErr != nil {
|
||||
err := t.readErr
|
||||
t.mu.Unlock()
|
||||
return nil, fmt.Errorf("plugin %q: %s: %w", t.name, method, err)
|
||||
}
|
||||
t.nextID++
|
||||
id := t.nextID
|
||||
ch := make(chan rpcResponse, 1)
|
||||
t.pending[id] = ch
|
||||
t.mu.Unlock()
|
||||
defer func() {
|
||||
t.mu.Lock()
|
||||
delete(t.pending, id)
|
||||
t.mu.Unlock()
|
||||
}()
|
||||
|
||||
body, err := json.Marshal(rpcRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.post(ctx, body); err != nil {
|
||||
return nil, fmt.Errorf("plugin %q: %s: %w", t.name, method, err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case response, ok := <-ch:
|
||||
if !ok {
|
||||
t.mu.Lock()
|
||||
err := t.readErr
|
||||
t.mu.Unlock()
|
||||
return nil, fmt.Errorf("plugin %q: %s: %w", t.name, method, err)
|
||||
}
|
||||
if response.Error != nil {
|
||||
return nil, fmt.Errorf("plugin %q: %w", t.name, response.Error)
|
||||
}
|
||||
return response.Result, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (t *sseTransport) notify(ctx context.Context, method string, params any) error {
|
||||
if err := t.waitEndpoint(ctx); err != nil {
|
||||
return fmt.Errorf("plugin %q: %s: %w", t.name, method, err)
|
||||
}
|
||||
body, err := json.Marshal(rpcRequest{JSONRPC: "2.0", Method: method, Params: params})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := t.post(ctx, body); err != nil {
|
||||
return fmt.Errorf("plugin %q: %s: %w", t.name, method, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *sseTransport) waitEndpoint(ctx context.Context) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.endpointReady:
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.endpointErr != nil {
|
||||
return t.endpointErr
|
||||
}
|
||||
if t.endpoint == nil {
|
||||
return errSSEEndpointMissing
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var errSSEEndpointMissing = errors.New("SSE stream ended before announcing an endpoint")
|
||||
|
||||
func (t *sseTransport) post(ctx context.Context, body []byte) error {
|
||||
t.mu.Lock()
|
||||
endpoint := t.endpoint
|
||||
t.mu.Unlock()
|
||||
if endpoint == nil {
|
||||
return errSSEEndpointMissing
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
for key, value := range t.headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(responseBody)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *sseTransport) fail(err error) {
|
||||
t.endpointOnce.Do(func() {
|
||||
t.mu.Lock()
|
||||
t.endpointErr = err
|
||||
t.mu.Unlock()
|
||||
close(t.endpointReady)
|
||||
})
|
||||
t.failPending(err)
|
||||
}
|
||||
|
||||
func (t *sseTransport) failPending(err error) {
|
||||
t.mu.Lock()
|
||||
if t.readErr == nil {
|
||||
t.readErr = err
|
||||
}
|
||||
for id, ch := range t.pending {
|
||||
close(ch)
|
||||
delete(t.pending, id)
|
||||
}
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *sseTransport) close() {
|
||||
t.closeOnce.Do(func() {
|
||||
t.cancel()
|
||||
t.client.CloseIdleConnections()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
func TestLegacySSETransportSupportsRootsToolsAndProgress(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
events := make(chan string, 16)
|
||||
serverErr := make(chan error, 4)
|
||||
var state struct {
|
||||
sync.Mutex
|
||||
initializeID int
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/sse", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer secret" {
|
||||
http.Error(w, "missing auth", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = fmt.Fprint(w, "event: endpoint\ndata: /messages?session=test\n\n")
|
||||
flusher.Flush()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case event := <-events:
|
||||
_, _ = fmt.Fprint(w, event)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer secret" || r.URL.Query().Get("session") != "test" {
|
||||
http.Error(w, "missing auth or session", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var message struct {
|
||||
ID json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&message); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
emit := func(payload any) {
|
||||
body, _ := json.Marshal(payload)
|
||||
events <- "event: message\ndata: " + string(body) + "\n\n"
|
||||
}
|
||||
switch message.Method {
|
||||
case "initialize":
|
||||
var params struct {
|
||||
Capabilities map[string]json.RawMessage `json:"capabilities"`
|
||||
}
|
||||
_ = json.Unmarshal(message.Params, ¶ms)
|
||||
if _, ok := params.Capabilities["roots"]; !ok {
|
||||
serverErr <- fmt.Errorf("initialize capabilities = %v, want roots", params.Capabilities)
|
||||
}
|
||||
var initializeID int
|
||||
_ = json.Unmarshal(message.ID, &initializeID)
|
||||
state.Lock()
|
||||
state.initializeID = initializeID
|
||||
state.Unlock()
|
||||
emit(map[string]any{"jsonrpc": "2.0", "id": "server-roots", "method": "roots/list"})
|
||||
case "notifications/initialized":
|
||||
case "tools/list":
|
||||
var id int
|
||||
_ = json.Unmarshal(message.ID, &id)
|
||||
emit(map[string]any{"jsonrpc": "2.0", "id": id, "result": map[string]any{
|
||||
"tools": []any{map[string]any{
|
||||
"name": "work", "description": "Do work", "inputSchema": map[string]any{"type": "object"},
|
||||
}},
|
||||
}})
|
||||
case "tools/call":
|
||||
var id int
|
||||
_ = json.Unmarshal(message.ID, &id)
|
||||
var params struct {
|
||||
Meta map[string]any `json:"_meta"`
|
||||
}
|
||||
_ = json.Unmarshal(message.Params, ¶ms)
|
||||
token, _ := params.Meta["progressToken"].(string)
|
||||
if token == "" {
|
||||
serverErr <- fmt.Errorf("tools/call missing progressToken: %s", message.Params)
|
||||
}
|
||||
emit(map[string]any{"jsonrpc": "2.0", "method": "notifications/progress", "params": map[string]any{
|
||||
"progressToken": token, "progress": 1, "total": 2, "message": "Working",
|
||||
}})
|
||||
emit(map[string]any{"jsonrpc": "2.0", "id": id, "result": map[string]any{
|
||||
"content": []any{map[string]any{"type": "text", "text": "done"}},
|
||||
}})
|
||||
case "":
|
||||
if strings.TrimSpace(string(message.ID)) != `"server-roots"` {
|
||||
serverErr <- fmt.Errorf("unexpected server response id %s", message.ID)
|
||||
break
|
||||
}
|
||||
var result struct {
|
||||
Roots []mcpRoot `json:"roots"`
|
||||
}
|
||||
_ = json.Unmarshal(message.Result, &result)
|
||||
want := mcpRoots(workspaceRoot)
|
||||
if len(result.Roots) != 1 || result.Roots[0] != want[0] {
|
||||
serverErr <- fmt.Errorf("roots/list result = %+v, want %+v", result.Roots, want)
|
||||
}
|
||||
state.Lock()
|
||||
initializeID := state.initializeID
|
||||
state.Unlock()
|
||||
emit(map[string]any{"jsonrpc": "2.0", "id": initializeID, "result": map[string]any{
|
||||
"protocolVersion": protocolVersion,
|
||||
"serverInfo": map[string]any{"name": "legacy", "version": "1"},
|
||||
"capabilities": map[string]any{"tools": map[string]any{}},
|
||||
}})
|
||||
default:
|
||||
serverErr <- fmt.Errorf("unexpected method %q", message.Method)
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
})
|
||||
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
host, tools, err := StartAll(ctx, []Spec{{
|
||||
Name: "legacy",
|
||||
Type: "sse",
|
||||
URL: server.URL + "/sse",
|
||||
Headers: map[string]string{"Authorization": "Bearer secret"},
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("StartAll legacy SSE: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
if len(tools) != 1 || tools[0].Name() != "mcp__legacy__work" {
|
||||
t.Fatalf("tools = %v", names(tools))
|
||||
}
|
||||
|
||||
progress := make(chan string, 1)
|
||||
toolCtx := tool.WithProgress(ctx, func(chunk string) { progress <- chunk })
|
||||
result, err := tools[0].Execute(toolCtx, json.RawMessage(`{}`))
|
||||
if err != nil || result != "done" {
|
||||
t.Fatalf("Execute = %q, %v", result, err)
|
||||
}
|
||||
select {
|
||||
case got := <-progress:
|
||||
if got != "Working (1/2)\n" {
|
||||
t.Fatalf("progress = %q", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("legacy SSE progress was not routed")
|
||||
}
|
||||
select {
|
||||
case err := <-serverErr:
|
||||
t.Fatal(err)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacySSERejectsCrossOriginEndpoint(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = fmt.Fprint(w, "event: endpoint\ndata: https://other.example/messages\n\n")
|
||||
}))
|
||||
defer server.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
transport, err := newSSETransport(ctx, Spec{Name: "unsafe", URL: server.URL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer transport.close()
|
||||
_, err = transport.call(ctx, "initialize", map[string]any{})
|
||||
if err == nil || !strings.Contains(err.Error(), "cross-origin endpoint") {
|
||||
t.Fatalf("cross-origin endpoint error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacySSEBoundsConcurrentServerRequestReplies(t *testing.T) {
|
||||
events := make(chan string, 2*sseReplyQueueBound+2)
|
||||
releasePosts := make(chan struct{})
|
||||
postStarted := make(chan struct{})
|
||||
var firstPost sync.Once
|
||||
var activePosts atomic.Int32
|
||||
var maxPosts atomic.Int32
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/sse", func(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = fmt.Fprint(w, "event: endpoint\ndata: /messages\n\n")
|
||||
flusher.Flush()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case event := <-events:
|
||||
_, _ = fmt.Fprint(w, event)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
|
||||
firstPost.Do(func() { close(postStarted) })
|
||||
active := activePosts.Add(1)
|
||||
defer activePosts.Add(-1)
|
||||
for {
|
||||
seen := maxPosts.Load()
|
||||
if active <= seen || maxPosts.CompareAndSwap(seen, active) {
|
||||
break
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-releasePosts:
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
})
|
||||
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
transport, err := newSSETransport(ctx, Spec{Name: "bounded", Type: "sse", URL: server.URL + "/sse"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer transport.close()
|
||||
defer close(releasePosts)
|
||||
if err := transport.waitEndpoint(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
waiting := make(chan rpcResponse, 1)
|
||||
transport.mu.Lock()
|
||||
transport.pending[7] = waiting
|
||||
transport.mu.Unlock()
|
||||
for i := 0; i < 2*sseReplyQueueBound; i++ {
|
||||
events <- fmt.Sprintf("event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":\"srv-%d\",\"method\":\"ping\"}\n\n", i)
|
||||
}
|
||||
events <- "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{}}\n\n"
|
||||
|
||||
select {
|
||||
case response := <-waiting:
|
||||
if response.ID != 7 {
|
||||
t.Fatalf("routed response id = %d, want 7", response.ID)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("SSE reader stopped routing responses while a reply POST was blocked")
|
||||
}
|
||||
select {
|
||||
case <-postStarted:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("SSE reply worker did not start its first POST")
|
||||
}
|
||||
if got := maxPosts.Load(); got != 1 {
|
||||
t.Fatalf("concurrent reply POSTs = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"reasonix/internal/proc"
|
||||
"reasonix/internal/sandbox"
|
||||
"reasonix/internal/secrets"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
const closeWaitBudget = 5 * time.Second
|
||||
@@ -31,6 +32,7 @@ const closeWaitBudget = 5 * time.Second
|
||||
// callMu serialises a request/response round-trip over the shared pipe.
|
||||
type stdioTransport struct {
|
||||
name string
|
||||
roots []mcpRoot
|
||||
cmd *exec.Cmd
|
||||
job uintptr // Windows Job Object handle (0 elsewhere); reaps detached grandchildren on close
|
||||
stdin io.WriteCloser
|
||||
@@ -47,6 +49,7 @@ type stdioTransport struct {
|
||||
|
||||
waitOnce sync.Once
|
||||
releaseSlot func() // returns a bounded instance slot (e.g. CodeGraph) on close; nil when unbounded
|
||||
progress progressRouter
|
||||
}
|
||||
|
||||
func newStdioTransport(ctx context.Context, s Spec) (*stdioTransport, error) {
|
||||
@@ -74,10 +77,9 @@ func newStdioTransport(ctx context.Context, s Spec) (*stdioTransport, error) {
|
||||
return nil, err
|
||||
}
|
||||
// A stateful stdio process cannot switch its OS sandbox after launch. Keep
|
||||
// one process in the server's normal writer sandbox; local permission, Plan,
|
||||
// read-only-child, and destructive-call gates still decide which tools may
|
||||
// be dispatched over the shared transport.
|
||||
processSandbox := s.WriterSandbox
|
||||
// one process in the server's process sandbox; Plan and strict read-only
|
||||
// checks still decide which tools may be dispatched over the shared transport.
|
||||
processSandbox := s.Sandbox
|
||||
processSandbox.MinimalWrites = true
|
||||
processSandbox, env, err = prepareMCPPrivateState(s, processSandbox, env)
|
||||
if err != nil {
|
||||
@@ -116,6 +118,7 @@ func newStdioTransport(ctx context.Context, s Spec) (*stdioTransport, error) {
|
||||
}
|
||||
t := &stdioTransport{
|
||||
name: s.Name,
|
||||
roots: mcpRoots(s.WorkspaceRoot),
|
||||
cmd: cmd,
|
||||
job: job,
|
||||
stdin: stdin,
|
||||
@@ -130,6 +133,10 @@ func newStdioTransport(ctx context.Context, s Spec) (*stdioTransport, error) {
|
||||
}
|
||||
|
||||
func prepareMCPPrivateState(s Spec, processSandbox sandbox.Spec, env []string) (sandbox.Spec, []string, error) {
|
||||
return prepareMCPPrivateStateForOS(s, processSandbox, env, runtime.GOOS)
|
||||
}
|
||||
|
||||
func prepareMCPPrivateStateForOS(s Spec, processSandbox sandbox.Spec, env []string, goos string) (sandbox.Spec, []string, error) {
|
||||
root := strings.TrimSpace(s.StateDir)
|
||||
if root == "" {
|
||||
return processSandbox, env, nil
|
||||
@@ -138,21 +145,32 @@ func prepareMCPPrivateState(s Spec, processSandbox sandbox.Spec, env []string) (
|
||||
return processSandbox, env, err
|
||||
}
|
||||
privateRoot := root
|
||||
tmpDir := filepath.Join(privateRoot, "tmp")
|
||||
cacheDir := filepath.Join(privateRoot, "cache")
|
||||
stateDir := filepath.Join(privateRoot, "state")
|
||||
for _, dir := range []string{tmpDir, cacheDir, stateDir} {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return processSandbox, env, err
|
||||
}
|
||||
}
|
||||
for key, value := range map[string]string{
|
||||
"TMP": tmpDir, "TEMP": tmpDir, "TMPDIR": tmpDir,
|
||||
dirs := []string{cacheDir, stateDir}
|
||||
privateEnv := map[string]string{
|
||||
"XDG_CACHE_HOME": cacheDir, "XDG_STATE_HOME": stateDir,
|
||||
"npm_config_cache": filepath.Join(cacheDir, "npm"),
|
||||
"UV_CACHE_DIR": filepath.Join(cacheDir, "uv"),
|
||||
"BUN_INSTALL_CACHE_DIR": filepath.Join(cacheDir, "bun"),
|
||||
} {
|
||||
}
|
||||
if goos != "windows" {
|
||||
tmpDir := filepath.Join(privateRoot, "tmp")
|
||||
dirs = append(dirs, tmpDir)
|
||||
privateEnv["TMP"] = tmpDir
|
||||
privateEnv["TEMP"] = tmpDir
|
||||
privateEnv["TMPDIR"] = tmpDir
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return processSandbox, env, err
|
||||
}
|
||||
}
|
||||
// Windows stdio processes are currently unsandboxed and must keep the host's
|
||||
// short temporary directory. Nesting TEMP below Reasonix's workspace-scoped
|
||||
// state path can exceed the 108-byte Unix-domain-socket limit used by MCP
|
||||
// servers such as MATLAB before their initialize response is written.
|
||||
for key, value := range privateEnv {
|
||||
env = setEnvValue(env, key, value)
|
||||
}
|
||||
processSandbox.WriteRoots = append(processSandbox.WriteRoots, root, privateRoot)
|
||||
@@ -499,7 +517,7 @@ func mergePathLists(primary, secondary string) string {
|
||||
const stdioReplyQueueBound = 16
|
||||
|
||||
// readLoop owns stdout for the transport's lifetime: it reads one JSON-RPC
|
||||
// message per line, ignores server notifications, answers server requests, and
|
||||
// message per line, routes progress notifications, answers server requests, and
|
||||
// hands each response to the call waiting on its id. On any read error it fails
|
||||
// every pending call and exits.
|
||||
func (t *stdioTransport) readLoop() {
|
||||
@@ -540,30 +558,18 @@ func (t *stdioTransport) replyLoop(replies <-chan any) {
|
||||
}
|
||||
|
||||
func (t *stdioTransport) handleInboundLine(line []byte, replies chan<- any) {
|
||||
var probe struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &probe); err != nil {
|
||||
probe, ok := decodeInboundMessage(line)
|
||||
if !ok {
|
||||
return // unparseable line cannot be routed; keep the transport alive
|
||||
}
|
||||
if probe.Method != "" {
|
||||
id := bytes.TrimSpace(probe.ID)
|
||||
if len(id) == 0 || bytes.Equal(id, []byte("null")) {
|
||||
return // server notification
|
||||
}
|
||||
response := struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
}{JSONRPC: "2.0", ID: append(json.RawMessage(nil), id...)}
|
||||
if probe.Method == "ping" {
|
||||
response.Result = map[string]any{}
|
||||
} else {
|
||||
response.Error = &rpcError{Code: -32601, Message: "Method not found"}
|
||||
if isNotificationID(probe.ID) {
|
||||
if probe.Method == "notifications/progress" {
|
||||
t.progress.dispatchProgress(probe.Params)
|
||||
}
|
||||
return
|
||||
}
|
||||
response := serverRequestReply(probe.ID, probe.Method, t.roots)
|
||||
select {
|
||||
case replies <- response:
|
||||
default:
|
||||
@@ -587,6 +593,10 @@ func (t *stdioTransport) handleInboundLine(line []byte, replies chan<- any) {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *stdioTransport) registerProgress(token string, sink tool.ProgressFunc) func() {
|
||||
return t.progress.registerProgress(token, sink)
|
||||
}
|
||||
|
||||
// failAll records the terminal read error and unblocks every pending call by
|
||||
// closing its channel; a caller distinguishes this from a real response by the
|
||||
// closed-channel receive.
|
||||
|
||||
@@ -2,10 +2,13 @@ package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/sandbox"
|
||||
"reasonix/internal/secrets"
|
||||
)
|
||||
|
||||
@@ -22,3 +25,50 @@ func TestStdioShellPATHProbeFiltersEnvWhenEnabled(t *testing.T) {
|
||||
t.Fatalf("stdio shell PATH probe leaked filtered env: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareMCPPrivateStateWindowsPreservesHostTemp(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "mcp-state", "0123456789abcdef", "matlab")
|
||||
hostTemp := `C:\Users\user\AppData\Local\Temp`
|
||||
env := []string{"TMP=" + hostTemp, "TEMP=" + hostTemp, "TMPDIR=" + hostTemp}
|
||||
|
||||
_, got, err := prepareMCPPrivateStateForOS(Spec{StateDir: root}, sandbox.Spec{}, env, "windows")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"TMP", "TEMP", "TMPDIR"} {
|
||||
if value, ok := envValue(got, key); !ok || value != hostTemp {
|
||||
t.Fatalf("%s = %q, %v; want inherited host temp %q", key, value, ok, hostTemp)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "tmp")); !os.IsNotExist(err) {
|
||||
t.Fatalf("private Windows temp directory exists or stat failed: %v", err)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"XDG_CACHE_HOME": filepath.Join(root, "cache"),
|
||||
"XDG_STATE_HOME": filepath.Join(root, "state"),
|
||||
} {
|
||||
if value, ok := envValue(got, key); !ok || value != want {
|
||||
t.Fatalf("%s = %q, %v; want %q", key, value, ok, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareMCPPrivateStateUnixIsolatesTemp(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "mcp-state", "matlab")
|
||||
hostTemp := "/tmp/host"
|
||||
env := []string{"TMP=" + hostTemp, "TEMP=" + hostTemp, "TMPDIR=" + hostTemp}
|
||||
|
||||
_, got, err := prepareMCPPrivateStateForOS(Spec{StateDir: root}, sandbox.Spec{}, env, "linux")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(root, "tmp")
|
||||
for _, key := range []string{"TMP", "TEMP", "TMPDIR"} {
|
||||
if value, ok := envValue(got, key); !ok || value != want {
|
||||
t.Fatalf("%s = %q, %v; want private temp %q", key, value, ok, want)
|
||||
}
|
||||
}
|
||||
if info, err := os.Stat(want); err != nil || !info.IsDir() {
|
||||
t.Fatalf("private Unix temp directory = (%v, %v), want directory", info, err)
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,11 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/command"
|
||||
"reasonix/internal/fileutil"
|
||||
fileencoding "reasonix/internal/fileutil/encoding"
|
||||
"reasonix/internal/frontmatter"
|
||||
"reasonix/internal/mcpcatalog"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -163,23 +161,14 @@ type State struct {
|
||||
}
|
||||
|
||||
type InstalledPlugin struct {
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Root string `json:"root"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
ManifestKind string `json:"manifestKind,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
Verification *Verification `json:"verification,omitempty"`
|
||||
}
|
||||
|
||||
type Verification struct {
|
||||
CatalogEntryID string `json:"catalogEntryId"`
|
||||
Commit string `json:"commit"`
|
||||
PackageSHA256 string `json:"packageSha256"`
|
||||
VerifiedAt time.Time `json:"verifiedAt"`
|
||||
CatalogSequence uint64 `json:"catalogSequence"`
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Root string `json:"root"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
ManifestKind string `json:"manifestKind,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
}
|
||||
|
||||
type InstalledPackage struct {
|
||||
@@ -305,10 +294,6 @@ func LoadInstalled(reasonixHome string) ([]InstalledPackage, []string) {
|
||||
if !installed.Enabled {
|
||||
continue
|
||||
}
|
||||
if installed.Verification != nil && !VerificationValid(reasonixHome, installed) {
|
||||
warnings = append(warnings, fmt.Sprintf("%s: installed package content changed; official verification removed", installed.Name))
|
||||
installed.Verification = nil
|
||||
}
|
||||
root := ResolveRoot(reasonixHome, installed.Root)
|
||||
pkg, pkgWarnings, err := ParseDir(root)
|
||||
if err != nil {
|
||||
@@ -328,17 +313,6 @@ func ResolveRoot(reasonixHome, root string) string {
|
||||
return filepath.Join(reasonixHome, filepath.Clean(root))
|
||||
}
|
||||
|
||||
// VerificationValid recomputes the installed package tree on every load/use.
|
||||
// A modified package immediately loses official status before any MCP reader is
|
||||
// auto-trusted.
|
||||
func VerificationValid(reasonixHome string, installed InstalledPlugin) bool {
|
||||
if installed.Verification == nil || strings.TrimSpace(installed.Verification.PackageSHA256) == "" {
|
||||
return false
|
||||
}
|
||||
digest, err := mcpcatalog.TreeSHA256(ResolveRoot(reasonixHome, installed.Root))
|
||||
return err == nil && strings.EqualFold(digest, installed.Verification.PackageSHA256)
|
||||
}
|
||||
|
||||
func RelativeRoot(reasonixHome, root string) string {
|
||||
if rel, err := filepath.Rel(reasonixHome, root); err == nil && rel != "." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".." {
|
||||
return filepath.ToSlash(rel)
|
||||
|
||||
@@ -7,37 +7,8 @@ import (
|
||||
"testing"
|
||||
|
||||
fileencoding "reasonix/internal/fileutil/encoding"
|
||||
"reasonix/internal/mcpcatalog"
|
||||
)
|
||||
|
||||
func TestModifiedInstalledPackageLosesOfficialVerification(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
root := InstallRoot(home, "verified")
|
||||
writeTestFile(t, filepath.Join(root, NativeManifest), `{"name":"verified","version":"1.0.0"}`)
|
||||
digest, err := mcpcatalog.TreeSHA256(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Upsert(home, InstalledPlugin{
|
||||
Name: "verified", Root: RelativeRoot(home, root), Version: "1.0.0", Enabled: true,
|
||||
Verification: &Verification{CatalogEntryID: "[email protected]", PackageSHA256: digest, CatalogSequence: 1},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installed, warnings := LoadInstalled(home)
|
||||
if len(warnings) != 0 || len(installed) != 1 || installed[0].Installed.Verification == nil {
|
||||
t.Fatalf("initial verified load = %+v, warnings=%v", installed, warnings)
|
||||
}
|
||||
writeTestFile(t, filepath.Join(root, "modified.txt"), "changed")
|
||||
installed, warnings = LoadInstalled(home)
|
||||
if len(installed) != 1 || installed[0].Installed.Verification != nil {
|
||||
t.Fatalf("modified package retained verification: %+v", installed)
|
||||
}
|
||||
if len(warnings) != 1 || !strings.Contains(warnings[0], "official verification removed") {
|
||||
t.Fatalf("modified package warnings = %v", warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCodexSuperpowersManifest(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, CodexManifest), `{
|
||||
|
||||
+14
-81
@@ -85,14 +85,6 @@ type PlanModeClassifier interface {
|
||||
PlanModeSafe() bool
|
||||
}
|
||||
|
||||
// PlanModeUntrustedReadOnly marks a tool whose ReadOnly classification comes
|
||||
// only from an external MCP server hint. The main Plan workflow may use that
|
||||
// hint for ordinary permission classification, while planner/read-only subagent
|
||||
// registries must not treat it as local trust.
|
||||
type PlanModeUntrustedReadOnly interface {
|
||||
PlanModeUntrustedReadOnly() bool
|
||||
}
|
||||
|
||||
// ReadOnlyExecutionHostMutation marks a target that is logically read-only but
|
||||
// must first mutate host state to become executable, such as starting an
|
||||
// on-demand MCP process. Strict read-only agents reject these targets even when
|
||||
@@ -110,8 +102,8 @@ type ReadOnlyExecutionBlockReason interface {
|
||||
|
||||
// MCPMetadata exposes the original MCP identity behind a model-visible
|
||||
// "mcp__<server>__<tool>" adapter. The model name may be normalized for provider
|
||||
// function-name rules; config such as trusted_read_only_tools must use the raw
|
||||
// server-local tool name.
|
||||
// function-name rules; host policy and diagnostics use the raw server-local
|
||||
// tool name.
|
||||
type MCPMetadata interface {
|
||||
MCPServerName() string
|
||||
MCPRawToolName() string
|
||||
@@ -148,19 +140,11 @@ type MCPAnnotations interface {
|
||||
MCPDestructiveHint() bool
|
||||
}
|
||||
|
||||
// MCPCapabilityFingerprint exposes the host-local security fingerprint used to
|
||||
// reject a cached-to-live schema drift before tools/call. It is deliberately
|
||||
// separate from Schema(), so it cannot perturb provider cache prefixes.
|
||||
type MCPCapabilityFingerprint interface {
|
||||
MCPCapabilityFingerprint() string
|
||||
}
|
||||
|
||||
// ReadOnlyExecutionAuthority reports whether an MCP-backed tool's reader
|
||||
// classification comes from explicit local policy or a signed official
|
||||
// package rather than an unaudited server hint. Strict read-only execution
|
||||
// requires that positive authority.
|
||||
type ReadOnlyExecutionAuthority interface {
|
||||
ReadOnlyExecutionAuthority() bool
|
||||
// MCPServerAuthorization reports whether the user installed this MCP server or
|
||||
// authorized its exact project identity. Authorization belongs to the server,
|
||||
// not to individual tools; readOnly/destructive metadata is checked separately.
|
||||
type MCPServerAuthorization interface {
|
||||
MCPServerAuthorized() bool
|
||||
}
|
||||
|
||||
// readerExecutionIntentKey carries a per-call, immutable authorization basis:
|
||||
@@ -170,67 +154,16 @@ type ReadOnlyExecutionAuthority interface {
|
||||
// error instead of executing.
|
||||
type readerExecutionIntentKey struct{}
|
||||
|
||||
// ReaderExecutionIntent pins what the authorization decision saw.
|
||||
type ReaderExecutionIntent struct {
|
||||
// CapabilityFingerprint, when non-empty, must still match the live tool's
|
||||
// security fingerprint at dispatch time.
|
||||
CapabilityFingerprint string
|
||||
}
|
||||
|
||||
// WithReaderExecutionIntent marks ctx as a reader-authorized MCP invocation.
|
||||
func WithReaderExecutionIntent(ctx context.Context, capabilityFingerprint string) context.Context {
|
||||
return context.WithValue(ctx, readerExecutionIntentKey{}, ReaderExecutionIntent{CapabilityFingerprint: capabilityFingerprint})
|
||||
func WithReaderExecutionIntent(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, readerExecutionIntentKey{}, true)
|
||||
}
|
||||
|
||||
// ReaderExecutionIntentFrom returns the pinned reader authorization, if any.
|
||||
func ReaderExecutionIntentFrom(ctx context.Context) (ReaderExecutionIntent, bool) {
|
||||
intent, ok := ctx.Value(readerExecutionIntentKey{}).(ReaderExecutionIntent)
|
||||
return intent, ok
|
||||
}
|
||||
|
||||
const (
|
||||
MCPApprovalAuto = "auto"
|
||||
MCPApprovalPrompt = "prompt"
|
||||
MCPApprovalWrites = "writes"
|
||||
MCPApprovalApprove = "approve"
|
||||
|
||||
MCPApprovalReviewerUser = "user"
|
||||
MCPApprovalReviewerAutoReview = "auto_review"
|
||||
)
|
||||
|
||||
// MCPApprovalPolicy exposes local execution policy for one MCP tool. These
|
||||
// values are intentionally not part of Schema(), so changing approval policy
|
||||
// does not alter the provider-visible tool contract or prompt-cache prefix.
|
||||
type MCPApprovalPolicy interface {
|
||||
MCPApprovalMode() string
|
||||
MCPApprovalReviewer() string
|
||||
}
|
||||
|
||||
// NormalizeMCPApprovalMode returns the conservative effective MCP approval
|
||||
// mode. Empty keeps annotation-driven behavior; unknown values force a prompt.
|
||||
func NormalizeMCPApprovalMode(mode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "":
|
||||
return MCPApprovalAuto
|
||||
case MCPApprovalAuto, MCPApprovalPrompt, MCPApprovalWrites, MCPApprovalApprove:
|
||||
return strings.ToLower(strings.TrimSpace(mode))
|
||||
default:
|
||||
return MCPApprovalPrompt
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeMCPApprovalReviewer returns the configured reviewer. Empty preserves
|
||||
// legacy behavior at the controller boundary; unknown values fail back to the
|
||||
// human reviewer rather than silently enabling automatic review.
|
||||
func NormalizeMCPApprovalReviewer(reviewer string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(reviewer)) {
|
||||
case "":
|
||||
return ""
|
||||
case MCPApprovalReviewerAutoReview, "guardian":
|
||||
return MCPApprovalReviewerAutoReview
|
||||
default:
|
||||
return MCPApprovalReviewerUser
|
||||
}
|
||||
// HasReaderExecutionIntent reports whether this call entered through the
|
||||
// non-destructive reader lane.
|
||||
func HasReaderExecutionIntent(ctx context.Context) bool {
|
||||
intent, _ := ctx.Value(readerExecutionIntentKey{}).(bool)
|
||||
return intent
|
||||
}
|
||||
|
||||
// SnipHint describes how context maintenance should shorten a stale, oversized
|
||||
|
||||
@@ -29,7 +29,6 @@ ask_request = true # notify when a question is waiting
|
||||
# system_prompt_file = "prompts/system.md" # overrides system_prompt when set
|
||||
temperature = 0.0
|
||||
# reasoning_language = "auto" # visible reasoning text: auto|zh|en
|
||||
# plan_mode_allowed_tools = ["mcp__legacy__reader"] # legacy MCP read-only trust alias; does not change Plan availability
|
||||
# plan_mode_read_only_commands = ["gh issue view"] # legacy compatibility only; Plan bash uses Permissions
|
||||
soft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact
|
||||
tool_result_snip_ratio = 0.6 # snip stale tool results before summary compaction
|
||||
@@ -168,14 +167,6 @@ stalled_warning_seconds = 900 # warn once per background job after this many q
|
||||
# # call_timeout_seconds = 600
|
||||
# # Raw MCP tool names with per-tool call timeouts.
|
||||
# # tool_timeout_seconds = { "generate_video" = 1800 }
|
||||
# # Locally audited Plan/read-only-research readers (raw MCP tool names).
|
||||
# # trusted_read_only_tools = ["search"]
|
||||
# # Local approval policy for this server's tools: auto|prompt|writes|approve.
|
||||
# # default_tools_approval_mode = "auto"
|
||||
# # Raw-tool overrides of the server default.
|
||||
# # tools = { "delete_all" = { approval_mode = "prompt" } }
|
||||
# # Who resolves prompt/writes/destructive reviews: user|auto_review.
|
||||
# # approvals_reviewer = "user"
|
||||
# # Enabled MCP servers connect automatically in the background after session
|
||||
# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.
|
||||
|
||||
|
||||
@@ -1484,12 +1484,12 @@
|
||||
{
|
||||
"kind": "security",
|
||||
"title": {
|
||||
"en": "Trusted MCP execution",
|
||||
"zh": "MCP 可信执行"
|
||||
"en": "MCP launch authorization",
|
||||
"zh": "MCP 启动授权"
|
||||
},
|
||||
"body": {
|
||||
"en": "MCP startup now verifies package identity, signed catalog metadata, approval receipts, executable paths, and credential boundaries before a server is trusted.",
|
||||
"zh": "MCP 启动前会校验包身份、签名目录元数据、审批回执、可执行路径与凭据边界,避免旧授权被错误复用。"
|
||||
"en": "User-installed MCP servers work immediately. Repository-declared servers still require one exact identity approval before startup, and identity changes invalidate that approval.",
|
||||
"zh": "用户主动安装的 MCP server 可直接使用;仓库声明的 server 仍需在首次启动前确认一次精确身份,身份变化后原授权自动失效。"
|
||||
},
|
||||
"refs": [
|
||||
6482,
|
||||
|
||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user