Skip to content

Commit bc03c56

Browse files
authored
fix(api): decode %2F in /servers/{id}/* path param via middleware (MCP-1118) (#624)
All /api/v1/servers/{id}/* sub-resource handlers were receiving the raw percent-encoded {id} param from chi (e.g. io.github.owner%2Frepo for a slash-name official-registry server). This caused 404s for the Tools and Logs tabs in the Web UI and for CLI sub-resource commands on slash-name servers, since exact-match server lookups never found the encoded literal. Fix: add decodeServerIDParam middleware to the /servers/{id} route group. It decodes {id} in the chi route context once, so every handler in the subtree (tools, logs, restart, approve, scan, …) receives the real name without each one needing an explicit decodePathParam call. Also remove the now-redundant per-handler decodePathParam in handleGetServerLogs (was the only handler previously fixed individually). Same root cause as MCP-1056 (registry add-server slash ids, PR #591). Tests: TestServerSubresource_SlashServerIDUnescaped (tools/restart/tool-calls) TestGetServerLogs_SlashServerIDUnescaped (existing, still passes)
1 parent efbdd0e commit bc03c56

2 files changed

Lines changed: 132 additions & 4 deletions

File tree

internal/httpapi/server.go

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,15 @@ func (s *Server) setupRoutes() {
602602
r.Post("/servers/enable_all", s.handleEnableAll)
603603
r.Post("/servers/disable_all", s.handleDisableAll)
604604
r.Route("/servers/{id}", func(r chi.Router) {
605+
// chi routes on RawPath, so the {id} param arrives percent-encoded.
606+
// Official modelcontextprotocol/registry v0.1 ids are namespace/name,
607+
// so the slash reaches handlers as %2F. Decode it once here so every
608+
// /servers/{id}/* sub-resource handler (tools, logs, restart, approve,
609+
// scan, …) does its exact-match server lookup against the real name
610+
// rather than 404ing on the encoded literal (MCP-1118, same class as
611+
// MCP-1056). Centralising the decode also prevents new sub-resource
612+
// routes from silently reintroducing the gap.
613+
r.Use(decodeServerIDParam)
605614
r.Patch("/", s.handlePatchServer) // Partial update server config
606615
r.Delete("/", s.handleRemoveServer) // T002: Remove server
607616
r.Post("/config-to-secret", s.handleConvertConfigToSecret) // Move a header / env value into OS keyring
@@ -2678,10 +2687,10 @@ func (s *Server) handleGetGlobalTools(w http.ResponseWriter, r *http.Request) {
26782687
// @Failure 500 {object} contracts.ErrorResponse "Internal server error"
26792688
// @Router /api/v1/servers/{id}/logs [get]
26802689
func (s *Server) handleGetServerLogs(w http.ResponseWriter, r *http.Request) {
2681-
// chi routes on RawPath, so a namespace/name server id (e.g.
2682-
// io.github.evidai/polymarket-guard) arrives percent-encoded. Decode it
2683-
// before lookup, matching handleAddFromRegistry (MCP-1111 / #598).
2684-
serverID := decodePathParam(chi.URLParam(r, "id"))
2690+
// The {id} param is percent-decoded by decodeServerIDParam middleware on the
2691+
// /servers/{id} subtree (MCP-1118), so a namespace/name server id such as
2692+
// io.github.evidai/polymarket-guard reaches us already unescaped.
2693+
serverID := chi.URLParam(r, "id")
26852694
if serverID == "" {
26862695
s.writeError(w, r, http.StatusBadRequest, "Server ID required")
26872696
return
@@ -4168,6 +4177,26 @@ func decodePathParam(raw string) string {
41684177
return raw
41694178
}
41704179

4180+
// decodeServerIDParam percent-decodes the {id} path param of the /servers/{id}
4181+
// route subtree in place. chi matches the parent /servers/{id} segment (and thus
4182+
// populates "id") before mounting this subrouter, so the value is available to
4183+
// this middleware. Slash-name servers (io.github.owner/repo) arrive as
4184+
// io.github.owner%2Frepo; decoding here makes every sub-resource handler's
4185+
// exact-match server lookup work without each one having to call
4186+
// decodePathParam (MCP-1118).
4187+
func decodeServerIDParam(next http.Handler) http.Handler {
4188+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
4189+
if rctx := chi.RouteContext(r.Context()); rctx != nil {
4190+
for i, k := range rctx.URLParams.Keys {
4191+
if k == "id" {
4192+
rctx.URLParams.Values[i] = decodePathParam(rctx.URLParams.Values[i])
4193+
}
4194+
}
4195+
}
4196+
next.ServeHTTP(w, r)
4197+
})
4198+
}
4199+
41714200
func (s *Server) handleAddFromRegistry(w http.ResponseWriter, r *http.Request) {
41724201
// chi routes on RawPath, so path params arrive percent-encoded. Official
41734202
// modelcontextprotocol/registry v0.1 ids are namespace/name, so the slash
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package httpapi
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
"go.uber.org/zap/zaptest"
12+
13+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts"
14+
)
15+
16+
// capturingToolCallsController records the serverID forwarded to the controller
17+
// so the test can assert the {id} path param was percent-decoded before lookup.
18+
type capturingToolCallsController struct {
19+
*MockServerController
20+
gotServerID string
21+
}
22+
23+
func (c *capturingToolCallsController) GetServerToolCalls(serverID string, _ int) ([]*contracts.ToolCallRecord, error) {
24+
c.gotServerID = serverID
25+
return nil, nil
26+
}
27+
28+
// TestServerSubresource_SlashServerIDUnescaped reproduces MCP-1118: official
29+
// modelcontextprotocol/registry v0.1 server ids are namespace/name (e.g.
30+
// io.github.owner/repo) and chi routes on RawPath, so the {id} param of every
31+
// /api/v1/servers/{id}/* sub-resource handler arrives percent-encoded
32+
// (io.github.owner%2Frepo). Without decoding, the exact-match server lookup
33+
// targets the literal encoded name and 404s, so the Web UI server-detail Tools
34+
// and Logs tabs (and CLI sub-resources) never load for slash-name servers.
35+
//
36+
// The fix decodes {id} once at the /servers/{id} route subtree, so this asserts
37+
// the decoded name reaches both the management-service path (tools, restart) and
38+
// the controller path (tool-calls).
39+
func TestServerSubresource_SlashServerIDUnescaped(t *testing.T) {
40+
const (
41+
encoded = "io.github.owner%2Frepo"
42+
decoded = "io.github.owner/repo"
43+
)
44+
45+
// dataField unwraps the APIResponse envelope and returns the requested
46+
// string field from the data object.
47+
dataString := func(t *testing.T, body []byte, key string) string {
48+
t.Helper()
49+
var resp struct {
50+
Success bool `json:"success"`
51+
Data map[string]interface{} `json:"data"`
52+
}
53+
require.NoError(t, json.Unmarshal(body, &resp), "body=%s", string(body))
54+
require.True(t, resp.Success, "request should succeed; body=%s", string(body))
55+
v, _ := resp.Data[key].(string)
56+
return v
57+
}
58+
59+
t.Run("GET /tools echoes decoded server name", func(t *testing.T) {
60+
logger := zaptest.NewLogger(t).Sugar()
61+
server := NewServer(&MockServerController{}, logger, nil)
62+
63+
req := httptest.NewRequest(http.MethodGet, "/api/v1/servers/"+encoded+"/tools", http.NoBody)
64+
w := httptest.NewRecorder()
65+
server.ServeHTTP(w, req)
66+
67+
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
68+
assert.Equal(t, decoded, dataString(t, w.Body.Bytes(), "server_name"),
69+
"server id must be percent-decoded before lookup")
70+
})
71+
72+
t.Run("POST /restart echoes decoded server name", func(t *testing.T) {
73+
logger := zaptest.NewLogger(t).Sugar()
74+
server := NewServer(&MockServerController{}, logger, nil)
75+
76+
req := httptest.NewRequest(http.MethodPost, "/api/v1/servers/"+encoded+"/restart", http.NoBody)
77+
w := httptest.NewRecorder()
78+
server.ServeHTTP(w, req)
79+
80+
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
81+
assert.Equal(t, decoded, dataString(t, w.Body.Bytes(), "server"),
82+
"server id must be percent-decoded before lookup")
83+
})
84+
85+
t.Run("GET /tool-calls forwards decoded server name to controller", func(t *testing.T) {
86+
logger := zaptest.NewLogger(t).Sugar()
87+
controller := &capturingToolCallsController{MockServerController: &MockServerController{}}
88+
server := NewServer(controller, logger, nil)
89+
90+
req := httptest.NewRequest(http.MethodGet, "/api/v1/servers/"+encoded+"/tool-calls", http.NoBody)
91+
w := httptest.NewRecorder()
92+
server.ServeHTTP(w, req)
93+
94+
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
95+
assert.Equal(t, decoded, controller.gotServerID,
96+
"server id must be percent-decoded before reaching the controller")
97+
assert.Equal(t, decoded, dataString(t, w.Body.Bytes(), "server_name"))
98+
})
99+
}

0 commit comments

Comments
 (0)