diff --git a/README.md b/README.md index 68742752f..c81e7629b 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,7 @@ The following sets of tools are available (all are on by default): | `dependabot` | Dependabot tools | | `discussions` | GitHub Discussions related tools | | `experiments` | Experimental features that are not considered stable yet | +| `graphql` | GitHub GraphQL API tools for direct query execution | | `issues` | GitHub Issues related tools | | `notifications` | GitHub Notifications related tools | | `orgs` | GitHub Organization related tools | @@ -602,6 +603,16 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
+Graphql + +- **execute_graphql_query** - Execute GraphQL query + - `query`: The GraphQL query string to execute (string, required) + - `variables`: Variables for the GraphQL query (optional) (object, optional) + +
+ +
+ Issues - **add_issue_comment** - Add comment to issue diff --git a/docs/graphql-tools.md b/docs/graphql-tools.md new file mode 100644 index 000000000..9249441b3 --- /dev/null +++ b/docs/graphql-tools.md @@ -0,0 +1,78 @@ +# GraphQL Tools + +This document describes the GraphQL tools added to the GitHub MCP server that provide direct access to GitHub's GraphQL API. + +## Tools + +### execute_graphql_query + +Executes a GraphQL query against GitHub's API and returns the results. + +#### Parameters + +- `query` (required): The GraphQL query string to execute +- `variables` (optional): Variables for the GraphQL query as a JSON object + +#### Response + +Returns a JSON object with: + +- `query`: The original query string +- `variables`: The variables passed to the query +- `success`: Boolean indicating if the query executed successfully +- `data`: The GraphQL response data (if successful) +- `error`: Error message if execution failed +- `error_type`: Type of execution error (rate_limit, authentication, permission, not_found, execution_error) +- `graphql_errors`: Any GraphQL-specific errors from the response + +#### Example + +```json +{ + "query": "query { viewer { login } }", + "variables": {}, + "success": true, + "data": { + "viewer": { + "login": "username" + } + } +} +``` + +## Implementation Details + +### Execution + +The execution tool uses GitHub's REST client to make raw HTTP requests to the GraphQL endpoint (`/graphql`), allowing for arbitrary GraphQL query execution while maintaining proper authentication and error handling. + +### Error Handling + +The tool provides comprehensive error categorization: + +- **Syntax errors**: Malformed GraphQL syntax +- **Field errors**: References to non-existent fields +- **Type errors**: Type-related validation issues +- **Client errors**: Authentication or connectivity issues +- **Rate limit errors**: API rate limiting +- **Permission errors**: Access denied to resources +- **Not found errors**: Referenced resources don't exist + +## Usage with MCP + +This tool is part of the "graphql" toolset and can be enabled through the dynamic toolset system: + +1. Enable the graphql toolset: `enable_toolset` with name "graphql" +2. Use `execute_graphql_query` to run queries and get results + +## Testing + +The tool includes comprehensive tests covering: + +- Tool definition validation +- Required parameter checking +- Response format validation +- Variable handling +- Error categorization + +Run tests with: `go test -v ./pkg/github -run GraphQL` diff --git a/docs/remote-server.md b/docs/remote-server.md index c36124ecc..8d9dcfd9d 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -23,6 +23,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | | Discussions | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | | Experiments | Experimental features that are not considered stable yet | https://api.githubcopilot.com/mcp/x/experiments | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/experiments/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%2Freadonly%22%7D) | +| GraphQL | GitHub GraphQL API tools for direct query execution | https://api.githubcopilot.com/mcp/x/graphql | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-graphql&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgraphql%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/graphql/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-graphql&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgraphql%2Freadonly%22%7D) | | Issues | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) | | Notifications | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) | | Organizations | GitHub Organization related tools | https://api.githubcopilot.com/mcp/x/orgs | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/orgs/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%2Freadonly%22%7D) | diff --git a/pkg/github/__toolsnaps__/execute_graphql_query.snap b/pkg/github/__toolsnaps__/execute_graphql_query.snap new file mode 100644 index 000000000..0fc44fc6a --- /dev/null +++ b/pkg/github/__toolsnaps__/execute_graphql_query.snap @@ -0,0 +1,25 @@ +{ + "annotations": { + "title": "Execute GraphQL query", + "readOnlyHint": false + }, + "description": "Execute a GraphQL query against GitHub's API and return the results.", + "inputSchema": { + "properties": { + "query": { + "description": "The GraphQL query string to execute", + "type": "string" + }, + "variables": { + "description": "Variables for the GraphQL query (optional)", + "properties": {}, + "type": "object" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "execute_graphql_query" +} \ No newline at end of file diff --git a/pkg/github/graphql_integration_test.go b/pkg/github/graphql_integration_test.go new file mode 100644 index 000000000..35eca4a2e --- /dev/null +++ b/pkg/github/graphql_integration_test.go @@ -0,0 +1,73 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGraphQLToolsIntegration tests that GraphQL tools can be created and called +func TestGraphQLToolsIntegration(t *testing.T) { + t.Parallel() + + // Create mock clients + mockHTTPClient := &http.Client{} + getClient := stubGetClientFromHTTPFn(mockHTTPClient) + + // Test that we can create execute tool without errors + t.Run("create_tools", func(t *testing.T) { + executeTool, executeHandler := ExecuteGraphQLQuery(getClient, translations.NullTranslationHelper) + + // Verify tool definitions + assert.Equal(t, "execute_graphql_query", executeTool.Name) + assert.NotNil(t, executeHandler) + + // Verify tool schemas have required fields + assert.Contains(t, executeTool.InputSchema.Properties, "query") + assert.Contains(t, executeTool.InputSchema.Properties, "variables") + + // Verify required parameters + assert.Contains(t, executeTool.InputSchema.Required, "query") + }) + + // Test basic invocation of execution tool + t.Run("invoke_execute_tool", func(t *testing.T) { + _, handler := ExecuteGraphQLQuery(getClient, translations.NullTranslationHelper) + + request := createMCPRequest(map[string]any{ + "query": `query { viewer { login } }`, + }) + + result, err := handler(context.Background(), request) + require.NoError(t, err) + require.NotNil(t, result) + + textContent := getTextResult(t, result) + var response map[string]interface{} + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + // Should have basic response structure + assert.Contains(t, response, "query") + assert.Contains(t, response, "variables") + assert.Contains(t, response, "success") + }) + + // Test error handling for missing required parameters + t.Run("error_handling", func(t *testing.T) { + _, executeHandler := ExecuteGraphQLQuery(getClient, translations.NullTranslationHelper) + + emptyRequest := createMCPRequest(map[string]any{}) + + // Execute tool should handle missing query parameter + executeResult, err := executeHandler(context.Background(), emptyRequest) + require.NoError(t, err) + textContent := getTextResult(t, executeResult) + assert.Contains(t, textContent.Text, "query") + }) +} diff --git a/pkg/github/graphql_tools.go b/pkg/github/graphql_tools.go new file mode 100644 index 000000000..038bcb12d --- /dev/null +++ b/pkg/github/graphql_tools.go @@ -0,0 +1,104 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// ExecuteGraphQLQuery creates a tool to execute a GraphQL query and return results +func ExecuteGraphQLQuery(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("execute_graphql_query", + mcp.WithDescription(t("TOOL_EXECUTE_GRAPHQL_QUERY_DESCRIPTION", "Execute a GraphQL query against GitHub's API and return the results.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_EXECUTE_GRAPHQL_QUERY_USER_TITLE", "Execute GraphQL query"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("query", + mcp.Required(), + mcp.Description("The GraphQL query string to execute"), + ), + mcp.WithObject("variables", + mcp.Description("Variables for the GraphQL query (optional)"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + queryStr, err := RequiredParam[string](request, "query") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + variables, _ := OptionalParam[map[string]interface{}](request, "variables") + if variables == nil { + variables = make(map[string]interface{}) + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Create a GraphQL request payload + graphqlPayload := map[string]interface{}{ + "query": queryStr, + "variables": variables, + } + + // Use the underlying HTTP client to make a raw GraphQL request + req, err := client.NewRequest("POST", "graphql", graphqlPayload) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to create request: %v", err)), nil + } + + // Execute the request + var response map[string]interface{} + _, err = client.Do(ctx, req, &response) + + result := map[string]interface{}{ + "query": queryStr, + "variables": variables, + } + + if err != nil { + // Query execution failed + result["success"] = false + result["error"] = err.Error() + + // Try to categorize the error + errorStr := err.Error() + switch { + case strings.Contains(errorStr, "rate limit"): + result["error_type"] = "rate_limit" + case strings.Contains(errorStr, "unauthorized") || strings.Contains(errorStr, "authentication"): + result["error_type"] = "authentication" + case strings.Contains(errorStr, "permission") || strings.Contains(errorStr, "forbidden"): + result["error_type"] = "permission" + case strings.Contains(errorStr, "not found") || strings.Contains(errorStr, "Could not resolve") || strings.Contains(errorStr, "not exist"): + result["error_type"] = "not_found" + default: + result["error_type"] = "execution_error" + } + } else { + // Query executed successfully + result["success"] = true + result["data"] = response["data"] + + // Include any errors from the GraphQL response + if errors, ok := response["errors"]; ok { + result["graphql_errors"] = errors + } + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/pkg/github/graphql_tools_test.go b/pkg/github/graphql_tools_test.go new file mode 100644 index 000000000..aae0e88a9 --- /dev/null +++ b/pkg/github/graphql_tools_test.go @@ -0,0 +1,96 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExecuteGraphQLQuery(t *testing.T) { + t.Parallel() + + // Verify tool definition + mockClient := &http.Client{} + tool, _ := ExecuteGraphQLQuery(stubGetClientFromHTTPFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "execute_graphql_query", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "query") + assert.Contains(t, tool.InputSchema.Properties, "variables") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) + + // Test basic functionality + tests := []struct { + name string + requestArgs map[string]any + }{ + { + name: "basic query structure", + requestArgs: map[string]any{ + "query": `query { viewer { login } }`, + }, + }, + { + name: "query with variables", + requestArgs: map[string]any{ + "query": `query($login: String!) { user(login: $login) { login } }`, + "variables": map[string]any{ + "login": "testuser", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, handler := ExecuteGraphQLQuery(stubGetClientFromHTTPFn(mockClient), translations.NullTranslationHelper) + + request := createMCPRequest(tt.requestArgs) + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.NotNil(t, result) + + textContent := getTextResult(t, result) + var response map[string]interface{} + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + // Verify that the response contains the expected fields + assert.Equal(t, tt.requestArgs["query"], response["query"]) + if variables, ok := tt.requestArgs["variables"]; ok { + assert.Equal(t, variables, response["variables"]) + } + + // The response should have either success=true or success=false + _, hasSuccess := response["success"] + assert.True(t, hasSuccess, "Response should have 'success' field") + }) + } +} + +func TestGraphQLToolsRequiredParams(t *testing.T) { + t.Parallel() + + t.Run("ExecuteGraphQLQuery requires query parameter", func(t *testing.T) { + mockClient := &http.Client{} + _, handler := ExecuteGraphQLQuery(stubGetClientFromHTTPFn(mockClient), translations.NullTranslationHelper) + + request := createMCPRequest(map[string]any{}) + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.NotNil(t, result) + + // Should return an error result for missing required parameter + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "query") + }) +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index a469b7678..d00b4fa24 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -152,6 +152,11 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG // Keep experiments alive so the system doesn't error out when it's always enabled experiments := toolsets.NewToolset("experiments", "Experimental features that are not considered stable yet") + graphql := toolsets.NewToolset("graphql", "GitHub GraphQL API tools for direct query execution"). + AddWriteTools( + toolsets.NewServerTool(ExecuteGraphQLQuery(getClient, t)), + ) + contextTools := toolsets.NewToolset("context", "Tools that provide context about the current user and GitHub context you are operating in"). AddReadTools( toolsets.NewServerTool(GetMe(getClient, t)), @@ -171,6 +176,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG tsg.AddToolset(notifications) tsg.AddToolset(experiments) tsg.AddToolset(discussions) + tsg.AddToolset(graphql) return tsg }