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
39 changes: 39 additions & 0 deletions cmd/mcpproxy/telemetry_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package main

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

Expand Down Expand Up @@ -235,3 +237,40 @@ func TestRunTelemetryDisable_SendsBeacon(t *testing.T) {
t.Errorf("telemetry.enabled must be persisted false")
}
}

// Parity row 28a: `telemetry status` prints the effective state, including an
// environment opt-out that overrides an enabled config.
func TestRunTelemetryStatus_ReportsEnvOverride(t *testing.T) {
sandboxHome(t)
t.Setenv("CI", "")
t.Setenv("DO_NOT_TRACK", "")
t.Setenv("MCPPROXY_TELEMETRY", "false")

tmpDir := t.TempDir()
customPath := filepath.Join(tmpDir, "custom_mcp_config.json")
writeMinimalConfig(t, customPath, filepath.Join(tmpDir, "data"))

prevCfg, prevFmt, prevJSON := configFile, globalOutputFormat, globalJSONOutput
configFile, globalOutputFormat, globalJSONOutput = customPath, "", false
t.Cleanup(func() { configFile, globalOutputFormat, globalJSONOutput = prevCfg, prevFmt, prevJSON })

r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
prevStdout := os.Stdout
os.Stdout = w
runErr := runTelemetryStatus(nil, nil)
os.Stdout = prevStdout
_ = w.Close()
out, _ := io.ReadAll(r)
if runErr != nil {
t.Fatalf("runTelemetryStatus: %v", runErr)
}
s := string(out)
for _, want := range []string{"Telemetry Status", "Disabled", "Override:"} {
if !strings.Contains(s, want) {
t.Errorf("status output missing %q:\n%s", want, s)
}
}
}
2 changes: 1 addition & 1 deletion docs/api/rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1014,7 +1014,7 @@ Search every enabled catalog source (registry) at once. Both editions; open to a
}
```

Results are ranked by how well the name matches the query first (the publisher equals the query, then an exact name, a name prefix, a name word, a substring or description, and last a match through the namespace alone, which is how `io.github.*` entries match), then official source, verified publisher, popularity (a missing value counts as zero), title and id. `verified` means the publisher owns the source repository, `official` means the entry comes from a built-in source, and `title` is the server's own title when it has one. The order is identical on the Web UI, macOS, the CLI (`mcpproxy catalog search`) and the MCP `search_servers` tool. A source that fails or times out is listed in `unavailable` and the other sources' results are still returned. When the daemon has a recent listing of that source (at most 24 hours old, kept in memory and filled by every successful fetch), the matches come from it instead: those results carry `from_cache: true` and the `unavailable` entry gains `fallback: "cached_listing"` and `cached_at`, so the source still reads as unavailable. An empty `q` lists `popular` before `official` in every surface, and `official` starts with the curated reference servers. `added` is true when a configured server visible to the caller has the same source and install target, and then `added_server_name` names it. Adding an entry stays `POST /api/v1/registries/{id}/servers/{serverId}/add`, which always quarantines the new server; see [Registry Add](../features/registry-add.md).
Results are ranked by how well the name matches the query first (the publisher equals the query, then an exact name, a name prefix, a name word, a substring or description, and last a match through the namespace alone, which is how `io.github.*` entries match), then official source, verified publisher, popularity (a missing value counts as zero), title and id. `verified` means the publisher owns the source repository (or the entry comes from a trusted Docker or reference source), `official` means the entry comes from a built-in source, and `title` is the server's own title when it has one. The order is identical on the Web UI, macOS, the CLI (`mcpproxy catalog search`) and the MCP `search_servers` tool. A source that fails or times out is listed in `unavailable` and the other sources' results are still returned. When the daemon has a recent listing of that source (at most 24 hours old, kept in memory and filled by every successful fetch), the matches come from it instead: those results carry `from_cache: true` and the `unavailable` entry gains `fallback: "cached_listing"` and `cached_at`, so the source still reads as unavailable. An empty `q` lists `popular` before `official` in every surface, and `official` starts with the curated reference servers. `added` is true when a configured server visible to the caller has the same source and install target, and then `added_server_name` names it. Adding an entry stays `POST /api/v1/registries/{id}/servers/{serverId}/add`, which always quarantines the new server; see [Registry Add](../features/registry-add.md).

### Registries

Expand Down
2 changes: 1 addition & 1 deletion docs/cli/catalog-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ mcpproxy catalog search # browse the Official and Popular sec
| `--limit`, `-l <n>` | Maximum results (default 20, maximum 50) |
| `--tag`, `-t` | Not supported: catalog entries carry no tags, so a non-empty value is rejected |

Results from every source are merged and ranked: by how well the name matches the query first (the publisher equals the query, an exact name, a name prefix, a name word, then a substring or description, with a match through the namespace alone last), then official source, verified publisher (the publisher owns the source repository), popularity (stars or installs when the source provides them; a missing value counts as zero) and title. A source that times out is reported as unavailable and the other sources' results still print. The order is identical on REST, the Web UI, macOS and the MCP `search_servers` tool.
Results from every source are merged and ranked: by how well the name matches the query first (the publisher equals the query, an exact name, a name prefix, a name word, then a substring or description, with a match through the namespace alone last), then official source, verified publisher (the publisher owns the source repository, or the entry comes from a trusted Docker or reference source), popularity (stars or installs when the source provides them; a missing value counts as zero) and title. A source that times out is reported as unavailable and the other sources' results still print. The order is identical on REST, the Web UI, macOS and the MCP `search_servers` tool.

```
SOURCE ID TITLE TRANSPORT ADDED
Expand Down
11 changes: 11 additions & 0 deletions internal/httpapi/status_telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,14 @@ func TestStatusTelemetry_SocketCallerIsServed(t *testing.T) {
require.True(t, ok, "socket/tray caller must be served the telemetry block")
assert.Equal(t, "env", block["source"])
}

// Parity row 29a: GET /api/v1/status names the running listen address, which
// Settings (macOS) and `mcpproxy status` render.
func TestStatus_ReportsListenAddr(t *testing.T) {
setStatusTelemetryEnv(t, "")
srv, _ := statusTelemetryServer(t, nil)

rec := scopeGet(t, srv, "/api/v1/status", scopeAdminAPIKey)
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Equal(t, ":8080", scopeDecodeData(t, rec)["listen_addr"])
}
15 changes: 14 additions & 1 deletion internal/registries/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -613,7 +613,7 @@ func namespaceOwner(id string) (string, bool) {
switch {
case labels[0] == "io" && labels[1] == "github":
i = 2
case len(labels) >= 3 && secondLevelSuffixes[labels[1]]:
case len(labels) >= 3 && isCountryCode(labels[0]) && secondLevelSuffixes[labels[1]]:
i = 2
}
if i >= len(labels) || labels[i] == "" {
Expand All @@ -622,6 +622,19 @@ func namespaceOwner(id string) (string, bool) {
return labels[i], true
}

// isCountryCode reports whether a label is a two-letter ASCII ccTLD (uk, au).
func isCountryCode(l string) bool {
if len(l) != 2 {
return false
}
for i := 0; i < 2; i++ {
if c := l[i] | 0x20; c < 'a' || c > 'z' {
return false
}
}
return true
}

// Rank is the pure, deterministic catalog ordering (Spec 109 D37.1, data-model
// §9, contracts/rest-api.md#catalog): match tier desc (how well the NAME
// matches q: publisher equals q > exact name > name prefix > name token >
Expand Down
2 changes: 2 additions & 0 deletions internal/registries/catalog_hit_signals_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ func TestNamespaceOwner(t *testing.T) {
{"com.quranmajeed.time/prayer-times", "quranmajeed", true}, // a subdomain label is not the publisher
{"uk.co.acme/x", "acme", true},
{"com.example.api.v2/x", "example", true},
{"com.org.github/x", "org", true}, // "com" is not a ccTLD, so org is not a second-level suffix here
{"au.com.acme/x", "acme", true},
{"io.github/x", "", false},
{"acme/github-fork", "", false}, // no dotted namespace: the registry-name fallback
{"fetch", "", false},
Expand Down
33 changes: 22 additions & 11 deletions internal/registries/listing_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,18 +133,29 @@ func pruneListingCache(regs []RegistryEntry) {
}
}

// matchCachedEntry is the fallback filter: a case-insensitive substring of the
// trimmed query in the entry's name, title, description OR id. The live path's
// filterServers skips the id, but the official registry's own search matches
// names, so including the id is what lets "github" find "io.github.*". An empty
// query matches everything (browse).
// matchCachedEntry is the fallback filter: every whitespace token of the
// trimmed query must appear (case-insensitive substring) in the entry's name,
// title, description OR id. '-' and '_' count as spaces on both sides, so
// "github actions" finds "github-actions". The live path's filterServers skips
// the id, but the official registry's own search matches names, so including
// the id is what lets "github" find "io.github.*". An empty query matches
// everything (browse).
func matchCachedEntry(e *ServerEntry, q string) bool {
q = strings.ToLower(strings.TrimSpace(q))
if q == "" {
tokens := strings.Fields(foldCachedMatchText(q))
if len(tokens) == 0 {
return true
}
return strings.Contains(strings.ToLower(e.Name), q) ||
strings.Contains(strings.ToLower(e.Title), q) ||
strings.Contains(strings.ToLower(e.Description), q) ||
strings.Contains(strings.ToLower(e.ID), q)
hay := foldCachedMatchText(e.Name + "\n" + e.Title + "\n" + e.Description + "\n" + e.ID)
for _, tok := range tokens {
if !strings.Contains(hay, tok) {
return false
}
}
return true
}

var matchSeparators = strings.NewReplacer("-", " ", "_", " ")

func foldCachedMatchText(s string) string {
return matchSeparators.Replace(strings.ToLower(s))
}
20 changes: 20 additions & 0 deletions internal/registries/search_catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,23 @@ func TestSearchServers_PerRegistryContractUnchanged(t *testing.T) {
t.Fatalf("limit is still capped at 50, got %d", len(got))
}
}

func TestMatchCachedEntry_TokensAndSeparators(t *testing.T) {
e := &ServerEntry{ID: "io.github.acme/github-actions", Name: "github-actions", Description: "Run CI_jobs"}
cases := []struct {
q string
want bool
}{
{"github actions", true},
{"github-actions", true},
{"Actions GITHUB", true},
{"ci jobs", true},
{"github gitlab", false},
{" ", true},
}
for _, c := range cases {
if got := matchCachedEntry(e, c.q); got != c.want {
t.Errorf("matchCachedEntry(%q) = %v want %v", c.q, got, c.want)
}
}
}
2 changes: 1 addition & 1 deletion specs/109-ux-navigation-consistency/contracts/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Small by design. MCP is a client surface with per-tool authorization. Search and
| Tool | Change | PR | Golden impact |
|---|---|---|---|
| `upstream_servers` `list` | each server's `health` gains `status`, `usable`, `actions` (shared struct) | 109-c | list-output goldens only (the tool schema is unchanged) |
| `quarantine_security` `inspect_quarantined`, `inspect_tools` | each tool gains `tier`, `annotations`, `scan_verdict` with the names and values of `GET /servers/{id}/review`; changed tools gain `previous` + `diff`; any server `command`/`url` in the output comes from the same redacted review composer (FR-021), never raw config. **The live inspection is kept**: when the composer reports `definitions_captured: false`, `inspect_quarantined` still performs today's temporary-exemption connect + `ListTools()` (`internal/server/mcp.go` ~4879–5018) and decorates those live tools with `tier` from `contracts.AnnotationTier` over their live annotations and `scan_verdict: "not_scanned"`, plus `definitions_source: "live"` (`"captured"` otherwise) — it never degrades to the composer's `tools: []`. `server_summary.scan` carries the same `coverage`, `tools_scanned` and `unscanned_tools` as the REST review (fix-review-screen). Captured tools carry `default_allowed` (the composer value); live tools carry `default_allowed: false`; `inspect_tools` does not carry it (fix-review-defaults, D43.7). No `quarantine_security` operation approves a server, and `approve_tool` and `approve_all_tools` approve only what they name | 109-f | output goldens only |
| `quarantine_security` `inspect_quarantined`, `inspect_tools` | each tool gains `tier`, `annotations`, `scan_verdict` with the names and values of `GET /servers/{id}/review`; changed tools gain `previous` + `diff`; any server `command`/`url` in the output comes from the same redacted review composer (FR-021), never raw config. **The live inspection is kept**: when the composer reports `definitions_captured: false`, `inspect_quarantined` still performs today's temporary-exemption connect + `ListTools()` (`internal/server/mcp.go` ~4879–5018) and decorates those live tools with `tier` from `contracts.AnnotationTier` over their live annotations and `scan_verdict: "not_scanned"`, plus `definitions_source: "live"` (`"captured"` otherwise) — it never degrades to the composer's `tools: []`. `server_summary.scan` carries the same `coverage`, `tools_scanned` and `unscanned_tools` as the REST review (fix-review-screen) only on the captured branch (`definitions_source: "captured"`); the live branch returns no `server_summary` (only a `scan_status` line), so a client must not read `scan.coverage` from it and should treat it as not captured. Captured tools carry `default_allowed` (the composer value); live tools carry `default_allowed: false`; `inspect_tools` does not carry it (fix-review-defaults, D43.7). No `quarantine_security` operation approves a server, and `approve_tool` and `approve_all_tools` approve only what they name | 109-f | output goldens only |
| `search_servers` | `registry` becomes optional (omitted = all sources through `registries.SearchAll`); results gain `title`, `publisher`, `verified`, `official`, `popularity`, `source`, in the FR-060 order (research D37; the tool description text still says "official-first" and is frozen by the schema goldens, so changing it is a follow-up), and `from_cache: true` when a source's cached listing answered (its `unavailable[]` entry then carries `fallback` and `cached_at`, D35); descriptions say "catalog" and "catalog source". The retained `tag` parameter accepts only an empty value; a non-empty value returns a visible error because catalog entries carry no tags. | 109-j | **schema golden changes** (`registry` no longer `required`; description text). Declared in the 109-j PR body |
| `list_registries` | description wording "catalog sources" | 109-j | schema golden (description text) |

Expand Down
Loading
Loading