Skip to content

Commit ecafd1c

Browse files
committed
feat: initial commit of mvp
0 parents  commit ecafd1c

19 files changed

Lines changed: 1572 additions & 0 deletions

cmd/root.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"flag"
6+
"fmt"
7+
"log"
8+
"os"
9+
"os/signal"
10+
11+
"skillful-mcp/internal/clientmanager"
12+
"skillful-mcp/internal/config"
13+
"skillful-mcp/internal/server"
14+
)
15+
16+
var (
17+
configPath string
18+
transport string
19+
host string
20+
port string
21+
)
22+
23+
func init() {
24+
flag.StringVar(&configPath, "config", "./mcp.json", "Path to MCP config file")
25+
flag.StringVar(&transport, "transport", "stdio", "Upstream transport: stdio or http")
26+
flag.StringVar(&host, "host", "localhost", "HTTP host (when transport=http)")
27+
flag.StringVar(&port, "port", "8080", "HTTP port (when transport=http)")
28+
}
29+
30+
func Execute() {
31+
flag.Parse()
32+
33+
cfg, err := config.Load(configPath)
34+
if err != nil {
35+
log.Fatalf("Failed to load config: %v", err)
36+
}
37+
if err := cfg.Validate(); err != nil {
38+
log.Fatalf("Invalid config: %v", err)
39+
}
40+
41+
fmt.Printf("Loaded %d server(s):\n", len(cfg.MCPServers))
42+
for name, srv := range cfg.MCPServers {
43+
tt, _ := srv.TransportType() // already validated
44+
switch tt {
45+
case config.TransportSTDIO:
46+
fmt.Printf(" [%s] %s → %s %v\n", name, tt, srv.Command, srv.Args)
47+
case config.TransportHTTP, config.TransportSSE:
48+
fmt.Printf(" [%s] %s → %s\n", name, tt, srv.URL)
49+
}
50+
}
51+
52+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
53+
defer stop()
54+
55+
mgr, err := clientmanager.ConnectAll(ctx, cfg)
56+
if err != nil {
57+
log.Fatalf("Failed to connect to servers: %v", err)
58+
}
59+
defer mgr.Close()
60+
61+
fmt.Printf("Connected to %d skill(s): %v\n", len(mgr.ListServerNames()), mgr.ListServerNames())
62+
63+
s := server.NewServer(mgr)
64+
if err := server.Serve(ctx, s, transport, host, port); err != nil {
65+
log.Fatalf("Server error: %v", err)
66+
}
67+
}

cmd/root_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package cmd
2+
3+
import (
4+
"flag"
5+
"os"
6+
"testing"
7+
)
8+
9+
func TestDefaultConfigFlag(t *testing.T) {
10+
// Reset flags for test isolation
11+
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
12+
configPath = ""
13+
flag.StringVar(&configPath, "config", "./mcp.json", "Path to MCP config file")
14+
flag.CommandLine.Parse([]string{})
15+
16+
if configPath != "./mcp.json" {
17+
t.Errorf("expected default config path './mcp.json', got %q", configPath)
18+
}
19+
}
20+
21+
func TestCustomConfigFlag(t *testing.T) {
22+
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
23+
configPath = ""
24+
flag.StringVar(&configPath, "config", "./mcp.json", "Path to MCP config file")
25+
flag.CommandLine.Parse([]string{"--config", "/tmp/custom.json"})
26+
27+
if configPath != "/tmp/custom.json" {
28+
t.Errorf("expected config path '/tmp/custom.json', got %q", configPath)
29+
}
30+
}

go.mod

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
module skillful-mcp
2+
3+
go 1.25.0
4+
5+
require github.com/modelcontextprotocol/go-sdk v1.4.1
6+
7+
require (
8+
github.com/ebitengine/purego v0.10.0 // indirect
9+
github.com/ewhauser/gomonty v0.0.14 // indirect
10+
github.com/google/jsonschema-go v0.4.2 // indirect
11+
github.com/segmentio/asm v1.1.3 // indirect
12+
github.com/segmentio/encoding v0.5.4 // indirect
13+
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
14+
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
15+
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
16+
golang.org/x/oauth2 v0.34.0 // indirect
17+
golang.org/x/sys v0.42.0 // indirect
18+
)

go.sum

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
2+
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
3+
github.com/ewhauser/gomonty v0.0.14 h1:DM+iSZ/WJzl+huEH5SGONLl8CTooQrfVFbC0xY1ghO4=
4+
github.com/ewhauser/gomonty v0.0.14/go.mod h1:XCLUVfUFX733MIidhDw3iLWwaVNxT23i9xK+ioAAOVc=
5+
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
6+
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
7+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
8+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
9+
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
10+
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
11+
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
12+
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
13+
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
14+
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
15+
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
16+
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
17+
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
18+
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
19+
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
20+
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
21+
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
22+
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
23+
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
24+
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
25+
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
26+
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
27+
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
28+
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
29+
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
30+
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=

internal/clientmanager/manager.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package clientmanager
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
"net/http"
8+
"os/exec"
9+
"sort"
10+
11+
"github.com/modelcontextprotocol/go-sdk/mcp"
12+
"skillful-mcp/internal/config"
13+
)
14+
15+
type Manager struct {
16+
sessions map[string]*mcp.ClientSession
17+
}
18+
19+
// ConnectAll creates a Manager by connecting to all servers in the config.
20+
func ConnectAll(ctx context.Context, cfg *config.Config) (*Manager, error) {
21+
m := &Manager{sessions: make(map[string]*mcp.ClientSession)}
22+
23+
for name, srv := range cfg.MCPServers {
24+
session, err := connect(ctx, name, &srv)
25+
if err != nil {
26+
// Close any sessions we already opened before returning.
27+
m.Close()
28+
return nil, fmt.Errorf("connecting to %q: %w", name, err)
29+
}
30+
m.sessions[name] = session
31+
tt, _ := srv.TransportType() // already validated
32+
slog.Info("connected to server", "skill", name, "transport", tt)
33+
}
34+
35+
return m, nil
36+
}
37+
38+
// NewFromSessions creates a Manager from pre-built sessions (useful for testing).
39+
func NewFromSessions(sessions map[string]*mcp.ClientSession) *Manager {
40+
return &Manager{sessions: sessions}
41+
}
42+
43+
func connect(ctx context.Context, name string, srv *config.ServerConfig) (*mcp.ClientSession, error) {
44+
client := mcp.NewClient(&mcp.Implementation{
45+
Name: "skillful-mcp",
46+
Version: "0.1.0",
47+
}, nil)
48+
49+
var transport mcp.Transport
50+
51+
tt, err := srv.TransportType()
52+
if err != nil {
53+
return nil, err
54+
}
55+
56+
switch tt {
57+
case config.TransportSTDIO:
58+
cmd := exec.Command(srv.Command, srv.Args...)
59+
cmd.Env = toEnv(srv.Env)
60+
transport = &mcp.CommandTransport{Command: cmd}
61+
62+
case config.TransportHTTP:
63+
httpClient := httpClientWithHeaders(srv.Headers)
64+
transport = &mcp.StreamableClientTransport{
65+
Endpoint: srv.URL,
66+
HTTPClient: httpClient,
67+
}
68+
69+
case config.TransportSSE:
70+
httpClient := httpClientWithHeaders(srv.Headers)
71+
transport = &mcp.SSEClientTransport{
72+
Endpoint: srv.URL,
73+
HTTPClient: httpClient,
74+
}
75+
}
76+
77+
session, err := client.Connect(ctx, transport, nil)
78+
if err != nil {
79+
return nil, err
80+
}
81+
return session, nil
82+
}
83+
84+
func (m *Manager) GetSession(name string) (*mcp.ClientSession, error) {
85+
s, ok := m.sessions[name]
86+
if !ok {
87+
return nil, fmt.Errorf("unknown skill: %q", name)
88+
}
89+
return s, nil
90+
}
91+
92+
func (m *Manager) ListServerNames() []string {
93+
names := make([]string, 0, len(m.sessions))
94+
for name := range m.sessions {
95+
names = append(names, name)
96+
}
97+
sort.Strings(names)
98+
return names
99+
}
100+
101+
func (m *Manager) Close() {
102+
for name, s := range m.sessions {
103+
if err := s.Close(); err != nil {
104+
slog.Warn("error closing session", "skill", name, "error", err)
105+
}
106+
}
107+
}
108+
109+
// toEnv converts the configured env map to a slice for exec.Cmd.
110+
// Only the explicitly specified vars are passed to the child process.
111+
// If no env vars are configured, returns nil (child inherits nothing).
112+
func toEnv(env map[string]string) []string {
113+
if len(env) == 0 {
114+
return nil
115+
}
116+
result := make([]string, 0, len(env))
117+
for k, v := range env {
118+
result = append(result, k+"="+v)
119+
}
120+
return result
121+
}
122+
123+
// headerTransport injects custom HTTP headers into every request.
124+
type headerTransport struct {
125+
base http.RoundTripper
126+
headers map[string]string
127+
}
128+
129+
func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
130+
for k, v := range t.headers {
131+
req.Header.Set(k, v)
132+
}
133+
return t.base.RoundTrip(req)
134+
}
135+
136+
func httpClientWithHeaders(headers map[string]string) *http.Client {
137+
if len(headers) == 0 {
138+
return http.DefaultClient
139+
}
140+
return &http.Client{
141+
Transport: &headerTransport{
142+
base: http.DefaultTransport,
143+
headers: headers,
144+
},
145+
}
146+
}

0 commit comments

Comments
 (0)