Skip to content

Commit 7b8c3c8

Browse files
feat(mcp): add tools for contexts, API keys, spaces, worker pools, and blueprints (#392)
* feat(mcp): add tools for contexts, API keys, spaces, worker pools, and blueprints Expose additional Spacelift GraphQL API resources as MCP tools: - Contexts: list_contexts, get_context, search_contexts - API Keys: list_api_keys, get_api_key - Spaces: list_spaces, get_space - Worker Pools: list_worker_pools, get_worker_pool - Blueprints: list_blueprints, get_blueprint Made-with: Cursor * fix: correct struct field alignment to pass gci/gofmt linting Made-with: Cursor
1 parent a34368a commit 7b8c3c8

6 files changed

Lines changed: 848 additions & 0 deletions

File tree

internal/cmd/apikey/mcp.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package apikey
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
8+
"github.com/mark3labs/mcp-go/mcp"
9+
"github.com/mark3labs/mcp-go/server"
10+
"github.com/pkg/errors"
11+
"github.com/shurcooL/graphql"
12+
13+
"github.com/spacelift-io/spacectl/internal/cmd/authenticated"
14+
)
15+
16+
func RegisterMCPTools(s *server.MCPServer) {
17+
registerListAPIKeysTool(s)
18+
registerGetAPIKeyTool(s)
19+
}
20+
21+
type apiKeyNode struct {
22+
ID string `graphql:"id" json:"id"`
23+
Name string `graphql:"name" json:"name"`
24+
Description string `graphql:"description" json:"description"`
25+
Type string `graphql:"type" json:"type"`
26+
Admin bool `graphql:"admin" json:"admin"`
27+
Creator string `graphql:"creator" json:"creator"`
28+
CreatedAt int `graphql:"createdAt" json:"createdAt"`
29+
LastUsedAt *int `graphql:"lastUsedAt" json:"lastUsedAt"`
30+
Deleted bool `graphql:"deleted" json:"deleted"`
31+
Teams []string `graphql:"teams" json:"teams"`
32+
TeamCount int `graphql:"teamCount" json:"teamCount"`
33+
SpaceCount int `graphql:"spaceCount" json:"spaceCount"`
34+
IsMachineUser bool `graphql:"isMachineUser" json:"isMachineUser"`
35+
ExpiresAt *int `graphql:"expiresAt" json:"expiresAt"`
36+
}
37+
38+
type apiKeyDetail struct {
39+
ID string `graphql:"id" json:"id"`
40+
Name string `graphql:"name" json:"name"`
41+
Description string `graphql:"description" json:"description"`
42+
Type string `graphql:"type" json:"type"`
43+
Admin bool `graphql:"admin" json:"admin"`
44+
Creator string `graphql:"creator" json:"creator"`
45+
CreatedAt int `graphql:"createdAt" json:"createdAt"`
46+
LastUsedAt *int `graphql:"lastUsedAt" json:"lastUsedAt"`
47+
Deleted bool `graphql:"deleted" json:"deleted"`
48+
Teams []string `graphql:"teams" json:"teams"`
49+
TeamCount int `graphql:"teamCount" json:"teamCount"`
50+
SpaceCount int `graphql:"spaceCount" json:"spaceCount"`
51+
IsMachineUser bool `graphql:"isMachineUser" json:"isMachineUser"`
52+
ExpiresAt *int `graphql:"expiresAt" json:"expiresAt"`
53+
AccessRules []apiKeyAccessRule `graphql:"accessRules" json:"accessRules"`
54+
}
55+
56+
type apiKeyAccessRule struct {
57+
Space string `graphql:"space" json:"space"`
58+
SpaceAccessLevel string `graphql:"spaceAccessLevel" json:"spaceAccessLevel"`
59+
}
60+
61+
func registerListAPIKeysTool(s *server.MCPServer) {
62+
tool := mcp.NewTool("list_api_keys",
63+
mcp.WithDescription(`Retrieve the list of all API keys for the Spacelift account. Returns key metadata including name, type, creator, and usage information. Does not return the secret values.`),
64+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
65+
Title: "List API Keys",
66+
ReadOnlyHint: mcp.ToBoolPtr(true),
67+
}),
68+
)
69+
70+
s.AddTool(tool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
71+
authenticated.Ensure(ctx, nil)
72+
73+
var query struct {
74+
APIKeys []apiKeyNode `graphql:"apiKeys"`
75+
}
76+
77+
if err := authenticated.Client().Query(ctx, &query, map[string]any{}); err != nil {
78+
return nil, errors.Wrap(err, "failed to query API keys")
79+
}
80+
81+
if len(query.APIKeys) == 0 {
82+
return mcp.NewToolResultText("No API keys found."), nil
83+
}
84+
85+
keysJSON, err := json.Marshal(query.APIKeys)
86+
if err != nil {
87+
return nil, errors.Wrap(err, "failed to marshal API keys to JSON")
88+
}
89+
90+
output := fmt.Sprintf("Found %d API keys:\n%s", len(query.APIKeys), string(keysJSON))
91+
return mcp.NewToolResultText(output), nil
92+
})
93+
}
94+
95+
func registerGetAPIKeyTool(s *server.MCPServer) {
96+
tool := mcp.NewTool("get_api_key",
97+
mcp.WithDescription(`Retrieve detailed information about a specific Spacelift API key including its access rules and space permissions.`),
98+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
99+
Title: "Get API Key",
100+
ReadOnlyHint: mcp.ToBoolPtr(true),
101+
}),
102+
mcp.WithString("api_key_id", mcp.Description("The ID of the API key to retrieve"), mcp.Required()),
103+
)
104+
105+
s.AddTool(tool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
106+
authenticated.Ensure(ctx, nil)
107+
apiKeyID, err := request.RequireString("api_key_id")
108+
if err != nil {
109+
return nil, err
110+
}
111+
112+
var query struct {
113+
APIKey *apiKeyDetail `graphql:"apiKey(id: $id)"`
114+
}
115+
116+
variables := map[string]any{
117+
"id": graphql.ID(apiKeyID),
118+
}
119+
120+
if err := authenticated.Client().Query(ctx, &query, variables); err != nil {
121+
return nil, errors.Wrapf(err, "failed to query for API key ID %q", apiKeyID)
122+
}
123+
124+
if query.APIKey == nil {
125+
return mcp.NewToolResultText("API key not found"), nil
126+
}
127+
128+
keyJSON, err := json.Marshal(query.APIKey)
129+
if err != nil {
130+
return nil, errors.Wrap(err, "failed to marshal API key to JSON")
131+
}
132+
133+
return mcp.NewToolResultText(string(keyJSON)), nil
134+
})
135+
}

internal/cmd/blueprint/mcp.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package blueprint
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
8+
"github.com/mark3labs/mcp-go/mcp"
9+
"github.com/mark3labs/mcp-go/server"
10+
"github.com/pkg/errors"
11+
"github.com/shurcooL/graphql"
12+
13+
"github.com/spacelift-io/spacectl/client/structs"
14+
"github.com/spacelift-io/spacectl/internal/cmd/authenticated"
15+
)
16+
17+
func RegisterMCPTools(s *server.MCPServer) {
18+
registerListBlueprintsTool(s)
19+
registerGetBlueprintTool(s)
20+
}
21+
22+
func registerListBlueprintsTool(s *server.MCPServer) {
23+
tool := mcp.NewTool("list_blueprints",
24+
mcp.WithDescription(`Retrieve a paginated list of Spacelift blueprints. Blueprints are reusable templates for creating stacks.`),
25+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
26+
Title: "List Blueprints",
27+
ReadOnlyHint: mcp.ToBoolPtr(true),
28+
}),
29+
mcp.WithNumber("limit", mcp.Description("The maximum number of blueprints to return, default is 50")),
30+
mcp.WithString("search", mcp.Description("Perform a full text search on blueprint name and description")),
31+
mcp.WithString("next_page_cursor", mcp.Description("The pagination cursor to use for fetching the next page of results")),
32+
)
33+
34+
s.AddTool(tool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
35+
authenticated.Ensure(ctx, nil)
36+
limit := request.GetInt("limit", 50)
37+
38+
var fullTextSearch *graphql.String
39+
if searchParam := request.GetString("search", ""); searchParam != "" {
40+
fullTextSearch = graphql.NewString(graphql.String(searchParam))
41+
}
42+
43+
var nextPageCursor *graphql.String
44+
if cursor := request.GetString("next_page_cursor", ""); cursor != "" {
45+
nextPageCursor = graphql.NewString(graphql.String(cursor))
46+
}
47+
48+
pageInput := structs.SearchInput{
49+
First: graphql.NewInt(graphql.Int(limit)), //nolint: gosec
50+
FullTextSearch: fullTextSearch,
51+
After: nextPageCursor,
52+
}
53+
54+
result, err := searchBlueprints(ctx, pageInput)
55+
if err != nil {
56+
return nil, errors.Wrap(err, "failed to search blueprints")
57+
}
58+
59+
blueprintsJSON, err := json.Marshal(result.Blueprints)
60+
if err != nil {
61+
return nil, errors.Wrap(err, "failed to marshal blueprints to JSON")
62+
}
63+
64+
output := string(blueprintsJSON)
65+
66+
if len(result.Blueprints) == 0 {
67+
if nextPageCursor != nil {
68+
output = "No more blueprints available. You have reached the end of the list."
69+
} else {
70+
output = "No blueprints found matching your criteria."
71+
}
72+
} else if result.PageInfo.HasNextPage {
73+
output = fmt.Sprintf("Showing %d blueprints:\n%s\n\nThis is not the complete list. To view more blueprints, use this tool again with this cursor as \"next_page_cursor\": \"%s\"", len(result.Blueprints), output, result.PageInfo.EndCursor)
74+
} else {
75+
output = fmt.Sprintf("Showing %d blueprints:\n%s\n\nThis is the complete list. There are no more blueprints to fetch.", len(result.Blueprints), output)
76+
}
77+
78+
return mcp.NewToolResultText(output), nil
79+
})
80+
}
81+
82+
func registerGetBlueprintTool(s *server.MCPServer) {
83+
tool := mcp.NewTool("get_blueprint",
84+
mcp.WithDescription(`Retrieve detailed information about a specific Spacelift blueprint including its template, inputs, and metadata.`),
85+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
86+
Title: "Get Blueprint",
87+
ReadOnlyHint: mcp.ToBoolPtr(true),
88+
}),
89+
mcp.WithString("blueprint_id", mcp.Description("The ID of the blueprint to retrieve"), mcp.Required()),
90+
)
91+
92+
s.AddTool(tool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
93+
authenticated.Ensure(ctx, nil)
94+
blueprintID, err := request.RequireString("blueprint_id")
95+
if err != nil {
96+
return nil, err
97+
}
98+
99+
b, found, err := getBlueprintByID(ctx, blueprintID)
100+
if err != nil {
101+
return nil, errors.Wrapf(err, "failed to query for blueprint ID %q", blueprintID)
102+
}
103+
104+
if !found {
105+
return mcp.NewToolResultText("Blueprint not found"), nil
106+
}
107+
108+
blueprintJSON, err := json.Marshal(b)
109+
if err != nil {
110+
return nil, errors.Wrap(err, "failed to marshal blueprint to JSON")
111+
}
112+
113+
return mcp.NewToolResultText(string(blueprintJSON)), nil
114+
})
115+
}

0 commit comments

Comments
 (0)