diff --git a/cmd/mcpcurl/main.go b/cmd/mcpcurl/main.go index bc192587a..914d8fed7 100644 --- a/cmd/mcpcurl/main.go +++ b/cmd/mcpcurl/main.go @@ -16,6 +16,10 @@ import ( "github.com/spf13/viper" ) +const ( + stdioServerCmdFlag = "stdio-server-cmd" +) + type ( // SchemaResponse represents the top-level response containing tools SchemaResponse struct { @@ -107,9 +111,9 @@ var ( } // Check if the required global flag is provided - serverCmd, _ := cmd.Flags().GetString("stdio-server-cmd") + serverCmd, _ := cmd.Flags().GetString(stdioServerCmdFlag) if serverCmd == "" { - return fmt.Errorf("--stdio-server-cmd is required") + return fmt.Errorf("--%s is required", stdioServerCmdFlag) } return nil }, @@ -121,9 +125,9 @@ var ( Short: "Fetch schema from MCP server", Long: "Fetches the tools schema from the MCP server specified by --stdio-server-cmd", RunE: func(cmd *cobra.Command, _ []string) error { - serverCmd, _ := cmd.Flags().GetString("stdio-server-cmd") + serverCmd, _ := cmd.Flags().GetString(stdioServerCmdFlag) if serverCmd == "" { - return fmt.Errorf("--stdio-server-cmd is required") + return fmt.Errorf("--%s is required", stdioServerCmdFlag) } // Build the JSON-RPC request for tools/list @@ -156,8 +160,8 @@ func main() { rootCmd.AddCommand(schemaCmd) // Add global flag for stdio server command - rootCmd.PersistentFlags().String("stdio-server-cmd", "", "Shell command to invoke MCP server via stdio (required)") - _ = rootCmd.MarkPersistentFlagRequired("stdio-server-cmd") + rootCmd.PersistentFlags().String(stdioServerCmdFlag, "", "Shell command to invoke MCP server via stdio (required)") + _ = rootCmd.MarkPersistentFlagRequired(stdioServerCmdFlag) // Add global flag for pretty printing rootCmd.PersistentFlags().Bool("pretty", true, "Pretty print MCP response (only for JSON or JSONL responses)") @@ -175,23 +179,9 @@ func main() { os.Exit(1) } // Get server command - serverCmd, err := rootCmd.Flags().GetString("stdio-server-cmd") + serverCmd, err := rootCmd.Flags().GetString(stdioServerCmdFlag) if err == nil && serverCmd != "" { - // Fetch schema from server - jsonRequest, err := buildJSONRPCRequest("tools/list", "", nil) - if err == nil { - response, err := executeServerCommand(serverCmd, jsonRequest) - if err == nil { - // Parse the schema response - var schemaResp SchemaResponse - if err := json.Unmarshal([]byte(response), &schemaResp); err == nil { - // Add all the generated commands as subcommands of tools - for _, tool := range schemaResp.Result.Tools { - addCommandFromTool(toolsCmd, &tool, prettyPrint) - } - } - } - } + loadSchemaAndAddCommands(serverCmd, prettyPrint) } // Execute @@ -201,6 +191,22 @@ func main() { } } +// loadSchemaAndAddCommands fetches schema from server and adds tool commands +func loadSchemaAndAddCommands(serverCmd string, prettyPrint bool) { + jsonRequest, err := buildJSONRPCRequest("tools/list", "", nil) + if err == nil { + response, err := executeServerCommand(serverCmd, jsonRequest) + if err == nil { + var schemaResp SchemaResponse + if err := json.Unmarshal([]byte(response), &schemaResp); err == nil { + for _, tool := range schemaResp.Result.Tools { + addCommandFromTool(toolsCmd, &tool, prettyPrint) + } + } + } + } +} + // addCommandFromTool creates a cobra command from a tool schema func addCommandFromTool(toolsCmd *cobra.Command, tool *Tool, prettyPrint bool) { // Create command from tool @@ -222,9 +228,9 @@ func addCommandFromTool(toolsCmd *cobra.Command, tool *Tool, prettyPrint bool) { } // Execute the server command - serverCmd, err := cmd.Flags().GetString("stdio-server-cmd") + serverCmd, err := cmd.Flags().GetString(stdioServerCmdFlag) if err != nil { - _, _ = fmt.Fprintf(os.Stderr, "failed to get stdio-server-cmd: %v\n", err) + _, _ = fmt.Fprintf(os.Stderr, "failed to get %s: %v\n", stdioServerCmdFlag, err) return } response, err := executeServerCommand(serverCmd, jsonData) diff --git a/pkg/github/search.go b/pkg/github/search.go index 5106b84d8..25ecaf25c 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -13,164 +13,145 @@ import ( "github.com/mark3labs/mcp-go/server" ) -// SearchRepositories creates a tool to search for GitHub repositories. -func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("search_repositories", - mcp.WithDescription(t("TOOL_SEARCH_REPOSITORIES_DESCRIPTION", "Search for GitHub repositories")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_SEARCH_REPOSITORIES_USER_TITLE", "Search repositories"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("query", - mcp.Required(), - mcp.Description("Search query"), - ), - WithPagination(), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - query, err := RequiredParam[string](request, "query") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } +const ( + errFailedToReadResponseBody = "failed to read response body: %w" + errFailedToMarshalResponse = "failed to marshal response: %w" +) - opts := &github.SearchOptions{ - ListOptions: github.ListOptions{ - Page: pagination.page, - PerPage: pagination.perPage, - }, - } +func buildSearchTool(toolName, description, userTitle, queryDesc string, t translations.TranslationHelperFunc) mcp.Tool { + return mcp.NewTool(toolName, + mcp.WithDescription(t(description+"_DESCRIPTION", description)), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t(userTitle+"_USER_TITLE", userTitle), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("query", + mcp.Required(), + mcp.Description(queryDesc), + ), + mcp.WithString("sort", + mcp.Description("Sort field by category"), + mcp.Enum("followers", "repositories", "joined"), + ), + mcp.WithString("order", + mcp.Description("Sort order"), + mcp.Enum("asc", "desc"), + ), + WithPagination(), + ) +} - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - result, resp, err := client.Search.Repositories(ctx, query, opts) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to search repositories with query '%s'", query), - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() +func buildSearchHandler(searchType string, getClient GetClientFn, searchFunc func(context.Context, *github.Client, string, *github.SearchOptions) (interface{}, *github.Response, error)) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + query, sort, order, pagination, err := extractSearchParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - if resp.StatusCode != 200 { - 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 search repositories: %s", string(body))), nil - } + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } + opts := &github.SearchOptions{ + Sort: sort, + Order: order, + ListOptions: github.ListOptions{ + PerPage: pagination.perPage, + Page: pagination.page, + }, + } - return mcp.NewToolResultText(string(r)), nil + result, resp, err := searchFunc(ctx, client, query, opts) + if errorResult, err := handleSearchResponse(ctx, resp, err, fmt.Sprintf("failed to search %s with query '%s'", searchType, query)); errorResult != nil || err != nil { + return errorResult, err } -} -// SearchCode creates a tool to search for code across GitHub repositories. -func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("search_code", - mcp.WithDescription(t("TOOL_SEARCH_CODE_DESCRIPTION", "Search for code across GitHub repositories")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_SEARCH_CODE_USER_TITLE", "Search code"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("q", - mcp.Required(), - mcp.Description("Search query using GitHub code search syntax"), - ), - mcp.WithString("sort", - mcp.Description("Sort field ('indexed' only)"), - ), - mcp.WithString("order", - mcp.Description("Sort order"), - mcp.Enum("asc", "desc"), - ), - WithPagination(), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - query, err := RequiredParam[string](request, "q") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - sort, err := OptionalParam[string](request, "sort") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - order, err := OptionalParam[string](request, "order") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + return marshalAndReturnResult(result) + } +} - opts := &github.SearchOptions{ - Sort: sort, - Order: order, - ListOptions: github.ListOptions{ - PerPage: pagination.perPage, - Page: pagination.page, - }, - } +func handleSearchResponse(ctx context.Context, resp *github.Response, err error, operation string) (*mcp.CallToolResult, error) { + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, operation, resp, err), nil + } + defer func() { _ = resp.Body.Close() }() - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } + if resp.StatusCode != 200 { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf(errFailedToReadResponseBody, err) + } + return mcp.NewToolResultError(fmt.Sprintf("%s: %s", operation, string(body))), nil + } + return nil, nil +} - result, resp, err := client.Search.Code(ctx, query, opts) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to search code with query '%s'", query), - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() +func marshalAndReturnResult(result interface{}) (*mcp.CallToolResult, error) { + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf(errFailedToMarshalResponse, err) + } + return mcp.NewToolResultText(string(r)), nil +} - if resp.StatusCode != 200 { - 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 search code: %s", string(body))), nil - } +func extractSearchParams(request mcp.CallToolRequest) (query, sort, order string, pagination PaginationParams, err error) { + query, err = RequiredParam[string](request, "query") + if err != nil { + return "", "", "", PaginationParams{}, err + } + sort, _ = OptionalParam[string](request, "sort") + order, _ = OptionalParam[string](request, "order") + pagination, err = OptionalPaginationParams(request) + return query, sort, order, pagination, err +} - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } +// SearchRepositories creates a tool to search for GitHub repositories. +func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + tool = mcp.NewTool("search_repositories", + mcp.WithDescription(t("TOOL_SEARCH_REPOSITORIES_DESCRIPTION", "Search for GitHub repositories")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_SEARCH_REPOSITORIES_USER_TITLE", "Search repositories"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("query", + mcp.Required(), + mcp.Description("Search query"), + ), + WithPagination(), + ) - return mcp.NewToolResultText(string(r)), nil - } -} + handler = buildSearchHandler("repositories", getClient, func(ctx context.Context, client *github.Client, query string, opts *github.SearchOptions) (interface{}, *github.Response, error) { + return client.Search.Repositories(ctx, query, opts) + }) -type MinimalUser struct { - Login string `json:"login"` - ID int64 `json:"id,omitempty"` - ProfileURL string `json:"profile_url,omitempty"` - AvatarURL string `json:"avatar_url,omitempty"` + return tool, handler } -type MinimalSearchUsersResult struct { - TotalCount int `json:"total_count"` - IncompleteResults bool `json:"incomplete_results"` - Items []MinimalUser `json:"items"` -} +// SearchCode creates a tool to search for code across GitHub repositories. +func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + tool = mcp.NewTool("search_code", + mcp.WithDescription(t("TOOL_SEARCH_CODE_DESCRIPTION", "Search for code across GitHub repositories")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_SEARCH_CODE_USER_TITLE", "Search code"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("q", + mcp.Required(), + mcp.Description("Search query using GitHub code search syntax"), + ), + mcp.WithString("sort", + mcp.Description("Sort field ('indexed' only)"), + ), + mcp.WithString("order", + mcp.Description("Sort order"), + mcp.Enum("asc", "desc"), + ), + WithPagination(), + ) -func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHandlerFunc { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - query, err := RequiredParam[string](request, "query") + handler = func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + query, err := RequiredParam[string](request, "q") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -187,6 +168,11 @@ func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHand return mcp.NewToolResultError(err.Error()), nil } + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + opts := &github.SearchOptions{ Sort: sort, Order: order, @@ -196,47 +182,39 @@ func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHand }, } - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) + result, resp, err := client.Search.Code(ctx, query, opts) + if errorResult, err := handleSearchResponse(ctx, resp, err, fmt.Sprintf("failed to search code with query '%s'", query)); errorResult != nil || err != nil { + return errorResult, err } + return marshalAndReturnResult(result) + } + + return tool, handler +} + +type MinimalUser struct { + Login string `json:"login"` + ID int64 `json:"id,omitempty"` + ProfileURL string `json:"profile_url,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` +} + +type MinimalSearchUsersResult struct { + TotalCount int `json:"total_count"` + IncompleteResults bool `json:"incomplete_results"` + Items []MinimalUser `json:"items"` +} + +func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHandlerFunc { + return buildSearchHandler(accountType+"s", getClient, func(ctx context.Context, client *github.Client, query string, opts *github.SearchOptions) (interface{}, *github.Response, error) { searchQuery := "type:" + accountType + " " + query result, resp, err := client.Search.Users(ctx, searchQuery, opts) if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to search %ss with query '%s'", accountType, query), - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != 200 { - 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 search %ss: %s", accountType, string(body))), nil + return nil, resp, err } - minimalUsers := make([]MinimalUser, 0, len(result.Users)) - - for _, user := range result.Users { - if user.Login != nil { - mu := MinimalUser{Login: *user.Login} - if user.ID != nil { - mu.ID = *user.ID - } - if user.HTMLURL != nil { - mu.ProfileURL = *user.HTMLURL - } - if user.AvatarURL != nil { - mu.AvatarURL = *user.AvatarURL - } - minimalUsers = append(minimalUsers, mu) - } - } + minimalUsers := processSearchUsers(result.Users) minimalResp := &MinimalSearchUsersResult{ TotalCount: result.GetTotal(), IncompleteResults: result.GetIncompleteResults(), @@ -249,58 +227,38 @@ func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHand minimalResp.IncompleteResults = *result.IncompleteResults } - r, err := json.Marshal(minimalResp) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return minimalResp, resp, nil + }) +} + +func processSearchUsers(users []*github.User) []MinimalUser { + minimalUsers := make([]MinimalUser, 0, len(users)) + for _, user := range users { + if user.Login != nil { + mu := MinimalUser{Login: *user.Login} + if user.ID != nil { + mu.ID = *user.ID + } + if user.HTMLURL != nil { + mu.ProfileURL = *user.HTMLURL + } + if user.AvatarURL != nil { + mu.AvatarURL = *user.AvatarURL + } + minimalUsers = append(minimalUsers, mu) } - return mcp.NewToolResultText(string(r)), nil } + return minimalUsers } // SearchUsers creates a tool to search for GitHub users. func SearchUsers(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("search_users", - mcp.WithDescription(t("TOOL_SEARCH_USERS_DESCRIPTION", "Search for GitHub users exclusively")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_SEARCH_USERS_USER_TITLE", "Search users"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("query", - mcp.Required(), - mcp.Description("Search query using GitHub users search syntax scoped to type:user"), - ), - mcp.WithString("sort", - mcp.Description("Sort field by category"), - mcp.Enum("followers", "repositories", "joined"), - ), - mcp.WithString("order", - mcp.Description("Sort order"), - mcp.Enum("asc", "desc"), - ), - WithPagination(), - ), userOrOrgHandler("user", getClient) + return buildSearchTool("search_users", "Search for GitHub users exclusively", "Search users", "Search query using GitHub users search syntax scoped to type:user", t), + userOrOrgHandler("user", getClient) } // SearchOrgs creates a tool to search for GitHub organizations. func SearchOrgs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("search_orgs", - mcp.WithDescription(t("TOOL_SEARCH_ORGS_DESCRIPTION", "Search for GitHub organizations exclusively")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_SEARCH_ORGS_USER_TITLE", "Search organizations"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("query", - mcp.Required(), - mcp.Description("Search query using GitHub organizations search syntax scoped to type:org"), - ), - mcp.WithString("sort", - mcp.Description("Sort field by category"), - mcp.Enum("followers", "repositories", "joined"), - ), - mcp.WithString("order", - mcp.Description("Sort order"), - mcp.Enum("asc", "desc"), - ), - WithPagination(), - ), userOrOrgHandler("org", getClient) + return buildSearchTool("search_orgs", "Search for GitHub organizations exclusively", "Search organizations", "Search query using GitHub organizations search syntax scoped to type:org", t), + userOrOrgHandler("org", getClient) }