diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 546bd716b..d7f87521a 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -225,8 +225,12 @@ func generateToolDoc(tool mcp.Tool) string { lines = append(lines, fmt.Sprintf("- **%s** - %s", tool.Name, tool.Annotations.Title)) // Parameters + if tool.InputSchema == nil { + lines = append(lines, " - No parameters required") + return strings.Join(lines, "\n") + } schema, ok := tool.InputSchema.(*jsonschema.Schema) - if !ok { + if !ok || schema == nil { lines = append(lines, " - No parameters required") return strings.Join(lines, "\n") } diff --git a/pkg/github/__toolsnaps__/create_gist.snap b/pkg/github/__toolsnaps__/create_gist.snap new file mode 100644 index 000000000..465206ab4 --- /dev/null +++ b/pkg/github/__toolsnaps__/create_gist.snap @@ -0,0 +1,33 @@ +{ + "annotations": { + "title": "Create Gist" + }, + "description": "Create a new gist", + "inputSchema": { + "type": "object", + "required": [ + "filename", + "content" + ], + "properties": { + "content": { + "type": "string", + "description": "Content for simple single-file gist creation" + }, + "description": { + "type": "string", + "description": "Description of the gist" + }, + "filename": { + "type": "string", + "description": "Filename for simple single-file gist creation" + }, + "public": { + "type": "boolean", + "description": "Whether the gist is public", + "default": false + } + } + }, + "name": "create_gist" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_gist.snap b/pkg/github/__toolsnaps__/get_gist.snap new file mode 100644 index 000000000..4d2661822 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_gist.snap @@ -0,0 +1,20 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get Gist Content" + }, + "description": "Get gist content of a particular gist, by gist ID", + "inputSchema": { + "type": "object", + "required": [ + "gist_id" + ], + "properties": { + "gist_id": { + "type": "string", + "description": "The ID of the gist" + } + } + }, + "name": "get_gist" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_gists.snap b/pkg/github/__toolsnaps__/list_gists.snap new file mode 100644 index 000000000..834b45205 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_gists.snap @@ -0,0 +1,32 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List Gists" + }, + "description": "List gists for a user", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number for pagination (min 1)", + "minimum": 1 + }, + "perPage": { + "type": "number", + "description": "Results per page for pagination (min 1, max 100)", + "minimum": 1, + "maximum": 100 + }, + "since": { + "type": "string", + "description": "Only gists updated after this time (ISO 8601 timestamp)" + }, + "username": { + "type": "string", + "description": "GitHub username (omit for authenticated user's gists)" + } + } + }, + "name": "list_gists" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_gist.snap b/pkg/github/__toolsnaps__/update_gist.snap new file mode 100644 index 000000000..a3907a88c --- /dev/null +++ b/pkg/github/__toolsnaps__/update_gist.snap @@ -0,0 +1,33 @@ +{ + "annotations": { + "title": "Update Gist" + }, + "description": "Update an existing gist", + "inputSchema": { + "type": "object", + "required": [ + "gist_id", + "filename", + "content" + ], + "properties": { + "content": { + "type": "string", + "description": "Content for the file" + }, + "description": { + "type": "string", + "description": "Updated description of the gist" + }, + "filename": { + "type": "string", + "description": "Filename to update or create" + }, + "gist_id": { + "type": "string", + "description": "ID of the gist to update" + } + } + }, + "name": "update_gist" +} \ No newline at end of file diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 5f248934b..a66902ef2 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -36,61 +36,58 @@ type UserDetails struct { // GetMe creates a tool to get details of the authenticated user. func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { - tool := mcp.Tool{ - Name: "get_me", - Description: t("TOOL_GET_ME_DESCRIPTION", "Get details of the authenticated GitHub user. Use this when a request is about the user's own profile for GitHub. Or when information is missing to build other tool calls."), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_GET_ME_USER_TITLE", "Get my user profile"), - ReadOnlyHint: true, + return mcp.Tool{ + Name: "get_me", + Description: t("TOOL_GET_ME_DESCRIPTION", "Get details of the authenticated GitHub user. Use this when a request is about the user's own profile for GitHub. Or when information is missing to build other tool calls."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_ME_USER_TITLE", "Get my user profile"), + ReadOnlyHint: true, + }, }, - } - - handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *mcp.CallToolRequest, _ map[string]any) (*mcp.CallToolResult, any, error) { - client, err := getClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, err - } - - user, res, err := client.Users.Get(ctx, "") - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get user", - res, - err, - ), nil, err - } + mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *mcp.CallToolRequest, _ map[string]any) (*mcp.CallToolResult, any, error) { + client, err := getClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, err + } - // Create minimal user representation instead of returning full user object - minimalUser := MinimalUser{ - Login: user.GetLogin(), - ID: user.GetID(), - ProfileURL: user.GetHTMLURL(), - AvatarURL: user.GetAvatarURL(), - Details: &UserDetails{ - Name: user.GetName(), - Company: user.GetCompany(), - Blog: user.GetBlog(), - Location: user.GetLocation(), - Email: user.GetEmail(), - Hireable: user.GetHireable(), - Bio: user.GetBio(), - TwitterUsername: user.GetTwitterUsername(), - PublicRepos: user.GetPublicRepos(), - PublicGists: user.GetPublicGists(), - Followers: user.GetFollowers(), - Following: user.GetFollowing(), - CreatedAt: user.GetCreatedAt().Time, - UpdatedAt: user.GetUpdatedAt().Time, - PrivateGists: user.GetPrivateGists(), - TotalPrivateRepos: user.GetTotalPrivateRepos(), - OwnedPrivateRepos: user.GetOwnedPrivateRepos(), - }, - } + user, res, err := client.Users.Get(ctx, "") + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get user", + res, + err, + ), nil, err + } - return MarshalledTextResult(minimalUser), nil, nil - }) + // Create minimal user representation instead of returning full user object + minimalUser := MinimalUser{ + Login: user.GetLogin(), + ID: user.GetID(), + ProfileURL: user.GetHTMLURL(), + AvatarURL: user.GetAvatarURL(), + Details: &UserDetails{ + Name: user.GetName(), + Company: user.GetCompany(), + Blog: user.GetBlog(), + Location: user.GetLocation(), + Email: user.GetEmail(), + Hireable: user.GetHireable(), + Bio: user.GetBio(), + TwitterUsername: user.GetTwitterUsername(), + PublicRepos: user.GetPublicRepos(), + PublicGists: user.GetPublicGists(), + Followers: user.GetFollowers(), + Following: user.GetFollowing(), + CreatedAt: user.GetCreatedAt().Time, + UpdatedAt: user.GetUpdatedAt().Time, + PrivateGists: user.GetPrivateGists(), + TotalPrivateRepos: user.GetTotalPrivateRepos(), + OwnedPrivateRepos: user.GetOwnedPrivateRepos(), + }, + } - return tool, handler + return MarshalledTextResult(minimalUser), nil, nil + }) } type TeamInfo struct { diff --git a/pkg/github/gists.go b/pkg/github/gists.go index ccda72486..b54553aac 100644 --- a/pkg/github/gists.go +++ b/pkg/github/gists.go @@ -1,5 +1,3 @@ -//go:build ignore - package github import ( @@ -10,309 +8,353 @@ import ( "net/http" "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" ) // ListGists creates a tool to list gists for a user -func ListGists(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_gists", - mcp.WithDescription(t("TOOL_LIST_GISTS_DESCRIPTION", "List gists for a user")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_GISTS", "List Gists"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("username", - mcp.Description("GitHub username (omit for authenticated user's gists)"), - ), - mcp.WithString("since", - mcp.Description("Only gists updated after this time (ISO 8601 timestamp)"), - ), - WithPagination(), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - username, err := OptionalParam[string](request, "username") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - since, err := OptionalParam[string](request, "since") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } +func ListGists(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { + tool := mcp.Tool{ + Name: "list_gists", + Description: t("TOOL_LIST_GISTS_DESCRIPTION", "List gists for a user"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_LIST_GISTS", "List Gists"), + ReadOnlyHint: true, + }, + InputSchema: WithPagination(&jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "username": { + Type: "string", + Description: "GitHub username (omit for authenticated user's gists)", + }, + "since": { + Type: "string", + Description: "Only gists updated after this time (ISO 8601 timestamp)", + }, + }, + }), + } + + handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + username, err := OptionalParam[string](args, "username") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + since, err := OptionalParam[string](args, "since") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - opts := &github.GistListOptions{ - ListOptions: github.ListOptions{ - Page: pagination.Page, - PerPage: pagination.PerPage, - }, - } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - // Parse since timestamp if provided - if since != "" { - sinceTime, err := parseISOTimestamp(since) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid since timestamp: %v", err)), nil - } - opts.Since = sinceTime - } + opts := &github.GistListOptions{ + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } - client, err := getClient(ctx) + // Parse since timestamp if provided + if since != "" { + sinceTime, err := parseISOTimestamp(since) if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) + return utils.NewToolResultError(fmt.Sprintf("invalid since timestamp: %v", err)), nil, nil } + opts.Since = sinceTime + } - gists, resp, err := client.Gists.List(ctx, username, opts) - if err != nil { - return nil, fmt.Errorf("failed to list gists: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("failed to list gists: %s", string(body))), nil - } + client, err := getClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + gists, resp, err := client.Gists.List(ctx, username, opts) + if err != nil { + return nil, nil, fmt.Errorf("failed to list gists: %w", err) + } + defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(gists) + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } + return utils.NewToolResultError(fmt.Sprintf("failed to list gists: %s", string(body))), nil, nil + } - return mcp.NewToolResultText(string(r)), nil + r, err := json.Marshal(gists) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } + + return utils.NewToolResultText(string(r)), nil, nil + }) + + return tool, handler } // GetGist creates a tool to get the content of a gist -func GetGist(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_gist", - mcp.WithDescription(t("TOOL_GET_GIST_DESCRIPTION", "Get gist content of a particular gist, by gist ID")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_GIST", "Get Gist Content"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("gist_id", - mcp.Required(), - mcp.Description("The ID of the gist"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - gistID, err := RequiredParam[string](request, "gist_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } +func GetGist(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { + tool := mcp.Tool{ + Name: "get_gist", + Description: t("TOOL_GET_GIST_DESCRIPTION", "Get gist content of a particular gist, by gist ID"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_GIST", "Get Gist Content"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "gist_id": { + Type: "string", + Description: "The ID of the gist", + }, + }, + Required: []string{"gist_id"}, + }, + } + + handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + gistID, err := RequiredParam[string](args, "gist_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } + client, err := getClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } - gist, resp, err := client.Gists.Get(ctx, gistID) - if err != nil { - return nil, fmt.Errorf("failed to get gist: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("failed to get gist: %s", string(body))), nil - } + gist, resp, err := client.Gists.Get(ctx, gistID) + if err != nil { + return nil, nil, fmt.Errorf("failed to get gist: %w", err) + } + defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(gist) + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } + return utils.NewToolResultError(fmt.Sprintf("failed to get gist: %s", string(body))), nil, nil + } - return mcp.NewToolResultText(string(r)), nil + r, err := json.Marshal(gist) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } + + return utils.NewToolResultText(string(r)), nil, nil + }) + + return tool, handler } // CreateGist creates a tool to create a new gist -func CreateGist(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("create_gist", - mcp.WithDescription(t("TOOL_CREATE_GIST_DESCRIPTION", "Create a new gist")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_CREATE_GIST", "Create Gist"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("description", - mcp.Description("Description of the gist"), - ), - mcp.WithString("filename", - mcp.Required(), - mcp.Description("Filename for simple single-file gist creation"), - ), - mcp.WithString("content", - mcp.Required(), - mcp.Description("Content for simple single-file gist creation"), - ), - mcp.WithBoolean("public", - mcp.Description("Whether the gist is public"), - mcp.DefaultBool(false), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - description, err := OptionalParam[string](request, "description") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - filename, err := RequiredParam[string](request, "filename") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } +func CreateGist(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { + tool := mcp.Tool{ + Name: "create_gist", + Description: t("TOOL_CREATE_GIST_DESCRIPTION", "Create a new gist"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CREATE_GIST", "Create Gist"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "description": { + Type: "string", + Description: "Description of the gist", + }, + "filename": { + Type: "string", + Description: "Filename for simple single-file gist creation", + }, + "content": { + Type: "string", + Description: "Content for simple single-file gist creation", + }, + "public": { + Type: "boolean", + Description: "Whether the gist is public", + Default: json.RawMessage(`false`), + }, + }, + Required: []string{"filename", "content"}, + }, + } + + handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + description, err := OptionalParam[string](args, "description") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - content, err := RequiredParam[string](request, "content") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + filename, err := RequiredParam[string](args, "filename") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - public, err := OptionalParam[bool](request, "public") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + content, err := RequiredParam[string](args, "content") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - files := make(map[github.GistFilename]github.GistFile) - files[github.GistFilename(filename)] = github.GistFile{ - Filename: github.Ptr(filename), - Content: github.Ptr(content), - } + public, err := OptionalParam[bool](args, "public") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - gist := &github.Gist{ - Files: files, - Public: github.Ptr(public), - Description: github.Ptr(description), - } + files := make(map[github.GistFilename]github.GistFile) + files[github.GistFilename(filename)] = github.GistFile{ + Filename: github.Ptr(filename), + Content: github.Ptr(content), + } - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } + gist := &github.Gist{ + Files: files, + Public: github.Ptr(public), + Description: github.Ptr(description), + } - createdGist, resp, err := client.Gists.Create(ctx, gist) - if err != nil { - return nil, fmt.Errorf("failed to create gist: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusCreated { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("failed to create gist: %s", string(body))), nil - } + client, err := getClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } - minimalResponse := MinimalResponse{ - ID: createdGist.GetID(), - URL: createdGist.GetHTMLURL(), - } + createdGist, resp, err := client.Gists.Create(ctx, gist) + if err != nil { + return nil, nil, fmt.Errorf("failed to create gist: %w", err) + } + defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(minimalResponse) + if resp.StatusCode != http.StatusCreated { + body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } + return utils.NewToolResultError(fmt.Sprintf("failed to create gist: %s", string(body))), nil, nil + } + + minimalResponse := MinimalResponse{ + ID: createdGist.GetID(), + URL: createdGist.GetHTMLURL(), + } - return mcp.NewToolResultText(string(r)), nil + r, err := json.Marshal(minimalResponse) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } + + return utils.NewToolResultText(string(r)), nil, nil + }) + + return tool, handler } // UpdateGist creates a tool to edit an existing gist -func UpdateGist(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("update_gist", - mcp.WithDescription(t("TOOL_UPDATE_GIST_DESCRIPTION", "Update an existing gist")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_UPDATE_GIST", "Update Gist"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("gist_id", - mcp.Required(), - mcp.Description("ID of the gist to update"), - ), - mcp.WithString("description", - mcp.Description("Updated description of the gist"), - ), - mcp.WithString("filename", - mcp.Required(), - mcp.Description("Filename to update or create"), - ), - mcp.WithString("content", - mcp.Required(), - mcp.Description("Content for the file"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - gistID, err := RequiredParam[string](request, "gist_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - description, err := OptionalParam[string](request, "description") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } +func UpdateGist(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { + tool := mcp.Tool{ + Name: "update_gist", + Description: t("TOOL_UPDATE_GIST_DESCRIPTION", "Update an existing gist"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_UPDATE_GIST", "Update Gist"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "gist_id": { + Type: "string", + Description: "ID of the gist to update", + }, + "description": { + Type: "string", + Description: "Updated description of the gist", + }, + "filename": { + Type: "string", + Description: "Filename to update or create", + }, + "content": { + Type: "string", + Description: "Content for the file", + }, + }, + Required: []string{"gist_id", "filename", "content"}, + }, + } + + handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + gistID, err := RequiredParam[string](args, "gist_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - filename, err := RequiredParam[string](request, "filename") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + description, err := OptionalParam[string](args, "description") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - content, err := RequiredParam[string](request, "content") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + filename, err := RequiredParam[string](args, "filename") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - files := make(map[github.GistFilename]github.GistFile) - files[github.GistFilename(filename)] = github.GistFile{ - Filename: github.Ptr(filename), - Content: github.Ptr(content), - } + content, err := RequiredParam[string](args, "content") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - gist := &github.Gist{ - Files: files, - Description: github.Ptr(description), - } + files := make(map[github.GistFilename]github.GistFile) + files[github.GistFilename(filename)] = github.GistFile{ + Filename: github.Ptr(filename), + Content: github.Ptr(content), + } - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } + gist := &github.Gist{ + Files: files, + Description: github.Ptr(description), + } - updatedGist, resp, err := client.Gists.Edit(ctx, gistID, gist) - if err != nil { - return nil, fmt.Errorf("failed to update gist: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("failed to update gist: %s", string(body))), nil - } + client, err := getClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } - minimalResponse := MinimalResponse{ - ID: updatedGist.GetID(), - URL: updatedGist.GetHTMLURL(), - } + updatedGist, resp, err := client.Gists.Edit(ctx, gistID, gist) + if err != nil { + return nil, nil, fmt.Errorf("failed to update gist: %w", err) + } + defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(minimalResponse) + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } + return utils.NewToolResultError(fmt.Sprintf("failed to update gist: %s", string(body))), nil, nil + } + + minimalResponse := MinimalResponse{ + ID: updatedGist.GetID(), + URL: updatedGist.GetHTMLURL(), + } - return mcp.NewToolResultText(string(r)), nil + r, err := json.Marshal(minimalResponse) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } + + return utils.NewToolResultText(string(r)), nil, nil + }) + + return tool, handler } diff --git a/pkg/github/gists_test.go b/pkg/github/gists_test.go index fa40ba1af..f0f62f420 100644 --- a/pkg/github/gists_test.go +++ b/pkg/github/gists_test.go @@ -1,5 +1,3 @@ -//go:build ignore - package github import ( @@ -9,8 +7,10 @@ import ( "testing" "time" + "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v79/github" + "github.com/google/jsonschema-go/jsonschema" "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -21,13 +21,19 @@ func Test_ListGists(t *testing.T) { mockClient := github.NewClient(nil) tool, _ := ListGists(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + assert.Equal(t, "list_gists", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "username") - assert.Contains(t, tool.InputSchema.Properties, "since") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.Empty(t, tool.InputSchema.Required) + assert.True(t, tool.Annotations.ReadOnlyHint, "list_gists tool should be read-only") + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.Contains(t, schema.Properties, "username") + assert.Contains(t, schema.Properties, "since") + assert.Contains(t, schema.Properties, "page") + assert.Contains(t, schema.Properties, "perPage") + assert.Empty(t, schema.Required) // Setup mock gists for success case mockGists := []*github.Gist{ @@ -158,7 +164,7 @@ func Test_ListGists(t *testing.T) { request := createMCPRequest(tc.requestArgs) // Call handler - result, err := handler(context.Background(), request) + result, _, err := handler(context.Background(), &request, tc.requestArgs) // Verify results if tc.expectError { @@ -199,11 +205,17 @@ func Test_GetGist(t *testing.T) { mockClient := github.NewClient(nil) tool, _ := GetGist(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + assert.Equal(t, "get_gist", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "gist_id") + assert.True(t, tool.Annotations.ReadOnlyHint, "get_gist tool should be read-only") + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.Contains(t, schema.Properties, "gist_id") - assert.Contains(t, tool.InputSchema.Required, "gist_id") + assert.Contains(t, schema.Required, "gist_id") // Setup mock gist for success case mockGist := github.Gist{ @@ -270,7 +282,7 @@ func Test_GetGist(t *testing.T) { request := createMCPRequest(tc.requestArgs) // Call handler - result, err := handler(context.Background(), request) + result, _, err := handler(context.Background(), &request, tc.requestArgs) // Verify results if tc.expectError { @@ -308,16 +320,22 @@ func Test_CreateGist(t *testing.T) { mockClient := github.NewClient(nil) tool, _ := CreateGist(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + assert.Equal(t, "create_gist", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "description") - assert.Contains(t, tool.InputSchema.Properties, "filename") - assert.Contains(t, tool.InputSchema.Properties, "content") - assert.Contains(t, tool.InputSchema.Properties, "public") + assert.False(t, tool.Annotations.ReadOnlyHint, "create_gist tool should not be read-only") + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.Contains(t, schema.Properties, "description") + assert.Contains(t, schema.Properties, "filename") + assert.Contains(t, schema.Properties, "content") + assert.Contains(t, schema.Properties, "public") // Verify required parameters - assert.Contains(t, tool.InputSchema.Required, "filename") - assert.Contains(t, tool.InputSchema.Required, "content") + assert.Contains(t, schema.Required, "filename") + assert.Contains(t, schema.Required, "content") // Setup mock data for test cases createdGist := &github.Gist{ @@ -411,7 +429,7 @@ func Test_CreateGist(t *testing.T) { request := createMCPRequest(tc.requestArgs) // Call handler - result, err := handler(context.Background(), request) + result, _, err := handler(context.Background(), &request, tc.requestArgs) // Verify results if tc.expectError { @@ -447,17 +465,23 @@ func Test_UpdateGist(t *testing.T) { mockClient := github.NewClient(nil) tool, _ := UpdateGist(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + assert.Equal(t, "update_gist", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "gist_id") - assert.Contains(t, tool.InputSchema.Properties, "description") - assert.Contains(t, tool.InputSchema.Properties, "filename") - assert.Contains(t, tool.InputSchema.Properties, "content") + assert.False(t, tool.Annotations.ReadOnlyHint, "update_gist tool should not be read-only") + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.Contains(t, schema.Properties, "gist_id") + assert.Contains(t, schema.Properties, "description") + assert.Contains(t, schema.Properties, "filename") + assert.Contains(t, schema.Properties, "content") // Verify required parameters - assert.Contains(t, tool.InputSchema.Required, "gist_id") - assert.Contains(t, tool.InputSchema.Required, "filename") - assert.Contains(t, tool.InputSchema.Required, "content") + assert.Contains(t, schema.Required, "gist_id") + assert.Contains(t, schema.Required, "filename") + assert.Contains(t, schema.Required, "content") // Setup mock data for test cases updatedGist := &github.Gist{ @@ -565,7 +589,7 @@ func Test_UpdateGist(t *testing.T) { request := createMCPRequest(tc.requestArgs) // Call handler - result, err := handler(context.Background(), request) + result, _, err := handler(context.Background(), &request, tc.requestArgs) // Verify results if tc.expectError { diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 1e4627544..8a65568e0 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -117,7 +117,9 @@ func createMCPRequest(args any) mcp.CallToolRequest { } argsJSON, err := json.Marshal(argsMap) - require.NoError(nil, err) + if err != nil { + return mcp.CallToolRequest{} + } jsonRawMessage := json.RawMessage(argsJSON) diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index b06b333bc..fcc2a13d8 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -1,6 +1,11 @@ package github -import "github.com/google/go-github/v79/github" +import ( + "fmt" + "time" + + "github.com/google/go-github/v79/github" +) // MinimalUser is the output type for user and organization search results. type MinimalUser struct { @@ -256,3 +261,24 @@ func convertToMinimalBranch(branch *github.Branch) MinimalBranch { Protected: branch.GetProtected(), } } + +// parseISOTimestamp parses an ISO 8601 timestamp string +func parseISOTimestamp(timestamp string) (time.Time, error) { + if timestamp == "" { + return time.Time{}, fmt.Errorf("empty timestamp") + } + + // Try RFC3339 format (standard ISO 8601 with time) + t, err := time.Parse(time.RFC3339, timestamp) + if err == nil { + return t, nil + } + + // Try simple date format (YYYY-MM-DD) + t, err = time.Parse("2006-01-02", timestamp) + if err == nil { + return t, nil + } + + return time.Time{}, fmt.Errorf("invalid timestamp format: %s", timestamp) +} diff --git a/pkg/github/server.go b/pkg/github/server.go index f474d06b4..270fe9338 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -268,13 +268,13 @@ func OptionalBigIntArrayParam(args map[string]any, p string) ([]int64, error) { // https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api func WithPagination(schema *jsonschema.Schema) *jsonschema.Schema { schema.Properties["page"] = &jsonschema.Schema{ - Type: "Number", + Type: "number", Description: "Page number for pagination (min 1)", Minimum: jsonschema.Ptr(1.0), } schema.Properties["perPage"] = &jsonschema.Schema{ - Type: "Number", + Type: "number", Description: "Results per page for pagination (min 1, max 100)", Minimum: jsonschema.Ptr(1.0), Maximum: jsonschema.Ptr(100.0), @@ -287,20 +287,20 @@ func WithPagination(schema *jsonschema.Schema) *jsonschema.Schema { // GraphQL tools will use this and convert page/perPage to GraphQL cursor parameters internally. func WithUnifiedPagination(schema *jsonschema.Schema) *jsonschema.Schema { schema.Properties["page"] = &jsonschema.Schema{ - Type: "Number", + Type: "number", Description: "Page number for pagination (min 1)", Minimum: jsonschema.Ptr(1.0), } schema.Properties["perPage"] = &jsonschema.Schema{ - Type: "Number", + Type: "number", Description: "Results per page for pagination (min 1, max 100)", Minimum: jsonschema.Ptr(1.0), Maximum: jsonschema.Ptr(100.0), } schema.Properties["after"] = &jsonschema.Schema{ - Type: "String", + Type: "string", Description: "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", } @@ -310,14 +310,14 @@ func WithUnifiedPagination(schema *jsonschema.Schema) *jsonschema.Schema { // WithCursorPagination adds only cursor-based pagination parameters to a tool (no page parameter). func WithCursorPagination(schema *jsonschema.Schema) *jsonschema.Schema { schema.Properties["perPage"] = &jsonschema.Schema{ - Type: "Number", + Type: "number", Description: "Results per page for pagination (min 1, max 100)", Minimum: jsonschema.Ptr(1.0), Maximum: jsonschema.Ptr(100.0), } schema.Properties["after"] = &jsonschema.Schema{ - Type: "String", + Type: "string", Description: "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", } diff --git a/pkg/github/tools.go b/pkg/github/tools.go index c024a31e9..9da6491a1 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -313,15 +313,15 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(GetTeamMembers(getGQLClient, t)), ) - // gists := toolsets.NewToolset(ToolsetMetadataGists.ID, ToolsetMetadataGists.Description). - // AddReadTools( - // toolsets.NewServerTool(ListGists(getClient, t)), - // toolsets.NewServerTool(GetGist(getClient, t)), - // ). - // AddWriteTools( - // toolsets.NewServerTool(CreateGist(getClient, t)), - // toolsets.NewServerTool(UpdateGist(getClient, t)), - // ) + gists := toolsets.NewToolset(ToolsetMetadataGists.ID, ToolsetMetadataGists.Description). + AddReadTools( + toolsets.NewServerTool(ListGists(getClient, t)), + toolsets.NewServerTool(GetGist(getClient, t)), + ). + AddWriteTools( + toolsets.NewServerTool(CreateGist(getClient, t)), + toolsets.NewServerTool(UpdateGist(getClient, t)), + ) // projects := toolsets.NewToolset(ToolsetMetadataProjects.ID, ToolsetMetadataProjects.Description). // AddReadTools( @@ -372,7 +372,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG // tsg.AddToolset(notifications) // tsg.AddToolset(experiments) // tsg.AddToolset(discussions) - // tsg.AddToolset(gists) + tsg.AddToolset(gists) // tsg.AddToolset(securityAdvisories) // tsg.AddToolset(projects) // tsg.AddToolset(stargazers) diff --git a/pkg/log/io.go b/pkg/log/io.go index 0f034c2a4..deaf4b7ea 100644 --- a/pkg/log/io.go +++ b/pkg/log/io.go @@ -45,3 +45,17 @@ func (l *IOLogger) Write(p []byte) (n int, err error) { l.logger.Info("[stdout]: sending bytes", "count", len(p), "data", string(p)) return l.writer.Write(p) } + +func (l *IOLogger) Close() error { + var errReader, errWriter error + if closer, ok := l.reader.(io.Closer); ok { + errReader = closer.Close() + } + if closer, ok := l.writer.(io.Closer); ok { + errWriter = closer.Close() + } + if errReader != nil { + return errReader + } + return errWriter +}