Skip to content

Commit c75a323

Browse files
committed
Replace JS ql-mcp-client with Go implementation
Rewrite the ql-mcp-client CLI in Go, replacing the Node.js/JavaScript implementation with a compiled binary that serves as both a standalone CLI and a gh CLI extension (gh-ql-mcp-client). Client (Go): - Cobra-based CLI with code-scanning and sarif command groups - code-scanning list-analyses, list-alerts, download-analysis subcommands using go-gh for GitHub API auth - sarif subcommand group for MCP-backed SARIF analysis workflows - MCP connection layer via mcp-go with stdio and HTTP transports - Integration test runner ported from JS with full tool parameter extraction for 30+ MCP tools, tool availability checking, and deprecated session tool skipping - 82/82 client integration tests passing with annotation tools enabled by default - Makefile with build, test, lint, and cross-compile targets Server (TypeScript): - New sarif_store tool for ingesting SARIF into session cache - New sarif_deduplicate_rules tool for pairwise rule comparison across two SARIF files using fingerprint and location overlap - New fingerprint overlap mode in sarif_compare_alerts using partialFingerprints with full-path fallback - computeFingerprintOverlap() utility in sarif-utils - 89 SARIF-related server tests passing (62 utils + 27 tools) Infrastructure: - Remove client from npm workspaces; root package.json scripts invoke make -C client for build, test, lint, and clean - Update client-integration-tests.yml workflow with setup-go, ENABLE_ANNOTATION_TOOLS=true, and make-based test invocation - Update lint-and-format.yml workflow with setup-go for go vet - run-integration-tests.sh builds Go binary, extracts test databases, and runs gh-ql-mcp-client integration-tests - Fix custom_log_directory test fixtures to use server-allowed log paths
1 parent 16b96a1 commit c75a323

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

63 files changed

+3392
-5716
lines changed

.github/workflows/client-integration-tests.yml

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ jobs:
3636
os: [ubuntu-latest, windows-latest]
3737

3838
env:
39+
ENABLE_ANNOTATION_TOOLS: 'true'
3940
HTTP_HOST: 'localhost'
4041
HTTP_PORT: '3000'
4142
MCP_MODE: ${{ matrix.mcp-mode }}
@@ -52,6 +53,12 @@ jobs:
5253
cache: 'npm'
5354
node-version-file: '.node-version'
5455

56+
- name: MCP Integration Tests - Setup Go environment
57+
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
58+
with:
59+
go-version-file: 'client/go.mod'
60+
cache-dependency-path: 'client/go.sum'
61+
5562
- name: MCP Integration Tests - Install OS dependencies (Ubuntu)
5663
if: runner.os == 'Linux'
5764
run: sudo apt-get install -y jq
@@ -60,8 +67,8 @@ jobs:
6067
if: runner.os == 'Windows'
6168
run: choco install jq -y
6269

63-
- name: MCP Integration Tests - Install node dependencies for client and server workspaces
64-
run: npm ci --workspace=client && npm ci --workspace=server
70+
- name: MCP Integration Tests - Install node dependencies for server workspace
71+
run: npm ci --workspace=server
6572

6673
- name: MCP Integration Tests - Setup CodeQL environment
6774
uses: ./.github/actions/setup-codeql-environment
@@ -109,7 +116,7 @@ jobs:
109116
## have a dedicated workflow (query-unit-tests.yml).
110117
- name: MCP Integration Tests - Run integration tests
111118
shell: bash
112-
run: npm run test:integration --workspace=client
119+
run: make -C client test-integration
113120

114121
- name: MCP Integration Tests - Stop the background MCP server process
115122
if: always() && matrix.mcp-mode == 'http'

.github/workflows/lint-and-format.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ jobs:
2525
cache: 'npm'
2626
node-version-file: '.node-version'
2727

28+
- name: Lint and Format - Setup Go
29+
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
30+
with:
31+
go-version-file: 'client/go.mod'
32+
cache-dependency-path: 'client/go.sum'
33+
2834
- name: Lint and Format - Install node dependencies for all workspaces
2935
run: npm ci
3036

client/.gitignore

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1-
dist/
1+
# Go build artifacts
2+
gh-ql-mcp-client
3+
gh-ql-mcp-client-*
4+
coverage.out
5+
coverage.html
6+
7+
# Downloaded SARIF files
8+
sarif-downloads/
9+
10+
# Legacy JS artifacts
211
node_modules/
12+
dist/
313
*.log
4-
.coverage/
5-
coverage/
6-
.nyc_output/
7-
scratch/*
8-
!scratch/.gitkeep

client/Makefile

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
BINARY_NAME := gh-ql-mcp-client
2+
MODULE := github.com/advanced-security/codeql-development-mcp-server/client
3+
VERSION := $(shell grep 'Version = ' cmd/root.go | head -1 | sed 's/.*"\(.*\)"/\1/')
4+
5+
# Disable CGO to avoid Xcode/C compiler dependency
6+
export CGO_ENABLED = 0
7+
8+
# Build flags
9+
LDFLAGS := -s -w
10+
11+
.PHONY: all build test test-unit test-integration lint clean install build-all
12+
13+
all: lint test build
14+
15+
## build: Build the binary for the current platform
16+
build:
17+
go build -ldflags "$(LDFLAGS)" -o $(BINARY_NAME) .
18+
19+
## test: Run unit tests and integration tests
20+
test: test-unit test-integration
21+
22+
## test-unit: Run Go unit tests only
23+
test-unit:
24+
go test ./...
25+
26+
## test-integration: Build binary and run integration tests via run-integration-tests.sh
27+
test-integration: build
28+
ENABLE_ANNOTATION_TOOLS=true scripts/run-integration-tests.sh --no-install-packs
29+
30+
## test-verbose: Run all unit tests with verbose output
31+
test-verbose:
32+
go test -v ./...
33+
34+
## test-coverage: Run tests with coverage
35+
test-coverage:
36+
go test -coverprofile=coverage.out ./...
37+
go tool cover -html=coverage.out -o coverage.html
38+
39+
## lint: Run linters
40+
lint:
41+
@if command -v golangci-lint > /dev/null 2>&1; then \
42+
golangci-lint run ./...; \
43+
else \
44+
echo "golangci-lint not found, running go vet only"; \
45+
go vet ./...; \
46+
fi
47+
48+
## clean: Remove build artifacts
49+
clean:
50+
rm -f $(BINARY_NAME) $(BINARY_NAME)-* coverage.out coverage.html
51+
52+
## install: Install the binary to GOPATH/bin
53+
install:
54+
go install -ldflags "$(LDFLAGS)" .
55+
56+
## build-all: Cross-compile for all supported platforms
57+
build-all:
58+
GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o $(BINARY_NAME)-darwin-amd64 .
59+
GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o $(BINARY_NAME)-darwin-arm64 .
60+
GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o $(BINARY_NAME)-linux-amd64 .
61+
GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o $(BINARY_NAME)-linux-arm64 .
62+
GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o $(BINARY_NAME)-windows-amd64.exe .
63+
64+
## tidy: Tidy go.mod
65+
tidy:
66+
go mod tidy
67+
68+
## help: Show this help
69+
help:
70+
@echo "Available targets:"
71+
@grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## / /'

client/cmd/code_scanning.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package cmd
2+
3+
import "github.com/spf13/cobra"
4+
5+
var codeScanningCmd = &cobra.Command{
6+
Use: "code-scanning",
7+
Aliases: []string{"cs"},
8+
Short: "Manage Code Scanning analyses and alerts",
9+
Long: "Commands for listing, downloading, dismissing, and reopening Code Scanning analyses and alerts via the GitHub REST API.",
10+
RunE: func(cmd *cobra.Command, args []string) error {
11+
return cmd.Help()
12+
},
13+
}
14+
15+
func init() {
16+
rootCmd.AddCommand(codeScanningCmd)
17+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
9+
gh "github.com/advanced-security/codeql-development-mcp-server/client/internal/github"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
var downloadAnalysisCmd = &cobra.Command{
14+
Use: "download-analysis",
15+
Short: "Download a Code Scanning analysis as SARIF",
16+
RunE: runDownloadAnalysis,
17+
}
18+
19+
var downloadAnalysisFlags struct {
20+
repo string
21+
analysisID int
22+
output string
23+
}
24+
25+
func init() {
26+
codeScanningCmd.AddCommand(downloadAnalysisCmd)
27+
28+
f := downloadAnalysisCmd.Flags()
29+
f.StringVar(&downloadAnalysisFlags.repo, "repo", "", "Repository in owner/repo format (required)")
30+
f.IntVar(&downloadAnalysisFlags.analysisID, "analysis-id", 0, "Analysis ID to download (required)")
31+
f.StringVar(&downloadAnalysisFlags.output, "output", "", "Output file path (default: sarif-downloads/<repo>/<id>.sarif)")
32+
33+
_ = downloadAnalysisCmd.MarkFlagRequired("repo")
34+
_ = downloadAnalysisCmd.MarkFlagRequired("analysis-id")
35+
}
36+
37+
func runDownloadAnalysis(cmd *cobra.Command, _ []string) error {
38+
owner, repo, err := parseRepo(downloadAnalysisFlags.repo)
39+
if err != nil {
40+
return err
41+
}
42+
43+
client, err := gh.NewClient()
44+
if err != nil {
45+
return err
46+
}
47+
48+
sarif, err := client.GetAnalysisSARIF(owner, repo, downloadAnalysisFlags.analysisID)
49+
if err != nil {
50+
return err
51+
}
52+
53+
// Determine output path
54+
outPath := downloadAnalysisFlags.output
55+
if outPath == "" {
56+
outPath = filepath.Join("sarif-downloads", fmt.Sprintf("%s_%s", owner, repo),
57+
fmt.Sprintf("%d.sarif", downloadAnalysisFlags.analysisID))
58+
}
59+
60+
// Ensure directory exists
61+
if err := os.MkdirAll(filepath.Dir(outPath), 0o750); err != nil {
62+
return fmt.Errorf("create output directory: %w", err)
63+
}
64+
65+
// Pretty-print the JSON
66+
var pretty json.RawMessage
67+
if err := json.Unmarshal(sarif, &pretty); err != nil {
68+
// If not valid JSON, write as-is
69+
if writeErr := os.WriteFile(outPath, sarif, 0o600); writeErr != nil {
70+
return fmt.Errorf("write SARIF file: %w", writeErr)
71+
}
72+
} else {
73+
formatted, _ := json.MarshalIndent(pretty, "", " ")
74+
if err := os.WriteFile(outPath, formatted, 0o600); err != nil {
75+
return fmt.Errorf("write SARIF file: %w", err)
76+
}
77+
}
78+
79+
fmt.Fprintf(cmd.OutOrStdout(), "Downloaded SARIF to %s (%d bytes)\n", outPath, len(sarif))
80+
return nil
81+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"text/tabwriter"
7+
8+
gh "github.com/advanced-security/codeql-development-mcp-server/client/internal/github"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
var listAlertsCmd = &cobra.Command{
13+
Use: "list-alerts",
14+
Short: "List Code Scanning alerts for a repository",
15+
RunE: runListAlerts,
16+
}
17+
18+
var listAlertsFlags struct {
19+
repo string
20+
ref string
21+
state string
22+
severity string
23+
toolName string
24+
sort string
25+
direction string
26+
perPage int
27+
}
28+
29+
func init() {
30+
codeScanningCmd.AddCommand(listAlertsCmd)
31+
32+
f := listAlertsCmd.Flags()
33+
f.StringVar(&listAlertsFlags.repo, "repo", "", "Repository in owner/repo format (required)")
34+
f.StringVar(&listAlertsFlags.ref, "ref", "", "Git ref to filter by")
35+
f.StringVar(&listAlertsFlags.state, "state", "", "Alert state: open, closed, dismissed, fixed")
36+
f.StringVar(&listAlertsFlags.severity, "severity", "", "Severity: critical, high, medium, low, warning, note, error")
37+
f.StringVar(&listAlertsFlags.toolName, "tool-name", "", "Tool name to filter by")
38+
f.StringVar(&listAlertsFlags.sort, "sort", "", "Sort by (created, updated)")
39+
f.StringVar(&listAlertsFlags.direction, "direction", "", "Sort direction (asc, desc)")
40+
f.IntVar(&listAlertsFlags.perPage, "per-page", 30, "Results per page (max 100)")
41+
42+
_ = listAlertsCmd.MarkFlagRequired("repo")
43+
}
44+
45+
func runListAlerts(cmd *cobra.Command, _ []string) error {
46+
owner, repo, err := parseRepo(listAlertsFlags.repo)
47+
if err != nil {
48+
return err
49+
}
50+
51+
client, err := gh.NewClient()
52+
if err != nil {
53+
return err
54+
}
55+
56+
alerts, err := client.ListAlerts(gh.ListAlertsOptions{
57+
Owner: owner,
58+
Repo: repo,
59+
Ref: listAlertsFlags.ref,
60+
State: listAlertsFlags.state,
61+
Severity: listAlertsFlags.severity,
62+
ToolName: listAlertsFlags.toolName,
63+
Sort: listAlertsFlags.sort,
64+
Direction: listAlertsFlags.direction,
65+
PerPage: listAlertsFlags.perPage,
66+
})
67+
if err != nil {
68+
return err
69+
}
70+
71+
if OutputFormat() == "json" {
72+
enc := json.NewEncoder(cmd.OutOrStdout())
73+
enc.SetIndent("", " ")
74+
return enc.Encode(alerts)
75+
}
76+
77+
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 4, 2, ' ', 0)
78+
fmt.Fprintln(w, "NUM\tSTATE\tRULE\tSEVERITY\tFILE:LINE\tCREATED")
79+
for _, a := range alerts {
80+
loc := a.MostRecentInstance.Location
81+
locStr := fmt.Sprintf("%s:%d", loc.Path, loc.StartLine)
82+
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n",
83+
a.Number, a.State, a.Rule.ID, a.Rule.Severity, locStr, a.CreatedAt)
84+
}
85+
return w.Flush()
86+
}

0 commit comments

Comments
 (0)