Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
71 changes: 18 additions & 53 deletions client/Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
BINARY_NAME := gh-ql-mcp-client
MODULE := github.com/advanced-security/codeql-development-mcp-server/client
VERSION := $(shell grep 'Version = ' cmd/root.go | head -1 | sed 's/.*"\(.*\)"/\1/')

# Disable CGO to avoid Xcode/C compiler dependency
export CGO_ENABLED = 0
Expand All @@ -13,59 +14,35 @@ all: lint test build

## build: Build the binary for the current platform
build:
@if [ -f go.mod ]; then \
go build -ldflags "$(LDFLAGS)" -o $(BINARY_NAME) .; \
else \
echo "Go source not yet available — skipping build"; \
fi
go build -ldflags "$(LDFLAGS)" -o $(BINARY_NAME) .

## test: Run unit tests and integration tests
test: test-unit test-integration

## test-unit: Run Go unit tests only
test-unit:
@if [ -f go.mod ]; then \
go test ./...; \
else \
echo "Go source not yet available — skipping unit tests"; \
fi
go test ./...

## test-integration: Build binary and run integration tests via run-integration-tests.sh
test-integration: build
@if [ -f go.mod ]; then \
ENABLE_ANNOTATION_TOOLS=true scripts/run-integration-tests.sh --no-install-packs; \
else \
echo "Go source not yet available — skipping integration tests"; \
fi
ENABLE_ANNOTATION_TOOLS=true scripts/run-integration-tests.sh --no-install-packs

## test-verbose: Run all unit tests with verbose output
test-verbose:
@if [ -f go.mod ]; then \
go test -v ./...; \
else \
echo "Go source not yet available — skipping verbose tests"; \
fi
go test -v ./...

## test-coverage: Run tests with coverage
test-coverage:
@if [ -f go.mod ]; then \
go test -coverprofile=coverage.out ./...; \
go tool cover -html=coverage.out -o coverage.html; \
else \
echo "Go source not yet available — skipping coverage"; \
fi
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html

## lint: Run linters
lint:
@if [ -f go.mod ]; then \
if command -v golangci-lint > /dev/null 2>&1; then \
golangci-lint run ./...; \
else \
echo "golangci-lint not found, running go vet only"; \
go vet ./...; \
fi; \
@if command -v golangci-lint > /dev/null 2>&1; then \
golangci-lint run ./...; \
else \
echo "Go source not yet available — skipping lint"; \
echo "golangci-lint not found, running go vet only"; \
go vet ./...; \
fi

## clean: Remove build artifacts
Expand All @@ -74,31 +51,19 @@ clean:

## install: Install the binary to GOPATH/bin
install:
@if [ -f go.mod ]; then \
go install -ldflags "$(LDFLAGS)" .; \
else \
echo "Go source not yet available — skipping install"; \
fi
go install -ldflags "$(LDFLAGS)" .

## build-all: Cross-compile for all supported platforms
build-all:
@if [ -f go.mod ]; then \
GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o $(BINARY_NAME)-darwin-amd64 .; \
GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o $(BINARY_NAME)-darwin-arm64 .; \
GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o $(BINARY_NAME)-linux-amd64 .; \
GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o $(BINARY_NAME)-linux-arm64 .; \
GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o $(BINARY_NAME)-windows-amd64.exe .; \
else \
echo "Go source not yet available — skipping cross-compile"; \
fi
GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o $(BINARY_NAME)-darwin-amd64 .
GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o $(BINARY_NAME)-darwin-arm64 .
GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o $(BINARY_NAME)-linux-amd64 .
GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o $(BINARY_NAME)-linux-arm64 .
GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o $(BINARY_NAME)-windows-amd64.exe .

## tidy: Tidy go.mod
tidy:
@if [ -f go.mod ]; then \
go mod tidy; \
else \
echo "Go source not yet available — skipping tidy"; \
fi
go mod tidy

## help: Show this help
help:
Expand Down
15 changes: 15 additions & 0 deletions client/cmd/helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package cmd

import (
"fmt"
"strings"
)

// parseRepo splits an "owner/repo" string into owner and repo components.
func parseRepo(nwo string) (string, string, error) {
parts := strings.SplitN(nwo, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("invalid repo format %q: expected owner/repo", nwo)
}
return parts[0], parts[1], nil
}
31 changes: 31 additions & 0 deletions client/cmd/helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package cmd

import "testing"

func TestParseRepo_Valid(t *testing.T) {
owner, repo, err := parseRepo("has-ghas/dubbo")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if owner != "has-ghas" {
t.Errorf("owner = %q, want %q", owner, "has-ghas")
Comment thread
data-douser marked this conversation as resolved.
Outdated
}
if repo != "dubbo" {
t.Errorf("repo = %q, want %q", repo, "dubbo")
}
}

func TestParseRepo_Invalid(t *testing.T) {
tests := []string{
"",
"noslash",
"/norepo",
"noowner/",
}
for _, input := range tests {
_, _, err := parseRepo(input)
if err == nil {
t.Errorf("parseRepo(%q) should return error", input)
}
}
}
168 changes: 168 additions & 0 deletions client/cmd/integration_tests.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package cmd

import (
"context"
"fmt"
"os"
"path/filepath"
"strings"

mcpclient "github.com/advanced-security/codeql-development-mcp-server/client/internal/mcp"
itesting "github.com/advanced-security/codeql-development-mcp-server/client/internal/testing"
"github.com/mark3labs/mcp-go/mcp"
"github.com/spf13/cobra"
)

var integrationTestsCmd = &cobra.Command{
Use: "integration-tests",
Short: "Run MCP server integration tests from client/integration-tests/",
Long: `Discovers and runs integration test fixtures against a connected MCP server.

Test fixtures live in client/integration-tests/primitives/tools/<tool>/<test>/
and use test-config.json or monitoring-state.json to define tool parameters.`,
RunE: runIntegrationTests,
}

var integrationTestsFlags struct {
tools string
tests string
noInstall bool
timeout int
}

func init() {
rootCmd.AddCommand(integrationTestsCmd)

f := integrationTestsCmd.Flags()
f.StringVar(&integrationTestsFlags.tools, "tools", "", "Comma-separated list of tool names to test")
f.StringVar(&integrationTestsFlags.tests, "tests", "", "Comma-separated list of test case names to run")
f.BoolVar(&integrationTestsFlags.noInstall, "no-install-packs", false, "Skip CodeQL pack installation")
f.IntVar(&integrationTestsFlags.timeout, "timeout", 30, "Per-tool-call timeout in seconds")
}

// mcpToolCaller adapts the MCP client to the ToolCaller interface.
type mcpToolCaller struct {
client *mcpclient.Client
}

func (c *mcpToolCaller) CallToolRaw(name string, params map[string]any) ([]itesting.ContentBlock, bool, error) {
result, err := c.client.CallTool(context.Background(), name, params)
if err != nil {
return nil, false, err
}

var blocks []itesting.ContentBlock
for _, item := range result.Content {
if textContent, ok := item.(mcp.TextContent); ok {
blocks = append(blocks, itesting.ContentBlock{
Type: "text",
Text: textContent.Text,
})
}
}

return blocks, result.IsError, nil
}

func (c *mcpToolCaller) ListToolNames() ([]string, error) {
tools, err := c.client.ListTools(context.Background())
if err != nil {
return nil, err
}
names := make([]string, len(tools))
for i, t := range tools {
names[i] = t.Name
}
return names, nil
}

func runIntegrationTests(cmd *cobra.Command, _ []string) error {
// Determine repo root
repoRoot, err := findRepoRoot()
if err != nil {
return fmt.Errorf("cannot determine repo root: %w", err)
}

// Change CWD to repo root so the MCP server subprocess resolves
// relative paths (from test-config.json, monitoring-state.json)
// correctly. The codeql CLI also resolves paths from CWD.
if err := os.Chdir(repoRoot); err != nil {
return fmt.Errorf("chdir to repo root: %w", err)
}
fmt.Printf("Working directory: %s\n", repoRoot)

// Connect to MCP server
client := mcpclient.NewClient(mcpclient.Config{
Mode: MCPMode(),
Host: MCPHost(),
Port: MCPPort(),
})

fmt.Println("🔌 Connecting to MCP server...")
ctx := context.Background()
if err := client.Connect(ctx); err != nil {
return fmt.Errorf("connect to MCP server: %w", err)
}
fmt.Println("✅ Connected to MCP server")

// Parse filters
var filterTools, filterTests []string
if integrationTestsFlags.tools != "" {
filterTools = strings.Split(integrationTestsFlags.tools, ",")
}
if integrationTestsFlags.tests != "" {
filterTests = strings.Split(integrationTestsFlags.tests, ",")
}

// Create and run the test runner
runner := itesting.NewRunner(&mcpToolCaller{client: client}, itesting.RunnerOptions{
RepoRoot: repoRoot,
FilterTools: filterTools,
FilterTests: filterTests,
})

allPassed, _ := runner.Run()

// Close the MCP client (and its stdio subprocess) before returning.
client.Close()

if !allPassed {
return fmt.Errorf("some integration tests failed")
}
return nil
}

// findRepoRoot walks up from the current directory to find the repo root
// (identified by the presence of codeql-workspace.yml).
func findRepoRoot() (string, error) {
// Try from current working directory
dir, err := os.Getwd()
if err != nil {
return "", err
}

for {
if _, err := os.Stat(filepath.Join(dir, "codeql-workspace.yml")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}

// Fallback: try relative to the binary
exe, err := os.Executable()
if err == nil {
dir = filepath.Dir(exe)
for i := 0; i < 5; i++ {
if _, err := os.Stat(filepath.Join(dir, "codeql-workspace.yml")); err == nil {
return dir, nil
}
dir = filepath.Dir(dir)
}
}

return "", fmt.Errorf("could not find repo root (codeql-workspace.yml)")
}
Loading