Skip to content

Commit 84de772

Browse files
h0tak88rclaude
andcommitted
feat: add MCP server for AI-assisted scan exploration
Adds a Model Context Protocol (MCP) server that lets AI assistants (Claude Code, Cursor, etc.) explore AutoAR scan results through 6 tools: list_scans, get_scan, list_scan_files, get_file_content, list_findings, and search_findings. The server runs over stdio with JSON-RPC 2.0 and Content-Length framing. No external dependencies needed. Also fixes the Reflection scan findings badge display. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9706bbb commit 84de772

3 files changed

Lines changed: 1328 additions & 0 deletions

File tree

internal/cmd/mcp.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/h0tak88r/AutoAR/internal/db"
8+
"github.com/h0tak88r/AutoAR/internal/mcp"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
var mcpCmd = &cobra.Command{
13+
Use: "mcp",
14+
Short: "Start the MCP server for AI assistant scan exploration",
15+
Long: `Start a Model Context Protocol (MCP) server over stdio.
16+
17+
This allows AI assistants (Claude Code, Cursor, etc.) to discover and explore
18+
scan results, findings, and files from the AutoAR platform.
19+
20+
Tools exposed:
21+
list_scans - List recent scans
22+
get_scan - Get scan details by ID
23+
list_scan_files - List result files for a scan
24+
get_file_content - Read file content
25+
list_findings - Get parsed findings for a scan
26+
search_findings - Search across all scans`,
27+
RunE: func(cmd *cobra.Command, args []string) error {
28+
// Initialize the database
29+
if err := db.Init(); err != nil {
30+
fmt.Fprintf(os.Stderr, "[autoar-mcp] database init error: %v\n", err)
31+
return fmt.Errorf("database init: %w", err)
32+
}
33+
if err := db.EnsureSchema(); err != nil {
34+
fmt.Fprintf(os.Stderr, "[autoar-mcp] database schema error: %v\n", err)
35+
return fmt.Errorf("database schema: %w", err)
36+
}
37+
38+
fmt.Fprintf(os.Stderr, "[autoar-mcp] starting MCP server...\n")
39+
server := mcp.NewServer()
40+
return server.Run()
41+
},
42+
}
43+
44+
func init() {
45+
rootCmd.AddCommand(mcpCmd)
46+
}

internal/mcp/server.go

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
package mcp
2+
3+
import (
4+
"bufio"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"os"
9+
"strconv"
10+
"strings"
11+
"sync"
12+
)
13+
14+
const protocolVersion = "2024-11-05"
15+
const serverName = "autoar-mcp"
16+
const serverVersion = "0.1.0"
17+
18+
// JSON-RPC 2.0 types
19+
type jsonRPCRequest struct {
20+
JSONRPC string `json:"jsonrpc"`
21+
ID interface{} `json:"id"`
22+
Method string `json:"method"`
23+
Params json.RawMessage `json:"params,omitempty"`
24+
}
25+
26+
type jsonRPCResponse struct {
27+
JSONRPC string `json:"jsonrpc"`
28+
ID interface{} `json:"id"`
29+
Result interface{} `json:"result,omitempty"`
30+
Error *rpcError `json:"error,omitempty"`
31+
}
32+
33+
type rpcError struct {
34+
Code int `json:"code"`
35+
Message string `json:"message"`
36+
}
37+
38+
func newJSONRPCError(id interface{}, code int, msg string) jsonRPCResponse {
39+
return jsonRPCResponse{
40+
JSONRPC: "2.0",
41+
ID: id,
42+
Error: &rpcError{Code: code, Message: msg},
43+
}
44+
}
45+
46+
// MCP Tool definition
47+
type Tool struct {
48+
Name string `json:"name"`
49+
Description string `json:"description"`
50+
InputSchema inputSchema `json:"inputSchema"`
51+
}
52+
53+
type inputSchema struct {
54+
Type string `json:"type"`
55+
Properties map[string]property `json:"properties"`
56+
Required []string `json:"required,omitempty"`
57+
}
58+
59+
type property struct {
60+
Type string `json:"type"`
61+
Description string `json:"description"`
62+
}
63+
64+
// Server is the MCP server.
65+
type Server struct {
66+
tools map[string]*registeredTool
67+
mu sync.RWMutex
68+
initialized bool
69+
}
70+
71+
type registeredTool struct {
72+
definition Tool
73+
handler func(args map[string]interface{}) (string, error)
74+
}
75+
76+
// NewServer creates a new MCP server with all scan exploration tools registered.
77+
func NewServer() *Server {
78+
s := &Server{
79+
tools: make(map[string]*registeredTool),
80+
}
81+
s.registerTools()
82+
return s
83+
}
84+
85+
func (s *Server) registerTool(t Tool, handler func(args map[string]interface{}) (string, error)) {
86+
s.tools[t.Name] = &registeredTool{definition: t, handler: handler}
87+
}
88+
89+
// Run starts the MCP server, reading JSON-RPC from stdin and writing to stdout.
90+
// Logs go to stderr to avoid corrupting the MCP transport.
91+
func (s *Server) Run() error {
92+
reader := bufio.NewReader(os.Stdin)
93+
writer := os.Stdout
94+
95+
for {
96+
req, err := readRequest(reader)
97+
if err != nil {
98+
if err == io.EOF {
99+
return nil
100+
}
101+
fmt.Fprintf(os.Stderr, "[autoar-mcp] read error: %v\n", err)
102+
return err
103+
}
104+
105+
resp := s.handleRequest(req)
106+
if resp != nil {
107+
if err := writeResponse(writer, *resp); err != nil {
108+
fmt.Fprintf(os.Stderr, "[autoar-mcp] write error: %v\n", err)
109+
return err
110+
}
111+
}
112+
}
113+
}
114+
115+
func (s *Server) handleRequest(req jsonRPCRequest) *jsonRPCResponse {
116+
// Notifications have no ID — no response.
117+
if req.ID == nil {
118+
s.handleNotification(req)
119+
return nil
120+
}
121+
122+
switch req.Method {
123+
case "initialize":
124+
return s.handleInitialize(req)
125+
case "tools/list":
126+
return s.handleToolsList(req)
127+
case "tools/call":
128+
return s.handleToolsCall(req)
129+
case "ping":
130+
return &jsonRPCResponse{
131+
JSONRPC: "2.0",
132+
ID: req.ID,
133+
Result: map[string]interface{}{},
134+
}
135+
default:
136+
errResp := newJSONRPCError(req.ID, -32601, fmt.Sprintf("unknown method: %s", req.Method))
137+
return &errResp
138+
}
139+
}
140+
141+
func (s *Server) handleNotification(req jsonRPCRequest) {
142+
switch req.Method {
143+
case "notifications/initialized":
144+
s.mu.Lock()
145+
s.initialized = true
146+
s.mu.Unlock()
147+
default:
148+
fmt.Fprintf(os.Stderr, "[autoar-mcp] unhandled notification: %s\n", req.Method)
149+
}
150+
}
151+
152+
func (s *Server) handleInitialize(req jsonRPCRequest) *jsonRPCResponse {
153+
return &jsonRPCResponse{
154+
JSONRPC: "2.0",
155+
ID: req.ID,
156+
Result: map[string]interface{}{
157+
"protocolVersion": protocolVersion,
158+
"serverInfo": map[string]string{
159+
"name": serverName,
160+
"version": serverVersion,
161+
},
162+
"capabilities": map[string]interface{}{
163+
"tools": map[string]bool{},
164+
},
165+
},
166+
}
167+
}
168+
169+
func (s *Server) handleToolsList(req jsonRPCRequest) *jsonRPCResponse {
170+
s.mu.RLock()
171+
defer s.mu.RUnlock()
172+
173+
tools := make([]Tool, 0, len(s.tools))
174+
for _, t := range s.tools {
175+
tools = append(tools, t.definition)
176+
}
177+
178+
return &jsonRPCResponse{
179+
JSONRPC: "2.0",
180+
ID: req.ID,
181+
Result: map[string]interface{}{
182+
"tools": tools,
183+
},
184+
}
185+
}
186+
187+
func (s *Server) handleToolsCall(req jsonRPCRequest) *jsonRPCResponse {
188+
var params struct {
189+
Name string `json:"name"`
190+
Arguments map[string]interface{} `json:"arguments"`
191+
}
192+
if err := json.Unmarshal(req.Params, &params); err != nil {
193+
errResp := newJSONRPCError(req.ID, -32602, "invalid params: "+err.Error())
194+
return &errResp
195+
}
196+
197+
s.mu.RLock()
198+
tool, ok := s.tools[params.Name]
199+
s.mu.RUnlock()
200+
201+
if !ok {
202+
errResp := newJSONRPCError(req.ID, -32602, fmt.Sprintf("unknown tool: %s", params.Name))
203+
return &errResp
204+
}
205+
206+
resultText, err := tool.handler(params.Arguments)
207+
if err != nil {
208+
return &jsonRPCResponse{
209+
JSONRPC: "2.0",
210+
ID: req.ID,
211+
Result: map[string]interface{}{
212+
"content": []map[string]interface{}{
213+
{"type": "text", "text": fmt.Sprintf("Error: %s", err.Error())},
214+
},
215+
"isError": true,
216+
},
217+
}
218+
}
219+
220+
return &jsonRPCResponse{
221+
JSONRPC: "2.0",
222+
ID: req.ID,
223+
Result: map[string]interface{}{
224+
"content": []map[string]interface{}{
225+
{"type": "text", "text": resultText},
226+
},
227+
},
228+
}
229+
}
230+
231+
// --- Transport framing ---
232+
233+
func readRequest(r *bufio.Reader) (jsonRPCRequest, error) {
234+
for {
235+
line, err := r.ReadString('\n')
236+
if err != nil {
237+
return jsonRPCRequest{}, err
238+
}
239+
line = strings.TrimSuffix(line, "\r")
240+
line = strings.TrimSuffix(line, "\n")
241+
242+
if line == "" {
243+
// empty line — skip header separators
244+
continue
245+
}
246+
247+
if strings.HasPrefix(line, "Content-Length:") {
248+
v := strings.TrimSpace(strings.TrimPrefix(line, "Content-Length:"))
249+
cl, err := strconv.Atoi(v)
250+
if err != nil {
251+
return jsonRPCRequest{}, fmt.Errorf("bad Content-Length: %s", v)
252+
}
253+
// Read the trailing \r\n after the header
254+
r.ReadString('\n')
255+
256+
body := make([]byte, cl)
257+
_, err = io.ReadFull(r, body)
258+
if err != nil {
259+
return jsonRPCRequest{}, fmt.Errorf("reading body: %w", err)
260+
}
261+
262+
var req jsonRPCRequest
263+
if err := json.Unmarshal(body, &req); err != nil {
264+
return jsonRPCRequest{}, fmt.Errorf("bad JSON: %w", err)
265+
}
266+
return req, nil
267+
}
268+
269+
// Try parsing the line as JSON directly (some clients send raw JSON lines)
270+
var req jsonRPCRequest
271+
if err := json.Unmarshal([]byte(line), &req); err == nil {
272+
return req, nil
273+
}
274+
}
275+
}
276+
277+
func writeResponse(w io.Writer, resp jsonRPCResponse) error {
278+
data, err := json.Marshal(resp)
279+
if err != nil {
280+
return err
281+
}
282+
_, err = fmt.Fprintf(w, "Content-Length: %d\r\n\r\n%s", len(data), data)
283+
return err
284+
}

0 commit comments

Comments
 (0)