From e62bc9e870e9b51e82044651d01df1d8d15aa89e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Proulx?= Date: Fri, 6 Jun 2025 18:11:11 -0400 Subject: [PATCH] First draft --- README.md | 36 +++++++++++++ cmd/serveMCP.go | 133 ++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 2 + go.sum | 4 ++ 4 files changed, 175 insertions(+) create mode 100644 cmd/serveMCP.go diff --git a/README.md b/README.md index ab8c7d3..77995d8 100644 --- a/README.md +++ b/README.md @@ -148,3 +148,39 @@ You may submit the flags you find in a [private vulnerability disclosure](https: ## License This project is licensed under the Apache License 2.0 - see the LICENSE file for details. + +### `poutine serve-mcp` + +Starts an MCP (Model Context Protocol) server to expose poutine's analysis capabilities as tools for AI models. + +This command runs a persistent server process over `stdio`. + +**Tools exposed:** + +* `analyze_repo`: Analyzes a remote repository for supply chain vulnerabilities. + * `github_repo` (string, required): The slug of the GitHub repository to analyze (i.e. org/repo). + * `ref` (string): Defaults to 'HEAD'. +* `analyze_org`: Analyzes all repositories in an organization. + * `github_org` (string, required): The slug of the GitHub organization to analyze. + * `threads` (string): Number of concurrent analyzers to run. Defaults to 4. +* `analyze_repo_stale_branches`: Analyzes a remote repository for stale branches. + * `github_repo` (string, required): The slug of the GitHub repository to analyze (i.e. org/repo). + * `regex` (string): Regex to match stale branches. Defaults to an empty string, matching all branches. + +**Example MCP Client configuration (e.g. Claude Desktop):** + +```json +{ + "mcpServers": { + "poutine": { + "command": "poutine", + "args": [ + "serve-mcp" + ], + "env": { + "GH_TOKEN": "..." + } + } + } +} +``` diff --git a/cmd/serveMCP.go b/cmd/serveMCP.go new file mode 100644 index 0000000..96527ca --- /dev/null +++ b/cmd/serveMCP.go @@ -0,0 +1,133 @@ +package cmd + +import ( + "context" + "encoding/json" + "regexp" + "strconv" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var serveMcpCmd = &cobra.Command{ + Use: "serve-mcp", + Short: "Starts the poutine MCP server", + Long: `Starts the poutine MCP server. +Example to start the MCP server: poutine serve-mcp --token "$GH_TOKEN"`, + RunE: func(cmd *cobra.Command, args []string) error { + Token = viper.GetString("token") + ctx := cmd.Context() + s := server.NewMCPServer("poutine", Version) + analyzer, err := GetAnalyzer(ctx, "") + if err != nil { + return err + } + + analyzeRepoTool := mcp.NewTool( + "analyze_repo", + mcp.WithDescription("Analyzes a remote repository for supply chain vulnerabilities."), + mcp.WithString("github_repo", mcp.Required(), mcp.Description("The slug of the GitHub repository to analyze (i.e. org/repo).")), + mcp.WithString("ref", mcp.Description("Defaults to 'HEAD'")), + ) + + s.AddTool(analyzeRepoTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + repo, err := request.RequireString("github_repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + ref := request.GetString("ref", "HEAD") + + packageInsights, err := analyzer.AnalyzeRepo(ctx, repo, ref) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + jsonData, err := json.Marshal(packageInsights) + if err != nil { + return mcp.NewToolResultError("Failed to marshal result to JSON: " + err.Error()), nil + } + return mcp.NewToolResultText(string(jsonData)), nil + }) + + analyzeOrgTool := mcp.NewTool( + "analyze_org", + mcp.WithDescription("Analyzes all repositories in an organization."), + mcp.WithString("github_org", mcp.Required(), mcp.Description("The slug of the GitHub organization to analyze.")), + mcp.WithString("threads", mcp.Description("Number of concurrent analyzers to run. Defaults to 4.")), // Define as string + ) + + s.AddTool(analyzeOrgTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := request.RequireString("github_org") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + threadsStr := request.GetString("threads", "4") + threads, err := strconv.Atoi(threadsStr) + if err != nil { + return mcp.NewToolResultError("Invalid format for threads: must be an integer."), nil + } + threadsPtr := &threads + + packageInsightsSlice, err := analyzer.AnalyzeOrg(ctx, org, threadsPtr) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + jsonResult, err := json.Marshal(packageInsightsSlice) + if err != nil { + return mcp.NewToolResultError("Failed to marshal result to JSON: " + err.Error()), nil + } + return mcp.NewToolResultText(string(jsonResult)), nil + }) + + analyzeRepoStaleBranchesTool := mcp.NewTool( + "analyze_repo_stale_branches", + mcp.WithDescription("Analyzes a remote repository for stale branches."), + mcp.WithString("github_repo", mcp.Required(), mcp.Description("The slug of the GitHub repository to analyze (i.e. org/repo).")), // Corrected parameter name + mcp.WithString("regex", mcp.Description("Regex to match stale branches. Defaults to an empty string, matching all branches.")), + ) + + s.AddTool(analyzeRepoStaleBranchesTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + repo, err := request.RequireString("github_repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + regexStr := request.GetString("regex", "") + + var compiledRegex *regexp.Regexp + var errRegex error + if regexStr != "" { + compiledRegex, errRegex = regexp.Compile(regexStr) + if errRegex != nil { + return mcp.NewToolResultError("Invalid regex: " + errRegex.Error()), nil + } + } + + packageInsights, err := analyzer.AnalyzeStaleBranches(ctx, repo, nil, nil, compiledRegex) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + jsonData, err := json.Marshal(packageInsights) + if err != nil { + return mcp.NewToolResultError("Failed to marshal result to JSON: " + err.Error()), nil + } + return mcp.NewToolResultText(string(jsonData)), nil + }) + + return server.ServeStdio(s) + }, +} + +func init() { + RootCmd.AddCommand(serveMcpCmd) + + serveMcpCmd.Flags().StringVarP(&Token, "token", "t", "", "SCM access token (env: GH_TOKEN)") + + viper.BindPFlag("token", serveMcpCmd.Flags().Lookup("token")) + viper.BindEnv("token", "GH_TOKEN") +} diff --git a/go.mod b/go.mod index 5cae948..32f9072 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/gofri/go-github-ratelimit v1.1.1 github.com/google/go-github/v59 v59.0.0 github.com/hashicorp/go-version v1.7.0 + github.com/mark3labs/mcp-go v0.31.0 github.com/olekukonko/tablewriter v0.0.5 github.com/open-policy-agent/opa v1.5.0 github.com/owenrumney/go-sarif/v2 v2.3.3 @@ -68,6 +69,7 @@ require ( github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/yashtewari/glob-intersection v0.2.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect diff --git a/go.sum b/go.sum index c928c45..efb2345 100644 --- a/go.sum +++ b/go.sum @@ -93,6 +93,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mark3labs/mcp-go v0.31.0 h1:4UxSV8aM770OPmTvaVe/b1rA2oZAjBMhGBfUgOGut+4= +github.com/mark3labs/mcp-go v0.31.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= @@ -185,6 +187,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/zclconf/go-cty v1.10.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= gitlab.com/gitlab-org/api/client-go v0.129.0 h1:o9KLn6fezmxBQWYnQrnilwyuOjlx4206KP0bUn3HuBE= gitlab.com/gitlab-org/api/client-go v0.129.0/go.mod h1:ZhSxLAWadqP6J9lMh40IAZOlOxBLPRh7yFOXR/bMJWM=