Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ Releases follow [Semantic Versioning](https://semver.org/).

### Breaking Changes

- **profiles / tool refusals:** a profile's tool refusal now names the profile to a caller whose
effective profile is its own (a pin, a client binding, the URL or `set_profile`):
`blocked by profile: github:create_issue is a write tool; profile "Work Read-only" (work-readonly)
allows read tools only`, and likewise for the deny-rule and unannotated texts. `retrieve_tools`
returns `profile` for the same callers. A caller that connects without a credential keeps the
previous, non-disclosing text and gets no `profile` field, so the operator's `anonymous_profile` is
never handed out. **Migration:** a consumer that matches the old refusal wording for a pinned or
bound credential should match on the `block_reason` (`profile_tier`, `profile_rule`,
`profile_unannotated`) instead. (spec 108 D39, narrowing D27)

- **mcp/describe_tool:** the per-id error code `invisible` is retired. An id on a server the
session cannot see (agent-token scope or active profile) now reports `not_found` — the same
code, `remediation` text and shape as an id that does not exist. A distinct code confirmed
Expand Down Expand Up @@ -61,6 +71,13 @@ Releases follow [Semantic Versioning](https://semver.org/).

### Features

- **catalog:** a catalog source whose live search times out or fails is now answered from the listing
the daemon last saw from it (at most 24 hours old, kept in memory). Those results are marked
`from_cache` ("From cached list" in the Web UI and macOS, `(cached)` in the CLI) and the source
stays in `unavailable[]` with `fallback` and `cached_at`. The empty-query browse lists Popular
before Official and the curated reference servers first. (spec 109 FR-060, D35)
- **web:** the command palette (Cmd/Ctrl+K) also finds profiles, clients and agent tokens. (spec 109 FR-054)

- **mcp:** schema-deferred direct mode — a new `direct_tool_response_mode` key (`full` | `deferred`,
**default `full`, so this is opt-in and changes nothing until you turn it on**). In `deferred`,
the direct enumeration surface (`/mcp/all`, and `/mcp` under `routing_mode: "direct"`) lists every
Expand Down Expand Up @@ -150,6 +167,14 @@ Releases follow [Semantic Versioning](https://semver.org/).

### Bug Fixes

- **web/settings:** toggles for nullable settings (`quarantine_enabled`, `telemetry.enabled` and the
`audit_log.*` booleans) show the value the core actually applies instead of OFF when the key is
absent, and the page header now says to press Save changes instead of "Changes save instantly".
(spec 109 D35)
- **web/clients:** the token Profile chip stays on one line, radio and checkbox labels sit next to
their control, and a client with no mcpproxy entry offers **Connect** (macOS: Connect…) instead of
"Upgrade to client credential". (spec 108 D39)

- **security/scope:** `set_profile` and `/mcp/p` now report the intersection of an agent
token's grant and the requested profile through a single selectable-profile predicate; a
non-selectable profile is refused identically to a nonexistent one, closing a scope-disclosure
Expand Down
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -1035,7 +1035,7 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—`
| [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | `in-flight` | 94/113 (83%) |
| [106-security-residual-fixes](./specs/106-security-residual-fixes/) | `shipped` | 18/19 (95%) |
| [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | `shipped` | 126/126 (100%) |
| [108-profiles-v3](./specs/108-profiles-v3/) | `shipped` | 180/181 (99%) |
| [109-ux-navigation-consistency](./specs/109-ux-navigation-consistency/) | `shipped` | 194/195 (99%) |
| [108-profiles-v3](./specs/108-profiles-v3/) | `shipped` | 185/186 (99%) |
| [109-ux-navigation-consistency](./specs/109-ux-navigation-consistency/) | `shipped` | 200/201 (100%) |
| [110-catalog-popularity](./specs/110-catalog-popularity/) | `in-flight` | 19/23 (83%) |
| [112-client-header-forwarding](./specs/112-client-header-forwarding/) | `shipped` | 38/40 (95%) |
98 changes: 98 additions & 0 deletions cmd/mcpproxy/activity_blocked_refusal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package main

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

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// Spec 108 D39 (T148): the CLI prints a blocked record's refusal text
// verbatim. The text is the disclosed refusal of
// internal/profile/testdata/contract/tool_refusals.json: the activity record is
// what the operator reads, so the CLI must neither truncate, re-wrap nor
// "tidy" the quoted profile title.

func toolRefusalFromGolden(t *testing.T, name, label string) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "internal", "profile", "testdata", "contract", "tool_refusals.json"))
require.NoError(t, err)
var g struct {
Refusals []struct {
Name string `json:"name"`
Disclosed string `json:"disclosed"`
} `json:"refusals"`
}
require.NoError(t, json.Unmarshal(raw, &g))
for _, r := range g.Refusals {
if r.Name == name {
return strings.NewReplacer(
"<server>", "github", "<tool>", "create_issue", "<tier>", "write", "<cap>", "read", "<label>", label,
).Replace(r.Disclosed)
}
}
t.Fatalf("no golden refusal %q", name)
return ""
}

func TestActivityBlockedRefusalTextVerbatim_CLI(t *testing.T) {
text := toolRefusalFromGolden(t, "tier", `"Work Read-only" (work-readonly)`)
require.Contains(t, text, `profile "Work Read-only" (work-readonly) allows read tools only`)

record := map[string]any{
"id": "01BLOCKED", "source": "mcp", "type": "policy_decision", "server_name": "github", "tool_name": "create_issue",
"status": "blocked", "timestamp": "2026-10-02T09:00:00Z", "error_message": text,
"client_id": "cursor", "profile": "work-readonly", "profile_source": "pin", "token_name": "client-cursor",
"metadata": map[string]any{"block_reason": "profile_tier", "reason": text},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/status":
_, _ = w.Write([]byte(`{"success":true,"data":{"running":true}}`))
case "/api/v1/activity":
body, _ := json.Marshal(map[string]any{"success": true, "data": map[string]any{
"activities": []map[string]any{record}, "total": 1, "limit": 50, "offset": 0,
}})
_, _ = w.Write(body)
case "/api/v1/activity/01BLOCKED":
body, _ := json.Marshal(map[string]any{"success": true, "data": map[string]any{"activity": record}})
_, _ = w.Write(body)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
withClientDaemon(t, srv.URL)

t.Run("activity list -o json carries the text verbatim", func(t *testing.T) {
p108ResetFlags(activityListCmd)
t.Cleanup(func() { p108ResetFlags(activityListCmd) })
out, _, err := runCLI(t, GetActivityCommand, "json", "list", "--status", "blocked")
require.NoError(t, err)
var doc struct {
Activities []struct {
ErrorMessage string `json:"error_message"`
Metadata struct {
Reason string `json:"reason"`
} `json:"metadata"`
} `json:"activities"`
}
require.NoError(t, json.Unmarshal([]byte(out), &doc), out)
require.Len(t, doc.Activities, 1)
assert.Equal(t, text, doc.Activities[0].ErrorMessage)
assert.Equal(t, text, doc.Activities[0].Metadata.Reason)
})

t.Run("activity show (table) prints the text verbatim", func(t *testing.T) {
out, _, err := runCLI(t, GetActivityCommand, "table", "show", "01BLOCKED")
require.NoError(t, err)
assert.Contains(t, out, "Error: "+text)
})
}
152 changes: 152 additions & 0 deletions cmd/mcpproxy/catalog_cached_fallback_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package main

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"

"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/registries"
)

// Spec 109 D35 (T160): the CLI leg of the cached-fallback chain. While one
// source is down, `catalog search github` lists the same ids in the same order
// as the REST golden (internal/registries/testdata/catalog_cached_fallback_order.json),
// `-o json` carries from_cache and unavailable[].fallback, and the table marks
// cached rows and says why.

type p109CachedFallbackFile struct {
Query string `json:"query"`
Sources []struct {
ID string `json:"id"`
Name string `json:"name"`
Provenance string `json:"provenance"`
Flaky bool `json:"flaky"`
Servers []json.RawMessage `json:"servers"`
} `json:"sources"`
IDs []string `json:"ids"`
Results []struct {
Source string `json:"source"`
ID string `json:"id"`
FromCache bool `json:"from_cache"`
} `json:"results"`
Unavailable []struct {
Source string `json:"source"`
Fallback string `json:"fallback"`
} `json:"unavailable"`
}

func TestCatalogCachedFallbackParityCLI(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "internal", "registries", "testdata", "catalog_cached_fallback_order.json"))
if err != nil {
t.Fatal(err)
}
var f p109CachedFallbackFile
if err := json.Unmarshal(raw, &f); err != nil {
t.Fatal(err)
}
if len(f.IDs) == 0 {
t.Fatal("the REST golden is missing; run the httpapi cached-fallback test with UPDATE_GOLDEN=1")
}

registries.ResetListingCacheForTest()
t.Cleanup(registries.ResetListingCacheForTest)
var broken atomic.Bool
var entries []registries.RegistryEntry
for _, src := range f.Sources {
body, _ := json.Marshal(src.Servers)
flaky := src.Flaky
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if flaky && broken.Load() {
http.Error(w, "down", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
}))
t.Cleanup(srv.Close)
entries = append(entries, registries.RegistryEntry{ID: src.ID, Name: src.Name, ServersURL: srv.URL, Provenance: src.Provenance})
}
t.Cleanup(registries.AllowPrivateRegistryFetchForTest())
t.Cleanup(registries.SetRegistriesForTest(entries))

cfg := &config.Config{}
if _, err := catalogSearchInProcess(context.Background(), cfg, "", "", "", 20); err != nil { // prime
t.Fatal(err)
}
broken.Store(true)
resp, err := catalogSearchInProcess(context.Background(), cfg, f.Query, "", "", 20)
if err != nil {
t.Fatalf("catalogSearchInProcess: %v", err)
}

var got []string
for i, r := range resp.Results {
got = append(got, r.Source+":"+r.ID)
if r.FromCache != f.Results[i].FromCache {
t.Errorf("%s: from_cache = %v, want %v", r.ID, r.FromCache, f.Results[i].FromCache)
}
}
if strings.Join(got, ",") != strings.Join(f.IDs, ",") {
t.Errorf("CLI order = %v, want the REST golden %v", got, f.IDs)
}
if len(resp.Unavailable) != len(f.Unavailable) || resp.Unavailable[0].Fallback != f.Unavailable[0].Fallback {
t.Fatalf("unavailable = %+v, want %+v", resp.Unavailable, f.Unavailable)
}

// -o json carries the same fields as REST.
setOutputGlobals(t, "json", false)
formatter, err := GetOutputFormatter()
if err != nil {
t.Fatal(err)
}
out := captureOutput(func() {
if err := renderCatalogSearch(formatter, resp); err != nil {
t.Fatalf("renderCatalogSearch: %v", err)
}
})
var decoded struct {
Results []registries.CatalogResult `json:"results"`
Unavailable []registries.SourceError `json:"unavailable"`
}
if err := json.Unmarshal([]byte(out), &decoded); err != nil {
t.Fatalf("-o json did not parse: %v\n%s", err, out)
}
var jsonIDs []string
for _, r := range decoded.Results {
jsonIDs = append(jsonIDs, r.Source+":"+r.ID)
}
if strings.Join(jsonIDs, ",") != strings.Join(f.IDs, ",") {
t.Errorf("-o json order = %v, want %v", jsonIDs, f.IDs)
}
if !strings.Contains(out, `"from_cache": true`) || !strings.Contains(out, `"fallback": "cached_listing"`) {
t.Errorf("-o json must carry from_cache and unavailable[].fallback, got:\n%s", out)
}

// The table marks cached rows and says what happened.
setOutputGlobals(t, "table", false)
formatter, err = GetOutputFormatter()
if err != nil {
t.Fatal(err)
}
table := captureOutput(func() {
if err := renderCatalogSearch(formatter, resp); err != nil {
t.Fatalf("renderCatalogSearch: %v", err)
}
})
if !strings.Contains(table, "slowreg (cached)") {
t.Errorf("cached rows must read `slowreg (cached)` in SOURCE, got:\n%s", table)
}
if strings.Contains(table, "official (cached)") {
t.Errorf("live rows must not be marked cached, got:\n%s", table)
}
if !strings.Contains(table, "slowreg unavailable:") || !strings.Contains(table, "showing matches from its cached list") {
t.Errorf("expected the unavailable line with the fallback sentence, got:\n%s", table)
}
}
36 changes: 31 additions & 5 deletions cmd/mcpproxy/catalog_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -375,14 +375,36 @@ func catalogInstallTargetForConfigServer(s *config.ServerConfig) string {
func renderCatalogSearch(formatter clioutput.OutputFormatter, resp *cliclient.CatalogSearchResponse) error {
if _, isTable := formatter.(*clioutput.TableFormatter); isTable {
if resp.Sections != nil {
fmt.Println("Official:")
printCatalogTable(formatter, resp.Sections.Official)
fmt.Println("\nPopular:")
printCatalogTable(formatter, resp.Sections.Popular)
// Popular first when it has entries (popularity if available, else
// the curated Official list), and never an empty section's header
// and table (Spec 109 D35). Same order as the Web UI and macOS.
printed := false
section := func(title string, rows []registries.CatalogResult) {
if len(rows) == 0 {
return
}
if printed {
fmt.Println()
}
fmt.Println(title + ":")
printCatalogTable(formatter, rows)
printed = true
}
section("Popular", resp.Sections.Popular)
section("Official", resp.Sections.Official)
if !printed {
printCatalogTable(formatter, nil)
}
} else {
printCatalogTable(formatter, resp.Results)
}
for _, u := range resp.Unavailable {
if u.Fallback != "" {
// The live fetch failed but its cached listing answered
// (Spec 109 D35); the rows above are marked (cached).
fmt.Printf("⚠ %s unavailable: %s; showing matches from its cached list\n", u.Source, u.Reason)
continue
}
fmt.Printf("⚠ %s unavailable: %s\n", u.Source, u.Reason)
}
return nil
Expand All @@ -403,7 +425,11 @@ func printCatalogTable(formatter clioutput.OutputFormatter, results []registries
if r.Added {
added = "✓"
}
rows = append(rows, []string{r.Source, r.ID, truncateStr(r.Title, 40), r.Transport, added})
source := r.Source
if r.FromCache {
source += " (cached)"
}
rows = append(rows, []string{source, r.ID, truncateStr(r.Title, 40), r.Transport, added})
}
out, err := formatter.FormatTable(headers, rows)
if err == nil {
Expand Down
Loading
Loading