Skip to content

Commit cb9f5c8

Browse files
Merge pull request #30 from UnitVectorY-Labs/refactor
Refactor the code moving from main.go into separate files & Update dependencies: bump mcp-go to v0.38.0
2 parents 8e3626d + b6611ee commit cb9f5c8

File tree

7 files changed

+464
-330
lines changed

7 files changed

+464
-330
lines changed

go.mod

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module github.com/UnitVectorY-Labs/mcp-graphql-forge
33
go 1.25.0 // GOVERSION
44

55
require (
6-
github.com/mark3labs/mcp-go v0.37.0
6+
github.com/mark3labs/mcp-go v0.38.0
77
gopkg.in/yaml.v3 v3.0.1
88
)
99

@@ -12,8 +12,8 @@ require (
1212
github.com/buger/jsonparser v1.1.1 // indirect
1313
github.com/google/uuid v1.6.0 // indirect
1414
github.com/invopop/jsonschema v0.13.0 // indirect
15-
github.com/mailru/easyjson v0.7.7 // indirect
16-
github.com/spf13/cast v1.7.1 // indirect
15+
github.com/mailru/easyjson v0.9.0 // indirect
16+
github.com/spf13/cast v1.9.2 // indirect
1717
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
1818
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
1919
)

go.sum

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,20 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
1212
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
1313
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
1414
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
15-
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
1615
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
1716
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
1817
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
1918
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
20-
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
21-
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
22-
github.com/mark3labs/mcp-go v0.37.0 h1:BywvZLPRT6Zx6mMG/MJfxLSZQkTGIcJSEGKsvr4DsoQ=
23-
github.com/mark3labs/mcp-go v0.37.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
19+
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
20+
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
21+
github.com/mark3labs/mcp-go v0.38.0 h1:E5tmJiIXkhwlV0pLAwAT0O5ZjUZSISE/2Jxg+6vpq4I=
22+
github.com/mark3labs/mcp-go v0.38.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
2423
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
2524
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
2625
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
2726
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
28-
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
29-
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
27+
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
28+
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
3029
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
3130
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
3231
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=

internal/forge/graphql.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package forge
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"log"
9+
"net/http"
10+
"net/http/httputil"
11+
)
12+
13+
// ExecuteGraphQL posts a query+vars to url with Bearer token, returning raw JSON
14+
func ExecuteGraphQL(url, query string, vars map[string]interface{}, token string, isDebug bool) ([]byte, error) {
15+
payload := GraphqlRequest{Query: query, Variables: vars}
16+
body, err := json.Marshal(payload)
17+
if err != nil {
18+
return nil, fmt.Errorf("marshal GraphQL payload: %w", err)
19+
}
20+
21+
req, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
22+
if err != nil {
23+
return nil, fmt.Errorf("create request: %w", err)
24+
}
25+
req.Header.Set("Content-Type", "application/json")
26+
27+
if token != "" {
28+
req.Header.Set("Authorization", token)
29+
}
30+
31+
if isDebug {
32+
log.Println("--- GraphQL Request ---")
33+
if dump, err := httputil.DumpRequestOut(req, true); err == nil {
34+
log.Printf("%s\n", dump)
35+
} else {
36+
log.Printf("dump error: %v\n", err)
37+
}
38+
log.Println("-----------------------")
39+
}
40+
41+
resp, err := http.DefaultClient.Do(req)
42+
if err != nil {
43+
return nil, fmt.Errorf("execute request: %w", err)
44+
}
45+
defer resp.Body.Close()
46+
47+
respBody, err := io.ReadAll(resp.Body)
48+
if err != nil {
49+
return nil, fmt.Errorf("read response: %w", err)
50+
}
51+
52+
if isDebug {
53+
log.Println("--- GraphQL Response ---")
54+
log.Printf("Status Code: %d\n", resp.StatusCode)
55+
// Attempt to pretty-print JSON response body if possible
56+
var pretty bytes.Buffer
57+
if json.Indent(&pretty, respBody, "", " ") == nil {
58+
log.Printf("Body:\n%s\n", pretty.String())
59+
} else {
60+
// Fallback to printing raw body if not valid JSON
61+
log.Printf("Body (raw): %s\n", respBody)
62+
}
63+
log.Println("------------------------")
64+
}
65+
66+
return respBody, nil
67+
}

internal/forge/handler.go

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
package forge
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"crypto/sha256"
7+
"fmt"
8+
"log"
9+
"os"
10+
"os/exec"
11+
"path/filepath"
12+
"runtime"
13+
"strings"
14+
15+
"github.com/mark3labs/mcp-go/mcp"
16+
"github.com/mark3labs/mcp-go/server"
17+
)
18+
19+
// CtxAuthKey is used as a key for storing auth tokens in context
20+
type CtxAuthKey struct{}
21+
22+
// CreateMCPServer creates and configures an MCP server with all tools registered
23+
func CreateMCPServer(appConfig *AppConfig, version string) (*server.MCPServer, error) {
24+
// Init MCP server
25+
srv := server.NewMCPServer(appConfig.Config.Name, version)
26+
27+
// Discover & register tools
28+
if err := RegisterTools(srv, appConfig.Config, appConfig.ConfigDir, appConfig.IsDebug); err != nil {
29+
return nil, fmt.Errorf("registering tools: %w", err)
30+
}
31+
32+
return srv, nil
33+
}
34+
35+
// RegisterTools discovers and registers all tools from the config directory
36+
func RegisterTools(srv *server.MCPServer, cfg *ForgeConfig, configDir string, isDebug bool) error {
37+
// Discover & register tools
38+
files, err := filepath.Glob(filepath.Join(configDir, "*.yaml"))
39+
if err != nil {
40+
return fmt.Errorf("error discovering tools: %w", err)
41+
}
42+
43+
for _, f := range files {
44+
if filepath.Base(f) == "forge.yaml" {
45+
continue
46+
}
47+
48+
tcfg, err := LoadToolConfig(f)
49+
if err != nil {
50+
fmt.Fprintf(os.Stderr, "Warning: skipping %s: %v\n", f, err)
51+
continue
52+
}
53+
54+
opts := []mcp.ToolOption{
55+
mcp.WithDescription(tcfg.Description),
56+
}
57+
58+
// Add annotations if specified
59+
if tcfg.Annotations.Title != "" {
60+
opts = append(opts, mcp.WithTitleAnnotation(tcfg.Annotations.Title))
61+
}
62+
if tcfg.Annotations.ReadOnlyHint != nil {
63+
opts = append(opts, mcp.WithReadOnlyHintAnnotation(*tcfg.Annotations.ReadOnlyHint))
64+
}
65+
if tcfg.Annotations.DestructiveHint != nil {
66+
opts = append(opts, mcp.WithDestructiveHintAnnotation(*tcfg.Annotations.DestructiveHint))
67+
}
68+
if tcfg.Annotations.IdempotentHint != nil {
69+
opts = append(opts, mcp.WithIdempotentHintAnnotation(*tcfg.Annotations.IdempotentHint))
70+
}
71+
if tcfg.Annotations.OpenWorldHint != nil {
72+
opts = append(opts, mcp.WithOpenWorldHintAnnotation(*tcfg.Annotations.OpenWorldHint))
73+
}
74+
75+
valid := true
76+
for _, inp := range tcfg.Inputs {
77+
pOpts := []mcp.PropertyOption{mcp.Description(inp.Description)}
78+
if inp.Required {
79+
pOpts = append(pOpts, mcp.Required())
80+
}
81+
switch inp.Type {
82+
case "string":
83+
opts = append(opts, mcp.WithString(inp.Name, pOpts...))
84+
case "number":
85+
opts = append(opts, mcp.WithNumber(inp.Name, pOpts...))
86+
default:
87+
fmt.Fprintf(os.Stderr, "Warning: unsupported type %q in %s\n", inp.Type, tcfg.Name)
88+
valid = false
89+
}
90+
}
91+
if !valid {
92+
continue
93+
}
94+
95+
tool := mcp.NewTool(tcfg.Name, opts...)
96+
srv.AddTool(tool, makeHandler(*cfg, *tcfg, isDebug))
97+
}
98+
99+
return nil
100+
}
101+
102+
// makeHandler produces a ToolHandler for the given configs
103+
func makeHandler(cfg ForgeConfig, tcfg ToolConfig, isDebug bool) server.ToolHandlerFunc {
104+
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
105+
// 1. Gather variables
106+
vars := map[string]interface{}{}
107+
args := req.GetArguments()
108+
for _, inp := range tcfg.Inputs {
109+
val, ok := args[inp.Name]
110+
if !ok && inp.Required {
111+
return mcp.NewToolResultError(fmt.Sprintf("missing required argument: %s", inp.Name)), nil
112+
}
113+
vars[inp.Name] = val
114+
}
115+
116+
// 2. Get the token
117+
token := ""
118+
if cfg.TokenCommand != "" {
119+
var cmd *exec.Cmd
120+
// Use the appropriate shell based on the OS
121+
if runtime.GOOS == "windows" {
122+
cmd = exec.Command("cmd", "/C", cfg.TokenCommand)
123+
} else {
124+
// Assume Unix-like shell for macOS, Linux, etc.
125+
cmd = exec.Command("sh", "-c", cfg.TokenCommand)
126+
}
127+
128+
// Build merged environment: start with os.Environ() if passthrough, else start empty,
129+
// then overlay values from cfg.Env to ensure overrides.
130+
var envList []string
131+
if cfg.EnvPassthrough {
132+
envList = os.Environ()
133+
} else {
134+
envList = []string{}
135+
}
136+
137+
for key, value := range cfg.Env {
138+
// Remove any existing entries for this key
139+
prefix := key + "="
140+
filtered := envList[:0]
141+
for _, e := range envList {
142+
if !strings.HasPrefix(e, prefix) {
143+
filtered = append(filtered, e)
144+
}
145+
}
146+
envList = append(filtered, fmt.Sprintf("%s=%s", key, value))
147+
}
148+
149+
cmd.Env = envList
150+
151+
if isDebug {
152+
log.Printf("Executing token command: %s", cfg.TokenCommand)
153+
if len(cmd.Env) > 0 {
154+
log.Printf("Environment variables: %v", cmd.Env)
155+
}
156+
}
157+
158+
// Only get a token if the command is specified
159+
out, err := cmd.Output()
160+
if err != nil {
161+
// Include stderr in the error message if available
162+
errMsg := "token_command failed"
163+
if exitErr, ok := err.(*exec.ExitError); ok {
164+
// Combine exit error message and stderr for better context
165+
stderr := string(bytes.TrimSpace(exitErr.Stderr))
166+
if stderr != "" {
167+
errMsg = fmt.Sprintf("%s: %v Stderr: %s", errMsg, exitErr, stderr)
168+
} else {
169+
errMsg = fmt.Sprintf("%s: %v", errMsg, exitErr)
170+
}
171+
}
172+
// Return nil error for MCP result error
173+
return mcp.NewToolResultErrorFromErr(errMsg, err), nil
174+
}
175+
token = "Bearer " + string(bytes.TrimSpace(out))
176+
177+
if isDebug {
178+
log.Printf("Obtained token (sha256): %x\n", sha256.Sum256([]byte(token)))
179+
}
180+
} else {
181+
// No token command specified, proceed with pass through token
182+
token, _ = ctx.Value(CtxAuthKey{}).(string)
183+
184+
if isDebug {
185+
log.Printf("Pass through token (sha256): %x\n", sha256.Sum256([]byte(token)))
186+
}
187+
}
188+
189+
// 3. Call GraphQL
190+
res, err := ExecuteGraphQL(cfg.URL, tcfg.Query, vars, token, isDebug)
191+
if err != nil {
192+
// Return error result to MCP instead of terminating
193+
return mcp.NewToolResultErrorFromErr("GraphQL execution failed", err), nil
194+
}
195+
196+
// 4. Return raw JSON
197+
return mcp.NewToolResultText(string(res)), nil
198+
}
199+
}

internal/forge/serve.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package forge
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
8+
"github.com/mark3labs/mcp-go/server"
9+
)
10+
11+
// ServeOptions holds options for serving the MCP server
12+
type ServeOptions struct {
13+
HTTPAddr string
14+
IsDebug bool
15+
}
16+
17+
// Serve starts the MCP server in either HTTP or stdio mode
18+
func Serve(srv *server.MCPServer, opts ServeOptions) error {
19+
if opts.HTTPAddr != "" {
20+
return serveHTTP(srv, opts.HTTPAddr, opts.IsDebug)
21+
}
22+
return serveStdio(srv)
23+
}
24+
25+
// serveHTTP starts the server in HTTP mode
26+
func serveHTTP(srv *server.MCPServer, httpAddr string, isDebug bool) error {
27+
if isDebug {
28+
fmt.Printf("Starting MCP server using Streamable HTTP transport on %s\n", httpAddr)
29+
}
30+
31+
streamSrv := server.NewStreamableHTTPServer(
32+
srv,
33+
server.WithHTTPContextFunc(func(ctx context.Context, r *http.Request) context.Context {
34+
// Inject authorization token into context
35+
if auth := r.Header.Get("Authorization"); auth != "" {
36+
ctx = context.WithValue(ctx, CtxAuthKey{}, auth)
37+
}
38+
return ctx
39+
}),
40+
)
41+
42+
if isDebug {
43+
fmt.Printf("Streamable HTTP Endpoint: http://localhost:%s/mcp\n", httpAddr)
44+
}
45+
46+
if err := streamSrv.Start(":" + httpAddr); err != nil {
47+
return fmt.Errorf("streamable HTTP server error: %w", err)
48+
}
49+
50+
return nil
51+
}
52+
53+
// serveStdio starts the server in stdio mode
54+
func serveStdio(srv *server.MCPServer) error {
55+
if err := server.ServeStdio(srv); err != nil {
56+
return fmt.Errorf("MCP server terminated: %w", err)
57+
}
58+
return nil
59+
}

0 commit comments

Comments
 (0)