Skip to content

Commit 6739e26

Browse files
authored
Add unauthenticated /reflect endpoint for live DIFC label snapshots in gateway and proxy modes (#7168)
This change adds a runtime reflection endpoint to inspect current DIFC agent label state, which was previously not exposed. `GET /reflect` now returns all known agent IDs with their secrecy/integrity labels, plus current enforcement mode and a timestamp. - **Shared reflect payload builder** - Added `difc.BuildReflectResponse(...)` to produce a consistent snapshot shape: - `agents` map (`agentID -> {secrecy, integrity}`) - `mode` (strict/filter/propagate) - `timestamp` (RFC3339 UTC) - Output normalizes tag arrays to sorted string slices. - **Gateway support (routed + unified)** - Added `server.HandleReflect(...)`. - Registered `/reflect` in common endpoint wiring, so it is available in both gateway HTTP modes. - Endpoint is intentionally **unauthenticated** per current requirement. - **Proxy support** - Added `GET /reflect` handling in proxy HTTP handler. - Works with normal paths and GH-host-prefixed paths (`/api/v3/reflect`). - **Focused coverage for reflect behavior** - Added tests for: - gateway routed + unified availability - unauthenticated access with API key configured - populated and empty registry snapshots - proxy `/reflect` and `/api/v3/reflect` - defensive handling of nil registry entries ```json { "agents": { "proxy": { "secrecy": ["repo:github/private-repo"], "integrity": ["approved"] }, "abc123def456": { "secrecy": [], "integrity": ["unapproved"] } }, "mode": "propagate", "timestamp": "2026-06-07T13:40:00Z" } ```
2 parents 27e350c + ede6b7b commit 6739e26

9 files changed

Lines changed: 270 additions & 0 deletions

File tree

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,29 @@ For the full gateway field list (including rate limiting, tracing, keepalive, an
225225
- `POST /mcp/{serverID}` — Routed mode (default): JSON-RPC request to specific server
226226
- `POST /mcp` — Unified mode: JSON-RPC request routed to configured servers
227227
- `GET /health` — Health check; returns JSON `{"status":"healthy" | "unhealthy","specVersion":"...","gatewayVersion":"...","servers":{...}}`
228+
- `GET /reflect` — Unauthenticated DIFC label snapshot for all known agents (gateway and proxy mode)
229+
230+
### `GET /reflect` response schema
231+
232+
```json
233+
{
234+
"agents": {
235+
"<agent-id>": {
236+
"secrecy": ["<tag>"],
237+
"integrity": ["<tag>"]
238+
}
239+
},
240+
"mode": "strict|filter|propagate",
241+
"timestamp": "RFC3339 UTC timestamp"
242+
}
243+
```
244+
245+
- `agents`: map keyed by agent ID with current DIFC labels.
246+
- `secrecy`/`integrity`: sorted arrays of label tags (empty array when none).
247+
- `mode`: active DIFC enforcement mode.
248+
- `timestamp`: snapshot generation time in UTC (`time.RFC3339`).
249+
250+
> Security note: `/reflect` is intentionally unauthenticated for local/runtime debugging and operational introspection. It exposes active agent IDs and current DIFC labels, so operators should only expose the gateway/proxy on trusted networks.
228251
229252
Supported MCP methods: `tools/list`, `tools/call` (proxied to backend servers), plus standard lifecycle methods (`initialize`, etc.) handled natively by the MCP SDK.
230253

docs/PROXY_MODE.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,33 @@ gh CLI → awmg proxy (localhost:8443, TLS) → api.github.com
6060

6161
Write operations (PUT, POST, DELETE, PATCH) pass through unmodified.
6262

63+
## Reflect Endpoint
64+
65+
Proxy mode exposes an unauthenticated DIFC reflection endpoint:
66+
67+
- `GET /reflect`
68+
- `GET /api/v3/reflect` (`GH_HOST` style REST base path used by `gh` against GHES/proxy targets; normalized to `/reflect`)
69+
70+
Response schema:
71+
72+
```json
73+
{
74+
"agents": {
75+
"<agent-id>": {
76+
"secrecy": ["<tag>"],
77+
"integrity": ["<tag>"]
78+
}
79+
},
80+
"mode": "strict|filter|propagate",
81+
"timestamp": "RFC3339 UTC timestamp"
82+
}
83+
```
84+
85+
- `agents`: map of known agent IDs to current DIFC labels.
86+
- `secrecy`/`integrity`: sorted label arrays in ascending lexicographic order (empty when no labels are present).
87+
- `mode`: current DIFC enforcement mode.
88+
- `timestamp`: snapshot creation time in UTC (`time.RFC3339`).
89+
6390
## Flags
6491

6592
| Flag | Default | Description |

internal/difc/reflect.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package difc
2+
3+
import (
4+
"sort"
5+
"time"
6+
)
7+
8+
// ReflectedAgentLabels is the JSON shape for an agent's current DIFC labels.
9+
type ReflectedAgentLabels struct {
10+
Secrecy []string `json:"secrecy"`
11+
Integrity []string `json:"integrity"`
12+
}
13+
14+
// ReflectResponse is the JSON response returned by /reflect endpoints.
15+
type ReflectResponse struct {
16+
Agents map[string]ReflectedAgentLabels `json:"agents"`
17+
Mode string `json:"mode"`
18+
Timestamp string `json:"timestamp"`
19+
}
20+
21+
// BuildReflectResponse returns a snapshot of all known agent labels.
22+
func BuildReflectResponse(components DIFCComponents) ReflectResponse {
23+
agents := map[string]ReflectedAgentLabels{}
24+
if components.AgentRegistry != nil {
25+
for _, agentID := range components.AgentRegistry.GetAllAgentIDs() {
26+
agent, ok := components.AgentRegistry.Get(agentID)
27+
if !ok || agent == nil {
28+
continue
29+
}
30+
agents[agentID] = ReflectedAgentLabels{
31+
Secrecy: tagsToStrings(agent.GetSecrecyTags()),
32+
Integrity: tagsToStrings(agent.GetIntegrityTags()),
33+
}
34+
}
35+
}
36+
37+
return ReflectResponse{
38+
Agents: agents,
39+
Mode: components.Mode.String(),
40+
Timestamp: time.Now().UTC().Format(time.RFC3339),
41+
}
42+
}
43+
44+
func tagsToStrings(tags []Tag) []string {
45+
out := make([]string, len(tags))
46+
for i, tag := range tags {
47+
out[i] = string(tag)
48+
}
49+
sort.Strings(out)
50+
return out
51+
}

internal/difc/reflect_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package difc
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestBuildReflectResponse_EmptyRegistry(t *testing.T) {
12+
resp := BuildReflectResponse(DIFCComponents{
13+
Mode: EnforcementFilter,
14+
AgentRegistry: NewAgentRegistry(),
15+
})
16+
17+
require.NotNil(t, resp.Agents)
18+
assert.Empty(t, resp.Agents)
19+
assert.Equal(t, "filter", resp.Mode)
20+
_, err := time.Parse(time.RFC3339, resp.Timestamp)
21+
assert.NoError(t, err)
22+
}
23+
24+
func TestBuildReflectResponse_SkipsNilAgentEntries(t *testing.T) {
25+
reg := NewAgentRegistry()
26+
reg.agents["broken"] = nil
27+
28+
resp := BuildReflectResponse(DIFCComponents{
29+
Mode: EnforcementStrict,
30+
AgentRegistry: reg,
31+
})
32+
33+
assert.NotContains(t, resp.Agents, "broken")
34+
}

internal/proxy/handler.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ func (h *proxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
5353
return
5454
}
5555

56+
// Reflect endpoint exposes a live DIFC label snapshot.
57+
if r.Method == http.MethodGet && rawPath == "/reflect" {
58+
httputil.WriteJSONResponse(w, http.StatusOK, difc.BuildReflectResponse(h.server.DIFCComponents))
59+
return
60+
}
61+
5662
// gh CLI probes /meta during initialization for feature detection.
5763
// Treat it like GraphQL introspection metadata and pass through unfiltered.
5864
if r.Method == http.MethodGet && rawPath == "/meta" {

internal/proxy/handler_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"net/http"
99
"net/http/httptest"
1010
"testing"
11+
"time"
1112

1213
"github.com/github/gh-aw-mcpg/internal/difc"
1314
"github.com/github/gh-aw-mcpg/internal/guard"
@@ -171,6 +172,43 @@ func TestServeHTTP_GHHostPrefixStripped(t *testing.T) {
171172
assert.Equal(t, "ok", got["status"])
172173
}
173174

175+
func TestServeHTTP_ReflectReturnsAllAgents(t *testing.T) {
176+
tests := []struct {
177+
name string
178+
path string
179+
}{
180+
{name: "/reflect", path: "/reflect"},
181+
{name: "/api/v3/reflect", path: "/api/v3/reflect"},
182+
}
183+
184+
for _, tt := range tests {
185+
t.Run(tt.name, func(t *testing.T) {
186+
s := newTestServer(t, "http://unused")
187+
s.Mode = difc.EnforcementPropagate
188+
s.AgentRegistry.Register("proxy", []difc.Tag{"repo:github/private-repo"}, []difc.Tag{"approved"})
189+
s.AgentRegistry.Register("abc123def456", nil, []difc.Tag{"unapproved"})
190+
191+
h := &proxyHandler{server: s}
192+
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
193+
w := httptest.NewRecorder()
194+
h.ServeHTTP(w, req)
195+
196+
require.Equal(t, http.StatusOK, w.Code)
197+
require.Equal(t, "application/json", w.Header().Get("Content-Type"))
198+
199+
var got difc.ReflectResponse
200+
require.NoError(t, json.NewDecoder(w.Body).Decode(&got))
201+
assert.Equal(t, "propagate", got.Mode)
202+
assert.ElementsMatch(t, []string{"repo:github/private-repo"}, got.Agents["proxy"].Secrecy)
203+
assert.ElementsMatch(t, []string{"approved"}, got.Agents["proxy"].Integrity)
204+
assert.Empty(t, got.Agents["abc123def456"].Secrecy)
205+
assert.ElementsMatch(t, []string{"unapproved"}, got.Agents["abc123def456"].Integrity)
206+
_, err := time.Parse(time.RFC3339, got.Timestamp)
207+
assert.NoError(t, err)
208+
})
209+
}
210+
}
211+
174212
// ─── ServeHTTP: unknown GraphQL query is blocked ─────────────────────────────
175213

176214
func TestServeHTTP_UnknownGraphQLBlocked(t *testing.T) {

internal/server/handlers.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ func registerCommonEndpoints(mux *http.ServeMux, unifiedServer *UnifiedServer, a
112112
healthHandler := HandleHealth(unifiedServer)
113113
mux.Handle("/health", withResponseLogging(healthHandler))
114114

115+
// Reflect endpoint exposes a live DIFC label snapshot.
116+
reflectHandler := HandleReflect(unifiedServer)
117+
mux.Handle("/reflect", withResponseLogging(reflectHandler))
118+
115119
// Close endpoint for graceful shutdown (spec 5.1.3)
116120
closeHandler := handleClose(unifiedServer)
117121
finalCloseHandler := applyAuthIfConfigured(apiKey, closeHandler.ServeHTTP)

internal/server/reflect.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package server
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/github/gh-aw-mcpg/internal/difc"
7+
"github.com/github/gh-aw-mcpg/internal/httputil"
8+
)
9+
10+
// HandleReflect returns an http.HandlerFunc that handles the /reflect endpoint.
11+
func HandleReflect(unifiedServer *UnifiedServer) http.HandlerFunc {
12+
return func(w http.ResponseWriter, _ *http.Request) {
13+
httputil.WriteJSONResponse(w, http.StatusOK, difc.BuildReflectResponse(unifiedServer.DIFCComponents))
14+
}
15+
}

internal/server/reflect_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package server
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
"time"
10+
11+
"github.com/github/gh-aw-mcpg/internal/config"
12+
"github.com/github/gh-aw-mcpg/internal/difc"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
func TestReflectEndpoint_BothModes_NoAuthRequired(t *testing.T) {
18+
tests := []struct {
19+
name string
20+
createServer func(addr string, us *UnifiedServer, apiKey, hmacSecret string) *http.Server
21+
}{
22+
{name: "routed mode", createServer: CreateHTTPServerForRoutedMode},
23+
{name: "gateway mode", createServer: CreateHTTPServerForMCP},
24+
}
25+
26+
for _, tt := range tests {
27+
t.Run(tt.name, func(t *testing.T) {
28+
us, err := NewUnified(context.Background(), &config.Config{Servers: map[string]*config.ServerConfig{}})
29+
require.NoError(t, err)
30+
t.Cleanup(func() { us.Close() })
31+
32+
us.AgentRegistry.Register("proxy", []difc.Tag{"repo:github/private-repo"}, []difc.Tag{"approved"})
33+
us.AgentRegistry.Register("abc123def456", nil, []difc.Tag{"unapproved"})
34+
35+
httpServer := tt.createServer(":0", us, "test-api-key", "")
36+
req := httptest.NewRequest(http.MethodGet, "/reflect", nil)
37+
w := httptest.NewRecorder()
38+
39+
httpServer.Handler.ServeHTTP(w, req)
40+
41+
require.Equal(t, http.StatusOK, w.Code)
42+
require.Equal(t, "application/json", w.Header().Get("Content-Type"))
43+
44+
var got difc.ReflectResponse
45+
require.NoError(t, json.NewDecoder(w.Body).Decode(&got))
46+
assert.Equal(t, us.Mode.String(), got.Mode)
47+
assert.ElementsMatch(t, []string{"repo:github/private-repo"}, got.Agents["proxy"].Secrecy)
48+
assert.ElementsMatch(t, []string{"approved"}, got.Agents["proxy"].Integrity)
49+
assert.Empty(t, got.Agents["abc123def456"].Secrecy)
50+
assert.ElementsMatch(t, []string{"unapproved"}, got.Agents["abc123def456"].Integrity)
51+
_, err = time.Parse(time.RFC3339, got.Timestamp)
52+
assert.NoError(t, err)
53+
})
54+
}
55+
}
56+
57+
func TestReflectEndpoint_EmptyRegistry(t *testing.T) {
58+
us, err := NewUnified(context.Background(), &config.Config{Servers: map[string]*config.ServerConfig{}})
59+
require.NoError(t, err)
60+
t.Cleanup(func() { us.Close() })
61+
62+
httpServer := CreateHTTPServerForMCP(":0", us, "", "")
63+
req := httptest.NewRequest(http.MethodGet, "/reflect", nil)
64+
w := httptest.NewRecorder()
65+
httpServer.Handler.ServeHTTP(w, req)
66+
67+
require.Equal(t, http.StatusOK, w.Code)
68+
69+
var got difc.ReflectResponse
70+
require.NoError(t, json.NewDecoder(w.Body).Decode(&got))
71+
assert.Empty(t, got.Agents)
72+
}

0 commit comments

Comments
 (0)