Skip to content

Commit 83ccbc2

Browse files
fix(cliclient): path-escape server and tool names in per-server URLs (#1751)
# Pull Request ## Description Official-registry server ids are `namespace/name` (`io.github.owner/repo`, `com.pulsemcp/google-flights`). The daemon handles them on `/api/v1/servers/{id}/*` by decoding a `%2F`-encoded `{id}` (`decodeServerIDParam`, MCP-1118), and `GetServerLogs` already sends `url.PathEscape(serverName)`. Every other per-server method in `internal/cliclient` splices the raw name into the URL with `fmt.Sprintf`, so the slash splits the name across path segments and the request lands on a different route (usually a 404): ``` mcpproxy upstream enable io.github.owner/repo -> POST /api/v1/servers/io.github.owner/repo/enable (route: {id}=io.github.owner, then /repo/enable) ``` Affected: `ServerAction` (enable/disable/restart), `GetServerTools`, `SetToolEnabled`, `SetAllToolsEnabled`, `TriggerOAuthLoginWithResult`, `TriggerOAuthLogout`, `PatchServer`, `RemoveServer`, `GetToolApprovals`, `GetToolDiff`, `ApproveTools`, `BlockTools`. Most of these declare a local `url` variable that shadows `net/url`, which is probably how the escape was missed. `ServerAction`'s `var url string` is renamed to `reqURL`; the `url := fmt.Sprintf(..., url.PathEscape(...))` sites are fine as written because the new variable's scope starts after the statement. Tool names in `SetToolEnabled` and `GetToolDiff` are escaped too. For a `/` in a tool name, the daemon also has to decode `{tool}`; that is #1750. ## Testing - [x] I have tested these changes locally - [x] I have added/updated tests that prove my fix is effective or my feature works - [x] All existing tests pass `TestPerServerRequests_EscapeServerAndToolNames` (`internal/cliclient/server_path_escape_test.go`) calls 12 methods against an `httptest` server and checks `r.URL.EscapedPath()`. ``` CGO_ENABLED=0 go vet ./internal/cliclient # clean CGO_ENABLED=0 go test ./internal/cliclient -count=1 # ok # with client.go reverted: CGO_ENABLED=0 go test ./internal/cliclient -run TestPerServerRequests -count=1 # FAIL, 12 of 12 cases gofmt -l internal/cliclient # clean ``` Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent e45f3b3 commit 83ccbc2

2 files changed

Lines changed: 74 additions & 19 deletions

File tree

‎internal/cliclient/client.go‎

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -607,21 +607,17 @@ func (c *Client) GetServerLogs(ctx context.Context, serverName string, tail int)
607607

608608
// ServerAction performs an action on a server (enable, disable, restart).
609609
func (c *Client) ServerAction(ctx context.Context, serverName, action string) error {
610-
var url string
610+
var reqURL string
611611
method := http.MethodPost
612612

613613
switch action {
614-
case "enable":
615-
url = fmt.Sprintf("%s/api/v1/servers/%s/enable", c.baseURL, serverName)
616-
case "disable":
617-
url = fmt.Sprintf("%s/api/v1/servers/%s/disable", c.baseURL, serverName)
618-
case "restart":
619-
url = fmt.Sprintf("%s/api/v1/servers/%s/restart", c.baseURL, serverName)
614+
case "enable", "disable", "restart":
615+
reqURL = fmt.Sprintf("%s/api/v1/servers/%s/%s", c.baseURL, url.PathEscape(serverName), action)
620616
default:
621617
return fmt.Errorf("unknown action: %s", action)
622618
}
623619

624-
req, err := http.NewRequestWithContext(ctx, method, url, nil)
620+
req, err := http.NewRequestWithContext(ctx, method, reqURL, nil)
625621
if err != nil {
626622
return fmt.Errorf("failed to create request: %w", err)
627623
}
@@ -1209,7 +1205,7 @@ func (c *Client) GetGlobalToolsView(ctx context.Context, query url.Values) ([]ma
12091205

12101206
// GetServerTools retrieves tools for a specific server from daemon.
12111207
func (c *Client) GetServerTools(ctx context.Context, serverName string) ([]map[string]interface{}, error) {
1212-
url := fmt.Sprintf("%s/api/v1/servers/%s/tools", c.baseURL, serverName)
1208+
url := fmt.Sprintf("%s/api/v1/servers/%s/tools", c.baseURL, url.PathEscape(serverName))
12131209
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
12141210
if err != nil {
12151211
return nil, fmt.Errorf("failed to create request: %w", err)
@@ -1256,7 +1252,7 @@ func (c *Client) GetServerTools(ctx context.Context, serverName string) ([]map[s
12561252
// quarantined or toggled before — see Runtime.SetToolEnabled.
12571253
func (c *Client) SetToolEnabled(ctx context.Context, serverName, toolName string, enabled bool) error {
12581254
url := fmt.Sprintf("%s/api/v1/servers/%s/tools/%s/enabled",
1259-
c.baseURL, serverName, toolName)
1255+
c.baseURL, url.PathEscape(serverName), url.PathEscape(toolName))
12601256
body, err := json.Marshal(map[string]bool{"enabled": enabled})
12611257
if err != nil {
12621258
return fmt.Errorf("failed to marshal request: %w", err)
@@ -1304,7 +1300,7 @@ func (c *Client) SetAllToolsEnabled(ctx context.Context, serverName string, enab
13041300
if enabled {
13051301
path = "enable_all"
13061302
}
1307-
url := fmt.Sprintf("%s/api/v1/servers/%s/tools/%s", c.baseURL, serverName, path)
1303+
url := fmt.Sprintf("%s/api/v1/servers/%s/tools/%s", c.baseURL, url.PathEscape(serverName), path)
13081304
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
13091305
if err != nil {
13101306
return 0, fmt.Errorf("failed to create request: %w", err)
@@ -1367,7 +1363,7 @@ func (c *Client) TriggerOAuthLogin(ctx context.Context, serverName string) error
13671363
// the browser could not be opened (headless hosts, SSH sessions).
13681364
// Returns *contracts.OAuthFlowError for structured OAuth errors (Spec 020).
13691365
func (c *Client) TriggerOAuthLoginWithResult(ctx context.Context, serverName string) (*OAuthLoginResult, error) {
1370-
url := fmt.Sprintf("%s/api/v1/servers/%s/login", c.baseURL, serverName)
1366+
url := fmt.Sprintf("%s/api/v1/servers/%s/login", c.baseURL, url.PathEscape(serverName))
13711367
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
13721368
if err != nil {
13731369
return nil, fmt.Errorf("failed to create request: %w", err)
@@ -1454,7 +1450,7 @@ func (c *Client) TriggerOAuthLoginWithResult(ctx context.Context, serverName str
14541450

14551451
// TriggerOAuthLogout clears OAuth token and disconnects a server.
14561452
func (c *Client) TriggerOAuthLogout(ctx context.Context, serverName string) error {
1457-
url := fmt.Sprintf("%s/api/v1/servers/%s/logout", c.baseURL, serverName)
1453+
url := fmt.Sprintf("%s/api/v1/servers/%s/logout", c.baseURL, url.PathEscape(serverName))
14581454
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
14591455
if err != nil {
14601456
return fmt.Errorf("failed to create request: %w", err)
@@ -1597,7 +1593,7 @@ func (c *Client) AddServer(ctx context.Context, req *AddServerRequest) (*AddServ
15971593
// {"headers": {"X-Set": "v", "X-Old": null}} -- combined
15981594
// {"env": {"API_KEY": null}, "enabled": true} -- env delete + enable
15991595
func (c *Client) PatchServer(ctx context.Context, serverName string, body []byte) error {
1600-
url := fmt.Sprintf("%s/api/v1/servers/%s", c.baseURL, serverName)
1596+
url := fmt.Sprintf("%s/api/v1/servers/%s", c.baseURL, url.PathEscape(serverName))
16011597
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, bytes.NewReader(body))
16021598
if err != nil {
16031599
return fmt.Errorf("failed to create request: %w", err)
@@ -1639,7 +1635,7 @@ func (c *Client) PatchServer(ctx context.Context, serverName string, body []byte
16391635

16401636
// RemoveServer removes an upstream server via the daemon API.
16411637
func (c *Client) RemoveServer(ctx context.Context, serverName string) error {
1642-
url := fmt.Sprintf("%s/api/v1/servers/%s", c.baseURL, serverName)
1638+
url := fmt.Sprintf("%s/api/v1/servers/%s", c.baseURL, url.PathEscape(serverName))
16431639
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
16441640
if err != nil {
16451641
return fmt.Errorf("failed to create request: %w", err)
@@ -1865,7 +1861,7 @@ type ToolApprovalRecord struct {
18651861

18661862
// GetToolApprovals fetches tool approval records for a server (Spec 032).
18671863
func (c *Client) GetToolApprovals(ctx context.Context, serverName string) ([]ToolApprovalRecord, error) {
1868-
url := fmt.Sprintf("%s/api/v1/servers/%s/tools/export", c.baseURL, serverName)
1864+
url := fmt.Sprintf("%s/api/v1/servers/%s/tools/export", c.baseURL, url.PathEscape(serverName))
18691865
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
18701866
if err != nil {
18711867
return nil, fmt.Errorf("failed to create request: %w", err)
@@ -1909,7 +1905,7 @@ func (c *Client) GetToolApprovals(ctx context.Context, serverName string) ([]Too
19091905

19101906
// GetToolDiff fetches the diff for a changed tool (Spec 032).
19111907
func (c *Client) GetToolDiff(ctx context.Context, serverName, toolName string) (*ToolApprovalRecord, error) {
1912-
url := fmt.Sprintf("%s/api/v1/servers/%s/tools/%s/diff", c.baseURL, serverName, toolName)
1908+
url := fmt.Sprintf("%s/api/v1/servers/%s/tools/%s/diff", c.baseURL, url.PathEscape(serverName), url.PathEscape(toolName))
19131909
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
19141910
if err != nil {
19151911
return nil, fmt.Errorf("failed to create request: %w", err)
@@ -1951,7 +1947,7 @@ func (c *Client) GetToolDiff(ctx context.Context, serverName, toolName string) (
19511947

19521948
// ApproveTools approves specific tools or all tools for a server (Spec 032).
19531949
func (c *Client) ApproveTools(ctx context.Context, serverName string, toolNames []string, approveAll bool) (int, error) {
1954-
url := fmt.Sprintf("%s/api/v1/servers/%s/tools/approve", c.baseURL, serverName)
1950+
url := fmt.Sprintf("%s/api/v1/servers/%s/tools/approve", c.baseURL, url.PathEscape(serverName))
19551951

19561952
reqBody := struct {
19571953
Tools []string `json:"tools,omitempty"`
@@ -2012,7 +2008,7 @@ func (c *Client) ApproveTools(ctx context.Context, serverName string, toolNames
20122008
// (Spec 032). Block atomically approves AND disables the tool so it is never
20132009
// left in the approved+enabled state — mirroring the Web UI "Block" action.
20142010
func (c *Client) BlockTools(ctx context.Context, serverName string, toolNames []string, blockAll bool) (int, error) {
2015-
url := fmt.Sprintf("%s/api/v1/servers/%s/tools/block", c.baseURL, serverName)
2011+
url := fmt.Sprintf("%s/api/v1/servers/%s/tools/block", c.baseURL, url.PathEscape(serverName))
20162012

20172013
reqBody := struct {
20182014
Tools []string `json:"tools,omitempty"`
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package cliclient
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"go.uber.org/zap"
10+
)
11+
12+
// Official-registry server ids are namespace/name (io.github.owner/repo). The
13+
// daemon decodes a %2F-encoded {id} (MCP-1118), but only if the client sends
14+
// one: an unescaped slash splits the name across path segments and the request
15+
// lands on a different route.
16+
func TestPerServerRequests_EscapeServerAndToolNames(t *testing.T) {
17+
const (
18+
server = "io.github.owner/repo"
19+
srvEnc = "io.github.owner%2Frepo"
20+
tool = "ns/erase"
21+
tlEnc = "ns%2Ferase"
22+
)
23+
24+
var got string
25+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
26+
got = r.URL.EscapedPath()
27+
w.Header().Set("Content-Type", "application/json")
28+
_, _ = w.Write([]byte(`{"success":true,"data":{}}`))
29+
}))
30+
defer ts.Close()
31+
32+
c := NewClient(ts.URL, zap.NewNop().Sugar())
33+
ctx := context.Background()
34+
35+
for _, tc := range []struct {
36+
name string
37+
call func()
38+
want string
39+
}{
40+
{"enable", func() { _ = c.ServerAction(ctx, server, "enable") }, "/api/v1/servers/" + srvEnc + "/enable"},
41+
{"restart", func() { _ = c.ServerAction(ctx, server, "restart") }, "/api/v1/servers/" + srvEnc + "/restart"},
42+
{"tools", func() { _, _ = c.GetServerTools(ctx, server) }, "/api/v1/servers/" + srvEnc + "/tools"},
43+
{"tool enabled", func() { _ = c.SetToolEnabled(ctx, server, tool, false) }, "/api/v1/servers/" + srvEnc + "/tools/" + tlEnc + "/enabled"},
44+
{"all tools", func() { _, _ = c.SetAllToolsEnabled(ctx, server, true) }, "/api/v1/servers/" + srvEnc + "/tools/enable_all"},
45+
{"logout", func() { _ = c.TriggerOAuthLogout(ctx, server) }, "/api/v1/servers/" + srvEnc + "/logout"},
46+
{"patch", func() { _ = c.PatchServer(ctx, server, []byte(`{}`)) }, "/api/v1/servers/" + srvEnc},
47+
{"remove", func() { _ = c.RemoveServer(ctx, server) }, "/api/v1/servers/" + srvEnc},
48+
{"approvals", func() { _, _ = c.GetToolApprovals(ctx, server) }, "/api/v1/servers/" + srvEnc + "/tools/export"},
49+
{"diff", func() { _, _ = c.GetToolDiff(ctx, server, tool) }, "/api/v1/servers/" + srvEnc + "/tools/" + tlEnc + "/diff"},
50+
{"approve", func() { _, _ = c.ApproveTools(ctx, server, nil, true) }, "/api/v1/servers/" + srvEnc + "/tools/approve"},
51+
{"block", func() { _, _ = c.BlockTools(ctx, server, nil, true) }, "/api/v1/servers/" + srvEnc + "/tools/block"},
52+
} {
53+
got = ""
54+
tc.call()
55+
if got != tc.want {
56+
t.Errorf("%s: request path = %q, want %q", tc.name, got, tc.want)
57+
}
58+
}
59+
}

0 commit comments

Comments
 (0)