Skip to content

Commit c1e99b5

Browse files
committed
Add an MCP server for askgod at <askgod_server_address>/mcp.
It has only one tool: Submit a flag. The MCP server is disabled by default and can be enabled with `mcp: true` in the askgod config. Internally, it uses the REST method calls to limit code duplication to a minimum. Signed-off-by: Émilio Gonzalez <little.moon6016@fastmail.com>
1 parent b58d8f1 commit c1e99b5

7 files changed

Lines changed: 327 additions & 1 deletion

File tree

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,10 @@ This will create two executables in `./bin/linux`: `askgod` and `askgod-server`.
4747

4848
```bash
4949
./bin/linux/askgod-server ./askgod.yaml.example
50-
```
50+
```
51+
52+
## MCP Server
53+
54+
The askgod server supports an MCP server at `<askgod_server_address>/mcp`.
55+
This MCP server allows users to submit flags.
56+
The MCP Server is disabled by default, but can be enabled by setting `mcp: true` in the config.

api/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ type Config struct {
99

1010
Daemon ConfigDaemon `json:"daemon" yaml:"daemon"`
1111
Database ConfigDatabase `json:"database" yaml:"database"`
12+
MCP bool `json:"mcp" yaml:"mcp"`
1213
}
1314

1415
// ConfigPut represents the editable Askgod configuration.

askgod.yaml.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,6 @@ subnets:
9595
guests:
9696
- ::/0
9797
- 0.0.0.0/0
98+
99+
# Enable or disable the MCP server. Defaults to false.
100+
mcp: true

internal/mcp/mcp.go

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
// Package mcp implements an MCP server using Streamable HTTP transport.
2+
package mcp
3+
4+
import (
5+
"encoding/json"
6+
"io"
7+
"net/http"
8+
9+
"github.com/inconshreveable/log15"
10+
)
11+
12+
// MCP implements an MCP server using Streamable HTTP transport.
13+
type MCP struct {
14+
logger log15.Logger
15+
handler http.Handler
16+
}
17+
18+
// NewMCP creates a new MCP server instance.
19+
func NewMCP(handler http.Handler, logger log15.Logger) *MCP {
20+
return &MCP{
21+
handler: handler,
22+
logger: logger,
23+
}
24+
}
25+
26+
// ServeHTTP handles incoming MCP requests over Streamable HTTP.
27+
func (m *MCP) ServeHTTP(w http.ResponseWriter, r *http.Request) {
28+
if r.Method != http.MethodPost {
29+
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
30+
31+
return
32+
}
33+
34+
body, err := io.ReadAll(r.Body)
35+
if err != nil {
36+
http.Error(w, "Bad Request", http.StatusBadRequest)
37+
38+
return
39+
}
40+
41+
var req Request
42+
43+
err = json.Unmarshal(body, &req)
44+
if err != nil {
45+
m.writeError(w, nil, -32700, "Parse error")
46+
47+
return
48+
}
49+
50+
// Notifications have no id — respond with 202 Accepted.
51+
if req.ID == nil {
52+
w.WriteHeader(http.StatusAccepted)
53+
54+
return
55+
}
56+
57+
var id any
58+
59+
err = json.Unmarshal(*req.ID, &id)
60+
if err != nil {
61+
id = nil
62+
}
63+
64+
switch req.Method {
65+
case "initialize":
66+
m.handleInitialize(w, id)
67+
case "tools/list":
68+
m.handleToolsList(w, id)
69+
case "tools/call":
70+
m.handleToolsCall(w, r, id, req.Params)
71+
default:
72+
m.writeError(w, id, -32601, "Method not found")
73+
}
74+
}
75+
76+
func (m *MCP) handleInitialize(w http.ResponseWriter, id any) {
77+
result := InitializeResult{
78+
ProtocolVersion: "2025-03-26",
79+
Capabilities: Capabilities{
80+
Tools: &ToolsCapability{},
81+
},
82+
ServerInfo: ServerInfo{
83+
Name: "askgod",
84+
Version: "1.0.0",
85+
},
86+
}
87+
88+
m.writeResult(w, id, result)
89+
}
90+
91+
func (m *MCP) handleToolsList(w http.ResponseWriter, id any) {
92+
result := ListToolsResult{
93+
Tools: []Tool{
94+
{
95+
Name: "submit_flag",
96+
Description: "Submit a CTF flag for your team. Returns whether the flag was valid, already submitted, or invalid.",
97+
InputSchema: ToolInputSchema{
98+
Type: "object",
99+
Properties: map[string]any{
100+
"flag": map[string]any{
101+
"type": "string",
102+
"description": "The flag string to submit",
103+
},
104+
},
105+
Required: []string{"flag"},
106+
},
107+
},
108+
},
109+
}
110+
111+
m.writeResult(w, id, result)
112+
}
113+
114+
func (m *MCP) handleToolsCall(w http.ResponseWriter, r *http.Request, id any, params json.RawMessage) {
115+
var p CallToolParams
116+
117+
err := json.Unmarshal(params, &p)
118+
if err != nil {
119+
m.writeError(w, id, -32602, "Invalid params")
120+
121+
return
122+
}
123+
124+
var result CallToolResult
125+
126+
switch p.Name {
127+
case "submit_flag":
128+
result = m.submitFlag(r, p.Arguments)
129+
default:
130+
m.writeError(w, id, -32602, "Unknown tool: "+p.Name)
131+
132+
return
133+
}
134+
135+
m.writeResult(w, id, result)
136+
}
137+
138+
func (m *MCP) writeResult(w http.ResponseWriter, id any, result any) {
139+
resp := Response{
140+
JSONRPC: "2.0",
141+
ID: id,
142+
Result: result,
143+
}
144+
145+
w.Header().Set("Content-Type", "application/json")
146+
147+
err := json.NewEncoder(w).Encode(resp)
148+
if err != nil {
149+
m.logger.Error("Failed to encode response", log15.Ctx{"error": err})
150+
}
151+
}
152+
153+
func (m *MCP) writeError(w http.ResponseWriter, id any, code int, message string) {
154+
resp := Response{
155+
JSONRPC: "2.0",
156+
ID: id,
157+
Error: &Error{
158+
Code: code,
159+
Message: message,
160+
},
161+
}
162+
163+
w.Header().Set("Content-Type", "application/json")
164+
165+
err := json.NewEncoder(w).Encode(resp)
166+
if err != nil {
167+
m.logger.Error("Failed to encode error response", log15.Ctx{"error": err})
168+
}
169+
}

internal/mcp/protocol.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package mcp
2+
3+
import "encoding/json"
4+
5+
// Request is a JSON-RPC 2.0 request.
6+
type Request struct {
7+
JSONRPC string `json:"jsonrpc"`
8+
ID *json.RawMessage `json:"id,omitempty"`
9+
Method string `json:"method"`
10+
Params json.RawMessage `json:"params,omitempty"`
11+
}
12+
13+
// Response is a JSON-RPC 2.0 response.
14+
type Response struct {
15+
JSONRPC string `json:"jsonrpc"`
16+
ID any `json:"id"`
17+
Result any `json:"result,omitempty"`
18+
Error *Error `json:"error,omitempty"`
19+
}
20+
21+
// Error is a JSON-RPC 2.0 error.
22+
type Error struct {
23+
Code int `json:"code"`
24+
Message string `json:"message"`
25+
}
26+
27+
// InitializeResult is the result of an MCP initialize request.
28+
type InitializeResult struct {
29+
ProtocolVersion string `json:"protocolVersion"` //nolint:tagliatelle
30+
Capabilities Capabilities `json:"capabilities"`
31+
ServerInfo ServerInfo `json:"serverInfo"` //nolint:tagliatelle
32+
}
33+
34+
// Capabilities describes what the server supports.
35+
type Capabilities struct {
36+
Tools *ToolsCapability `json:"tools,omitempty"`
37+
}
38+
39+
// ToolsCapability indicates the server supports tools.
40+
type ToolsCapability struct{}
41+
42+
// ServerInfo holds the server name and version.
43+
type ServerInfo struct {
44+
Name string `json:"name"`
45+
Version string `json:"version"`
46+
}
47+
48+
// Tool is a tool available on the server.
49+
type Tool struct {
50+
Name string `json:"name"`
51+
Description string `json:"description"`
52+
InputSchema ToolInputSchema `json:"inputSchema"` //nolint:tagliatelle
53+
}
54+
55+
// ToolInputSchema is a JSON Schema object describing the tool's parameters.
56+
type ToolInputSchema struct {
57+
Type string `json:"type"`
58+
Properties map[string]any `json:"properties,omitempty"`
59+
Required []string `json:"required,omitempty"`
60+
}
61+
62+
// CallToolParams holds the parameters for a tools/call request.
63+
type CallToolParams struct {
64+
Name string `json:"name"`
65+
Arguments map[string]any `json:"arguments,omitempty"`
66+
}
67+
68+
// CallToolResult holds the result of a tool call.
69+
type CallToolResult struct {
70+
Content []Content `json:"content"`
71+
IsError bool `json:"isError,omitempty"` //nolint:tagliatelle
72+
}
73+
74+
// Content is a content block in a tool result.
75+
type Content struct {
76+
Type string `json:"type"`
77+
Text string `json:"text"`
78+
}
79+
80+
// ListToolsResult holds the result of a tools/list request.
81+
type ListToolsResult struct {
82+
Tools []Tool `json:"tools"`
83+
}

internal/mcp/tools.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package mcp
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"net/http/httptest"
9+
"strings"
10+
11+
"github.com/nsec/askgod/api"
12+
)
13+
14+
func (m *MCP) submitFlag(r *http.Request, args map[string]any) CallToolResult {
15+
flagStr, ok := args["flag"].(string)
16+
if !ok || flagStr == "" {
17+
return errorResult("Missing or invalid 'flag' argument")
18+
}
19+
20+
body, err := json.Marshal(api.FlagPost{Flag: flagStr, AIAgent: true})
21+
if err != nil {
22+
return errorResult("Internal server error")
23+
}
24+
25+
req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, "/1.0/team/flags", bytes.NewReader(body))
26+
req.RemoteAddr = r.RemoteAddr
27+
req.Header.Set("Content-Type", "application/json")
28+
29+
rec := httptest.NewRecorder()
30+
m.handler.ServeHTTP(rec, req)
31+
32+
if rec.Code != http.StatusOK {
33+
return errorResult(strings.TrimSpace(rec.Body.String()))
34+
}
35+
36+
var result api.Flag
37+
38+
err = json.NewDecoder(rec.Body).Decode(&result)
39+
if err != nil {
40+
return errorResult("Internal server error")
41+
}
42+
43+
msg := fmt.Sprintf("Correct flag! +%d points\n", result.Value)
44+
if result.ReturnString != "" {
45+
msg += fmt.Sprintf("Return message: %s\n", result.ReturnString)
46+
}
47+
48+
return CallToolResult{Content: []Content{{Type: "text", Text: msg}}}
49+
}
50+
51+
func errorResult(msg string) CallToolResult {
52+
return CallToolResult{Content: []Content{{Type: "text", Text: msg}}, IsError: true}
53+
}

internal/rest/attach.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/nsec/askgod/internal/config"
1313
"github.com/nsec/askgod/internal/database"
14+
"github.com/nsec/askgod/internal/mcp"
1415
)
1516

1617
var clusterPeers []string
@@ -56,6 +57,16 @@ func AttachFunctions(ctx context.Context, conf *config.Config, router *http.Serv
5657
r.registerEndpoint("/1.0/teams", "admin", r.adminGetTeams, r.adminCreateTeam, nil, r.adminClearTeams)
5758
r.registerEndpoint("/1.0/teams/{id}", "admin", r.adminGetTeam, nil, r.adminUpdateTeam, r.adminDeleteTeam)
5859

60+
// MCP server endpoint (disabled by default)
61+
if conf.MCP {
62+
r.logger.Info("Starting the MCP server")
63+
mcpServer := mcp.NewMCP(r.router, logger.New("component", "mcp"))
64+
65+
r.registerEndpoint("/mcp", "team", nil, func(writer http.ResponseWriter, request *http.Request, _ log15.Logger) {
66+
mcpServer.ServeHTTP(writer, request)
67+
}, nil, nil)
68+
}
69+
5970
// Setup forwarder
6071
for _, peer := range conf.Daemon.ClusterPeers {
6172
u, err := url.ParseRequestURI(peer)

0 commit comments

Comments
 (0)