mirror-github.paniser.workers.dev/github/github-mcp-server
Advanced tools
| package main | ||
| import ( | ||
| "bufio" | ||
| "encoding/json" | ||
| "strings" | ||
| "testing" | ||
| ) | ||
| func TestReadJSONRPCResponse_DirectResponse(t *testing.T) { | ||
| t.Parallel() | ||
| input := `{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}` + "\n" | ||
| scanner := bufio.NewScanner(strings.NewReader(input)) | ||
| got, err := readJSONRPCResponse(scanner) | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| if got != `{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}` { | ||
| t.Fatalf("unexpected response: %s", got) | ||
| } | ||
| } | ||
| func TestReadJSONRPCResponse_SkipsNotifications(t *testing.T) { | ||
| t.Parallel() | ||
| input := strings.Join([]string{ | ||
| `{"jsonrpc":"2.0","method":"notifications/resources/list_changed","params":{}}`, | ||
| `{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}`, | ||
| `{"jsonrpc":"2.0","id":42,"result":{"content":[{"type":"text","text":"hello"}]}}`, | ||
| }, "\n") + "\n" | ||
| scanner := bufio.NewScanner(strings.NewReader(input)) | ||
| got, err := readJSONRPCResponse(scanner) | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| var msg map[string]json.RawMessage | ||
| if err := json.Unmarshal([]byte(got), &msg); err != nil { | ||
| t.Fatalf("response is not valid JSON: %v", err) | ||
| } | ||
| // Verify we got the response with id:42, not a notification | ||
| var id int | ||
| if err := json.Unmarshal(msg["id"], &id); err != nil { | ||
| t.Fatalf("failed to parse id: %v", err) | ||
| } | ||
| if id != 42 { | ||
| t.Fatalf("expected id 42, got %d", id) | ||
| } | ||
| } | ||
| func TestReadJSONRPCResponse_NoResponse(t *testing.T) { | ||
| t.Parallel() | ||
| // Only notifications, no response | ||
| input := `{"jsonrpc":"2.0","method":"notifications/resources/list_changed","params":{}}` + "\n" | ||
| scanner := bufio.NewScanner(strings.NewReader(input)) | ||
| _, err := readJSONRPCResponse(scanner) | ||
| if err == nil { | ||
| t.Fatal("expected error for missing response, got nil") | ||
| } | ||
| if !strings.Contains(err.Error(), "unexpected end of output") { | ||
| t.Fatalf("expected 'unexpected end of output' error, got: %v", err) | ||
| } | ||
| } | ||
| func TestReadJSONRPCResponse_EmptyInput(t *testing.T) { | ||
| t.Parallel() | ||
| scanner := bufio.NewScanner(strings.NewReader("")) | ||
| _, err := readJSONRPCResponse(scanner) | ||
| if err == nil { | ||
| t.Fatal("expected error for empty input, got nil") | ||
| } | ||
| } | ||
| func TestReadJSONRPCResponse_InvalidJSON(t *testing.T) { | ||
| t.Parallel() | ||
| input := "not valid json\n" | ||
| scanner := bufio.NewScanner(strings.NewReader(input)) | ||
| _, err := readJSONRPCResponse(scanner) | ||
| if err == nil { | ||
| t.Fatal("expected error for invalid JSON, got nil") | ||
| } | ||
| if !strings.Contains(err.Error(), "failed to parse JSON-RPC message") { | ||
| t.Fatalf("expected parse error, got: %v", err) | ||
| } | ||
| } | ||
| func TestReadJSONRPCResponse_ServerError(t *testing.T) { | ||
| t.Parallel() | ||
| input := `{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"method not found"}}` + "\n" | ||
| scanner := bufio.NewScanner(strings.NewReader(input)) | ||
| _, err := readJSONRPCResponse(scanner) | ||
| if err == nil { | ||
| t.Fatal("expected error for server error response, got nil") | ||
| } | ||
| if !strings.Contains(err.Error(), "server returned error") { | ||
| t.Fatalf("expected 'server returned error', got: %v", err) | ||
| } | ||
| if !strings.Contains(err.Error(), "method not found") { | ||
| t.Fatalf("expected error to contain server message, got: %v", err) | ||
| } | ||
| } | ||
| func TestBuildInitializeRequest(t *testing.T) { | ||
| t.Parallel() | ||
| got, err := buildInitializeRequest() | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| var msg map[string]json.RawMessage | ||
| if err := json.Unmarshal([]byte(got), &msg); err != nil { | ||
| t.Fatalf("result is not valid JSON: %v", err) | ||
| } | ||
| // Verify required fields | ||
| for _, field := range []string{"jsonrpc", "id", "method", "params"} { | ||
| if _, ok := msg[field]; !ok { | ||
| t.Errorf("missing required field %q", field) | ||
| } | ||
| } | ||
| // Verify method | ||
| var method string | ||
| if err := json.Unmarshal(msg["method"], &method); err != nil { | ||
| t.Fatalf("failed to parse method: %v", err) | ||
| } | ||
| if method != "initialize" { | ||
| t.Errorf("expected method 'initialize', got %q", method) | ||
| } | ||
| // Verify params contain protocolVersion and clientInfo | ||
| var params map[string]json.RawMessage | ||
| if err := json.Unmarshal(msg["params"], ¶ms); err != nil { | ||
| t.Fatalf("failed to parse params: %v", err) | ||
| } | ||
| for _, field := range []string{"protocolVersion", "capabilities", "clientInfo"} { | ||
| if _, ok := params[field]; !ok { | ||
| t.Errorf("missing params field %q", field) | ||
| } | ||
| } | ||
| var version string | ||
| if err := json.Unmarshal(params["protocolVersion"], &version); err != nil { | ||
| t.Fatalf("failed to parse protocolVersion: %v", err) | ||
| } | ||
| if version != "2024-11-05" { | ||
| t.Errorf("expected protocolVersion '2024-11-05', got %q", version) | ||
| } | ||
| } | ||
| func TestBuildInitializedNotification(t *testing.T) { | ||
| t.Parallel() | ||
| got := buildInitializedNotification() | ||
| var msg map[string]json.RawMessage | ||
| if err := json.Unmarshal([]byte(got), &msg); err != nil { | ||
| t.Fatalf("result is not valid JSON: %v", err) | ||
| } | ||
| // Must have jsonrpc and method | ||
| var method string | ||
| if err := json.Unmarshal(msg["method"], &method); err != nil { | ||
| t.Fatalf("failed to parse method: %v", err) | ||
| } | ||
| if method != "notifications/initialized" { | ||
| t.Errorf("expected method 'notifications/initialized', got %q", method) | ||
| } | ||
| // Must NOT have an id (it's a notification) | ||
| if _, hasID := msg["id"]; hasID { | ||
| t.Error("notification should not have an 'id' field") | ||
| } | ||
| } |
+104
-13
| package main | ||
| import ( | ||
| "bytes" | ||
| "bufio" | ||
| "crypto/rand" | ||
@@ -379,4 +379,4 @@ "encoding/json" | ||
| // executeServerCommand runs the specified command, sends the JSON request to stdin, | ||
| // and returns the response from stdout | ||
| // executeServerCommand runs the specified command, performs the MCP initialization | ||
| // handshake, sends the JSON request to stdin, and returns the response from stdout. | ||
| func executeServerCommand(cmdStr, jsonRequest string) (string, error) { | ||
@@ -397,5 +397,10 @@ // Split the command string into command and arguments | ||
| // Setup stdout and stderr pipes | ||
| var stdout, stderr bytes.Buffer | ||
| cmd.Stdout = &stdout | ||
| // Setup stdout pipe for line-by-line reading | ||
| stdoutPipe, err := cmd.StdoutPipe() | ||
| if err != nil { | ||
| return "", fmt.Errorf("failed to create stdout pipe: %w", err) | ||
| } | ||
| // Stderr still uses a buffer | ||
| var stderr strings.Builder | ||
| cmd.Stderr = &stderr | ||
@@ -408,16 +413,102 @@ | ||
| // Write the JSON request to stdin | ||
| // Ensure the child process is cleaned up on every return path. | ||
| // stdin must be closed before Wait so the server sees EOF and exits; | ||
| // its non-zero exit status on EOF is expected, so we ignore the error. | ||
| defer func() { | ||
| _ = stdin.Close() | ||
| _ = cmd.Wait() | ||
| }() | ||
| // Use a scanner with a large buffer for reading JSON-RPC responses | ||
| scanner := bufio.NewScanner(stdoutPipe) | ||
| scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) // 1MB max line size | ||
| // Step 1: Send MCP initialize request | ||
| initReq, err := buildInitializeRequest() | ||
| if err != nil { | ||
| return "", fmt.Errorf("failed to build initialize request: %w", err) | ||
| } | ||
| if _, err := io.WriteString(stdin, initReq+"\n"); err != nil { | ||
| return "", fmt.Errorf("failed to write initialize request: %w", err) | ||
| } | ||
| // Step 2: Read initialize response (skip any server notifications) | ||
| if _, err := readJSONRPCResponse(scanner); err != nil { | ||
| return "", fmt.Errorf("failed to read initialize response: %w, stderr: %s", err, stderr.String()) | ||
| } | ||
| // Step 3: Send initialized notification | ||
| if _, err := io.WriteString(stdin, buildInitializedNotification()+"\n"); err != nil { | ||
| return "", fmt.Errorf("failed to write initialized notification: %w", err) | ||
| } | ||
| // Step 4: Send the actual request | ||
| if _, err := io.WriteString(stdin, jsonRequest+"\n"); err != nil { | ||
| return "", fmt.Errorf("failed to write to stdin: %w", err) | ||
| return "", fmt.Errorf("failed to write request: %w", err) | ||
| } | ||
| _ = stdin.Close() | ||
| // Wait for the command to complete | ||
| if err := cmd.Wait(); err != nil { | ||
| return "", fmt.Errorf("command failed: %w, stderr: %s", err, stderr.String()) | ||
| // Step 5: Read the actual response (skip any server notifications) | ||
| response, err := readJSONRPCResponse(scanner) | ||
| if err != nil { | ||
| return "", fmt.Errorf("failed to read response: %w, stderr: %s", err, stderr.String()) | ||
| } | ||
| return stdout.String(), nil | ||
| return response, nil | ||
| } | ||
| // buildInitializeRequest creates the MCP initialize handshake request. | ||
| func buildInitializeRequest() (string, error) { | ||
| id, err := rand.Int(rand.Reader, big.NewInt(10000)) | ||
| if err != nil { | ||
| return "", fmt.Errorf("failed to generate random ID: %w", err) | ||
| } | ||
| msg := map[string]any{ | ||
| "jsonrpc": "2.0", | ||
| "id": int(id.Int64()), | ||
| "method": "initialize", | ||
| "params": map[string]any{ | ||
| "protocolVersion": "2024-11-05", | ||
| "capabilities": map[string]any{}, | ||
| "clientInfo": map[string]any{ | ||
| "name": "mcpcurl", | ||
| "version": "0.1.0", | ||
| }, | ||
| }, | ||
| } | ||
| data, err := json.Marshal(msg) | ||
| if err != nil { | ||
| return "", fmt.Errorf("failed to marshal initialize request: %w", err) | ||
| } | ||
| return string(data), nil | ||
| } | ||
| // buildInitializedNotification creates the MCP initialized notification. | ||
| func buildInitializedNotification() string { | ||
| return `{"jsonrpc":"2.0","method":"notifications/initialized"}` | ||
| } | ||
| // readJSONRPCResponse reads lines from the scanner, skipping server-initiated | ||
| // notifications (messages without an "id" field), and returns the first response. | ||
| func readJSONRPCResponse(scanner *bufio.Scanner) (string, error) { | ||
| for scanner.Scan() { | ||
| line := scanner.Text() | ||
| // JSON-RPC responses have an "id" field; notifications do not. | ||
| var msg map[string]json.RawMessage | ||
| if err := json.Unmarshal([]byte(line), &msg); err != nil { | ||
| return "", fmt.Errorf("failed to parse JSON-RPC message: %w", err) | ||
| } | ||
| if _, hasID := msg["id"]; hasID { | ||
| if errField, hasErr := msg["error"]; hasErr { | ||
| return "", fmt.Errorf("server returned error: %s", string(errField)) | ||
| } | ||
| return line, nil | ||
| } | ||
| // No "id" — this is a notification, skip it | ||
| } | ||
| if err := scanner.Err(); err != nil { | ||
| return "", err | ||
| } | ||
| return "", fmt.Errorf("unexpected end of output") | ||
| } | ||
| func printResponse(response string, prettyPrint bool) error { | ||
@@ -424,0 +515,0 @@ if !prettyPrint { |
@@ -201,2 +201,3 @@ # Feature Flags | ||
| - **Required OAuth Scopes**: `repo` | ||
| - `confidence`: How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal. (string, optional) | ||
| - `is_suggestion`: If true, this issue type change is sent to the API as a suggestion (suggest:true) rather than an applied value. Whether the type is applied or recorded as a proposal is determined by the API. (boolean, optional) | ||
@@ -244,3 +245,3 @@ - `issue_number`: The issue number to update (number, required) | ||
| - `repo`: Repository name (string, required) | ||
| - `reviewers`: GitHub usernames to request reviews from (string[], required) | ||
| - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], required) | ||
@@ -247,0 +248,0 @@ - **resolve_review_thread** - Resolve Review Thread |
@@ -464,2 +464,87 @@ package github | ||
| func TestGranularUpdateIssueLabelsConfidence(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| requestArgs map[string]any | ||
| expectedReq map[string]any | ||
| }{ | ||
| { | ||
| name: "label with confidence triggers object form", | ||
| requestArgs: map[string]any{ | ||
| "owner": "owner", | ||
| "repo": "repo", | ||
| "issue_number": float64(1), | ||
| "labels": []any{ | ||
| map[string]any{"name": "bug", "confidence": "high"}, | ||
| }, | ||
| }, | ||
| expectedReq: map[string]any{ | ||
| "labels": []any{ | ||
| map[string]any{"name": "bug", "confidence": "high"}, | ||
| }, | ||
| }, | ||
| }, | ||
| { | ||
| name: "label with confidence and rationale", | ||
| requestArgs: map[string]any{ | ||
| "owner": "owner", | ||
| "repo": "repo", | ||
| "issue_number": float64(1), | ||
| "labels": []any{ | ||
| map[string]any{"name": "bug", "rationale": "Reports a crash", "confidence": "medium"}, | ||
| }, | ||
| }, | ||
| expectedReq: map[string]any{ | ||
| "labels": []any{ | ||
| map[string]any{"name": "bug", "rationale": "Reports a crash", "confidence": "medium"}, | ||
| }, | ||
| }, | ||
| }, | ||
| { | ||
| name: "invalid confidence value", | ||
| requestArgs: map[string]any{ | ||
| "owner": "owner", | ||
| "repo": "repo", | ||
| "issue_number": float64(1), | ||
| "labels": []any{ | ||
| map[string]any{"name": "bug", "confidence": "very_high"}, | ||
| }, | ||
| }, | ||
| expectedReq: nil, | ||
| }, | ||
| } | ||
| for _, tc := range tests { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| if tc.expectedReq == nil { | ||
| // Error case | ||
| deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(nil))} | ||
| serverTool := GranularUpdateIssueLabels(translations.NullTranslationHelper) | ||
| handler := serverTool.Handler(deps) | ||
| request := createMCPRequest(tc.requestArgs) | ||
| result, err := handler(ContextWithDeps(context.Background(), deps), &request) | ||
| require.NoError(t, err) | ||
| errorContent := getErrorResult(t, result) | ||
| assert.Contains(t, errorContent.Text, "confidence must be one of: low, medium, high") | ||
| return | ||
| } | ||
| client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ | ||
| PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq). | ||
| andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), | ||
| })) | ||
| deps := BaseDeps{Client: client} | ||
| serverTool := GranularUpdateIssueLabels(translations.NullTranslationHelper) | ||
| handler := serverTool.Handler(deps) | ||
| request := createMCPRequest(tc.requestArgs) | ||
| result, err := handler(ContextWithDeps(context.Background(), deps), &request) | ||
| require.NoError(t, err) | ||
| assert.False(t, result.IsError) | ||
| }) | ||
| } | ||
| } | ||
| func TestGranularUpdateIssueMilestone(t *testing.T) { | ||
@@ -646,2 +731,124 @@ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ | ||
| func TestGranularUpdateIssueTypeConfidence(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| requestArgs map[string]any | ||
| expectedReq map[string]any | ||
| }{ | ||
| { | ||
| name: "type with confidence only", | ||
| requestArgs: map[string]any{ | ||
| "owner": "owner", | ||
| "repo": "repo", | ||
| "issue_number": float64(1), | ||
| "issue_type": "bug", | ||
| "confidence": "high", | ||
| }, | ||
| expectedReq: map[string]any{ | ||
| "type": map[string]any{ | ||
| "value": "bug", | ||
| "confidence": "high", | ||
| }, | ||
| }, | ||
| }, | ||
| { | ||
| name: "type with confidence and rationale", | ||
| requestArgs: map[string]any{ | ||
| "owner": "owner", | ||
| "repo": "repo", | ||
| "issue_number": float64(1), | ||
| "issue_type": "feature", | ||
| "rationale": "Asks for dark mode support", | ||
| "confidence": "medium", | ||
| }, | ||
| expectedReq: map[string]any{ | ||
| "type": map[string]any{ | ||
| "value": "feature", | ||
| "rationale": "Asks for dark mode support", | ||
| "confidence": "medium", | ||
| }, | ||
| }, | ||
| }, | ||
| { | ||
| name: "type with low confidence triggers object form", | ||
| requestArgs: map[string]any{ | ||
| "owner": "owner", | ||
| "repo": "repo", | ||
| "issue_number": float64(1), | ||
| "issue_type": "bug", | ||
| "confidence": "low", | ||
| }, | ||
| expectedReq: map[string]any{ | ||
| "type": map[string]any{ | ||
| "value": "bug", | ||
| "confidence": "low", | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
| for _, tc := range tests { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ | ||
| PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq). | ||
| andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), | ||
| })) | ||
| deps := BaseDeps{Client: client} | ||
| serverTool := GranularUpdateIssueType(translations.NullTranslationHelper) | ||
| handler := serverTool.Handler(deps) | ||
| request := createMCPRequest(tc.requestArgs) | ||
| result, err := handler(ContextWithDeps(context.Background(), deps), &request) | ||
| require.NoError(t, err) | ||
| assert.False(t, result.IsError) | ||
| }) | ||
| } | ||
| } | ||
| func TestGranularUpdateIssueTypeInvalidConfidence(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| requestArgs map[string]any | ||
| expectedErrText string | ||
| }{ | ||
| { | ||
| name: "invalid confidence value", | ||
| requestArgs: map[string]any{ | ||
| "owner": "owner", | ||
| "repo": "repo", | ||
| "issue_number": float64(1), | ||
| "issue_type": "bug", | ||
| "confidence": "very_high", | ||
| }, | ||
| expectedErrText: "confidence must be one of: low, medium, high", | ||
| }, | ||
| { | ||
| name: "confidence wrong type", | ||
| requestArgs: map[string]any{ | ||
| "owner": "owner", | ||
| "repo": "repo", | ||
| "issue_number": float64(1), | ||
| "issue_type": "bug", | ||
| "confidence": float64(85), | ||
| }, | ||
| expectedErrText: "parameter confidence is not of type string", | ||
| }, | ||
| } | ||
| for _, tc := range tests { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(nil))} | ||
| serverTool := GranularUpdateIssueType(translations.NullTranslationHelper) | ||
| handler := serverTool.Handler(deps) | ||
| request := createMCPRequest(tc.requestArgs) | ||
| result, err := handler(ContextWithDeps(context.Background(), deps), &request) | ||
| require.NoError(t, err) | ||
| errorContent := getErrorResult(t, result) | ||
| assert.Contains(t, errorContent.Text, tc.expectedErrText) | ||
| }) | ||
| } | ||
| } | ||
| func TestGranularUpdateIssueState(t *testing.T) { | ||
@@ -778,3 +985,6 @@ tests := []struct { | ||
| client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ | ||
| PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &gogithub.PullRequest{Number: gogithub.Ptr(1)}), | ||
| PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ | ||
| "reviewers": []any{"user1"}, | ||
| "team_reviewers": []any{"team1"}, | ||
| }).andThen(mockResponse(t, http.StatusOK, &gogithub.PullRequest{Number: gogithub.Ptr(1)})), | ||
| })) | ||
@@ -789,3 +999,3 @@ deps := BaseDeps{Client: client} | ||
| "pullNumber": float64(1), | ||
| "reviewers": []string{"user1", "user2"}, | ||
| "reviewers": []string{"user1", "owner/team1"}, | ||
| }) | ||
@@ -1393,2 +1603,116 @@ result, err := handler(ContextWithDeps(context.Background(), deps), &request) | ||
| t.Run("successful set with confidence", func(t *testing.T) { | ||
| confidence := "high" | ||
| matchers := []githubv4mock.Matcher{ | ||
| githubv4mock.NewQueryMatcher( | ||
| struct { | ||
| Repository struct { | ||
| Issue struct { | ||
| ID githubv4.ID | ||
| } `graphql:"issue(number: $issueNumber)"` | ||
| } `graphql:"repository(owner: $owner, name: $repo)"` | ||
| }{}, | ||
| map[string]any{ | ||
| "owner": githubv4.String("owner"), | ||
| "repo": githubv4.String("repo"), | ||
| "issueNumber": githubv4.Int(5), | ||
| }, | ||
| githubv4mock.DataResponse(map[string]any{ | ||
| "repository": map[string]any{ | ||
| "issue": map[string]any{"id": "ISSUE_123"}, | ||
| }, | ||
| }), | ||
| ), | ||
| githubv4mock.NewMutationMatcher( | ||
| struct { | ||
| SetIssueFieldValue struct { | ||
| Issue struct { | ||
| ID githubv4.ID | ||
| Number githubv4.Int | ||
| URL githubv4.String | ||
| } | ||
| IssueFieldValues []struct { | ||
| TextValue struct { | ||
| Value string | ||
| } `graphql:"... on IssueFieldTextValue"` | ||
| SingleSelectValue struct { | ||
| Name string | ||
| } `graphql:"... on IssueFieldSingleSelectValue"` | ||
| DateValue struct { | ||
| Value string | ||
| } `graphql:"... on IssueFieldDateValue"` | ||
| NumberValue struct { | ||
| Value float64 | ||
| } `graphql:"... on IssueFieldNumberValue"` | ||
| } | ||
| } `graphql:"setIssueFieldValue(input: $input)"` | ||
| }{}, | ||
| SetIssueFieldValueInput{ | ||
| IssueID: githubv4.ID("ISSUE_123"), | ||
| IssueFields: []IssueFieldCreateOrUpdateInput{ | ||
| { | ||
| FieldID: githubv4.ID("FIELD_1"), | ||
| TextValue: githubv4.NewString(githubv4.String("hello")), | ||
| Confidence: &confidence, | ||
| }, | ||
| }, | ||
| }, | ||
| nil, | ||
| githubv4mock.DataResponse(map[string]any{ | ||
| "setIssueFieldValue": map[string]any{ | ||
| "issue": map[string]any{ | ||
| "id": "ISSUE_123", | ||
| "number": 5, | ||
| "url": "https://github.com/owner/repo/issues/5", | ||
| }, | ||
| }, | ||
| }), | ||
| ), | ||
| } | ||
| gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matchers...)) | ||
| deps := BaseDeps{GQLClient: gqlClient} | ||
| serverTool := GranularSetIssueFields(translations.NullTranslationHelper) | ||
| handler := serverTool.Handler(deps) | ||
| request := createMCPRequest(map[string]any{ | ||
| "owner": "owner", | ||
| "repo": "repo", | ||
| "issue_number": float64(5), | ||
| "fields": []any{ | ||
| map[string]any{ | ||
| "field_id": "FIELD_1", | ||
| "text_value": "hello", | ||
| "confidence": "high", | ||
| }, | ||
| }, | ||
| }) | ||
| result, err := handler(ContextWithDeps(context.Background(), deps), &request) | ||
| require.NoError(t, err) | ||
| assert.False(t, result.IsError) | ||
| }) | ||
| t.Run("invalid confidence value returns error", func(t *testing.T) { | ||
| deps := BaseDeps{} | ||
| serverTool := GranularSetIssueFields(translations.NullTranslationHelper) | ||
| handler := serverTool.Handler(deps) | ||
| request := createMCPRequest(map[string]any{ | ||
| "owner": "owner", | ||
| "repo": "repo", | ||
| "issue_number": float64(5), | ||
| "fields": []any{ | ||
| map[string]any{ | ||
| "field_id": "FIELD_1", | ||
| "text_value": "hello", | ||
| "confidence": "very_high", | ||
| }, | ||
| }, | ||
| }) | ||
| result, err := handler(ContextWithDeps(context.Background(), deps), &request) | ||
| require.NoError(t, err) | ||
| textContent := getTextResult(t, result) | ||
| assert.Contains(t, textContent.Text, "confidence must be one of: low, medium, high") | ||
| }) | ||
| t.Run("successful set with suggest flag", func(t *testing.T) { | ||
@@ -1395,0 +1719,0 @@ suggestTrue := githubv4.Boolean(true) |
@@ -261,8 +261,9 @@ package github | ||
| // labelWithRationale represents the object form of a label entry, allowing a | ||
| // rationale and/or suggest flag to be sent alongside the label name. | ||
| type labelWithRationale struct { | ||
| Name string `json:"name"` | ||
| Rationale string `json:"rationale,omitempty"` | ||
| Suggest bool `json:"suggest,omitempty"` | ||
| // labelWithIntent represents the object form of a label entry, allowing a | ||
| // rationale, confidence level, and/or suggest flag to be sent alongside the label name. | ||
| type labelWithIntent struct { | ||
| Name string `json:"name"` | ||
| Rationale string `json:"rationale,omitempty"` | ||
| Confidence string `json:"confidence,omitempty"` | ||
| Suggest bool `json:"suggest,omitempty"` | ||
| } | ||
@@ -272,3 +273,3 @@ | ||
| // where individual labels may optionally include a rationale. Each element of | ||
| // Labels is either a string (label name) or a labelWithRationale object. | ||
| // Labels is either a string (label name) or a labelWithIntent object. | ||
| type labelsUpdateRequest struct { | ||
@@ -284,3 +285,3 @@ Labels []any `json:"labels"` | ||
| Name: "update_issue_labels", | ||
| Description: t("TOOL_UPDATE_ISSUE_LABELS_DESCRIPTION", "Update the labels of an existing issue. This replaces the current labels with the provided list."), | ||
| Description: t("TOOL_UPDATE_ISSUE_LABELS_DESCRIPTION", "Update the labels of an existing issue. This replaces the current labels with the provided list. When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice."), | ||
| Annotations: &mcp.ToolAnnotations{ | ||
@@ -327,2 +328,7 @@ Title: t("TOOL_UPDATE_ISSUE_LABELS_USER_TITLE", "Update Issue Labels"), | ||
| }, | ||
| "confidence": { | ||
| Type: "string", | ||
| Description: "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.", | ||
| Enum: []any{"low", "medium", "high"}, | ||
| }, | ||
| "is_suggestion": { | ||
@@ -394,2 +400,9 @@ Type: "boolean", | ||
| } | ||
| confidence, err := OptionalParam[string](v, "confidence") | ||
| if err != nil { | ||
| return utils.NewToolResultError(err.Error()), nil, nil | ||
| } | ||
| if confidence != "" && confidence != "low" && confidence != "medium" && confidence != "high" { | ||
| return utils.NewToolResultError("confidence must be one of: low, medium, high"), nil, nil | ||
| } | ||
| isSuggestion, err := OptionalParam[bool](v, "is_suggestion") | ||
@@ -399,10 +412,10 @@ if err != nil { | ||
| } | ||
| if rationale == "" && !isSuggestion { | ||
| if rationale == "" && !isSuggestion && confidence == "" { | ||
| payload = append(payload, name) | ||
| } else { | ||
| useObjectForm = true | ||
| payload = append(payload, labelWithRationale{Name: name, Rationale: rationale, Suggest: isSuggestion}) | ||
| payload = append(payload, labelWithIntent{Name: name, Rationale: rationale, Confidence: confidence, Suggest: isSuggestion}) | ||
| } | ||
| default: | ||
| return utils.NewToolResultError("each label must be a string or an object with 'name' and optional 'rationale' and/or 'is_suggestion'"), nil, nil | ||
| return utils.NewToolResultError("each label must be a string or an object with 'name' and optional 'rationale', 'confidence', and/or 'is_suggestion'"), nil, nil | ||
| } | ||
@@ -479,14 +492,15 @@ } | ||
| // issueTypeWithRationale represents the object form of the issue type field, | ||
| // allowing a rationale and/or suggest flag to be sent alongside the type name. | ||
| type issueTypeWithRationale struct { | ||
| Value string `json:"value"` | ||
| Rationale string `json:"rationale,omitempty"` | ||
| Suggest bool `json:"suggest,omitempty"` | ||
| // issueTypeWithIntent represents the object form of the issue type field, | ||
| // allowing a rationale, confidence level, and/or suggest flag to be sent alongside the type name. | ||
| type issueTypeWithIntent struct { | ||
| Value string `json:"value"` | ||
| Rationale string `json:"rationale,omitempty"` | ||
| Confidence string `json:"confidence,omitempty"` | ||
| Suggest bool `json:"suggest,omitempty"` | ||
| } | ||
| // issueTypeUpdateRequest is a custom request body for updating an issue type | ||
| // with an optional rationale, using the object form that the REST API accepts. | ||
| // with optional intent metadata, using the object form that the REST API accepts. | ||
| type issueTypeUpdateRequest struct { | ||
| Type issueTypeWithRationale `json:"type"` | ||
| Type issueTypeWithIntent `json:"type"` | ||
| } | ||
@@ -500,3 +514,3 @@ | ||
| Name: "update_issue_type", | ||
| Description: t("TOOL_UPDATE_ISSUE_TYPE_DESCRIPTION", "Update the type of an existing issue (e.g. 'bug', 'feature')."), | ||
| Description: t("TOOL_UPDATE_ISSUE_TYPE_DESCRIPTION", "Update the type of an existing issue (e.g. 'bug', 'feature'). When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice."), | ||
| Annotations: &mcp.ToolAnnotations{ | ||
@@ -534,2 +548,7 @@ Title: t("TOOL_UPDATE_ISSUE_TYPE_USER_TITLE", "Update Issue Type"), | ||
| }, | ||
| "confidence": { | ||
| Type: "string", | ||
| Description: "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.", | ||
| Enum: []any{"low", "medium", "high"}, | ||
| }, | ||
| "is_suggestion": { | ||
@@ -570,2 +589,9 @@ Type: "boolean", | ||
| } | ||
| confidence, err := OptionalParam[string](args, "confidence") | ||
| if err != nil { | ||
| return utils.NewToolResultError(err.Error()), nil, nil | ||
| } | ||
| if confidence != "" && confidence != "low" && confidence != "medium" && confidence != "high" { | ||
| return utils.NewToolResultError("confidence must be one of: low, medium, high"), nil, nil | ||
| } | ||
| isSuggestion, err := OptionalParam[bool](args, "is_suggestion") | ||
@@ -582,8 +608,9 @@ if err != nil { | ||
| var body any | ||
| if rationale != "" || isSuggestion { | ||
| if rationale != "" || isSuggestion || confidence != "" { | ||
| body = &issueTypeUpdateRequest{ | ||
| Type: issueTypeWithRationale{ | ||
| Value: issueType, | ||
| Rationale: rationale, | ||
| Suggest: isSuggestion, | ||
| Type: issueTypeWithIntent{ | ||
| Value: issueType, | ||
| Rationale: rationale, | ||
| Confidence: confidence, | ||
| Suggest: isSuggestion, | ||
| }, | ||
@@ -901,2 +928,3 @@ } | ||
| Rationale *githubv4.String `json:"rationale,omitempty"` | ||
| Confidence *string `json:"confidence,omitempty"` | ||
| Suggest *githubv4.Boolean `json:"suggest,omitempty"` | ||
@@ -911,3 +939,3 @@ } | ||
| Name: "set_issue_fields", | ||
| Description: t("TOOL_SET_ISSUE_FIELDS_DESCRIPTION", "Set issue field values for an issue. Fields are organization-level custom fields (text, number, date, or single select). Use this to create or update field values on an issue."), | ||
| Description: t("TOOL_SET_ISSUE_FIELDS_DESCRIPTION", "Set issue field values for an issue. Fields are organization-level custom fields (text, number, date, or single select). Use this to create or update field values on an issue. When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice."), | ||
| Annotations: &mcp.ToolAnnotations{ | ||
@@ -972,2 +1000,7 @@ Title: t("TOOL_SET_ISSUE_FIELDS_USER_TITLE", "Set Issue Fields"), | ||
| }, | ||
| "confidence": { | ||
| Type: "string", | ||
| Description: "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.", | ||
| Enum: []any{"low", "medium", "high"}, | ||
| }, | ||
| "is_suggestion": { | ||
@@ -1090,2 +1123,13 @@ Type: "boolean", | ||
| confidence, err := OptionalParam[string](fieldMap, "confidence") | ||
| if err != nil { | ||
| return utils.NewToolResultError(err.Error()), nil, nil | ||
| } | ||
| if confidence != "" && confidence != "low" && confidence != "medium" && confidence != "high" { | ||
| return utils.NewToolResultError("confidence must be one of: low, medium, high"), nil, nil | ||
| } | ||
| if confidence != "" { | ||
| input.Confidence = &confidence | ||
| } | ||
| isSuggestion, err := OptionalParam[bool](fieldMap, "is_suggestion") | ||
@@ -1092,0 +1136,0 @@ if err != nil { |
@@ -111,2 +111,3 @@ package github | ||
| Changes int `json:"changes,omitempty"` | ||
| Patch string `json:"patch,omitempty"` | ||
| } | ||
@@ -1467,4 +1468,30 @@ | ||
| // convertToMinimalCommit converts a GitHub API RepositoryCommit to MinimalCommit | ||
| func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) MinimalCommit { | ||
| // commitDetail controls how much per-file information convertToMinimalCommit | ||
| // includes in its output. | ||
| type commitDetail string | ||
| const ( | ||
| // commitDetailNone omits Stats and Files entirely. | ||
| commitDetailNone commitDetail = "none" | ||
| // commitDetailStats includes Stats and Files with metadata only | ||
| // (filename, status, additions, deletions, changes) but no patch text. | ||
| commitDetailStats commitDetail = "stats" | ||
| // commitDetailFullPatch additionally includes the unified diff for each file. | ||
| commitDetailFullPatch commitDetail = "full_patch" | ||
| ) | ||
| // parseCommitDetail validates the user-supplied detail value and returns the | ||
| // default (stats) when the value is empty. | ||
| func parseCommitDetail(s string) (commitDetail, error) { | ||
| switch s { | ||
| case "": | ||
| return commitDetailStats, nil | ||
| case string(commitDetailNone), string(commitDetailStats), string(commitDetailFullPatch): | ||
| return commitDetail(s), nil | ||
| default: | ||
| return "", fmt.Errorf("invalid detail %q: must be one of \"none\", \"stats\", \"full_patch\"", s) | ||
| } | ||
| } | ||
| func convertToMinimalCommit(commit *github.RepositoryCommit, detail commitDetail) MinimalCommit { | ||
| minimalCommit := newMinimalCommitFromCore( | ||
@@ -1478,24 +1505,28 @@ commit.GetSHA(), | ||
| // Only include stats and files if includeDiffs is true | ||
| if includeDiffs { | ||
| if commit.Stats != nil { | ||
| minimalCommit.Stats = &MinimalCommitStats{ | ||
| Additions: commit.Stats.GetAdditions(), | ||
| Deletions: commit.Stats.GetDeletions(), | ||
| Total: commit.Stats.GetTotal(), | ||
| } | ||
| if detail == commitDetailNone { | ||
| return minimalCommit | ||
| } | ||
| if commit.Stats != nil { | ||
| minimalCommit.Stats = &MinimalCommitStats{ | ||
| Additions: commit.Stats.GetAdditions(), | ||
| Deletions: commit.Stats.GetDeletions(), | ||
| Total: commit.Stats.GetTotal(), | ||
| } | ||
| } | ||
| if len(commit.Files) > 0 { | ||
| minimalCommit.Files = make([]MinimalCommitFile, 0, len(commit.Files)) | ||
| for _, file := range commit.Files { | ||
| minimalFile := MinimalCommitFile{ | ||
| Filename: file.GetFilename(), | ||
| Status: file.GetStatus(), | ||
| Additions: file.GetAdditions(), | ||
| Deletions: file.GetDeletions(), | ||
| Changes: file.GetChanges(), | ||
| } | ||
| minimalCommit.Files = append(minimalCommit.Files, minimalFile) | ||
| if len(commit.Files) > 0 { | ||
| minimalCommit.Files = make([]MinimalCommitFile, 0, len(commit.Files)) | ||
| for _, file := range commit.Files { | ||
| minimalFile := MinimalCommitFile{ | ||
| Filename: file.GetFilename(), | ||
| Status: file.GetStatus(), | ||
| Additions: file.GetAdditions(), | ||
| Deletions: file.GetDeletions(), | ||
| Changes: file.GetChanges(), | ||
| } | ||
| if detail == commitDetailFullPatch { | ||
| minimalFile.Patch = file.GetPatch() | ||
| } | ||
| minimalCommit.Files = append(minimalCommit.Files, minimalFile) | ||
| } | ||
@@ -1502,0 +1533,0 @@ } |
@@ -633,3 +633,4 @@ package github | ||
| Item struct { | ||
| ID githubv4.ID | ||
| ID githubv4.ID | ||
| FullDatabaseID string `graphql:"fullDatabaseId"` | ||
| } | ||
@@ -646,3 +647,4 @@ } `graphql:"addProjectV2ItemById(input: $input)"` | ||
| "item": map[string]any{ | ||
| "id": "PVTI_item1", | ||
| "id": "PVTI_item1", | ||
| "fullDatabaseId": "1001", | ||
| }, | ||
@@ -679,2 +681,4 @@ }, | ||
| assert.NotNil(t, response["id"]) | ||
| assert.Equal(t, float64(1001), response["item_id"]) | ||
| assert.Equal(t, "1001", response["full_database_id"]) | ||
| assert.Contains(t, response["message"], "Successfully added") | ||
@@ -733,3 +737,4 @@ }) | ||
| Item struct { | ||
| ID githubv4.ID | ||
| ID githubv4.ID | ||
| FullDatabaseID string `graphql:"fullDatabaseId"` | ||
| } | ||
@@ -746,3 +751,4 @@ } `graphql:"addProjectV2ItemById(input: $input)"` | ||
| "item": map[string]any{ | ||
| "id": "PVTI_item2", | ||
| "id": "PVTI_item2", | ||
| "fullDatabaseId": "1002", | ||
| }, | ||
@@ -779,2 +785,4 @@ }, | ||
| assert.NotNil(t, response["id"]) | ||
| assert.Equal(t, float64(1002), response["item_id"]) | ||
| assert.Equal(t, "1002", response["full_database_id"]) | ||
| assert.Contains(t, response["message"], "Successfully added") | ||
@@ -781,0 +789,0 @@ }) |
@@ -9,2 +9,3 @@ package github | ||
| "net/http" | ||
| "strconv" | ||
| "time" | ||
@@ -1129,3 +1130,4 @@ | ||
| Item struct { | ||
| ID githubv4.ID | ||
| ID githubv4.ID | ||
| FullDatabaseID string `graphql:"fullDatabaseId"` | ||
| } | ||
@@ -1156,2 +1158,8 @@ } `graphql:"addProjectV2ItemById(input: $input)"` | ||
| } | ||
| if fullDatabaseID := mutation.AddProjectV2ItemByID.Item.FullDatabaseID; fullDatabaseID != "" { | ||
| result["full_database_id"] = fullDatabaseID | ||
| if itemID, err := strconv.ParseInt(fullDatabaseID, 10, 64); err == nil { | ||
| result["item_id"] = itemID | ||
| } | ||
| } | ||
@@ -1158,0 +1166,0 @@ r, err := json.Marshal(result) |
@@ -300,3 +300,3 @@ package github | ||
| Type: "array", | ||
| Description: "GitHub usernames to request reviews from", | ||
| Description: "GitHub usernames or ORG/team-slug team reviewers to request reviews from", | ||
| Items: &jsonschema.Schema{Type: "string"}, | ||
@@ -329,2 +329,3 @@ }, | ||
| } | ||
| userReviewers, teamReviewers := splitPullRequestReviewers(reviewers) | ||
@@ -336,3 +337,6 @@ client, err := deps.GetClient(ctx) | ||
| pr, resp, err := client.PullRequests.RequestReviewers(ctx, owner, repo, pullNumber, gogithub.ReviewersRequest{Reviewers: reviewers}) | ||
| pr, resp, err := client.PullRequests.RequestReviewers(ctx, owner, repo, pullNumber, gogithub.ReviewersRequest{ | ||
| Reviewers: userReviewers, | ||
| TeamReviewers: teamReviewers, | ||
| }) | ||
| if err != nil { | ||
@@ -357,2 +361,18 @@ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to request reviewers", resp, err), nil, nil | ||
| func splitPullRequestReviewers(reviewers []string) ([]string, []string) { | ||
| userReviewers := make([]string, 0, len(reviewers)) | ||
| teamReviewers := make([]string, 0) | ||
| for _, reviewer := range reviewers { | ||
| org, team, ok := strings.Cut(reviewer, "/") | ||
| if ok && org != "" && team != "" && !strings.Contains(team, "/") { | ||
| teamReviewers = append(teamReviewers, team) | ||
| continue | ||
| } | ||
| userReviewers = append(userReviewers, reviewer) | ||
| } | ||
| return userReviewers, teamReviewers | ||
| } | ||
| // GranularCreatePullRequestReview creates a tool to create a PR review. | ||
@@ -359,0 +379,0 @@ func GranularCreatePullRequestReview(t translations.TranslationHelperFunc) inventory.ServerTool { |
@@ -36,2 +36,3 @@ import { StrictMode, useState, useCallback, useEffect } from "react"; | ||
| isUpdate, | ||
| openLink, | ||
| }: { | ||
@@ -43,2 +44,3 @@ issue: IssueResult; | ||
| isUpdate: boolean; | ||
| openLink: (url: string) => Promise<void>; | ||
| }) { | ||
@@ -92,2 +94,10 @@ const issueUrl = issue.html_url || issue.url || issue.URL || "#"; | ||
| rel="noopener noreferrer" | ||
| onClick={(e) => { | ||
| // MCP Apps run in a sandboxed iframe where a plain anchor may be | ||
| // blocked, so route the click through the host's open-link | ||
| // capability (falls back to window.open). | ||
| e.preventDefault(); | ||
| if (issueUrl === "#") return; | ||
| void openLink(issueUrl); | ||
| }} | ||
| style={{ | ||
@@ -127,3 +137,3 @@ fontWeight: 600, | ||
| const { app, error: appError, toolInput, callTool, hostContext, setModelContext } = useMcpApp({ | ||
| const { app, error: appError, toolInput, callTool, hostContext, setModelContext, openLink } = useMcpApp({ | ||
| appName: "github-mcp-server-issue-write", | ||
@@ -159,2 +169,3 @@ }); | ||
| const params: Record<string, unknown> = { | ||
| ...(toolInput as Record<string, unknown> | undefined), | ||
| method: isUpdateMode ? "update" : "create", | ||
@@ -212,3 +223,3 @@ owner, | ||
| } | ||
| }, [title, body, owner, repo, isUpdateMode, issueNumber, callTool, setModelContext]); | ||
| }, [title, body, owner, repo, isUpdateMode, issueNumber, toolInput, callTool, setModelContext]); | ||
@@ -240,2 +251,3 @@ const body_node = (() => { | ||
| isUpdate={isUpdateMode} | ||
| openLink={openLink} | ||
| /> | ||
@@ -242,0 +254,0 @@ ); |
@@ -39,2 +39,3 @@ import { StrictMode, useState, useCallback, useEffect } from "react"; | ||
| submittedTitle, | ||
| openLink, | ||
| }: { | ||
@@ -45,2 +46,3 @@ pr: PRResult; | ||
| submittedTitle: string; | ||
| openLink: (url: string) => Promise<void>; | ||
| }) { | ||
@@ -94,2 +96,10 @@ const prUrl = pr.html_url || pr.url || pr.URL || "#"; | ||
| rel="noopener noreferrer" | ||
| onClick={(e) => { | ||
| // MCP Apps run in a sandboxed iframe where a plain anchor may be | ||
| // blocked, so route the click through the host's open-link | ||
| // capability (falls back to window.open). | ||
| e.preventDefault(); | ||
| if (prUrl === "#") return; | ||
| void openLink(prUrl); | ||
| }} | ||
| style={{ | ||
@@ -132,3 +142,3 @@ fontWeight: 600, | ||
| const { app, error: appError, toolInput, callTool, hostContext, setModelContext } = useMcpApp({ | ||
| const { app, error: appError, toolInput, callTool, hostContext, setModelContext, openLink } = useMcpApp({ | ||
| appName: "github-mcp-server-create-pull-request", | ||
@@ -163,2 +173,3 @@ }); | ||
| const result = await callTool("create_pull_request", { | ||
| ...(toolInput as Record<string, unknown> | undefined), | ||
| owner, repo, | ||
@@ -201,3 +212,3 @@ title: title.trim(), | ||
| } | ||
| }, [title, body, owner, repo, head, base, isDraft, maintainerCanModify, callTool, setModelContext]); | ||
| }, [title, body, owner, repo, head, base, isDraft, maintainerCanModify, toolInput, callTool, setModelContext]); | ||
@@ -207,3 +218,3 @@ if (successPR) { | ||
| <AppProvider hostContext={hostContext}> | ||
| <SuccessView pr={successPR} owner={owner} repo={repo} submittedTitle={submittedTitle} /> | ||
| <SuccessView pr={successPR} owner={owner} repo={repo} submittedTitle={submittedTitle} openLink={openLink} /> | ||
| </AppProvider> | ||
@@ -210,0 +221,0 @@ ); |
@@ -109,3 +109,8 @@ import { useApp as useExtApp } from "@modelcontextprotocol/ext-apps/react"; | ||
| } | ||
| await app.openLink({ url }); | ||
| const result = await app.openLink({ url }); | ||
| // The host may deny the request (e.g. blocked domain or user cancelled). | ||
| // Fall back to a direct window.open so the link still works. | ||
| if (result?.isError) { | ||
| window.open(url, "_blank", "noopener,noreferrer"); | ||
| } | ||
| }, | ||
@@ -112,0 +117,0 @@ [app] |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display