Skip to content

Add MCP server command for poutine analysis tools #314

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "..."
}
}
}
}
```
133 changes: 133 additions & 0 deletions cmd/serveMCP.go
Original file line number Diff line number Diff line change
@@ -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, "")
Copy link
Contributor

@SUSTAPLE117 SUSTAPLE117 Jun 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fproulx-boostsecurity yeah initializing poutine this way won't work properly because it looks for vars already assigned in the root cmd to set scm params and stuff, to make it not be hardcoded to just Github we will have to bring all those root params here and have them exposed in the mcp server

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why string can just use an integer?

)

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"))

Check failure on line 131 in cmd/serveMCP.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `viper.BindPFlag` is not checked (errcheck)
viper.BindEnv("token", "GH_TOKEN")

Check failure on line 132 in cmd/serveMCP.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `viper.BindEnv` is not checked (errcheck)
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
Loading