Files
Adam Holt a26079a4b1
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
Build and Test Go Project / build (macos-latest) (push) Has been cancelled
Build and Test Go Project / build (ubuntu-latest) (push) Has been cancelled
Build and Test Go Project / build (windows-latest) (push) Has been cancelled
Add open world hints for tools.
Tools that return user generated content should have open world hints,
to allow clients to inform users that the content may be untrusted and
prompt them to approve the request before executing it.
2025-12-30 13:47:36 +01:00

442 lines
13 KiB
Go

package github
import (
"context"
"encoding/json"
"fmt"
"strings"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/shurcooL/githubv4"
)
// GetLabel retrieves a specific label by name from a GitHub repository
func GetLabel(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool(
ToolsetMetadataIssues,
mcp.Tool{
Name: "get_label",
Description: t("TOOL_GET_LABEL_DESCRIPTION", "Get a specific label from a repository."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_GET_LABEL_TITLE", "Get a specific label from a repository."),
ReadOnlyHint: true,
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "Repository owner (username or organization name)",
},
"repo": {
Type: "string",
Description: "Repository name",
},
"name": {
Type: "string",
Description: "Label name.",
},
},
Required: []string{"owner", "repo", "name"},
},
},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
name, err := RequiredParam[string](args, "name")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
var query struct {
Repository struct {
Label struct {
ID githubv4.ID
Name githubv4.String
Color githubv4.String
Description githubv4.String
} `graphql:"label(name: $name)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
vars := map[string]any{
"owner": githubv4.String(owner),
"repo": githubv4.String(repo),
"name": githubv4.String(name),
}
client, err := deps.GetGQLClient(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
if err := client.Query(ctx, &query, vars); err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find label", err), nil, nil
}
if query.Repository.Label.Name == "" {
return utils.NewToolResultError(fmt.Sprintf("label '%s' not found in %s/%s", name, owner, repo)), nil, nil
}
label := map[string]any{
"id": fmt.Sprintf("%v", query.Repository.Label.ID),
"name": string(query.Repository.Label.Name),
"color": string(query.Repository.Label.Color),
"description": string(query.Repository.Label.Description),
}
out, err := json.Marshal(label)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal label: %w", err)
}
return utils.NewToolResultText(string(out)), nil, nil
},
)
}
// GetLabelForLabelsToolset returns the same GetLabel tool but registered in the labels toolset.
// This provides conformance with the original behavior where get_label was in both toolsets.
func GetLabelForLabelsToolset(t translations.TranslationHelperFunc) inventory.ServerTool {
tool := GetLabel(t)
tool.Toolset = ToolsetLabels
return tool
}
// ListLabels lists labels from a repository
func ListLabels(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool(
ToolsetLabels,
mcp.Tool{
Name: "list_label",
Description: t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository"),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository."),
ReadOnlyHint: true,
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "Repository owner (username or organization name) - required for all operations",
},
"repo": {
Type: "string",
Description: "Repository name - required for all operations",
},
},
Required: []string{"owner", "repo"},
},
},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetGQLClient(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
var query struct {
Repository struct {
Labels struct {
Nodes []struct {
ID githubv4.ID
Name githubv4.String
Color githubv4.String
Description githubv4.String
}
TotalCount githubv4.Int
} `graphql:"labels(first: 100)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
vars := map[string]any{
"owner": githubv4.String(owner),
"repo": githubv4.String(repo),
}
if err := client.Query(ctx, &query, vars); err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to list labels", err), nil, nil
}
labels := make([]map[string]any, len(query.Repository.Labels.Nodes))
for i, labelNode := range query.Repository.Labels.Nodes {
labels[i] = map[string]any{
"id": fmt.Sprintf("%v", labelNode.ID),
"name": string(labelNode.Name),
"color": string(labelNode.Color),
"description": string(labelNode.Description),
}
}
response := map[string]any{
"labels": labels,
"totalCount": int(query.Repository.Labels.TotalCount),
}
out, err := json.Marshal(response)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal labels: %w", err)
}
return utils.NewToolResultText(string(out)), nil, nil
},
)
}
// LabelWrite handles create, update, and delete operations for GitHub labels
func LabelWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool(
ToolsetLabels,
mcp.Tool{
Name: "label_write",
Description: t("TOOL_LABEL_WRITE_DESCRIPTION", "Perform write operations on repository labels. To set labels on issues, use the 'update_issue' tool."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_LABEL_WRITE_TITLE", "Write operations on repository labels."),
ReadOnlyHint: false,
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"method": {
Type: "string",
Description: "Operation to perform: 'create', 'update', or 'delete'",
Enum: []any{"create", "update", "delete"},
},
"owner": {
Type: "string",
Description: "Repository owner (username or organization name)",
},
"repo": {
Type: "string",
Description: "Repository name",
},
"name": {
Type: "string",
Description: "Label name - required for all operations",
},
"new_name": {
Type: "string",
Description: "New name for the label (used only with 'update' method to rename)",
},
"color": {
Type: "string",
Description: "Label color as 6-character hex code without '#' prefix (e.g., 'f29513'). Required for 'create', optional for 'update'.",
},
"description": {
Type: "string",
Description: "Label description text. Optional for 'create' and 'update'.",
},
},
Required: []string{"method", "owner", "repo", "name"},
},
},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
// Get and validate required parameters
method, err := RequiredParam[string](args, "method")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
method = strings.ToLower(method)
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
name, err := RequiredParam[string](args, "name")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
// Get optional parameters
newName, _ := OptionalParam[string](args, "new_name")
color, _ := OptionalParam[string](args, "color")
description, _ := OptionalParam[string](args, "description")
client, err := deps.GetGQLClient(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
switch method {
case "create":
// Validate required params for create
if color == "" {
return utils.NewToolResultError("color is required for create"), nil, nil
}
// Get repository ID
repoID, err := getRepositoryID(ctx, client, owner, repo)
if err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find repository", err), nil, nil
}
input := githubv4.CreateLabelInput{
RepositoryID: repoID,
Name: githubv4.String(name),
Color: githubv4.String(color),
}
if description != "" {
d := githubv4.String(description)
input.Description = &d
}
var mutation struct {
CreateLabel struct {
Label struct {
Name githubv4.String
ID githubv4.ID
}
} `graphql:"createLabel(input: $input)"`
}
if err := client.Mutate(ctx, &mutation, input, nil); err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to create label", err), nil, nil
}
return utils.NewToolResultText(fmt.Sprintf("label '%s' created successfully", mutation.CreateLabel.Label.Name)), nil, nil
case "update":
// Validate required params for update
if newName == "" && color == "" && description == "" {
return utils.NewToolResultError("at least one of new_name, color, or description must be provided for update"), nil, nil
}
// Get the label ID
labelID, err := getLabelID(ctx, client, owner, repo, name)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
input := githubv4.UpdateLabelInput{
ID: labelID,
}
if newName != "" {
n := githubv4.String(newName)
input.Name = &n
}
if color != "" {
c := githubv4.String(color)
input.Color = &c
}
if description != "" {
d := githubv4.String(description)
input.Description = &d
}
var mutation struct {
UpdateLabel struct {
Label struct {
Name githubv4.String
ID githubv4.ID
}
} `graphql:"updateLabel(input: $input)"`
}
if err := client.Mutate(ctx, &mutation, input, nil); err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to update label", err), nil, nil
}
return utils.NewToolResultText(fmt.Sprintf("label '%s' updated successfully", mutation.UpdateLabel.Label.Name)), nil, nil
case "delete":
// Get the label ID
labelID, err := getLabelID(ctx, client, owner, repo, name)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
input := githubv4.DeleteLabelInput{
ID: labelID,
}
var mutation struct {
DeleteLabel struct {
ClientMutationID githubv4.String
} `graphql:"deleteLabel(input: $input)"`
}
if err := client.Mutate(ctx, &mutation, input, nil); err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to delete label", err), nil, nil
}
return utils.NewToolResultText(fmt.Sprintf("label '%s' deleted successfully", name)), nil, nil
default:
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s. Supported methods are: create, update, delete", method)), nil, nil
}
},
)
}
// Helper function to get repository ID
func getRepositoryID(ctx context.Context, client *githubv4.Client, owner, repo string) (githubv4.ID, error) {
var repoQuery struct {
Repository struct {
ID githubv4.ID
} `graphql:"repository(owner: $owner, name: $repo)"`
}
vars := map[string]any{
"owner": githubv4.String(owner),
"repo": githubv4.String(repo),
}
if err := client.Query(ctx, &repoQuery, vars); err != nil {
return "", err
}
return repoQuery.Repository.ID, nil
}
// Helper function to get label by name
func getLabelID(ctx context.Context, client *githubv4.Client, owner, repo, labelName string) (githubv4.ID, error) {
var query struct {
Repository struct {
Label struct {
ID githubv4.ID
Name githubv4.String
} `graphql:"label(name: $name)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
vars := map[string]any{
"owner": githubv4.String(owner),
"repo": githubv4.String(repo),
"name": githubv4.String(labelName),
}
if err := client.Query(ctx, &query, vars); err != nil {
return "", err
}
if query.Repository.Label.Name == "" {
return "", fmt.Errorf("label '%s' not found in %s/%s", labelName, owner, repo)
}
return query.Repository.Label.ID, nil
}