Skip to content
Merged
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
74 changes: 74 additions & 0 deletions sast-engine/cmd/serve.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package cmd

import (
"fmt"
"os"
"time"

"github.com/shivasurya/code-pathfinder/sast-engine/graph"
"github.com/shivasurya/code-pathfinder/sast-engine/graph/callgraph/builder"
"github.com/shivasurya/code-pathfinder/sast-engine/graph/callgraph/registry"
"github.com/shivasurya/code-pathfinder/sast-engine/mcp"
"github.com/shivasurya/code-pathfinder/sast-engine/output"
"github.com/spf13/cobra"
)

var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start MCP server for AI coding assistants",
Long: `Builds code index and starts MCP server on stdio.

Designed for integration with Claude Code, Codex CLI, and other AI assistants
that support the Model Context Protocol (MCP).

The server indexes the codebase once at startup, then responds to queries
about symbols, call graphs, and code relationships.`,
RunE: runServe,
}

func init() {
rootCmd.AddCommand(serveCmd)
serveCmd.Flags().StringP("project", "p", ".", "Project path to index")
serveCmd.Flags().String("python-version", "3.11", "Python version for stdlib resolution")
}

func runServe(cmd *cobra.Command, _ []string) error {
projectPath, _ := cmd.Flags().GetString("project")
pythonVersion, _ := cmd.Flags().GetString("python-version")

fmt.Fprintln(os.Stderr, "Building index...")
start := time.Now()

// Create logger for build process (verbose to stderr)
logger := output.NewLogger(output.VerbosityVerbose)

// 1. Initialize code graph (AST parsing)
codeGraph := graph.Initialize(projectPath)
if codeGraph == nil {
return fmt.Errorf("failed to initialize code graph")
}

// 2. Build module registry
moduleRegistry, err := registry.BuildModuleRegistry(projectPath, true) // skip tests
if err != nil {
return fmt.Errorf("failed to build module registry: %w", err)
}

// 3. Build call graph (5-pass algorithm)
callGraph, err := builder.BuildCallGraph(codeGraph, moduleRegistry, projectPath, logger)
if err != nil {
return fmt.Errorf("failed to build call graph: %w", err)
}

buildTime := time.Since(start)
fmt.Fprintf(os.Stderr, "Index built in %v\n", buildTime)
fmt.Fprintf(os.Stderr, " Functions: %d\n", len(callGraph.Functions))
fmt.Fprintf(os.Stderr, " Call edges: %d\n", len(callGraph.Edges))
fmt.Fprintf(os.Stderr, " Modules: %d\n", len(moduleRegistry.Modules))

// 4. Create and run MCP server
server := mcp.NewServer(projectPath, pythonVersion, callGraph, moduleRegistry, codeGraph, buildTime)

fmt.Fprintln(os.Stderr, "Starting MCP server on stdio...")
return server.ServeStdio()
}
2 changes: 1 addition & 1 deletion sast-engine/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func TestExecute(t *testing.T) {
{
name: "Successful execution",
mockExecuteErr: nil,
expectedOutput: "Code Pathfinder is designed for identifying vulnerabilities in source code.\n\nUsage:\n pathfinder [command]\n\nAvailable Commands:\n ci CI mode with SARIF, JSON, or CSV output for CI/CD integration\n completion Generate the autocompletion script for the specified shell\n diagnose Validate intra-procedural taint analysis against LLM ground truth\n help Help about any command\n resolution-report Generate a diagnostic report on call resolution statistics\n scan Scan code for security vulnerabilities using Python DSL rules\n version Print the version and commit information\n\nFlags:\n --disable-metrics Disable metrics collection\n -h, --help help for pathfinder\n --verbose Verbose output\n\nUse \"pathfinder [command] --help\" for more information about a command.\n",
expectedOutput: "Code Pathfinder is designed for identifying vulnerabilities in source code.\n\nUsage:\n pathfinder [command]\n\nAvailable Commands:\n ci CI mode with SARIF, JSON, or CSV output for CI/CD integration\n completion Generate the autocompletion script for the specified shell\n diagnose Validate intra-procedural taint analysis against LLM ground truth\n help Help about any command\n resolution-report Generate a diagnostic report on call resolution statistics\n scan Scan code for security vulnerabilities using Python DSL rules\n serve Start MCP server for AI coding assistants\n version Print the version and commit information\n\nFlags:\n --disable-metrics Disable metrics collection\n -h, --help help for pathfinder\n --verbose Verbose output\n\nUse \"pathfinder [command] --help\" for more information about a command.\n",
expectedExit: 0,
},
}
Expand Down
176 changes: 176 additions & 0 deletions sast-engine/mcp/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package mcp

import (
"bufio"
"encoding/json"
"fmt"
"io"
"os"
"time"

"github.com/shivasurya/code-pathfinder/sast-engine/graph"
"github.com/shivasurya/code-pathfinder/sast-engine/graph/callgraph/core"
)

// Server handles MCP protocol communication.
type Server struct {
projectPath string
pythonVersion string
callGraph *core.CallGraph
moduleRegistry *core.ModuleRegistry
codeGraph *graph.CodeGraph
indexedAt time.Time
buildTime time.Duration
}

// NewServer creates a new MCP server with the given index data.
func NewServer(
projectPath string,
pythonVersion string,
callGraph *core.CallGraph,
moduleRegistry *core.ModuleRegistry,
codeGraph *graph.CodeGraph,
buildTime time.Duration,
) *Server {
return &Server{
projectPath: projectPath,
pythonVersion: pythonVersion,
callGraph: callGraph,
moduleRegistry: moduleRegistry,
codeGraph: codeGraph,
indexedAt: time.Now(),
buildTime: buildTime,
}
}

// ServeStdio starts the MCP server on stdin/stdout.
func (s *Server) ServeStdio() error {
reader := bufio.NewReader(os.Stdin)

for {
// Read line from stdin.
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
fmt.Fprintln(os.Stderr, "Client disconnected")
return nil // Clean shutdown
}
return fmt.Errorf("read error: %w", err)
}

// Skip empty lines.
if len(line) <= 1 {
continue
}

// Parse JSON-RPC request.
var request JSONRPCRequest
if err := json.Unmarshal([]byte(line), &request); err != nil {
s.sendResponse(ErrorResponse(nil, -32700, "Parse error: "+err.Error()))
continue
}

// Handle request and send response.
response := s.handleRequest(&request)
if response != nil {
s.sendResponse(response)
}
}
}

// sendResponse writes a JSON-RPC response to stdout.
func (s *Server) sendResponse(resp *JSONRPCResponse) {
bytes, err := json.Marshal(resp)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to marshal response: %v\n", err)
return
}
fmt.Println(string(bytes))
}

// handleRequest dispatches to the appropriate handler.
func (s *Server) handleRequest(req *JSONRPCRequest) *JSONRPCResponse {
startTime := time.Now()

var response *JSONRPCResponse

switch req.Method {
case "initialize":
response = s.handleInitialize(req)
case "initialized":
// Acknowledgment notification - no response needed.
fmt.Fprintln(os.Stderr, "Client initialized")
return nil
case "notifications/initialized":
// Alternative notification format.
return nil
case "tools/list":
response = s.handleToolsList(req)
case "tools/call":
response = s.handleToolsCall(req)
case "ping":
response = SuccessResponse(req.ID, map[string]string{"status": "ok"})
default:
response = ErrorResponse(req.ID, -32601, fmt.Sprintf("Method not found: %s", req.Method))
}

// Log request timing.
elapsed := time.Since(startTime)
fmt.Fprintf(os.Stderr, "[%s] %s (%v)\n", req.Method, "completed", elapsed)

return response
}

// handleInitialize responds to the initialize request.
func (s *Server) handleInitialize(req *JSONRPCRequest) *JSONRPCResponse {
// Parse client info if needed.
var params InitializeParams
if req.Params != nil {
_ = json.Unmarshal(req.Params, &params)
fmt.Fprintf(os.Stderr, "Client: %s %s\n", params.ClientInfo.Name, params.ClientInfo.Version)
}

return SuccessResponse(req.ID, InitializeResult{
ProtocolVersion: "2024-11-05",
ServerInfo: ServerInfo{
Name: "pathfinder",
Version: "0.1.0-poc",
},
Capabilities: Capabilities{
Tools: &ToolsCapability{
ListChanged: false,
},
},
})
}

// handleToolsList returns the list of available tools.
func (s *Server) handleToolsList(req *JSONRPCRequest) *JSONRPCResponse {
tools := s.getToolDefinitions()
return SuccessResponse(req.ID, ToolsListResult{
Tools: tools,
})
}

// handleToolsCall executes a tool.
func (s *Server) handleToolsCall(req *JSONRPCRequest) *JSONRPCResponse {
var params ToolCallParams
if err := json.Unmarshal(req.Params, &params); err != nil {
return ErrorResponse(req.ID, -32602, "Invalid params: "+err.Error())
}

fmt.Fprintf(os.Stderr, "Tool call: %s\n", params.Name)

result, isError := s.executeTool(params.Name, params.Arguments)

return SuccessResponse(req.ID, ToolResult{
Content: []ContentBlock{
{
Type: "text",
Text: result,
},
},
IsError: isError,
})
}

Loading
Loading