Skip to content

Commit a8d0e99

Browse files
committed
Graphql toolset
1 parent 5904a03 commit a8d0e99

File tree

6 files changed

+381
-0
lines changed

6 files changed

+381
-0
lines changed

docs/graphql-tools.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# GraphQL Tools
2+
3+
This document describes the GraphQL tools added to the GitHub MCP server that provide direct access to GitHub's GraphQL API.
4+
5+
## Tools
6+
7+
### execute_graphql_query
8+
9+
Executes a GraphQL query against GitHub's API and returns the results.
10+
11+
#### Parameters
12+
13+
- `query` (required): The GraphQL query string to execute
14+
- `variables` (optional): Variables for the GraphQL query as a JSON object
15+
16+
#### Response
17+
18+
Returns a JSON object with:
19+
20+
- `query`: The original query string
21+
- `variables`: The variables passed to the query
22+
- `success`: Boolean indicating if the query executed successfully
23+
- `data`: The GraphQL response data (if successful)
24+
- `error`: Error message if execution failed
25+
- `error_type`: Type of execution error (rate_limit, authentication, permission, not_found, execution_error)
26+
- `graphql_errors`: Any GraphQL-specific errors from the response
27+
28+
#### Example
29+
30+
```json
31+
{
32+
"query": "query { viewer { login } }",
33+
"variables": {},
34+
"success": true,
35+
"data": {
36+
"viewer": {
37+
"login": "username"
38+
}
39+
}
40+
}
41+
```
42+
43+
## Implementation Details
44+
45+
### Execution
46+
47+
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.
48+
49+
### Error Handling
50+
51+
The tool provides comprehensive error categorization:
52+
53+
- **Syntax errors**: Malformed GraphQL syntax
54+
- **Field errors**: References to non-existent fields
55+
- **Type errors**: Type-related validation issues
56+
- **Client errors**: Authentication or connectivity issues
57+
- **Rate limit errors**: API rate limiting
58+
- **Permission errors**: Access denied to resources
59+
- **Not found errors**: Referenced resources don't exist
60+
61+
## Usage with MCP
62+
63+
This tool is part of the "graphql" toolset and can be enabled through the dynamic toolset system:
64+
65+
1. Enable the graphql toolset: `enable_toolset` with name "graphql"
66+
2. Use `execute_graphql_query` to run queries and get results
67+
68+
## Testing
69+
70+
The tool includes comprehensive tests covering:
71+
72+
- Tool definition validation
73+
- Required parameter checking
74+
- Response format validation
75+
- Variable handling
76+
- Error categorization
77+
78+
Run tests with: `go test -v ./pkg/github -run GraphQL`
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"annotations": {
3+
"title": "Execute GraphQL query",
4+
"readOnlyHint": false
5+
},
6+
"description": "Execute a GraphQL query against GitHub's API and return the results.",
7+
"inputSchema": {
8+
"properties": {
9+
"query": {
10+
"description": "The GraphQL query string to execute",
11+
"type": "string"
12+
},
13+
"variables": {
14+
"description": "Variables for the GraphQL query (optional)",
15+
"properties": {},
16+
"type": "object"
17+
}
18+
},
19+
"required": [
20+
"query"
21+
],
22+
"type": "object"
23+
},
24+
"name": "execute_graphql_query"
25+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"testing"
8+
9+
"github.com/github/github-mcp-server/pkg/translations"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// TestGraphQLToolsIntegration tests that GraphQL tools can be created and called
15+
func TestGraphQLToolsIntegration(t *testing.T) {
16+
t.Parallel()
17+
18+
// Create mock clients
19+
mockHTTPClient := &http.Client{}
20+
getClient := stubGetClientFromHTTPFn(mockHTTPClient)
21+
22+
// Test that we can create execute tool without errors
23+
t.Run("create_tools", func(t *testing.T) {
24+
executeTool, executeHandler := ExecuteGraphQLQuery(getClient, translations.NullTranslationHelper)
25+
26+
// Verify tool definitions
27+
assert.Equal(t, "execute_graphql_query", executeTool.Name)
28+
assert.NotNil(t, executeHandler)
29+
30+
// Verify tool schemas have required fields
31+
assert.Contains(t, executeTool.InputSchema.Properties, "query")
32+
assert.Contains(t, executeTool.InputSchema.Properties, "variables")
33+
34+
// Verify required parameters
35+
assert.Contains(t, executeTool.InputSchema.Required, "query")
36+
})
37+
38+
// Test basic invocation of execution tool
39+
t.Run("invoke_execute_tool", func(t *testing.T) {
40+
_, handler := ExecuteGraphQLQuery(getClient, translations.NullTranslationHelper)
41+
42+
request := createMCPRequest(map[string]any{
43+
"query": `query { viewer { login } }`,
44+
})
45+
46+
result, err := handler(context.Background(), request)
47+
require.NoError(t, err)
48+
require.NotNil(t, result)
49+
50+
textContent := getTextResult(t, result)
51+
var response map[string]interface{}
52+
err = json.Unmarshal([]byte(textContent.Text), &response)
53+
require.NoError(t, err)
54+
55+
// Should have basic response structure
56+
assert.Contains(t, response, "query")
57+
assert.Contains(t, response, "variables")
58+
assert.Contains(t, response, "success")
59+
})
60+
61+
// Test error handling for missing required parameters
62+
t.Run("error_handling", func(t *testing.T) {
63+
_, executeHandler := ExecuteGraphQLQuery(getClient, translations.NullTranslationHelper)
64+
65+
emptyRequest := createMCPRequest(map[string]any{})
66+
67+
// Execute tool should handle missing query parameter
68+
executeResult, err := executeHandler(context.Background(), emptyRequest)
69+
require.NoError(t, err)
70+
textContent := getTextResult(t, executeResult)
71+
assert.Contains(t, textContent.Text, "query")
72+
})
73+
}

pkg/github/graphql_tools.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"strings"
8+
9+
"github.com/github/github-mcp-server/pkg/translations"
10+
"github.com/mark3labs/mcp-go/mcp"
11+
"github.com/mark3labs/mcp-go/server"
12+
)
13+
14+
// ExecuteGraphQLQuery creates a tool to execute a GraphQL query and return results
15+
func ExecuteGraphQLQuery(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
16+
return mcp.NewTool("execute_graphql_query",
17+
mcp.WithDescription(t("TOOL_EXECUTE_GRAPHQL_QUERY_DESCRIPTION", "Execute a GraphQL query against GitHub's API and return the results.")),
18+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
19+
Title: t("TOOL_EXECUTE_GRAPHQL_QUERY_USER_TITLE", "Execute GraphQL query"),
20+
ReadOnlyHint: ToBoolPtr(false),
21+
}),
22+
mcp.WithString("query",
23+
mcp.Required(),
24+
mcp.Description("The GraphQL query string to execute"),
25+
),
26+
mcp.WithObject("variables",
27+
mcp.Description("Variables for the GraphQL query (optional)"),
28+
),
29+
),
30+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
31+
queryStr, err := RequiredParam[string](request, "query")
32+
if err != nil {
33+
return mcp.NewToolResultError(err.Error()), nil
34+
}
35+
36+
variables, _ := OptionalParam[map[string]interface{}](request, "variables")
37+
if variables == nil {
38+
variables = make(map[string]interface{})
39+
}
40+
41+
client, err := getClient(ctx)
42+
if err != nil {
43+
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
44+
}
45+
46+
// Create a GraphQL request payload
47+
graphqlPayload := map[string]interface{}{
48+
"query": queryStr,
49+
"variables": variables,
50+
}
51+
52+
// Use the underlying HTTP client to make a raw GraphQL request
53+
req, err := client.NewRequest("POST", "graphql", graphqlPayload)
54+
if err != nil {
55+
return mcp.NewToolResultError(fmt.Sprintf("failed to create request: %v", err)), nil
56+
}
57+
58+
// Execute the request
59+
var response map[string]interface{}
60+
_, err = client.Do(ctx, req, &response)
61+
62+
result := map[string]interface{}{
63+
"query": queryStr,
64+
"variables": variables,
65+
}
66+
67+
if err != nil {
68+
// Query execution failed
69+
result["success"] = false
70+
result["error"] = err.Error()
71+
72+
// Try to categorize the error
73+
errorStr := err.Error()
74+
if strings.Contains(errorStr, "rate limit") {
75+
result["error_type"] = "rate_limit"
76+
} else if strings.Contains(errorStr, "unauthorized") || strings.Contains(errorStr, "authentication") {
77+
result["error_type"] = "authentication"
78+
} else if strings.Contains(errorStr, "permission") || strings.Contains(errorStr, "forbidden") {
79+
result["error_type"] = "permission"
80+
} else if strings.Contains(errorStr, "not found") || strings.Contains(errorStr, "Could not resolve") || strings.Contains(errorStr, "not exist") {
81+
result["error_type"] = "not_found"
82+
} else {
83+
result["error_type"] = "execution_error"
84+
}
85+
} else {
86+
// Query executed successfully
87+
result["success"] = true
88+
result["data"] = response["data"]
89+
90+
// Include any errors from the GraphQL response
91+
if errors, ok := response["errors"]; ok {
92+
result["graphql_errors"] = errors
93+
}
94+
}
95+
96+
r, err := json.Marshal(result)
97+
if err != nil {
98+
return nil, fmt.Errorf("failed to marshal response: %w", err)
99+
}
100+
101+
return mcp.NewToolResultText(string(r)), nil
102+
}
103+
}

pkg/github/graphql_tools_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"testing"
8+
9+
"github.com/github/github-mcp-server/internal/toolsnaps"
10+
"github.com/github/github-mcp-server/pkg/translations"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestExecuteGraphQLQuery(t *testing.T) {
16+
t.Parallel()
17+
18+
// Verify tool definition
19+
mockClient := &http.Client{}
20+
tool, _ := ExecuteGraphQLQuery(stubGetClientFromHTTPFn(mockClient), translations.NullTranslationHelper)
21+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
22+
23+
assert.Equal(t, "execute_graphql_query", tool.Name)
24+
assert.NotEmpty(t, tool.Description)
25+
assert.Contains(t, tool.InputSchema.Properties, "query")
26+
assert.Contains(t, tool.InputSchema.Properties, "variables")
27+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"})
28+
29+
// Test basic functionality
30+
tests := []struct {
31+
name string
32+
requestArgs map[string]any
33+
}{
34+
{
35+
name: "basic query structure",
36+
requestArgs: map[string]any{
37+
"query": `query { viewer { login } }`,
38+
},
39+
},
40+
{
41+
name: "query with variables",
42+
requestArgs: map[string]any{
43+
"query": `query($login: String!) { user(login: $login) { login } }`,
44+
"variables": map[string]any{
45+
"login": "testuser",
46+
},
47+
},
48+
},
49+
}
50+
51+
for _, tt := range tests {
52+
t.Run(tt.name, func(t *testing.T) {
53+
_, handler := ExecuteGraphQLQuery(stubGetClientFromHTTPFn(mockClient), translations.NullTranslationHelper)
54+
55+
request := createMCPRequest(tt.requestArgs)
56+
result, err := handler(context.Background(), request)
57+
58+
require.NoError(t, err)
59+
require.NotNil(t, result)
60+
61+
textContent := getTextResult(t, result)
62+
var response map[string]interface{}
63+
err = json.Unmarshal([]byte(textContent.Text), &response)
64+
require.NoError(t, err)
65+
66+
// Verify that the response contains the expected fields
67+
assert.Equal(t, tt.requestArgs["query"], response["query"])
68+
if variables, ok := tt.requestArgs["variables"]; ok {
69+
assert.Equal(t, variables, response["variables"])
70+
}
71+
72+
// The response should have either success=true or success=false
73+
_, hasSuccess := response["success"]
74+
assert.True(t, hasSuccess, "Response should have 'success' field")
75+
})
76+
}
77+
}
78+
79+
func TestGraphQLToolsRequiredParams(t *testing.T) {
80+
t.Parallel()
81+
82+
t.Run("ExecuteGraphQLQuery requires query parameter", func(t *testing.T) {
83+
mockClient := &http.Client{}
84+
_, handler := ExecuteGraphQLQuery(stubGetClientFromHTTPFn(mockClient), translations.NullTranslationHelper)
85+
86+
request := createMCPRequest(map[string]any{})
87+
result, err := handler(context.Background(), request)
88+
89+
require.NoError(t, err)
90+
require.NotNil(t, result)
91+
92+
// Should return an error result for missing required parameter
93+
textContent := getTextResult(t, result)
94+
assert.Contains(t, textContent.Text, "query")
95+
})
96+
}

0 commit comments

Comments
 (0)