Skip to content

Commit dc31eb0

Browse files
tjholmjyecusch
andauthored
feat(cli): embedded MCP server (#131)
Co-authored-by: Jye Cusch <jye.cusch@gmail.com>
1 parent b5b48af commit dc31eb0

File tree

16 files changed

+3192
-33
lines changed

16 files changed

+3192
-33
lines changed

cli/cmd/mcp.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package cmd
2+
3+
import (
4+
"github.com/nitrictech/suga/cli/pkg/app"
5+
"github.com/samber/do/v2"
6+
"github.com/spf13/cobra"
7+
)
8+
9+
// NewMcpCmd creates the mcp command
10+
func NewMcpCmd(injector do.Injector) *cobra.Command {
11+
mcpCmd := &cobra.Command{
12+
Use: "mcp",
13+
Short: "Start the Suga MCP (Model Context Protocol) server",
14+
Long: `Start the Suga MCP server that provides access to Suga platform APIs
15+
through the Model Context Protocol. This allows AI assistants to interact
16+
with your Suga templates, platforms, and build manifests.
17+
18+
The server uses stdio transport and requires authentication via 'suga login'.`,
19+
Run: func(cmd *cobra.Command, args []string) {
20+
app, err := do.Invoke[*app.SugaApp](injector)
21+
if err != nil {
22+
cobra.CheckErr(err)
23+
}
24+
cobra.CheckErr(app.MCP())
25+
},
26+
}
27+
28+
return mcpCmd
29+
}

cli/cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ func NewRootCmd(injector do.Injector) *cobra.Command {
4242
rootCmd.AddCommand(NewConfigCmd(injector))
4343
rootCmd.AddCommand(NewTeamCmd(injector))
4444
rootCmd.AddCommand(NewPluginCmd(injector))
45+
rootCmd.AddCommand(NewMcpCmd(injector))
4546

4647
return rootCmd
4748
}

cli/go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ require (
1111
github.com/hashicorp/go-getter v1.7.8
1212
github.com/invopop/jsonschema v0.13.0
1313
github.com/mitchellh/mapstructure v1.5.0
14+
github.com/modelcontextprotocol/go-sdk v1.0.0
1415
github.com/nitrictech/suga/engines v0.0.0-20250822044031-c54426614b80
1516
github.com/nitrictech/suga/proto v0.0.0-20250822044031-c54426614b80
1617
github.com/pkg/errors v0.9.1
@@ -77,6 +78,7 @@ require (
7778
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
7879
github.com/godbus/dbus/v5 v5.1.0 // indirect
7980
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
81+
github.com/google/jsonschema-go v0.3.0 // indirect
8082
github.com/google/s2a-go v0.1.9 // indirect
8183
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
8284
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
@@ -117,6 +119,7 @@ require (
117119
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
118120
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
119121
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
122+
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
120123
github.com/yuin/goldmark v1.5.2 // indirect
121124
github.com/zeebo/errs v1.4.0 // indirect
122125
go.opencensus.io v0.24.0 // indirect

cli/go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -867,6 +867,8 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
867867
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
868868
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
869869
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
870+
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
871+
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
870872
github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
871873
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
872874
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@@ -1001,6 +1003,8 @@ github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4
10011003
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
10021004
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
10031005
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
1006+
github.com/modelcontextprotocol/go-sdk v1.0.0 h1:Z4MSjLi38bTgLrd/LjSmofqRqyBiVKRyQSJgw8q8V74=
1007+
github.com/modelcontextprotocol/go-sdk v1.0.0/go.mod h1:nYtYQroQ2KQiM0/SbyEPUWQ6xs4B95gJjEalc9AQyOs=
10041008
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
10051009
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
10061010
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -1106,6 +1110,8 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17
11061110
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
11071111
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
11081112
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
1113+
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
1114+
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
11091115
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
11101116
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
11111117
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=

cli/internal/api/platform.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,63 @@ func (c *SugaApiClient) GetPublicPlatform(team, name string, revision int) (*ter
7070
}
7171
return &platformRevision.Revision.Content, nil
7272
}
73+
74+
func (c *SugaApiClient) ListPlatforms(team string) ([]PlatformResponse, error) {
75+
response, err := c.get(fmt.Sprintf("/api/teams/%s/platforms", url.PathEscape(team)), true)
76+
if err != nil {
77+
return nil, err
78+
}
79+
defer response.Body.Close()
80+
81+
if response.StatusCode != 200 {
82+
if response.StatusCode == 404 {
83+
return nil, ErrNotFound
84+
}
85+
86+
if response.StatusCode == 401 {
87+
return nil, ErrUnauthenticated
88+
}
89+
90+
return nil, fmt.Errorf("received non 200 response from %s platforms list endpoint: %d", version.ProductName, response.StatusCode)
91+
}
92+
93+
body, err := io.ReadAll(response.Body)
94+
if err != nil {
95+
return nil, fmt.Errorf("failed to read response from %s platforms list endpoint: %v", version.ProductName, err)
96+
}
97+
98+
var platformsResponse PlatformsResponse
99+
err = json.Unmarshal(body, &platformsResponse)
100+
if err != nil {
101+
return nil, fmt.Errorf("unexpected response from %s platforms list endpoint: %v", version.ProductName, err)
102+
}
103+
return platformsResponse.Platforms, nil
104+
}
105+
106+
func (c *SugaApiClient) ListPublicPlatforms() ([]PlatformResponse, error) {
107+
response, err := c.get("/api/public/platforms", true)
108+
if err != nil {
109+
return nil, err
110+
}
111+
defer response.Body.Close()
112+
113+
if response.StatusCode != 200 {
114+
if response.StatusCode == 404 {
115+
return nil, ErrNotFound
116+
}
117+
118+
return nil, fmt.Errorf("received non 200 response from %s public platforms list endpoint: %d", version.ProductName, response.StatusCode)
119+
}
120+
121+
body, err := io.ReadAll(response.Body)
122+
if err != nil {
123+
return nil, fmt.Errorf("failed to read response from %s public platforms list endpoint: %v", version.ProductName, err)
124+
}
125+
126+
var platformsResponse PlatformsResponse
127+
err = json.Unmarshal(body, &platformsResponse)
128+
if err != nil {
129+
return nil, fmt.Errorf("unexpected response from %s public platforms list endpoint: %v", version.ProductName, err)
130+
}
131+
return platformsResponse.Platforms, nil
132+
}

cli/internal/api/plugin.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,123 @@ func (c *SugaApiClient) GetPublicPluginManifest(team, lib, libVersion, name stri
9494

9595
return c.parsePluginManifest(body, "public")
9696
}
97+
98+
func (c *SugaApiClient) ListPluginLibraries(team string) ([]PluginLibraryWithVersions, error) {
99+
response, err := c.get(fmt.Sprintf("/api/teams/%s/plugin_libraries", url.PathEscape(team)), true)
100+
if err != nil {
101+
return nil, err
102+
}
103+
defer response.Body.Close()
104+
105+
if response.StatusCode != 200 {
106+
if response.StatusCode == 404 {
107+
return nil, ErrNotFound
108+
}
109+
110+
if response.StatusCode == 401 {
111+
return nil, ErrUnauthenticated
112+
}
113+
114+
return nil, fmt.Errorf("received non 200 response from %s plugin libraries list endpoint: %d", version.ProductName, response.StatusCode)
115+
}
116+
117+
body, err := io.ReadAll(response.Body)
118+
if err != nil {
119+
return nil, fmt.Errorf("failed to read response from %s plugin libraries list endpoint: %v", version.ProductName, err)
120+
}
121+
122+
var librariesResponse ListPluginLibrariesResponse
123+
err = json.Unmarshal(body, &librariesResponse)
124+
if err != nil {
125+
return nil, fmt.Errorf("unexpected response from %s plugin libraries list endpoint: %v", version.ProductName, err)
126+
}
127+
return librariesResponse.Libraries, nil
128+
}
129+
130+
func (c *SugaApiClient) ListPublicPluginLibraries() ([]PluginLibraryWithVersions, error) {
131+
response, err := c.get("/api/public/plugin_libraries", true)
132+
if err != nil {
133+
return nil, err
134+
}
135+
defer response.Body.Close()
136+
137+
if response.StatusCode != 200 {
138+
if response.StatusCode == 404 {
139+
return nil, ErrNotFound
140+
}
141+
142+
return nil, fmt.Errorf("received non 200 response from %s public plugin libraries list endpoint: %d", version.ProductName, response.StatusCode)
143+
}
144+
145+
body, err := io.ReadAll(response.Body)
146+
if err != nil {
147+
return nil, fmt.Errorf("failed to read response from %s public plugin libraries list endpoint: %v", version.ProductName, err)
148+
}
149+
150+
var librariesResponse ListPluginLibrariesResponse
151+
err = json.Unmarshal(body, &librariesResponse)
152+
if err != nil {
153+
return nil, fmt.Errorf("unexpected response from %s public plugin libraries list endpoint: %v", version.ProductName, err)
154+
}
155+
return librariesResponse.Libraries, nil
156+
}
157+
158+
func (c *SugaApiClient) GetPluginLibraryVersion(team, lib, libVersion string) (*PluginLibraryVersion, error) {
159+
response, err := c.get(fmt.Sprintf("/api/teams/%s/plugin_libraries/%s/versions/%s", url.PathEscape(team), url.PathEscape(lib), url.PathEscape(libVersion)), true)
160+
if err != nil {
161+
return nil, err
162+
}
163+
defer response.Body.Close()
164+
165+
if response.StatusCode != 200 {
166+
if response.StatusCode == 404 {
167+
return nil, ErrNotFound
168+
}
169+
170+
if response.StatusCode == 401 {
171+
return nil, ErrUnauthenticated
172+
}
173+
174+
return nil, fmt.Errorf("received non 200 response from %s plugin library version endpoint: %d", version.ProductName, response.StatusCode)
175+
}
176+
177+
body, err := io.ReadAll(response.Body)
178+
if err != nil {
179+
return nil, fmt.Errorf("failed to read response from %s plugin library version endpoint: %v", version.ProductName, err)
180+
}
181+
182+
var versionResponse GetPluginLibraryVersionResponse
183+
err = json.Unmarshal(body, &versionResponse)
184+
if err != nil {
185+
return nil, fmt.Errorf("unexpected response from %s plugin library version endpoint: %v", version.ProductName, err)
186+
}
187+
return versionResponse.Version, nil
188+
}
189+
190+
func (c *SugaApiClient) GetPublicPluginLibraryVersion(team, lib, libVersion string) (*PluginLibraryVersion, error) {
191+
response, err := c.get(fmt.Sprintf("/api/public/plugin_libraries/%s/%s/versions/%s", url.PathEscape(team), url.PathEscape(lib), url.PathEscape(libVersion)), true)
192+
if err != nil {
193+
return nil, err
194+
}
195+
defer response.Body.Close()
196+
197+
if response.StatusCode != 200 {
198+
if response.StatusCode == 404 {
199+
return nil, ErrNotFound
200+
}
201+
202+
return nil, fmt.Errorf("received non 200 response from %s public plugin library version endpoint: %d", version.ProductName, response.StatusCode)
203+
}
204+
205+
body, err := io.ReadAll(response.Body)
206+
if err != nil {
207+
return nil, fmt.Errorf("failed to read response from %s public plugin library version endpoint: %v", version.ProductName, err)
208+
}
209+
210+
var versionResponse GetPluginLibraryVersionResponse
211+
err = json.Unmarshal(body, &versionResponse)
212+
if err != nil {
213+
return nil, fmt.Errorf("unexpected response from %s public plugin library version endpoint: %v", version.ProductName, err)
214+
}
215+
return versionResponse.Version, nil
216+
}

0 commit comments

Comments
 (0)