From 261d9d1c85d227ac32b4661a106b009a2015011a Mon Sep 17 00:00:00 2001 From: Ruslan Strazhnyk Date: Tue, 28 Jul 2026 11:10:44 +0200 Subject: [PATCH 1/3] feat(mcp): route authenticated tools through cloud --- internal/agent/tools.go | 7 + internal/exposure/exposure.go | 8 +- internal/mcp/cloud.go | 306 +++++++++++++++++++++++ internal/mcp/cloud_test.go | 457 ++++++++++++++++++++++++++++++++++ internal/mcp/compat.go | 277 +++++++++++++++++++++ internal/mcp/compat_test.go | 157 ++++++++++++ internal/mcp/server.go | 255 +++++++++++++++++-- 7 files changed, 1449 insertions(+), 18 deletions(-) create mode 100644 internal/mcp/cloud.go create mode 100644 internal/mcp/cloud_test.go create mode 100644 internal/mcp/compat.go create mode 100644 internal/mcp/compat_test.go diff --git a/internal/agent/tools.go b/internal/agent/tools.go index 6f5e6ca..2d877f9 100644 --- a/internal/agent/tools.go +++ b/internal/agent/tools.go @@ -247,6 +247,13 @@ var localOnlyToolNames = map[string]bool{ "write_file": true, } +// IsLocalTool reports whether name is implemented entirely inside qmax-code +// and therefore must never be forwarded to QualityMax cloud. MCP uses this as +// an execution boundary, not merely a discovery filter. +func IsLocalTool(name string) bool { + return localOnlyToolNames[name] +} + // BuildToolDefs returns the connected-mode public tool definitions exposed to // the LLM agent. Experimental tools are filtered out unless // QMAX_EXPERIMENTAL=1 is set. diff --git a/internal/exposure/exposure.go b/internal/exposure/exposure.go index 420cab2..8053329 100644 --- a/internal/exposure/exposure.go +++ b/internal/exposure/exposure.go @@ -25,14 +25,14 @@ import ( // the prompt (CatLLMPrompt), and the model's answer comes back as that entry's // response bytes. CatLLMCompletion is reserved for any future response-side // accounting (and keeps the taxonomy symmetric with how humans describe LLM -// traffic); Classify does not emit it today. CatMCPTraffic is reserved for -// outbound MCP-over-HTTP egress — qmax-code's MCP server is stdio-only today, -// so no request is classified as MCP yet, but the constant documents the slot. +// traffic); Classify does not emit it today. Outbound MCP-over-HTTP clients +// explicitly apply CatMCPTraffic because the URL alone does not identify the +// protocol reliably. const ( CatLLMPrompt = "llm-prompt" // outbound inference request carrying a prompt CatLLMCompletion = "llm-completion" // reserved: response-side LLM accounting CatCloudAPI = "cloud-api" // QualityMax cloud REST API (projects, scripts, integrations) - CatMCPTraffic = "mcp-traffic" // reserved: outbound MCP-over-HTTP egress + CatMCPTraffic = "mcp-traffic" // outbound MCP-over-HTTP egress CatTelemetry = "telemetry" // opt-in error-reporting envelope CatVNCControl = "vnc-control" // noVNC WebSocket handshake/control channel CatControl = "control" // metadata only: auth check, login poll, health/reachability probes diff --git a/internal/mcp/cloud.go b/internal/mcp/cloud.go new file mode 100644 index 0000000..cc369ad --- /dev/null +++ b/internal/mcp/cloud.go @@ -0,0 +1,306 @@ +package mcp + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + "unicode" + + "github.com/qualitymax/qmax-code/internal/api" + "github.com/qualitymax/qmax-code/internal/exposure" + "github.com/qualitymax/qmax-code/internal/httpx" + "github.com/qualitymax/qmax-code/internal/security" +) + +const ( + cloudMCPPath = "/api/mcp/" + maxCloudResponseLen = 16 << 20 +) + +type clientInfo struct { + Name string `json:"name"` + Version string `json:"version"` +} + +type initializeParams struct { + ProtocolVersion string `json:"protocolVersion,omitempty"` + Capabilities map[string]interface{} `json:"capabilities,omitempty"` + ClientInfo clientInfo `json:"clientInfo"` +} + +type cloudMCPClient struct { + url string + apiKey string + http *http.Client + clientInfo clientInfo + proxyName string +} + +func newCloudMCPClient(auth *api.AuthConfig, version string) *cloudMCPClient { + if auth == nil || !auth.IsAuthenticated() { + return nil + } + httpClient := httpx.NewClient(120 * time.Second) + // A 307/308 redirect can replay a POST body. MCP tools/call may mutate + // durable state, so redirects must never be followed implicitly. + httpClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } + return &cloudMCPClient{ + url: auth.GetCloudURL() + cloudMCPPath, + apiKey: auth.APIKey, + http: httpClient, + proxyName: "qmax-code/" + safeIdentityPart(version, "unknown"), + } +} + +func (c *cloudMCPClient) setClientInfo(raw json.RawMessage) (json.RawMessage, error) { + params := make(map[string]interface{}) + if len(raw) > 0 { + if err := json.Unmarshal(raw, ¶ms); err != nil { + return nil, fmt.Errorf("decode initialize params: %w", err) + } + } + + var downstream clientInfo + if value, ok := params["clientInfo"]; ok { + encoded, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("decode downstream clientInfo: %w", err) + } + _ = json.Unmarshal(encoded, &downstream) + } + downstream = clientInfo{ + Name: safeIdentityPart(downstream.Name, "unknown"), + Version: safeIdentityPart(downstream.Version, "unknown"), + } + c.clientInfo = downstream + params["clientInfo"] = downstream + + encoded, err := json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("encode initialize params: %w", err) + } + return encoded, nil +} + +func safeIdentityPart(value, fallback string) string { + value = strings.TrimSpace(value) + lower := strings.ToLower(value) + if value == "" || + strings.Contains(lower, "bearer") || + strings.HasPrefix(lower, "qm-") || + strings.HasPrefix(lower, "sk-") || + looksLikeCredentialIdentity(value) || + looksLikeJWT(value) { + return fallback + } + + var out strings.Builder + for _, r := range value { + if out.Len() >= 64 { + break + } + if unicode.IsLetter(r) || unicode.IsDigit(r) || strings.ContainsRune("._+-", r) { + out.WriteRune(r) + } else { + out.WriteByte('_') + } + } + safe := strings.Trim(out.String(), "_") + if safe == "" || strings.Contains(security.RedactSensitive(safe), "[REDACTED]") { + return fallback + } + return safe +} + +func looksLikeCredentialIdentity(value string) bool { + lower := strings.ToLower(value) + for _, prefix := range []string{ + "ghp_", + "github_pat_", + "xoxb-", + "xoxp-", + "xoxa-", + "xoxr-", + "xoxs-", + } { + if strings.HasPrefix(lower, prefix) { + return true + } + } + if len(value) == 20 && + (strings.HasPrefix(value, "AKIA") || strings.HasPrefix(value, "ASIA")) { + for _, r := range value[4:] { + if !unicode.IsUpper(r) && !unicode.IsDigit(r) { + return false + } + } + return true + } + if len(value) < 32 { + return false + } + for _, r := range value { + if !unicode.IsLetter(r) && + !unicode.IsDigit(r) && + !strings.ContainsRune("_+-=.", r) { + return false + } + } + return true +} + +func looksLikeJWT(value string) bool { + parts := strings.Split(value, ".") + if len(parts) != 3 { + return false + } + for _, part := range parts { + if len(part) < 8 { + return false + } + for _, r := range part { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' && r != '_' { + return false + } + } + } + return true +} + +func (c *cloudMCPClient) call(ctx context.Context, req request) (response, bool, error) { + payload, err := json.Marshal(req) + if err != nil { + return response{}, false, fmt.Errorf("encode cloud MCP request: %w", err) + } + ctx = httpx.WithCategory(ctx, exposure.CatMCPTraffic) + httpReq, err := httpx.NewRequest(ctx, http.MethodPost, c.url, bytes.NewReader(payload)) + if err != nil { + return response{}, false, fmt.Errorf("build cloud MCP request: %w", err) + } + httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json, text/event-stream") + httpReq.Header.Set("User-Agent", c.userAgent()) + + httpResp, err := c.http.Do(httpReq) + if err != nil { + return response{}, false, fmt.Errorf("cloud MCP request failed: %s", security.RedactSensitive(err.Error())) + } + defer httpResp.Body.Close() + + data, err := io.ReadAll(io.LimitReader(httpResp.Body, maxCloudResponseLen+1)) + if err != nil { + return response{}, false, fmt.Errorf("read cloud MCP response: %w", err) + } + if len(data) > maxCloudResponseLen { + return response{}, false, fmt.Errorf("cloud MCP response exceeds %d bytes", maxCloudResponseLen) + } + if httpResp.StatusCode >= 300 { + return response{}, false, cloudHTTPError(httpResp.StatusCode, data) + } + if len(bytes.TrimSpace(data)) == 0 { + return response{}, false, nil + } + + var rpcResp response + contentType := httpResp.Header.Get("Content-Type") + if strings.Contains(contentType, "text/event-stream") { + rpcResp, err = decodeSSEResponse(data, req.ID) + } else { + err = json.Unmarshal(data, &rpcResp) + } + if err != nil { + return response{}, false, fmt.Errorf("decode cloud MCP response: %w", err) + } + return rpcResp, true, nil +} + +func (c *cloudMCPClient) userAgent() string { + if c.clientInfo.Name == "" { + return c.proxyName + } + return fmt.Sprintf("%s downstream/%s/%s", c.proxyName, c.clientInfo.Name, c.clientInfo.Version) +} + +func cloudHTTPError(status int, data []byte) error { + var body struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + Detail string `json:"detail"` + } + _ = json.Unmarshal(data, &body) + message := body.ErrorDescription + if message == "" { + message = body.Detail + } + if message == "" { + message = body.Error + } + message = security.RedactSensitive(message) + if len(message) > 500 { + message = message[:500] + } + if message == "" { + return fmt.Errorf("cloud MCP returned HTTP %d", status) + } + return fmt.Errorf("cloud MCP returned HTTP %d: %s", status, message) +} + +func decodeSSEResponse(data []byte, expectedID interface{}) (response, error) { + scanner := bufio.NewScanner(bytes.NewReader(data)) + scanner.Buffer(make([]byte, 64<<10), maxCloudResponseLen) + var eventData strings.Builder + decodeEvent := func() (response, bool) { + if eventData.Len() == 0 { + return response{}, false + } + var rpcResp response + if err := json.Unmarshal([]byte(eventData.String()), &rpcResp); err != nil { + eventData.Reset() + return response{}, false + } + eventData.Reset() + if rpcResp.ID == nil || !sameRPCID(rpcResp.ID, expectedID) { + return response{}, false + } + return rpcResp, true + } + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "data:") { + if eventData.Len() > 0 { + eventData.WriteByte('\n') + } + eventData.WriteString(strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + } + if line == "" { + if rpcResp, ok := decodeEvent(); ok { + return rpcResp, nil + } + } + } + if err := scanner.Err(); err != nil { + return response{}, err + } + if rpcResp, ok := decodeEvent(); ok { + return rpcResp, nil + } + return response{}, fmt.Errorf("SSE response contained no response for request id") +} + +func sameRPCID(got, want interface{}) bool { + gotJSON, gotErr := json.Marshal(got) + wantJSON, wantErr := json.Marshal(want) + if gotErr != nil || wantErr != nil { + return false + } + return bytes.Equal(gotJSON, wantJSON) +} diff --git a/internal/mcp/cloud_test.go b/internal/mcp/cloud_test.go new file mode 100644 index 0000000..3e82030 --- /dev/null +++ b/internal/mcp/cloud_test.go @@ -0,0 +1,457 @@ +package mcp + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/qualitymax/qmax-code/internal/api" +) + +func authenticatedTestState(cloudURL string) *serverState { + return newServerState(&api.SessionContext{ + Auth: &api.AuthConfig{ + APIKey: "test-key", + CloudURL: cloudURL, + }, + ProjectID: 73, + }, "test-version") +} + +func writeRPCResponse(t *testing.T, w http.ResponseWriter, id interface{}, result interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(okResp(id, result)); err != nil { + t.Errorf("encode response: %v", err) + } +} + +func TestAuthenticatedInitializePropagatesSafeClientIdentity(t *testing.T) { + var got request + var gotUserAgent string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUserAgent = r.Header.Get("User-Agent") + if r.URL.Path != cloudMCPPath { + t.Errorf("path = %q, want %q", r.URL.Path, cloudMCPPath) + } + if r.Header.Get("Authorization") == "" { + t.Error("missing authorization header") + } + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Errorf("decode request: %v", err) + } + writeRPCResponse(t, w, got.ID, map[string]interface{}{ + "protocolVersion": "2024-11-05", + "capabilities": map[string]interface{}{"tools": map[string]interface{}{}}, + "serverInfo": map[string]interface{}{"name": "qualitymax-cloud", "version": "cloud"}, + }) + })) + defer server.Close() + + req := request{ + JSONRPC: "2.0", + ID: 1, + Method: "initialize", + Params: json.RawMessage(`{ + "protocolVersion":"2024-11-05", + "capabilities":{}, + "clientInfo":{"name":"codex-cli","version":"2.4.1"} + }`), + } + resp := dispatchWithState(req, authenticatedTestState(server.URL)) + if resp.Error != nil { + t.Fatalf("initialize error: %+v", resp.Error) + } + + var params initializeParams + if err := json.Unmarshal(got.Params, ¶ms); err != nil { + t.Fatalf("decode forwarded params: %v", err) + } + if params.ClientInfo != (clientInfo{Name: "codex-cli", Version: "2.4.1"}) { + t.Fatalf("forwarded clientInfo = %+v", params.ClientInfo) + } + if gotUserAgent != "qmax-code/test-version downstream/codex-cli/2.4.1" { + t.Fatalf("User-Agent = %q", gotUserAgent) + } + + result := resp.Result.(map[string]interface{}) + serverInfo := result["serverInfo"].(map[string]interface{}) + if serverInfo["name"] != "qmax-code" || serverInfo["version"] != "test-version" { + t.Fatalf("downstream serverInfo = %+v", serverInfo) + } +} + +func TestSafeIdentityPartRejectsCredentialShapes(t *testing.T) { + for _, value := range []string{ + "Bearer credential", + "qm-placeholder", + "sk-placeholder", + "longheader.longpayload.signaturepart", + "github_pat_placeholderplaceholder", + "ghp_placeholderplaceholderplaceholder", + "xoxb-placeholderplaceholder", + "AKIAIOSFODNN7EXAMPLE", + "abcdefghijklmnopqrstuvwxyz012345", + } { + if got := safeIdentityPart(value, "unknown"); got != "unknown" { + t.Errorf("safeIdentityPart(%q) = %q, want unknown", value, got) + } + } +} + +func TestAuthenticatedToolDiscoveryUsesCloudRegistryWithCompatibilityAliases(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + var req request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode request: %v", err) + } + var params struct { + Cursor string `json:"cursor"` + } + _ = json.Unmarshal(req.Params, ¶ms) + if params.Cursor == "cloud-page-2" { + writeRPCResponse(t, w, req.ID, map[string]interface{}{ + "tools": []toolDef{ + {Name: "ai_review", InputSchema: map[string]interface{}{"type": "object"}}, + }, + }) + return + } + writeRPCResponse(t, w, req.ID, map[string]interface{}{ + "tools": []map[string]interface{}{ + { + "name": "generate_code_for_test_case", + "title": "Generate test code", + "description": "Generate code", + "inputSchema": map[string]interface{}{"type": "object"}, + "outputSchema": map[string]interface{}{"type": "object"}, + "annotations": map[string]interface{}{"readOnlyHint": false}, + "_meta": map[string]interface{}{"cloud": true}, + "futureProtocol": map[string]interface{}{"preserved": true}, + }, + {"name": "run_tests", "inputSchema": map[string]interface{}{"type": "object"}}, + {"name": "whoami", "inputSchema": map[string]interface{}{"type": "object"}}, + }, + "nextCursor": "cloud-page-2", + }) + })) + defer server.Close() + + resp := dispatchWithState(request{JSONRPC: "2.0", ID: 2, Method: "tools/list"}, authenticatedTestState(server.URL)) + if resp.Error != nil { + t.Fatalf("tools/list error: %+v", resp.Error) + } + tools := resp.Result.(map[string]interface{})["tools"].([]toolDef) + names := make(map[string]bool, len(tools)) + for _, tool := range tools { + names[tool.Name] = true + } + + for _, want := range []string{ + "generate_code_for_test_case", + "run_tests", + "whoami", + "generate_test_code", + "run_test", + "run_tests_batch", + "ai_review", + "review_repo", + "read_file", + "run_command", + "edit_file", + "write_file", + } { + if !names[want] { + t.Errorf("discovered tools missing %q", want) + } + } + if names["list_projects"] { + t.Error("blindly exposed static Go tool list_projects without cloud discovery") + } + if calls.Load() != 2 { + t.Fatalf("cloud tools/list calls = %d, want 2 pages", calls.Load()) + } + result := resp.Result.(map[string]interface{}) + if _, ok := result["nextCursor"]; ok { + t.Fatalf("aggregated tools/list unexpectedly exposed nextCursor = %#v", result["nextCursor"]) + } + first := tools[0] + if first.Title != "Generate test code" || + first.OutputSchema["type"] != "object" || + first.Annotations["readOnlyHint"] != false || + first.Meta["cloud"] != true { + t.Fatalf("cloud tool protocol fields were not preserved: %+v", first) + } + if string(first.Extra["futureProtocol"]) != `{"preserved":true}` { + t.Fatalf("future cloud tool field was not preserved: %s", first.Extra["futureProtocol"]) + } + localCount := 0 + for _, tool := range tools { + if tool.Name == "read_file" { + localCount++ + } + } + if localCount != 1 { + t.Fatalf("read_file discovery count = %d, want 1 after pagination aggregation", localCount) + } +} + +func TestAuthenticatedCloudNativeAliasNameWinsAfterDiscovery(t *testing.T) { + var got callParams + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode request: %v", err) + } + switch req.Method { + case "tools/list": + writeRPCResponse(t, w, req.ID, map[string]interface{}{ + "tools": []toolDef{ + {Name: "run_test", InputSchema: map[string]interface{}{"type": "object"}}, + {Name: "run_tests", InputSchema: map[string]interface{}{"type": "object"}}, + }, + }) + case "tools/call": + if err := json.Unmarshal(req.Params, &got); err != nil { + t.Errorf("decode params: %v", err) + } + writeRPCResponse(t, w, req.ID, map[string]interface{}{"content": []interface{}{}}) + default: + t.Errorf("unexpected method %q", req.Method) + } + })) + defer server.Close() + + state := authenticatedTestState(server.URL) + listResp := dispatchWithState(request{JSONRPC: "2.0", ID: 201, Method: "tools/list"}, state) + if listResp.Error != nil { + t.Fatalf("tools/list error: %+v", listResp.Error) + } + tools := listResp.Result.(map[string]interface{})["tools"].([]toolDef) + runTestCount := 0 + for _, tool := range tools { + if tool.Name == "run_test" { + runTestCount++ + } + } + if runTestCount != 1 { + t.Fatalf("run_test discovery count = %d, want native cloud tool exactly once", runTestCount) + } + + callResp := dispatchWithState(request{ + JSONRPC: "2.0", + ID: 202, + Method: "tools/call", + Params: json.RawMessage(`{"name":"run_test","arguments":{"native":true}}`), + }, state) + if callResp.Error != nil { + t.Fatalf("tools/call error: %+v", callResp.Error) + } + if got.Name != "run_test" || got.Arguments["native"] != true { + t.Fatalf("native cloud call was translated: %+v", got) + } +} + +func TestAuthenticatedAliasExecutesExactlyOnceThroughCloudMCP(t *testing.T) { + var calls atomic.Int32 + var got callParams + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + var req request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode request: %v", err) + } + if err := json.Unmarshal(req.Params, &got); err != nil { + t.Errorf("decode params: %v", err) + } + writeRPCResponse(t, w, req.ID, map[string]interface{}{ + "content": []map[string]interface{}{{"type": "text", "text": `{"execution_id":"example"}`}}, + "isError": false, + }) + })) + defer server.Close() + + req := request{ + JSONRPC: "2.0", + ID: 3, + Method: "tools/call", + Params: json.RawMessage(`{"name":"run_test","arguments":{"script_id":41,"headless":true}}`), + } + resp := dispatchWithState(req, authenticatedTestState(server.URL)) + if resp.Error != nil { + t.Fatalf("tools/call error: %+v", resp.Error) + } + if calls.Load() != 1 { + t.Fatalf("cloud MCP calls = %d, want exactly 1", calls.Load()) + } + if got.Name != "run_tests" { + t.Fatalf("forwarded tool name = %q, want run_tests", got.Name) + } + ids, ok := got.Arguments["script_ids"].([]interface{}) + if !ok || len(ids) != 1 || ids[0] != "41" { + t.Fatalf("forwarded script_ids = %#v, want [\"41\"]", got.Arguments["script_ids"]) + } + if got.Arguments["headless"] != true { + t.Fatalf("forwarded headless = %#v, want true", got.Arguments["headless"]) + } +} + +func TestAuthenticatedArgumentlessCallForwardsEmptyObject(t *testing.T) { + var got callParams + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode request: %v", err) + } + if err := json.Unmarshal(req.Params, &got); err != nil { + t.Errorf("decode params: %v", err) + } + writeRPCResponse(t, w, req.ID, map[string]interface{}{"content": []interface{}{}}) + })) + defer server.Close() + + resp := dispatchWithState(request{ + JSONRPC: "2.0", + ID: 31, + Method: "tools/call", + Params: json.RawMessage(`{"name":"whoami"}`), + }, authenticatedTestState(server.URL)) + if resp.Error != nil { + t.Fatalf("tools/call error: %+v", resp.Error) + } + if got.Name != "whoami" || got.Arguments == nil || len(got.Arguments) != 0 { + t.Fatalf("forwarded call = %+v, want whoami with an empty arguments object", got) + } +} + +func TestAuthenticatedLocalToolNeverCallsCloud(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + writeRPCResponse(t, w, 1, map[string]interface{}{}) + })) + defer server.Close() + + path := filepath.Join(t.TempDir(), "local.txt") + if err := os.WriteFile(path, []byte("local-only"), 0o600); err != nil { + t.Fatal(err) + } + rawParams, err := json.Marshal(callParams{ + Name: "read_file", + Arguments: map[string]interface{}{"path": path}, + }) + if err != nil { + t.Fatal(err) + } + resp := dispatchWithState(request{ + JSONRPC: "2.0", + ID: 4, + Method: "tools/call", + Params: rawParams, + }, authenticatedTestState(server.URL)) + if resp.Error != nil { + t.Fatalf("local tools/call error: %+v", resp.Error) + } + if calls.Load() != 0 { + t.Fatalf("cloud MCP calls = %d, want 0 for a local tool", calls.Load()) + } + data, err := json.Marshal(resp.Result) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "local-only") { + t.Fatalf("local tool result = %s", data) + } +} + +func TestAuthenticatedNotificationIsForwardedWithoutIDOrResponse(t *testing.T) { + var calls atomic.Int32 + var hasID bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + var message map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&message); err != nil { + t.Errorf("decode request: %v", err) + } + _, hasID = message["id"] + w.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + + _, ok := handleLineWithState( + []byte(`{"jsonrpc":"2.0","method":"notifications/initialized"}`), + authenticatedTestState(server.URL), + ) + if ok { + t.Fatal("notification should not produce a downstream response") + } + if calls.Load() != 1 { + t.Fatalf("cloud MCP calls = %d, want 1", calls.Load()) + } + if hasID { + t.Fatal("forwarded JSON-RPC notification unexpectedly contained an id") + } +} + +func TestCloudMCPClientDecodesSSE(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte( + "event: message\n" + + "data: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\",\"params\":{\"progress\":1}}\n\n" + + "event: message\n" + + "data: {\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{\"wrong\":true}}\n\n" + + "event: message\n" + + "data: {\"jsonrpc\":\"2.0\",\"id\":8,\"result\":{\"ok\":true}}\n\n", + )) + })) + defer server.Close() + + client := authenticatedTestState(server.URL).cloud + resp, ok, err := client.call(t.Context(), request{JSONRPC: "2.0", ID: 8, Method: "tools/list"}) + if err != nil { + t.Fatalf("call() error = %v", err) + } + if !ok || resp.Error != nil { + t.Fatalf("call() = (%+v, %v), want successful response", resp, ok) + } + result, valid := resp.Result.(map[string]interface{}) + if !valid || result["ok"] != true { + t.Fatalf("SSE result = %#v", resp.Result) + } +} + +func TestCloudMCPClientNeverFollowsRedirects(t *testing.T) { + var redirectTargetCalls atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + redirectTargetCalls.Add(1) + writeRPCResponse(t, w, 9, map[string]interface{}{"unexpected": true}) + })) + defer target.Close() + + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL+cloudMCPPath, http.StatusTemporaryRedirect) + })) + defer source.Close() + + client := authenticatedTestState(source.URL).cloud + _, _, err := client.call(t.Context(), request{ + JSONRPC: "2.0", + ID: 9, + Method: "tools/call", + Params: json.RawMessage(`{"name":"mutating_tool","arguments":{}}`), + }) + if err == nil || !strings.Contains(err.Error(), "HTTP 307") { + t.Fatalf("call() error = %v, want an HTTP 307 rejection", err) + } + if redirectTargetCalls.Load() != 0 { + t.Fatalf("redirect target POSTs = %d, want 0", redirectTargetCalls.Load()) + } +} diff --git a/internal/mcp/compat.go b/internal/mcp/compat.go new file mode 100644 index 0000000..e701a0f --- /dev/null +++ b/internal/mcp/compat.go @@ -0,0 +1,277 @@ +package mcp + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/qualitymax/qmax-code/internal/agent" +) + +type compatibilityAlias struct { + CloudName string + Translate func(map[string]interface{}, int) (map[string]interface{}, error) +} + +var compatibilityAliases = map[string]compatibilityAlias{ + "generate_test_code": { + CloudName: "generate_code_for_test_case", + Translate: func(args map[string]interface{}, _ int) (map[string]interface{}, error) { + out := copyArgs(args) + delete(out, "force") + switch out["framework"] { + case "go_test": + out["framework"] = "go" + case "rust_cargo": + out["framework"] = "rust" + } + return out, nil + }, + }, + "run_test": { + CloudName: "run_tests", + Translate: func(args map[string]interface{}, _ int) (map[string]interface{}, error) { + scriptID, ok := numberString(args["script_id"]) + if !ok { + return nil, fmt.Errorf("script_id is required") + } + out := copySelected(args, "base_url", "headless") + out["script_ids"] = []string{scriptID} + return out, nil + }, + }, + "run_tests_batch": { + CloudName: "run_tests", + Translate: func(args map[string]interface{}, _ int) (map[string]interface{}, error) { + out := copySelected(args, "base_url") + ids, err := scriptIDList(args["script_ids"]) + if err != nil { + return nil, err + } + out["script_ids"] = ids + return out, nil + }, + }, + "check_test_status": { + CloudName: "get_execution", + Translate: passthroughArgs, + }, + "start_crawl": { + CloudName: "start_ai_crawl", + Translate: func(args map[string]interface{}, projectID int) (map[string]interface{}, error) { + out := copyArgs(args) + renameArg(out, "pages", "pages_limit") + renameArg(out, "instructions", "custom_instructions") + injectProjectID(out, projectID) + return out, nil + }, + }, + "start_crawl_from_test_case": { + CloudName: "start_ai_crawl_from_test_case", + Translate: passthroughArgs, + }, + "crawl_status": { + CloudName: "check_ai_crawl_status", + Translate: passthroughArgs, + }, + "crawl_results": { + CloudName: "get_ai_crawl_results", + Translate: passthroughArgs, + }, + "list_crawl_jobs": { + CloudName: "list_ai_crawl_jobs", + Translate: func(args map[string]interface{}, projectID int) (map[string]interface{}, error) { + out := copyArgs(args) + injectProjectID(out, projectID) + return out, nil + }, + }, + "list_repos": { + CloudName: "list_repositories", + Translate: func(args map[string]interface{}, projectID int) (map[string]interface{}, error) { + out := copyArgs(args) + injectProjectID(out, projectID) + return out, nil + }, + }, + "review_repo": { + CloudName: "ai_review", + Translate: passthroughArgs, + }, + "import_repo": { + CloudName: "import_repository", + Translate: func(args map[string]interface{}, _ int) (map[string]interface{}, error) { + out := copyArgs(args) + renameArg(out, "url", "repo_url") + delete(out, "base_url") + return out, nil + }, + }, + "import_document": { + CloudName: "import_test_cases_from_document", + Translate: func(args map[string]interface{}, projectID int) (map[string]interface{}, error) { + out := copyArgs(args) + renameArg(out, "text", "text_content") + injectProjectID(out, projectID) + return out, nil + }, + }, + "export_qtml": { + CloudName: "export_project_as_qtml", + Translate: func(args map[string]interface{}, projectID int) (map[string]interface{}, error) { + out := copyArgs(args) + injectProjectID(out, projectID) + return out, nil + }, + }, + "import_qtml": { + CloudName: "import_qtml", + Translate: func(args map[string]interface{}, projectID int) (map[string]interface{}, error) { + out := copyArgs(args) + renameArg(out, "content", "source") + injectProjectID(out, projectID) + return out, nil + }, + }, +} + +func buildAuthenticatedToolList(cloudTools []toolDef) []toolDef { + out := make([]toolDef, 0, len(cloudTools)+len(compatibilityAliases)+4) + cloudNames := make(map[string]bool, len(cloudTools)) + for _, tool := range cloudTools { + if agent.IsLocalTool(tool.Name) { + continue + } + cloudNames[tool.Name] = true + out = append(out, tool) + } + + for _, local := range buildToolList(true) { + out = append(out, local) + } + + goDefs := make(map[string]toolDef) + for _, tool := range buildToolList(false) { + goDefs[tool.Name] = tool + } + aliasNames := make([]string, 0, len(compatibilityAliases)) + for aliasName := range compatibilityAliases { + aliasNames = append(aliasNames, aliasName) + } + sort.Strings(aliasNames) + for _, aliasName := range aliasNames { + alias := compatibilityAliases[aliasName] + if aliasName == alias.CloudName || cloudNames[aliasName] || !cloudNames[alias.CloudName] { + continue + } + def, ok := goDefs[aliasName] + if !ok { + continue + } + def.Description = fmt.Sprintf("%s Compatibility alias for cloud tool %q.", def.Description, alias.CloudName) + out = append(out, def) + } + return out +} + +func translateCloudCall(name string, args map[string]interface{}, projectID int, cloudNames map[string]bool) (string, map[string]interface{}, error) { + if cloudNames[name] { + return name, args, nil + } + if alias, ok := compatibilityAliases[name]; ok { + translated, err := alias.Translate(args, projectID) + return alias.CloudName, translated, err + } + return name, args, nil +} + +func passthroughArgs(args map[string]interface{}, _ int) (map[string]interface{}, error) { + return copyArgs(args), nil +} + +func copyArgs(args map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}, len(args)) + for key, value := range args { + out[key] = value + } + return out +} + +func copySelected(args map[string]interface{}, keys ...string) map[string]interface{} { + out := make(map[string]interface{}, len(keys)) + for _, key := range keys { + if value, ok := args[key]; ok { + out[key] = value + } + } + return out +} + +func renameArg(args map[string]interface{}, from, to string) { + if value, ok := args[from]; ok { + args[to] = value + delete(args, from) + } +} + +func injectProjectID(args map[string]interface{}, projectID int) { + if _, ok := args["project_id"]; !ok && projectID > 0 { + args["project_id"] = projectID + } +} + +func numberString(value interface{}) (string, bool) { + switch v := value.(type) { + case string: + v = strings.TrimSpace(v) + parsed, err := strconv.ParseInt(v, 10, 64) + return strconv.FormatInt(parsed, 10), err == nil && parsed > 0 + case float64: + return strconv.FormatInt(int64(v), 10), v > 0 && v == float64(int64(v)) + case int: + return strconv.Itoa(v), v > 0 + case int64: + return strconv.FormatInt(v, 10), v > 0 + default: + return "", false + } +} + +func scriptIDList(value interface{}) ([]string, error) { + var values []interface{} + switch v := value.(type) { + case string: + for _, item := range strings.Split(v, ",") { + values = append(values, strings.TrimSpace(item)) + } + case []interface{}: + values = v + case []string: + out := make([]string, 0, len(v)) + for _, item := range v { + if strings.TrimSpace(item) != "" { + out = append(out, strings.TrimSpace(item)) + } + } + if len(out) == 0 { + return nil, fmt.Errorf("script_ids is required") + } + return out, nil + default: + return nil, fmt.Errorf("script_ids must be a comma-separated string or array") + } + + out := make([]string, 0, len(values)) + for _, value := range values { + id, ok := numberString(value) + if !ok { + return nil, fmt.Errorf("script_ids contains an invalid id") + } + out = append(out, id) + } + if len(out) == 0 { + return nil, fmt.Errorf("script_ids is required") + } + return out, nil +} diff --git a/internal/mcp/compat_test.go b/internal/mcp/compat_test.go new file mode 100644 index 0000000..c314f70 --- /dev/null +++ b/internal/mcp/compat_test.go @@ -0,0 +1,157 @@ +package mcp + +import ( + "reflect" + "testing" +) + +func TestCompatibilityAliasTranslations(t *testing.T) { + tests := []struct { + name string + args map[string]interface{} + projectID int + cloudName string + want map[string]interface{} + }{ + { + name: "generate_test_code", + args: map[string]interface{}{"test_case_id": float64(7), "framework": "go_test", "force": true}, + cloudName: "generate_code_for_test_case", + want: map[string]interface{}{"test_case_id": float64(7), "framework": "go"}, + }, + { + name: "run_test", + args: map[string]interface{}{"script_id": float64(41), "headless": true, "browser": "webkit"}, + cloudName: "run_tests", + want: map[string]interface{}{"script_ids": []string{"41"}, "headless": true}, + }, + { + name: "run_tests_batch", + args: map[string]interface{}{"script_ids": "41, 42", "base_url": "https://example.test"}, + cloudName: "run_tests", + want: map[string]interface{}{"script_ids": []string{"41", "42"}, "base_url": "https://example.test"}, + }, + { + name: "check_test_status", + args: map[string]interface{}{"execution_id": "execution-1"}, + cloudName: "get_execution", + want: map[string]interface{}{"execution_id": "execution-1"}, + }, + { + name: "start_crawl", + args: map[string]interface{}{"url": "https://example.test", "pages": float64(5), "instructions": "checkout"}, + projectID: 73, + cloudName: "start_ai_crawl", + want: map[string]interface{}{ + "url": "https://example.test", + "pages_limit": float64(5), + "custom_instructions": "checkout", + "project_id": 73, + }, + }, + { + name: "list_crawl_jobs", + args: map[string]interface{}{"limit": float64(10)}, + projectID: 73, + cloudName: "list_ai_crawl_jobs", + want: map[string]interface{}{"limit": float64(10), "project_id": 73}, + }, + { + name: "list_repos", + args: map[string]interface{}{}, + projectID: 73, + cloudName: "list_repositories", + want: map[string]interface{}{"project_id": 73}, + }, + { + name: "review_repo", + args: map[string]interface{}{"repo_id": float64(9)}, + cloudName: "ai_review", + want: map[string]interface{}{"repo_id": float64(9)}, + }, + { + name: "import_repo", + args: map[string]interface{}{ + "url": "https://github.com/example/repo", + "project_id": float64(73), + "base_url": "https://example.test", + "training_consent": "opt_out", + }, + cloudName: "import_repository", + want: map[string]interface{}{ + "repo_url": "https://github.com/example/repo", + "project_id": float64(73), + "training_consent": "opt_out", + }, + }, + { + name: "import_document", + args: map[string]interface{}{"text": "requirements", "source_name": "PRD"}, + projectID: 73, + cloudName: "import_test_cases_from_document", + want: map[string]interface{}{"text_content": "requirements", "source_name": "PRD", "project_id": 73}, + }, + { + name: "export_qtml", + args: map[string]interface{}{}, + projectID: 73, + cloudName: "export_project_as_qtml", + want: map[string]interface{}{"project_id": 73}, + }, + { + name: "import_qtml", + args: map[string]interface{}{"content": "project: demo"}, + projectID: 73, + cloudName: "import_qtml", + want: map[string]interface{}{"source": "project: demo", "project_id": 73}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotName, gotArgs, err := translateCloudCall(tt.name, tt.args, tt.projectID, nil) + if err != nil { + t.Fatalf("translateCloudCall() error = %v", err) + } + if gotName != tt.cloudName { + t.Fatalf("cloud name = %q, want %q", gotName, tt.cloudName) + } + if !reflect.DeepEqual(gotArgs, tt.want) { + t.Fatalf("translated args = %#v, want %#v", gotArgs, tt.want) + } + }) + } +} + +func TestCompatibilityAliasTranslationRejectsInvalidScriptIDs(t *testing.T) { + for _, tt := range []struct { + name string + args map[string]interface{} + }{ + {name: "run_test", args: map[string]interface{}{"script_id": 4.5}}, + {name: "run_tests_batch", args: map[string]interface{}{"script_ids": "41, nope"}}, + {name: "run_tests_batch", args: map[string]interface{}{"script_ids": ""}}, + } { + t.Run(tt.name, func(t *testing.T) { + if _, _, err := translateCloudCall(tt.name, tt.args, 73, nil); err == nil { + t.Fatal("translateCloudCall() unexpectedly accepted invalid script IDs") + } + }) + } +} + +func TestCompatibilityAliasDefersToDiscoveredCloudTool(t *testing.T) { + args := map[string]interface{}{"native": true} + gotName, gotArgs, err := translateCloudCall( + "run_test", + args, + 73, + map[string]bool{"run_test": true, "run_tests": true}, + ) + if err != nil { + t.Fatalf("translateCloudCall() error = %v", err) + } + if gotName != "run_test" || !reflect.DeepEqual(gotArgs, args) { + t.Fatalf("discovered cloud tool was translated: name=%q args=%#v", gotName, gotArgs) + } +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index a3bdf62..6b86f3d 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -8,9 +8,11 @@ import ( "io" "os" "strconv" + "strings" "github.com/qualitymax/qmax-code/internal/agent" "github.com/qualitymax/qmax-code/internal/api" + "github.com/qualitymax/qmax-code/internal/security" "github.com/qualitymax/qmax-code/internal/sysutil" ) @@ -81,6 +83,25 @@ func RunServer(version string) { // RunServer so the output contract — only valid JSON-RPC lines on out — can be // tested without swapping global os.Stdin/os.Stdout. func serveMCP(in io.Reader, out io.Writer, sctx *api.SessionContext, version string) { + serveMCPWithState(in, out, newServerState(sctx, version)) +} + +type serverState struct { + sctx *api.SessionContext + version string + cloud *cloudMCPClient + cloudToolNames map[string]bool +} + +func newServerState(sctx *api.SessionContext, version string) *serverState { + var cloud *cloudMCPClient + if sctx != nil && !sctx.LocalOnly { + cloud = newCloudMCPClient(sctx.Auth, version) + } + return &serverState{sctx: sctx, version: version, cloud: cloud} +} + +func serveMCPWithState(in io.Reader, out io.Writer, state *serverState) { encoder := json.NewEncoder(out) scanner := bufio.NewScanner(in) scanner.Buffer(make([]byte, 1<<20), 1<<20) // 1 MiB — tool results can be verbose @@ -91,13 +112,17 @@ func serveMCP(in io.Reader, out io.Writer, sctx *api.SessionContext, version str continue } - if resp, ok := handleLine(line, sctx, version); ok { + if resp, ok := handleLineWithState(line, state); ok { _ = encoder.Encode(resp) } } } func handleLine(line []byte, sctx *api.SessionContext, version string) (response, bool) { + return handleLineWithState(line, newServerState(sctx, version)) +} + +func handleLineWithState(line []byte, state *serverState) (response, bool) { var req request if err := json.Unmarshal(line, &req); err != nil { return errResp(nil, -32700, "parse error"), true @@ -105,6 +130,12 @@ func handleLine(line []byte, sctx *api.SessionContext, version string) (response // JSON-RPC notifications have no id and require no response. if req.ID == nil { + if state.cloud != nil && strings.HasPrefix(req.Method, "notifications/") { + if _, _, err := state.cloud.call(context.Background(), req); err != nil { + fmt.Fprintf(os.Stderr, "qmax-code MCP notification forwarding failed: %s\n", + security.RedactSensitive(err.Error())) + } + } return response{}, false } @@ -115,14 +146,14 @@ func handleLine(line []byte, sctx *api.SessionContext, version string) (response return errResp(req.ID, -32600, "invalid request: method is required"), true } - return dispatch(req, sctx, version), true + return dispatchWithState(req, state), true } // --- JSON-RPC / MCP types --- type request struct { JSONRPC string `json:"jsonrpc"` - ID interface{} `json:"id"` + ID interface{} `json:"id,omitempty"` Method string `json:"method"` Params json.RawMessage `json:"params,omitempty"` } @@ -140,9 +171,59 @@ type rpcErr struct { } type toolDef struct { - Name string `json:"name"` - Description string `json:"description"` - InputSchema map[string]interface{} `json:"inputSchema"` + Name string `json:"name"` + Title string `json:"title,omitempty"` + Description string `json:"description"` + InputSchema map[string]interface{} `json:"inputSchema"` + OutputSchema map[string]interface{} `json:"outputSchema,omitempty"` + Annotations map[string]interface{} `json:"annotations,omitempty"` + Meta map[string]interface{} `json:"_meta,omitempty"` + Icons []interface{} `json:"icons,omitempty"` + Extra map[string]json.RawMessage `json:"-"` +} + +func (t *toolDef) UnmarshalJSON(data []byte) error { + type knownToolDef toolDef + var known knownToolDef + if err := json.Unmarshal(data, &known); err != nil { + return err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + for _, name := range []string{ + "name", "title", "description", "inputSchema", "outputSchema", + "annotations", "_meta", "icons", + } { + delete(fields, name) + } + *t = toolDef(known) + if len(fields) > 0 { + t.Extra = fields + } + return nil +} + +func (t toolDef) MarshalJSON() ([]byte, error) { + type knownToolDef toolDef + data, err := json.Marshal(knownToolDef(t)) + if err != nil { + return nil, err + } + if len(t.Extra) == 0 { + return data, nil + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return nil, err + } + for name, value := range t.Extra { + if _, known := fields[name]; !known { + fields[name] = value + } + } + return json.Marshal(fields) } type callParams struct { @@ -158,6 +239,10 @@ type callParams struct { // server process — a crash would EOF stdout and kill the client's transport // worker the same way stray stdout writes do. func dispatch(req request, sctx *api.SessionContext, version string) (resp response) { + return dispatchWithState(req, newServerState(sctx, version)) +} + +func dispatchWithState(req request, state *serverState) (resp response) { defer func() { if r := recover(); r != nil { resp = errResp(req.ID, -32603, fmt.Sprintf("internal error: %v", r)) @@ -166,14 +251,51 @@ func dispatch(req request, sctx *api.SessionContext, version string) (resp respo switch req.Method { case "initialize": + if state.cloud != nil { + rawParams, err := state.cloud.setClientInfo(req.Params) + if err != nil { + return errResp(req.ID, -32602, "invalid params: "+err.Error()) + } + upstream := req + upstream.Params = rawParams + cloudResp, ok, err := state.cloud.call(context.Background(), upstream) + if err != nil { + return errResp(req.ID, -32603, err.Error()) + } + if !ok { + return errResp(req.ID, -32603, "cloud MCP returned no initialize response") + } + if cloudResp.Error != nil { + return cloudErrorResponse(req.ID, cloudResp.Error) + } + result, ok := cloudResp.Result.(map[string]interface{}) + if !ok { + return errResp(req.ID, -32603, "cloud MCP returned an invalid initialize result") + } + result["serverInfo"] = map[string]interface{}{ + "name": "qmax-code", + "version": state.version, + } + return okResp(req.ID, result) + } return okResp(req.ID, map[string]interface{}{ "protocolVersion": "2024-11-05", "capabilities": map[string]interface{}{"tools": map[string]interface{}{}}, - "serverInfo": map[string]interface{}{"name": "qmax-code", "version": version}, + "serverInfo": map[string]interface{}{"name": "qmax-code", "version": state.version}, }) case "tools/list": - localOnly := sctx != nil && sctx.LocalOnly + if state.cloud != nil { + result, cloudErr, err := state.listCloudTools(context.Background(), req) + if err != nil { + return errResp(req.ID, -32603, err.Error()) + } + if cloudErr != nil { + return cloudErrorResponse(req.ID, cloudErr) + } + return okResp(req.ID, result) + } + localOnly := state.sctx != nil && state.sctx.LocalOnly return okResp(req.ID, map[string]interface{}{"tools": buildToolList(localOnly)}) case "tools/call": @@ -181,18 +303,56 @@ func dispatch(req request, sctx *api.SessionContext, version string) (resp respo if err := json.Unmarshal(req.Params, ¶ms); err != nil { return errResp(req.ID, -32602, "invalid params: "+err.Error()) } - // Refresh LiveFeed from on-disk config every call so the + if params.Arguments == nil { + params.Arguments = map[string]interface{}{} + } + if state.cloud != nil && !agent.IsLocalTool(params.Name) { + projectID := 0 + if state.sctx != nil { + projectID = state.sctx.ProjectID + } + cloudName, cloudArgs, err := translateCloudCall( + params.Name, + params.Arguments, + projectID, + state.cloudToolNames, + ) + if err != nil { + return errResp(req.ID, -32602, "invalid params: "+err.Error()) + } + rawParams, err := json.Marshal(callParams{Name: cloudName, Arguments: cloudArgs}) + if err != nil { + return errResp(req.ID, -32603, "failed to encode cloud tool call") + } + upstream := req + upstream.Params = rawParams + cloudResp, ok, err := state.cloud.call(context.Background(), upstream) + if err != nil { + return errResp(req.ID, -32603, err.Error()) + } + if !ok { + return errResp(req.ID, -32603, "cloud MCP returned no tools/call response") + } + if cloudResp.Error != nil { + return cloudErrorResponse(req.ID, cloudResp.Error) + } + return okResp(req.ID, cloudResp.Result) + } + + // Refresh LiveFeed from on-disk config every local or standalone call so the // parent REPL's `/live on|off` toggle takes effect without // restarting the subprocess. ProjectID is read once at startup // because it's plumbed via env; LiveFeed flips often enough // during a session that a per-call disk read pays for itself. if cfg := api.LoadQMaxCodeConfig(); cfg != nil { - sctx.LiveFeed = cfg.LiveFeed - if v := os.Getenv("QMAX_LIVE_FEED"); v == "1" || v == "true" { - sctx.LiveFeed = true + if state.sctx != nil { + state.sctx.LiveFeed = cfg.LiveFeed + if v := os.Getenv("QMAX_LIVE_FEED"); v == "1" || v == "true" { + state.sctx.LiveFeed = true + } } } - result := agent.ExecuteTool(params.Name, params.Arguments, sctx, context.Background()) + result := agent.ExecuteTool(params.Name, params.Arguments, state.sctx, context.Background()) return okResp(req.ID, map[string]interface{}{ "content": []map[string]interface{}{{"type": "text", "text": result}}, "isError": false, @@ -203,6 +363,65 @@ func dispatch(req request, sctx *api.SessionContext, version string) (resp respo } } +func (state *serverState) listCloudTools(ctx context.Context, downstream request) (map[string]interface{}, *rpcErr, error) { + const maxToolPages = 100 + + pageReq := downstream + pageReq.Params = nil + var combined map[string]interface{} + var allTools []toolDef + seenCursors := make(map[string]bool) + + for page := 0; page < maxToolPages; page++ { + cloudResp, ok, err := state.cloud.call(ctx, pageReq) + if err != nil { + return nil, nil, err + } + if !ok { + return nil, nil, fmt.Errorf("cloud MCP returned no tools/list response") + } + if cloudResp.Error != nil { + return nil, cloudResp.Error, nil + } + result, ok := cloudResp.Result.(map[string]interface{}) + if !ok { + return nil, nil, fmt.Errorf("cloud MCP returned an invalid tools/list result") + } + rawTools, err := json.Marshal(result["tools"]) + if err != nil { + return nil, nil, fmt.Errorf("cloud MCP returned an invalid tools/list result") + } + var pageTools []toolDef + if err := json.Unmarshal(rawTools, &pageTools); err != nil || pageTools == nil { + return nil, nil, fmt.Errorf("cloud MCP returned an invalid tools/list result") + } + if combined == nil { + combined = result + } + allTools = append(allTools, pageTools...) + + nextCursor, _ := result["nextCursor"].(string) + if nextCursor == "" { + state.cloudToolNames = make(map[string]bool, len(allTools)) + for _, tool := range allTools { + state.cloudToolNames[tool.Name] = true + } + delete(combined, "nextCursor") + combined["tools"] = buildAuthenticatedToolList(allTools) + return combined, nil, nil + } + if seenCursors[nextCursor] { + return nil, nil, fmt.Errorf("cloud MCP tools/list repeated a pagination cursor") + } + seenCursors[nextCursor] = true + pageReq.Params, err = json.Marshal(map[string]string{"cursor": nextCursor}) + if err != nil { + return nil, nil, fmt.Errorf("encode cloud MCP tools/list cursor: %w", err) + } + } + return nil, nil, fmt.Errorf("cloud MCP tools/list exceeded %d pages", maxToolPages) +} + func okResp(id interface{}, result interface{}) response { return response{JSONRPC: "2.0", ID: id, Result: result} } @@ -211,13 +430,21 @@ func errResp(id interface{}, code int, msg string) response { return response{JSONRPC: "2.0", ID: id, Error: &rpcErr{Code: code, Message: msg}} } +func cloudErrorResponse(id interface{}, cloudErr *rpcErr) response { + return errResp(id, cloudErr.Code, security.RedactSensitive(cloudErr.Message)) +} + // buildToolList converts qmax ToolDefs to MCP format. // The only structural difference is camelCase inputSchema vs Anthropic's input_schema. func buildToolList(localOnly bool) []toolDef { defs := agent.BuildMCPToolDefsForMode(localOnly) out := make([]toolDef, len(defs)) for i, d := range defs { - out[i] = toolDef(d) + out[i] = toolDef{ + Name: d.Name, + Description: d.Description, + InputSchema: d.InputSchema, + } } return out } From a7bc951bfbfb9350e1b3dfca78a7c16bba85cd6d Mon Sep 17 00:00:00 2001 From: Ruslan Strazhnyk Date: Tue, 28 Jul 2026 11:12:57 +0200 Subject: [PATCH 2/3] style(mcp): simplify local tool append --- internal/mcp/compat.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/mcp/compat.go b/internal/mcp/compat.go index e701a0f..8e4acd9 100644 --- a/internal/mcp/compat.go +++ b/internal/mcp/compat.go @@ -147,9 +147,7 @@ func buildAuthenticatedToolList(cloudTools []toolDef) []toolDef { out = append(out, tool) } - for _, local := range buildToolList(true) { - out = append(out, local) - } + out = append(out, buildToolList(true)...) goDefs := make(map[string]toolDef) for _, tool := range buildToolList(false) { From d4682255e795ddb74a47dbc22b3037db1ff2caa5 Mon Sep 17 00:00:00 2001 From: Ruslan Strazhnyk Date: Tue, 28 Jul 2026 11:59:50 +0200 Subject: [PATCH 3/3] fix(auth): mint MCP-compatible token after browser login --- internal/setup/interactive.go | 105 +++++++++++++--- internal/setup/interactive_test.go | 184 +++++++++++++++++++++++++++++ 2 files changed, 275 insertions(+), 14 deletions(-) create mode 100644 internal/setup/interactive_test.go diff --git a/internal/setup/interactive.go b/internal/setup/interactive.go index 242be54..c245e27 100644 --- a/internal/setup/interactive.go +++ b/internal/setup/interactive.go @@ -2,10 +2,12 @@ package setup import ( "bufio" + "bytes" "context" "encoding/json" "fmt" "io" + "net/http" "net/url" "os" "os/exec" @@ -49,16 +51,100 @@ type cliPollResponse struct { UserID string `json:"user_id,omitempty"` } +type apiTokenResponse struct { + Token string `json:"token"` +} + +type httpDoer interface { + Do(*http.Request) (*http.Response, error) +} + +const cliAPITokenLifetimeDays = 90 + +func newBrowserLoginHTTPClient() *http.Client { + client := httpx.NewClient(10 * time.Second) + client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } + return client +} + +func mintCLIAPIToken(ctx context.Context, client httpDoer, cloudURL, accessToken string) (string, error) { + body, err := json.Marshal(map[string]any{ + "name": "qmax-code CLI", + "expires_days": cliAPITokenLifetimeDays, + }) + if err != nil { + return "", fmt.Errorf("encode API token request: %w", err) + } + + req, err := httpx.NewRequest( + ctx, + http.MethodPost, + strings.TrimRight(cloudURL, "/")+"/api/auth/api-token", + bytes.NewReader(body), + ) + if err != nil { + return "", fmt.Errorf("build API token request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("create API token: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("create API token failed (HTTP %d)", resp.StatusCode) + } + + var result apiTokenResponse + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil { + return "", fmt.Errorf("decode API token response: %w", err) + } + if !strings.HasPrefix(result.Token, "qm-") || len(result.Token) == len("qm-") { + return "", fmt.Errorf("create API token returned an invalid token") + } + return result.Token, nil +} + +func completeBrowserLogin( + ctx context.Context, + client httpDoer, + cloudURL string, + poll cliPollResponse, + saveAuth func(*api.AuthConfig) error, +) (*api.AuthConfig, error) { + apiToken, err := mintCLIAPIToken(ctx, client, cloudURL, poll.Token) + if err != nil { + return nil, fmt.Errorf("browser authorized but API token setup failed: %w", err) + } + cfg := &api.AuthConfig{ + APIKey: apiToken, + Email: poll.Email, + UserID: poll.UserID, + CloudURL: cloudURL, + } + if err := saveAuth(cfg); err != nil { + return cfg, fmt.Errorf("logged in but failed to save: %w", err) + } + return cfg, nil +} + // LoginViaBrowser performs Railway-style browser login: -// 1. POST /api/auth/cli-login → get code + auth URL -// 2. Open browser to auth URL -// 3. Poll /api/auth/cli-poll until authorized or expired +// 1. POST /api/auth/cli-login → get code + auth URL +// 2. Open browser to auth URL +// 3. Poll /api/auth/cli-poll until authorized or expired +// 4. Exchange the user access token for a registered API token that works +// with both the REST API and the cloud MCP endpoint func LoginViaBrowser() (*api.AuthConfig, error) { cloudURL := os.Getenv("QUALITYMAX_URL") if cloudURL == "" { cloudURL = api.DefaultCloudURL } - client := httpx.NewClient(10 * time.Second) + client := newBrowserLoginHTTPClient() // Step 1: Get a CLI auth code req, err := httpx.NewRequest(context.Background(), "POST", cloudURL+"/api/auth/cli-login", nil) @@ -120,16 +206,7 @@ func LoginViaBrowser() (*api.AuthConfig, error) { switch poll.Status { case "authorized": - cfg := &api.AuthConfig{ - APIKey: poll.Token, - Email: poll.Email, - UserID: poll.UserID, - CloudURL: cloudURL, - } - if err := api.SaveAuth(cfg); err != nil { - return cfg, fmt.Errorf("logged in but failed to save: %w", err) - } - return cfg, nil + return completeBrowserLogin(context.Background(), client, cloudURL, poll, api.SaveAuth) case "expired": return nil, fmt.Errorf("auth code expired — please try again") diff --git a/internal/setup/interactive_test.go b/internal/setup/interactive_test.go new file mode 100644 index 0000000..7d7861c --- /dev/null +++ b/internal/setup/interactive_test.go @@ -0,0 +1,184 @@ +package setup + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/qualitymax/qmax-code/internal/api" +) + +func TestMintCLIAPIToken(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %q, want POST", r.Method) + } + if r.URL.Path != "/api/auth/api-token" { + t.Errorf("path = %q, want /api/auth/api-token", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer qm-user-access" { + t.Errorf("Authorization = %q", got) + } + if got := r.Header.Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want application/json", got) + } + + var body struct { + Name string `json:"name"` + ExpiresDays int `json:"expires_days"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request: %v", err) + } + if body.Name != "qmax-code CLI" { + t.Errorf("name = %q, want qmax-code CLI", body.Name) + } + if body.ExpiresDays != cliAPITokenLifetimeDays { + t.Errorf("expires_days = %d, want %d", body.ExpiresDays, cliAPITokenLifetimeDays) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":"qm-registered-api-token"}`)) + })) + defer server.Close() + + token, err := mintCLIAPIToken(context.Background(), server.Client(), server.URL+"/", "qm-user-access") + if err != nil { + t.Fatalf("mintCLIAPIToken: %v", err) + } + if token != "qm-registered-api-token" { + t.Fatalf("token = %q, want registered API token", token) + } +} + +func TestMintCLIAPITokenRejectsFailedOrInvalidResponse(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + statusCode int + body string + }{ + {name: "server failure", statusCode: http.StatusInternalServerError, body: `{"detail":"failed"}`}, + {name: "missing prefix", statusCode: http.StatusOK, body: `{"token":"not-an-api-token"}`}, + {name: "empty prefixed token", statusCode: http.StatusOK, body: `{"token":"qm-"}`}, + {name: "invalid json", statusCode: http.StatusOK, body: `{`}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tt.statusCode) + _, _ = w.Write([]byte(tt.body)) + })) + defer server.Close() + + if _, err := mintCLIAPIToken(context.Background(), server.Client(), server.URL, "qm-user-access"); err == nil { + t.Fatal("mintCLIAPIToken unexpectedly succeeded") + } + }) + } +} + +func TestCompleteBrowserLoginMintsAndSavesRegisteredToken(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"token":"qm-registered-api-token"}`)) + })) + defer server.Close() + + poll := cliPollResponse{ + Token: "qm-browser-access-token", + Email: "user@example.test", + UserID: "user-123", + } + var saved *api.AuthConfig + cfg, err := completeBrowserLogin( + context.Background(), + server.Client(), + server.URL, + poll, + func(got *api.AuthConfig) error { + saved = got + return nil + }, + ) + if err != nil { + t.Fatalf("completeBrowserLogin: %v", err) + } + if cfg.APIKey != "qm-registered-api-token" { + t.Fatalf("APIKey = %q, want minted registered token", cfg.APIKey) + } + if cfg.APIKey == poll.Token { + t.Fatal("saved browser access token instead of minted registered token") + } + if saved != cfg { + t.Fatal("saved config does not match returned config") + } + if cfg.Email != poll.Email || cfg.UserID != poll.UserID || cfg.CloudURL != server.URL { + t.Fatalf("metadata not preserved: %+v", cfg) + } +} + +func TestCompleteBrowserLoginReturnsConfigWhenSaveFails(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"token":"qm-registered-api-token"}`)) + })) + defer server.Close() + + saveErr := errors.New("save failed") + cfg, err := completeBrowserLogin( + context.Background(), + server.Client(), + server.URL, + cliPollResponse{Token: "qm-browser-access-token"}, + func(*api.AuthConfig) error { return saveErr }, + ) + if !errors.Is(err, saveErr) { + t.Fatalf("error = %v, want save failure", err) + } + if cfg == nil || cfg.APIKey != "qm-registered-api-token" { + t.Fatalf("config = %+v, want minted token for recovery", cfg) + } +} + +func TestBrowserLoginHTTPClientDoesNotFollowRedirects(t *testing.T) { + t.Parallel() + + redirected := false + target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + redirected = true + })) + defer target.Close() + + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Redirect(w, &http.Request{}, target.URL, http.StatusTemporaryRedirect) + })) + defer source.Close() + + req, err := http.NewRequest(http.MethodPost, source.URL, nil) + if err != nil { + t.Fatalf("create request: %v", err) + } + resp, err := newBrowserLoginHTTPClient().Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusTemporaryRedirect { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusTemporaryRedirect) + } + if redirected { + t.Fatal("redirect target was contacted") + } +}