Skip to content

Commit cdf1a0a

Browse files
authored
feat(api): project source registry provenance onto contracts.Server (#577)
The REST contracts.Server projection omitted source_registry_id and source_registry_provenance, so the server approval/quarantine view could not show where a server originated even though config.ServerConfig records both at add-time (MCP-866). Add both fields to contracts.Server and populate them at every ServerConfig -> contracts.Server projection site: - Runtime.GetAllServers (StateView path + legacy storage fallback) emits the fields into the server map from serverStatus.Config. - management.ListServers extracts them onto contracts.Server. The SSE servers.changed embed shares this projection (buildServersChangedPayload -> ListServers), so it stays in parity automatically. - contracts.ConvertGenericServersToTyped (legacy fallback) and contracts.ConvertServerConfig also carry them. Both fields are json-omitempty and optional; clients that pre-date this treat them as absent. Regenerated the OpenAPI spec to document them. A frontend follow-up renders an "added from <registry> · unverified" badge on ServerCard/ServerDetail. Related #575
1 parent f119f48 commit cdf1a0a

8 files changed

Lines changed: 157 additions & 1 deletion

File tree

internal/contracts/converters.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ func ConvertServerConfig(cfg *config.ServerConfig, status string, connected bool
2929
Updated: cfg.Updated,
3030
ReconnectCount: 0, // TODO: Get from runtime status
3131
Authenticated: authenticated,
32+
// MCP-901: carry registry provenance so the approval/quarantine view
33+
// can show a server's origin. Empty for manually-configured servers.
34+
SourceRegistryID: cfg.SourceRegistryID,
35+
SourceRegistryProvenance: cfg.SourceRegistryProvenance,
3236
}
3337

3438
// Convert OAuth config if present
@@ -322,6 +326,15 @@ func ConvertGenericServersToTyped(genericServers []map[string]interface{}) []Ser
322326
server.Diagnostic = d
323327
}
324328

329+
// MCP-901 — registry provenance, carried through the legacy fallback
330+
// projection in parity with the management.ListServers happy path.
331+
if regID, ok := generic["source_registry_id"].(string); ok && regID != "" {
332+
server.SourceRegistryID = regID
333+
}
334+
if prov, ok := generic["source_registry_provenance"].(string); ok && prov != "" {
335+
server.SourceRegistryProvenance = prov
336+
}
337+
325338
servers = append(servers, server)
326339
}
327340

internal/contracts/converters_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"testing"
55
"time"
66

7+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
78
"github.com/stretchr/testify/assert"
89
"github.com/stretchr/testify/require"
910
)
@@ -84,6 +85,58 @@ func TestConvertGenericServersToTyped_EmptyOAuth(t *testing.T) {
8485
assert.Empty(t, servers[0].OAuth.ClientID)
8586
}
8687

88+
// TestConvertGenericServersToTyped_SourceRegistry verifies registry provenance
89+
// (MCP-901) is carried through the generic-map fallback projection so the
90+
// approval/quarantine view can show a server's origin.
91+
func TestConvertGenericServersToTyped_SourceRegistry(t *testing.T) {
92+
genericServers := []map[string]interface{}{
93+
{
94+
"id": "everything",
95+
"name": "everything",
96+
"enabled": true,
97+
"source_registry_id": "modelcontextprotocol",
98+
"source_registry_provenance": "custom/unverified",
99+
},
100+
{
101+
// Manually-configured server: both fields absent → empty.
102+
"id": "manual",
103+
"name": "manual",
104+
"enabled": true,
105+
},
106+
}
107+
108+
servers := ConvertGenericServersToTyped(genericServers)
109+
require.Len(t, servers, 2)
110+
111+
assert.Equal(t, "modelcontextprotocol", servers[0].SourceRegistryID)
112+
assert.Equal(t, "custom/unverified", servers[0].SourceRegistryProvenance)
113+
114+
assert.Empty(t, servers[1].SourceRegistryID, "manual server carries no registry id")
115+
assert.Empty(t, servers[1].SourceRegistryProvenance)
116+
}
117+
118+
// TestConvertServerConfig_SourceRegistry verifies the direct config→contracts
119+
// mapper populates registry provenance (MCP-901).
120+
func TestConvertServerConfig_SourceRegistry(t *testing.T) {
121+
cfg := &config.ServerConfig{
122+
Name: "everything",
123+
Protocol: "stdio",
124+
Enabled: true,
125+
SourceRegistryID: "modelcontextprotocol",
126+
SourceRegistryProvenance: config.RegistryProvenanceCustom,
127+
}
128+
129+
server := ConvertServerConfig(cfg, "ready", true, 3, false)
130+
require.NotNil(t, server)
131+
assert.Equal(t, "modelcontextprotocol", server.SourceRegistryID)
132+
assert.Equal(t, config.RegistryProvenanceCustom, server.SourceRegistryProvenance)
133+
134+
// Manual server (no source registry) leaves both empty.
135+
manual := ConvertServerConfig(&config.ServerConfig{Name: "manual", Enabled: true}, "ready", true, 0, false)
136+
assert.Empty(t, manual.SourceRegistryID)
137+
assert.Empty(t, manual.SourceRegistryProvenance)
138+
}
139+
87140
// TestConvertGenericServersToTyped_NoOAuth verifies servers without OAuth have nil OAuth field
88141
func TestConvertGenericServersToTyped_NoOAuth(t *testing.T) {
89142
genericServers := []map[string]interface{}{

internal/contracts/types.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,15 @@ type Server struct {
6060
// these fields.
6161
Diagnostic *Diagnostic `json:"diagnostic,omitempty"`
6262
ErrorCode string `json:"error_code,omitempty"`
63+
// MCP-901 — registry provenance of an upstream that was added from a
64+
// registry. SourceRegistryID names the source registry (empty for
65+
// manually-configured servers); SourceRegistryProvenance is the trust tag
66+
// recorded at add time ("official/trusted" or "custom/unverified"). Both
67+
// are projected from config.ServerConfig so the approval/quarantine view
68+
// can render an "added from <registry> · unverified" origin badge. Optional
69+
// and omitted when empty — clients that pre-date this treat them as absent.
70+
SourceRegistryID string `json:"source_registry_id,omitempty"`
71+
SourceRegistryProvenance string `json:"source_registry_provenance,omitempty"`
6372
}
6473

6574
// Diagnostic is the REST-API representation of a classified server failure.

internal/management/service.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,17 @@ func (s *service) ListServers(ctx context.Context) ([]*contracts.Server, *contra
521521
srv.Diagnostic = d
522522
}
523523

524+
// MCP-901 — project registry provenance so the approval/quarantine
525+
// view can show a server's origin. The SSE servers.changed embed
526+
// shares this projection (buildServersChangedPayload → ListServers),
527+
// so it stays in parity automatically.
528+
if regID, ok := srvRaw["source_registry_id"].(string); ok && regID != "" {
529+
srv.SourceRegistryID = regID
530+
}
531+
if prov, ok := srvRaw["source_registry_provenance"].(string); ok && prov != "" {
532+
srv.SourceRegistryProvenance = prov
533+
}
534+
524535
servers = append(servers, srv)
525536

526537
// Update stats

internal/management/service_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,44 @@ func TestListServers(t *testing.T) {
125125
assert.Equal(t, 1, stats.QuarantinedServers)
126126
})
127127

128+
// MCP-901: source registry provenance is projected onto contracts.Server so
129+
// the approval/quarantine view (and the SSE servers.changed embed, which
130+
// shares this projection) can show a server's origin.
131+
t.Run("source registry provenance projected", func(t *testing.T) {
132+
runtime := newMockRuntime()
133+
runtime.servers = []map[string]interface{}{
134+
{
135+
"id": "everything",
136+
"name": "everything",
137+
"enabled": true,
138+
"source_registry_id": "modelcontextprotocol",
139+
"source_registry_provenance": "custom/unverified",
140+
},
141+
{
142+
"id": "manual",
143+
"name": "manual",
144+
"enabled": true,
145+
},
146+
}
147+
148+
svc := NewService(runtime, cfg, "", emitter, nil, logger)
149+
servers, _, err := svc.ListServers(context.Background())
150+
require.NoError(t, err)
151+
require.Len(t, servers, 2)
152+
153+
byName := map[string]*contracts.Server{}
154+
for _, s := range servers {
155+
byName[s.Name] = s
156+
}
157+
require.Contains(t, byName, "everything")
158+
assert.Equal(t, "modelcontextprotocol", byName["everything"].SourceRegistryID)
159+
assert.Equal(t, "custom/unverified", byName["everything"].SourceRegistryProvenance)
160+
161+
require.Contains(t, byName, "manual")
162+
assert.Empty(t, byName["manual"].SourceRegistryID)
163+
assert.Empty(t, byName["manual"].SourceRegistryProvenance)
164+
})
165+
128166
// T094: Test that TotalTools only counts enabled servers' tools (Issue #285 fix)
129167
t.Run("TotalTools excludes disabled servers", func(t *testing.T) {
130168
runtime := newMockRuntime()

internal/runtime/runtime.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1888,6 +1888,18 @@ func (r *Runtime) GetAllServers() ([]map[string]interface{}, error) {
18881888
serverMap["reconnect_on_use"] = true
18891889
}
18901890

1891+
// MCP-901: carry registry provenance through to the REST/SSE projection
1892+
// so the approval/quarantine view can show a server's origin. Empty for
1893+
// manually-configured servers.
1894+
if serverStatus.Config != nil {
1895+
if serverStatus.Config.SourceRegistryID != "" {
1896+
serverMap["source_registry_id"] = serverStatus.Config.SourceRegistryID
1897+
}
1898+
if serverStatus.Config.SourceRegistryProvenance != "" {
1899+
serverMap["source_registry_provenance"] = serverStatus.Config.SourceRegistryProvenance
1900+
}
1901+
}
1902+
18911903
// Spec 044: include structured diagnostic error when available.
18921904
if serverStatus.Diagnostic != nil {
18931905
d := serverStatus.Diagnostic
@@ -2021,6 +2033,14 @@ func (r *Runtime) getAllServersLegacy() ([]map[string]interface{}, error) {
20212033
"status": "unknown",
20222034
}
20232035

2036+
// MCP-901: registry provenance in parity with the StateView path.
2037+
if srv.SourceRegistryID != "" {
2038+
serverInfo["source_registry_id"] = srv.SourceRegistryID
2039+
}
2040+
if srv.SourceRegistryProvenance != "" {
2041+
serverInfo["source_registry_provenance"] = srv.SourceRegistryProvenance
2042+
}
2043+
20242044
// Try to get connection status
20252045
if r.upstreamManager != nil {
20262046
if client, exists := r.upstreamManager.GetClient(srv.Name); exists && client != nil {

oas/docs.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

oas/swagger.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1761,6 +1761,18 @@ components:
17611761
$ref: '#/components/schemas/contracts.SecurityScanSummary'
17621762
should_retry:
17631763
type: boolean
1764+
source_registry_id:
1765+
description: |-
1766+
MCP-901 — registry provenance of an upstream that was added from a
1767+
registry. SourceRegistryID names the source registry (empty for
1768+
manually-configured servers); SourceRegistryProvenance is the trust tag
1769+
recorded at add time ("official/trusted" or "custom/unverified"). Both
1770+
are projected from config.ServerConfig so the approval/quarantine view
1771+
can render an "added from <registry> · unverified" origin badge. Optional
1772+
and omitted when empty — clients that pre-date this treat them as absent.
1773+
type: string
1774+
source_registry_provenance:
1775+
type: string
17641776
status:
17651777
type: string
17661778
token_expires_at:

0 commit comments

Comments
 (0)