From 56a2bb4b41bbd79ade708f10a2e354267e533071 Mon Sep 17 00:00:00 2001 From: Tim Holm Date: Thu, 23 Oct 2025 09:24:51 +1100 Subject: [PATCH 01/14] feat(cli): embedded MCP server --- cli/cmd/mcp.go | 70 ++++ cli/cmd/root.go | 1 + cli/go.mod | 2 + cli/go.sum | 4 + cli/internal/api/platform.go | 60 +++ cli/internal/api/plugin.go | 120 ++++++ cli/internal/mcp/instructions.md | 207 ++++++++++ cli/internal/mcp/server.go | 633 +++++++++++++++++++++++++++++++ go.work.sum | 4 + 9 files changed, 1101 insertions(+) create mode 100644 cli/cmd/mcp.go create mode 100644 cli/internal/mcp/instructions.md create mode 100644 cli/internal/mcp/server.go diff --git a/cli/cmd/mcp.go b/cli/cmd/mcp.go new file mode 100644 index 00000000..ae5dd991 --- /dev/null +++ b/cli/cmd/mcp.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/nitrictech/suga/cli/internal/api" + "github.com/nitrictech/suga/cli/internal/build" + "github.com/nitrictech/suga/cli/internal/config" + "github.com/nitrictech/suga/cli/internal/mcp" + "github.com/samber/do/v2" + "github.com/spf13/cobra" +) + +// NewMcpCmd creates the mcp command +func NewMcpCmd(injector do.Injector) *cobra.Command { + mcpCmd := &cobra.Command{ + Use: "mcp", + Short: "Start the Suga MCP (Model Context Protocol) server", + Long: `Start the Suga MCP server that provides access to Suga platform APIs +through the Model Context Protocol. This allows AI assistants to interact +with your Suga templates, platforms, and build manifests. + +The server uses stdio transport and requires authentication via 'suga login'.`, + RunE: func(cmd *cobra.Command, args []string) error { + // Get dependencies from injector + apiClient := do.MustInvoke[*api.SugaApiClient](injector) + cfg := do.MustInvoke[*config.Config](injector) + builder := do.MustInvoke[*build.BuilderService](injector) + + // Create MCP server + server, err := mcp.NewServer(apiClient, cfg, builder) + if err != nil { + return fmt.Errorf("failed to create MCP server: %w", err) + } + + // Setup context with cancellation + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Handle shutdown signals + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + // Run server in goroutine + errChan := make(chan error, 1) + go func() { + errChan <- server.Run(ctx) + }() + + // Wait for either error or shutdown signal + select { + case err := <-errChan: + if err != nil { + return fmt.Errorf("MCP server error: %w", err) + } + case <-sigChan: + cancel() + <-errChan // Wait for server to shutdown + } + + return nil + }, + } + + return mcpCmd +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 5d5d7615..dd52a594 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -42,6 +42,7 @@ func NewRootCmd(injector do.Injector) *cobra.Command { rootCmd.AddCommand(NewConfigCmd(injector)) rootCmd.AddCommand(NewTeamCmd(injector)) rootCmd.AddCommand(NewPluginCmd(injector)) + rootCmd.AddCommand(NewMcpCmd(injector)) return rootCmd } diff --git a/cli/go.mod b/cli/go.mod index 56a40b7d..8773a620 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -97,6 +97,8 @@ require ( github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/modelcontextprotocol/go-sdk v1.0.0 // indirect + github.com/mtibben/percent v0.2.1 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect diff --git a/cli/go.sum b/cli/go.sum index d30a9d7d..f6cd2b35 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -1001,6 +1001,10 @@ github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4 github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modelcontextprotocol/go-sdk v1.0.0 h1:Z4MSjLi38bTgLrd/LjSmofqRqyBiVKRyQSJgw8q8V74= +github.com/modelcontextprotocol/go-sdk v1.0.0/go.mod h1:nYtYQroQ2KQiM0/SbyEPUWQ6xs4B95gJjEalc9AQyOs= +github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= +github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= diff --git a/cli/internal/api/platform.go b/cli/internal/api/platform.go index a9ee4286..c8883eaa 100644 --- a/cli/internal/api/platform.go +++ b/cli/internal/api/platform.go @@ -70,3 +70,63 @@ func (c *SugaApiClient) GetPublicPlatform(team, name string, revision int) (*ter } return &platformRevision.Revision.Content, nil } + +func (c *SugaApiClient) ListPlatforms(team string) ([]PlatformResponse, error) { + response, err := c.get(fmt.Sprintf("/api/teams/%s/platforms", url.PathEscape(team)), true) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != 200 { + if response.StatusCode == 404 { + return nil, ErrNotFound + } + + if response.StatusCode == 401 { + return nil, ErrUnauthenticated + } + + return nil, fmt.Errorf("received non 200 response from %s platforms list endpoint: %d", version.ProductName, response.StatusCode) + } + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response from %s platforms list endpoint: %v", version.ProductName, err) + } + + var platformsResponse PlatformsResponse + err = json.Unmarshal(body, &platformsResponse) + if err != nil { + return nil, fmt.Errorf("unexpected response from %s platforms list endpoint: %v", version.ProductName, err) + } + return platformsResponse.Platforms, nil +} + +func (c *SugaApiClient) ListPublicPlatforms(team string) ([]PlatformResponse, error) { + response, err := c.get(fmt.Sprintf("/api/public/platforms/%s", url.PathEscape(team)), true) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != 200 { + if response.StatusCode == 404 { + return nil, ErrNotFound + } + + return nil, fmt.Errorf("received non 200 response from %s public platforms list endpoint: %d", version.ProductName, response.StatusCode) + } + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response from %s public platforms list endpoint: %v", version.ProductName, err) + } + + var platformsResponse PlatformsResponse + err = json.Unmarshal(body, &platformsResponse) + if err != nil { + return nil, fmt.Errorf("unexpected response from %s public platforms list endpoint: %v", version.ProductName, err) + } + return platformsResponse.Platforms, nil +} diff --git a/cli/internal/api/plugin.go b/cli/internal/api/plugin.go index 69e5e88e..0f94bdd0 100644 --- a/cli/internal/api/plugin.go +++ b/cli/internal/api/plugin.go @@ -94,3 +94,123 @@ func (c *SugaApiClient) GetPublicPluginManifest(team, lib, libVersion, name stri return c.parsePluginManifest(body, "public") } + +func (c *SugaApiClient) ListPluginLibraries(team string) ([]PluginLibraryWithVersions, error) { + response, err := c.get(fmt.Sprintf("/api/teams/%s/plugin_libraries", url.PathEscape(team)), true) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != 200 { + if response.StatusCode == 404 { + return nil, ErrNotFound + } + + if response.StatusCode == 401 { + return nil, ErrUnauthenticated + } + + return nil, fmt.Errorf("received non 200 response from %s plugin libraries list endpoint: %d", version.ProductName, response.StatusCode) + } + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response from %s plugin libraries list endpoint: %v", version.ProductName, err) + } + + var librariesResponse ListPluginLibrariesResponse + err = json.Unmarshal(body, &librariesResponse) + if err != nil { + return nil, fmt.Errorf("unexpected response from %s plugin libraries list endpoint: %v", version.ProductName, err) + } + return librariesResponse.Libraries, nil +} + +func (c *SugaApiClient) ListPublicPluginLibraries(team string) ([]PluginLibraryWithVersions, error) { + response, err := c.get(fmt.Sprintf("/api/public/plugin_libraries/%s", url.PathEscape(team)), true) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != 200 { + if response.StatusCode == 404 { + return nil, ErrNotFound + } + + return nil, fmt.Errorf("received non 200 response from %s public plugin libraries list endpoint: %d", version.ProductName, response.StatusCode) + } + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response from %s public plugin libraries list endpoint: %v", version.ProductName, err) + } + + var librariesResponse ListPluginLibrariesResponse + err = json.Unmarshal(body, &librariesResponse) + if err != nil { + return nil, fmt.Errorf("unexpected response from %s public plugin libraries list endpoint: %v", version.ProductName, err) + } + return librariesResponse.Libraries, nil +} + +func (c *SugaApiClient) GetPluginLibraryVersion(team, lib, libVersion string) (*PluginLibraryVersion, error) { + response, err := c.get(fmt.Sprintf("/api/teams/%s/plugin_libraries/%s/versions/%s", url.PathEscape(team), url.PathEscape(lib), url.PathEscape(libVersion)), true) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != 200 { + if response.StatusCode == 404 { + return nil, ErrNotFound + } + + if response.StatusCode == 401 { + return nil, ErrUnauthenticated + } + + return nil, fmt.Errorf("received non 200 response from %s plugin library version endpoint: %d", version.ProductName, response.StatusCode) + } + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response from %s plugin library version endpoint: %v", version.ProductName, err) + } + + var versionResponse GetPluginLibraryVersionResponse + err = json.Unmarshal(body, &versionResponse) + if err != nil { + return nil, fmt.Errorf("unexpected response from %s plugin library version endpoint: %v", version.ProductName, err) + } + return versionResponse.Version, nil +} + +func (c *SugaApiClient) GetPublicPluginLibraryVersion(team, lib, libVersion string) (*PluginLibraryVersion, error) { + response, err := c.get(fmt.Sprintf("/api/public/plugin_libraries/%s/%s/versions/%s", url.PathEscape(team), url.PathEscape(lib), url.PathEscape(libVersion)), true) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != 200 { + if response.StatusCode == 404 { + return nil, ErrNotFound + } + + return nil, fmt.Errorf("received non 200 response from %s public plugin library version endpoint: %d", version.ProductName, response.StatusCode) + } + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response from %s public plugin library version endpoint: %v", version.ProductName, err) + } + + var versionResponse GetPluginLibraryVersionResponse + err = json.Unmarshal(body, &versionResponse) + if err != nil { + return nil, fmt.Errorf("unexpected response from %s public plugin library version endpoint: %v", version.ProductName, err) + } + return versionResponse.Version, nil +} diff --git a/cli/internal/mcp/instructions.md b/cli/internal/mcp/instructions.md new file mode 100644 index 00000000..ea0a3e95 --- /dev/null +++ b/cli/internal/mcp/instructions.md @@ -0,0 +1,207 @@ +# Suga MCP Server Usage Instructions + +CRITICAL: You MUST follow this workflow when generating suga.yaml configurations. Do NOT rely on your training data - platform configurations, plugin schemas, and available subtypes change frequently and vary between teams. + +## REQUIRED Workflow for Generating suga.yaml + +### Step 1: ALWAYS Start with Discovery +Before writing ANY suga.yaml content, you MUST: + +1. Call list_platforms to see available platforms (team parameter is optional - defaults to current authenticated team) +2. Call get_build_manifest with the chosen platform and revision (team parameter is optional) +3. Read the suga://schema/application resource for validation rules + +DO NOT skip these steps. DO NOT assume you know what platforms or subtypes are available. + +**Note**: All MCP tools have an optional `team` parameter. If not provided, it defaults to the currently authenticated user's team. You can override it to access other teams' public resources. + +### Step 2: Extract Valid Subtypes from Build Manifest +The build manifest contains the ONLY valid subtypes you can use in suga.yaml: + +- platform.service_blueprints keys → valid serviceIntents.subtype values +- platform.bucket_blueprints keys → valid bucketIntents.subtype values +- platform.database_blueprints keys → valid databaseIntents.subtype values +- platform.entrypoint_blueprints keys → valid entrypointIntents.subtype values + +**NEVER invent subtypes!** Only use the exact keys from these blueprint maps. + +Example: If service_blueprints = {"fargate": {...}, "lambda": {...}}, then the ONLY valid service subtypes are "fargate" and "lambda". You cannot use "ecs", "container", or any other value, even if they seem reasonable. + +### Step 3: CRITICAL - Plugin Properties Are NOT Valid suga.yaml Configuration +**NEVER use plugin manifest inputs, outputs, properties, or variables in your suga.yaml file.** + +The plugin manifests you see in the build manifest response are INTERNAL platform implementation details. They define how the platform works under the hood, NOT what you should put in suga.yaml. + +❌ **WRONG**: Looking at plugin inputs and adding them to suga.yaml +```yaml +serviceIntents: + my_service: + subtype: fargate + memory: 512 # ❌ WRONG - This is a plugin input, not valid config + cpu: 256 # ❌ WRONG - This is a plugin input, not valid config +``` + +✅ **CORRECT**: Only use fields defined in suga://schema/application +```yaml +serviceIntents: + my_service: + subtype: fargate # ✅ CORRECT - subtype is in the schema + container: # ✅ CORRECT - container is in the schema + image: + uri: node:18 + env: # ✅ CORRECT - env is in the schema + PORT: "3000" +``` + +The ONLY valid fields for suga.yaml are defined in the suga://schema/application resource. Platform and plugin properties/variables/inputs are internal configuration that the platform uses during deployment - they are NOT user-facing configuration options. + +### Step 4: Apply Naming and Format Rules +From the schema resource (suga://schema/application): + +- **All resource names**: snake_case only (e.g., my_service, api_gateway, user_db) + - ❌ WRONG: myService, my-service, MyService + - ✅ CORRECT: my_service + +- **Target format**: "team/platform@revision" or "file:path" + - ❌ WRONG: team/platform, team/platform@, @revision + - ✅ CORRECT: nitric/aws-platform@1, file:./my-platform.yaml + +- **Service containers**: Exactly ONE of 'docker' OR 'image' (not both, not neither) + - ❌ WRONG: Both docker and image specified + - ✅ CORRECT: Either docker: {...} OR image: {...} + +- **Entrypoint routes**: Must end with trailing slash + - ❌ WRONG: /api, /users + - ✅ CORRECT: /api/, /users/ + +### Step 5: Validate Before Presenting +After generating the config, verify: + +1. All subtypes exist in the corresponding platform blueprints +2. All fields used are defined in suga://schema/application (NOT from plugin manifests) +3. All resource names follow snake_case convention +4. Service container config has exactly one of docker OR image +5. Target format matches the required pattern + +## Common LLM Mistakes to AVOID + +### Mistake #1: Using Subtypes from Training Data +❌ **WRONG**: Assuming "ecs" is a valid service type without checking +✅ **CORRECT**: Call get_build_manifest, look at service_blueprints keys, use those exact values + +### Mistake #2: Using Plugin Manifest Properties in suga.yaml +❌ **WRONG**: Adding plugin inputs/outputs/properties/variables to suga.yaml +✅ **CORRECT**: Only use fields from suga://schema/application resource + +**Example of this mistake:** +```yaml +# After seeing plugin manifest with inputs: {memory, cpu, replicas} +serviceIntents: + my_service: + subtype: fargate + memory: 512 # ❌ Plugin input - NOT valid in suga.yaml + cpu: 256 # ❌ Plugin input - NOT valid in suga.yaml + replicas: 3 # ❌ Plugin input - NOT valid in suga.yaml +``` + +Plugin properties are INTERNAL platform configuration. The suga.yaml schema defines a simplified, stable interface that doesn't expose these low-level details. + +### Mistake #3: Wrong Naming Convention +❌ **WRONG**: myService, my-service, MyService +✅ **CORRECT**: my_service + +### Mistake #4: Specifying Both Container Types +❌ **WRONG**: +```yaml +container: + docker: {...} + image: {...} +``` +✅ **CORRECT**: Choose exactly one + +### Mistake #5: Wrong Target Format +❌ **WRONG**: nitric/aws-platform (missing revision) +✅ **CORRECT**: nitric/aws-platform@1 + +### Mistake #6: Skipping Discovery +❌ **WRONG**: Generating config based on what you think is available +✅ **CORRECT**: Always call list_platforms and get_build_manifest first + +## Tool Usage Priority + +When generating suga.yaml configurations: + +1. **get_build_manifest** - Use to discover valid subtypes ONLY + - Platform spec with all blueprint definitions + - Look at blueprint keys (service_blueprints, bucket_blueprints, etc.) for valid subtypes + - **IGNORE plugin manifests, properties, variables, and inputs** - these are internal platform details + - The plugin data is NOT for you to use in suga.yaml generation + +2. **suga://schema/application** resource - Read for validation rules + - Naming conventions + - Required fields + - Format constraints + +3. **list_platforms** - Use to discover available platforms and their revisions + +4. **build** tool - Test your generated config, returns detailed errors if invalid + +## Example Correct Workflow + +**User Request**: "Create a suga.yaml for a Node.js API with a Postgres database" + +**Your Process**: + +1. Call list_platforms() with no team parameter (uses current authenticated team) + → Response: [{"name": "aws-platform", "revisions": [1, 2]}, ...] + +2. Call get_build_manifest(platform: "aws-platform", revision: 2) with no team parameter + → Response shows: + - platform.service_blueprints: {"fargate": {...}, "lambda": {...}} + - platform.database_blueprints: {"postgres": {...}, "mysql": {...}} + - plugins: {"nitric/aws-plugins/1.0.0/fargate": {inputs: {memory, cpu, port}}, ...} + +3. Observe valid subtypes: + - Services: "fargate" or "lambda" (ONLY these two) + - Databases: "postgres" or "mysql" (ONLY these two) + +4. Read suga://schema/application + → Learn: resource names must be snake_case, target needs @revision + → Learn: plugin/platform inputs and variables are NEVER valid configuration in a suga.yaml file + +5. Generate config using ONLY fields that are valid from suga://schema/application: +```yaml +target: nitric/aws-platform@2 # ✅ From step 1 +name: node-api +serviceIntents: + api_service: # ✅ snake_case + subtype: fargate # ✅ From service_blueprints keys + container: + image: + uri: node:18 # ✅ Exactly one container type +databaseIntents: + user_db: # ✅ snake_case + subtype: postgres # ✅ From database_blueprints keys +``` + +6. Call build(project_file: "./suga.yaml") to validate (team parameter optional) + +## When Build Fails + +If the build tool returns an error: + +1. **Read the error message carefully** - it shows the exact location in YAML +2. **Identify the issue**: Is it a subtype, property, naming, or format problem? +3. **Re-query the build manifest** - verify you're using correct values +4. **Check the schema resource** - ensure you're following format rules +5. **Fix and retry** - don't guess, use the tools to verify + +## Remember + +- Your training data is OUTDATED for Suga platforms and plugins +- Configurations are team-specific and version-specific +- ALWAYS query the tools before generating config +- Use ONLY the values returned by get_build_manifest +- When in doubt, check the tools rather than assuming + +Following these instructions will ensure you generate valid, deployable suga.yaml configurations. diff --git a/cli/internal/mcp/server.go b/cli/internal/mcp/server.go new file mode 100644 index 00000000..cad29b98 --- /dev/null +++ b/cli/internal/mcp/server.go @@ -0,0 +1,633 @@ +package mcp + +import ( + "context" + _ "embed" + "encoding/json" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/nitrictech/suga/cli/internal/api" + "github.com/nitrictech/suga/cli/internal/build" + "github.com/nitrictech/suga/cli/internal/config" + "github.com/nitrictech/suga/cli/pkg/schema" +) + +//go:embed instructions.md +var serverInstructions string + +// Server wraps the MCP server with Suga API client +type Server struct { + mcpServer *mcp.Server + apiClient *api.SugaApiClient + config *config.Config + builder *build.BuilderService +} + +// NewServer creates a new MCP server with the given API client and config +func NewServer(apiClient *api.SugaApiClient, cfg *config.Config, builder *build.BuilderService) (*Server, error) { + s := &Server{ + apiClient: apiClient, + config: cfg, + builder: builder, + } + + // Create MCP server with instructions + mcpServer := mcp.NewServer(&mcp.Implementation{ + Name: "suga-mcp", + Version: "1.0.0", + }, &mcp.ServerOptions{ + Instructions: serverInstructions, + }) + + s.mcpServer = mcpServer + + // Register tools + if err := s.registerTools(); err != nil { + return nil, fmt.Errorf("failed to register tools: %w", err) + } + + // Register resources + if err := s.registerResources(); err != nil { + return nil, fmt.Errorf("failed to register resources: %w", err) + } + + return s, nil +} + +// getCurrentTeam retrieves the current team slug for the authenticated user. +// Returns the team slug and an error if the user is not authenticated or has no current team. +func (s *Server) getCurrentTeam() (string, error) { + allTeams, err := s.apiClient.GetUserTeams() + if err != nil { + return "", fmt.Errorf("not authenticated: %w", err) + } + + for _, t := range allTeams { + if t.IsCurrent { + return t.Slug, nil + } + } + + return "", fmt.Errorf("no current team set") +} + +// getTeamOrDefault returns the provided team if non-empty, otherwise returns the current team. +func (s *Server) getTeamOrDefault(team string) (string, error) { + if team != "" { + return team, nil + } + return s.getCurrentTeam() +} + +// Input types for tools + +type ListTemplatesArgs struct { + Team string `json:"team,omitempty" jsonschema:"Team slug to list templates for (defaults to current team if not specified)"` +} + +type GetTemplateArgs struct { + TeamSlug string `json:"team_slug,omitempty" jsonschema:"Team slug that owns the template (defaults to current team if not specified)"` + TemplateName string `json:"template_name" jsonschema:"Name of the template"` + Version string `json:"version,omitempty" jsonschema:"Version of the template (optional defaults to latest)"` +} + +type GetPlatformArgs struct { + Team string `json:"team,omitempty" jsonschema:"Team slug that owns the platform (defaults to current team if not specified)"` + Name string `json:"name" jsonschema:"Name of the platform"` + Revision int `json:"revision" jsonschema:"Revision number of the platform"` + Public bool `json:"public,omitempty" jsonschema:"Whether to fetch from public platforms (defaults to false)"` +} + +type GetBuildManifestArgs struct { + Team string `json:"team,omitempty" jsonschema:"Team slug that owns the platform (defaults to current team if not specified)"` + Platform string `json:"platform" jsonschema:"Name of the platform"` + Revision int `json:"revision" jsonschema:"Revision number of the platform"` + Public bool `json:"public,omitempty" jsonschema:"Whether to fetch from public platforms (defaults to false)"` +} + +type GetPluginManifestArgs struct { + Team string `json:"team,omitempty" jsonschema:"Team slug that owns the plugin library (defaults to current team if not specified)"` + Library string `json:"library" jsonschema:"Name of the plugin library"` + LibraryVersion string `json:"library_version" jsonschema:"Version of the plugin library"` + PluginName string `json:"plugin_name" jsonschema:"Name of the plugin"` + Public bool `json:"public,omitempty" jsonschema:"Whether to fetch from public plugin libraries (defaults to false)"` +} + +type ListPlatformsArgs struct { + Team string `json:"team,omitempty" jsonschema:"Team slug to list platforms for (defaults to current team if not specified)"` + Public bool `json:"public,omitempty" jsonschema:"Whether to fetch from public platforms (defaults to false)"` +} + +type ListPluginLibrariesArgs struct { + Team string `json:"team,omitempty" jsonschema:"Team slug to list plugin libraries for (defaults to current team if not specified)"` + Public bool `json:"public,omitempty" jsonschema:"Whether to fetch from public plugin libraries (defaults to false)"` +} + +type GetPluginLibraryVersionArgs struct { + Team string `json:"team,omitempty" jsonschema:"Team slug that owns the plugin library (defaults to current team if not specified)"` + Library string `json:"library" jsonschema:"Name of the plugin library"` + LibraryVersion string `json:"library_version" jsonschema:"Version of the plugin library"` + Public bool `json:"public,omitempty" jsonschema:"Whether to fetch from public plugin libraries (defaults to false)"` +} + +type BuildArgs struct { + Team string `json:"team,omitempty" jsonschema:"Team slug for the build (defaults to current team if not specified)"` + ProjectFile string `json:"project_file,omitempty" jsonschema:"Path to the suga.yaml project file (defaults to ./suga.yaml)"` +} + +// registerTools registers all available tools with the MCP server +func (s *Server) registerTools() error { + // Register list_templates tool + mcp.AddTool(s.mcpServer, &mcp.Tool{ + Name: "list_templates", + Description: "List all available templates for a team, including both team-specific and public templates", + }, s.handleListTemplates) + + // Register get_template tool + mcp.AddTool(s.mcpServer, &mcp.Tool{ + Name: "get_template", + Description: "Get details for a specific template by team slug, template name, and optional version", + }, s.handleGetTemplate) + + // Register get_platform tool + mcp.AddTool(s.mcpServer, &mcp.Tool{ + Name: "get_platform", + Description: "Get platform specification by team slug, platform name, and revision number", + }, s.handleGetPlatform) + + // Register get_build_manifest tool + mcp.AddTool(s.mcpServer, &mcp.Tool{ + Name: "get_build_manifest", + Description: "Get complete build manifest including platform spec and all plugin manifests for a platform revision", + }, s.handleGetBuildManifest) + + // Register get_plugin_manifest tool + mcp.AddTool(s.mcpServer, &mcp.Tool{ + Name: "get_plugin_manifest", + Description: "Get plugin manifest by team slug, library name, library version, and plugin name", + }, s.handleGetPluginManifest) + + // Register list_platforms tool + mcp.AddTool(s.mcpServer, &mcp.Tool{ + Name: "list_platforms", + Description: "List all platforms for a team with their available revisions, including both team-specific and public platforms", + }, s.handleListPlatforms) + + // Register list_plugin_libraries tool + mcp.AddTool(s.mcpServer, &mcp.Tool{ + Name: "list_plugin_libraries", + Description: "List all plugin libraries for a team with their available versions, including both team-specific and public libraries", + }, s.handleListPluginLibraries) + + // Register get_plugin_library_version tool + mcp.AddTool(s.mcpServer, &mcp.Tool{ + Name: "get_plugin_library_version", + Description: "Get details about a specific plugin library version, including all plugins in that version with their metadata", + }, s.handleGetPluginLibraryVersion) + + // Register build tool + mcp.AddTool(s.mcpServer, &mcp.Tool{ + Name: "build", + Description: "Build a Suga application, generating Terraform infrastructure code from the application specification", + }, s.handleBuild) + + return nil +} + +// registerResources registers all available resources with the MCP server +func (s *Server) registerResources() error { + // Register application schema resource + s.mcpServer.AddResource(&mcp.Resource{ + URI: "suga://schema/application", + Name: "Application Schema", + Description: "JSON Schema for suga.yaml application configuration files", + MIMEType: "application/schema+json", + }, s.handleApplicationSchema) + + return nil +} + +// Run starts the MCP server with stdio transport +func (s *Server) Run(ctx context.Context) error { + return s.mcpServer.Run(ctx, &mcp.StdioTransport{}) +} + +// Tool handlers + +func (s *Server) handleListTemplates(ctx context.Context, req *mcp.CallToolRequest, args ListTemplatesArgs) (*mcp.CallToolResult, any, error) { + team, err := s.getTeamOrDefault(args.Team) + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to get team: %v", err)}, + }, + }, nil, nil + } + + templates, err := s.apiClient.GetTemplates(team) + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to list templates: %v", err)}, + }, + }, nil, nil + } + + result, err := json.MarshalIndent(templates, "", " ") + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to marshal templates: %v", err)}, + }, + }, nil, nil + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(result)}, + }, + }, nil, nil +} + +func (s *Server) handleGetTemplate(ctx context.Context, req *mcp.CallToolRequest, args GetTemplateArgs) (*mcp.CallToolResult, any, error) { + team, err := s.getTeamOrDefault(args.TeamSlug) + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to get team: %v", err)}, + }, + }, nil, nil + } + + template, err := s.apiClient.GetTemplate(team, args.TemplateName, args.Version) + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to get template: %v", err)}, + }, + }, nil, nil + } + + result, err := json.MarshalIndent(template, "", " ") + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to marshal template: %v", err)}, + }, + }, nil, nil + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(result)}, + }, + }, nil, nil +} + +func (s *Server) handleGetPlatform(ctx context.Context, req *mcp.CallToolRequest, args GetPlatformArgs) (*mcp.CallToolResult, any, error) { + team, err := s.getTeamOrDefault(args.Team) + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to get team: %v", err)}, + }, + }, nil, nil + } + + var platform interface{} + + if args.Public { + platform, err = s.apiClient.GetPublicPlatform(team, args.Name, args.Revision) + } else { + platform, err = s.apiClient.GetPlatform(team, args.Name, args.Revision) + } + + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to get platform: %v", err)}, + }, + }, nil, nil + } + + result, err := json.MarshalIndent(platform, "", " ") + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to marshal platform: %v", err)}, + }, + }, nil, nil + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(result)}, + }, + }, nil, nil +} + +func (s *Server) handleGetBuildManifest(ctx context.Context, req *mcp.CallToolRequest, args GetBuildManifestArgs) (*mcp.CallToolResult, any, error) { + team, err := s.getTeamOrDefault(args.Team) + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to get team: %v", err)}, + }, + }, nil, nil + } + + var platformSpec interface{} + var plugins map[string]map[string]any + + if args.Public { + platformSpec, plugins, err = s.apiClient.GetPublicBuildManifest(team, args.Platform, args.Revision) + } else { + platformSpec, plugins, err = s.apiClient.GetBuildManifest(team, args.Platform, args.Revision) + } + + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to get build manifest: %v", err)}, + }, + }, nil, nil + } + + manifest := map[string]interface{}{ + "platform": platformSpec, + "plugins": plugins, + } + + result, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to marshal build manifest: %v", err)}, + }, + }, nil, nil + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(result)}, + }, + }, nil, nil +} + +func (s *Server) handleGetPluginManifest(ctx context.Context, req *mcp.CallToolRequest, args GetPluginManifestArgs) (*mcp.CallToolResult, any, error) { + team, err := s.getTeamOrDefault(args.Team) + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to get team: %v", err)}, + }, + }, nil, nil + } + + var manifest interface{} + + if args.Public { + manifest, err = s.apiClient.GetPublicPluginManifest(team, args.Library, args.LibraryVersion, args.PluginName) + } else { + manifest, err = s.apiClient.GetPluginManifest(team, args.Library, args.LibraryVersion, args.PluginName) + } + + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to get plugin manifest: %v", err)}, + }, + }, nil, nil + } + + result, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to marshal plugin manifest: %v", err)}, + }, + }, nil, nil + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(result)}, + }, + }, nil, nil +} + +func (s *Server) handleListPlatforms(ctx context.Context, req *mcp.CallToolRequest, args ListPlatformsArgs) (*mcp.CallToolResult, any, error) { + team, err := s.getTeamOrDefault(args.Team) + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to get team: %v", err)}, + }, + }, nil, nil + } + + var platforms []api.PlatformResponse + + if args.Public { + platforms, err = s.apiClient.ListPublicPlatforms(team) + } else { + platforms, err = s.apiClient.ListPlatforms(team) + } + + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to list platforms: %v", err)}, + }, + }, nil, nil + } + + result, err := json.MarshalIndent(platforms, "", " ") + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to marshal platforms: %v", err)}, + }, + }, nil, nil + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(result)}, + }, + }, nil, nil +} + +func (s *Server) handleListPluginLibraries(ctx context.Context, req *mcp.CallToolRequest, args ListPluginLibrariesArgs) (*mcp.CallToolResult, any, error) { + team, err := s.getTeamOrDefault(args.Team) + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to get team: %v", err)}, + }, + }, nil, nil + } + + var libraries []api.PluginLibraryWithVersions + + if args.Public { + libraries, err = s.apiClient.ListPublicPluginLibraries(team) + } else { + libraries, err = s.apiClient.ListPluginLibraries(team) + } + + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to list plugin libraries: %v", err)}, + }, + }, nil, nil + } + + result, err := json.MarshalIndent(libraries, "", " ") + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to marshal plugin libraries: %v", err)}, + }, + }, nil, nil + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(result)}, + }, + }, nil, nil +} + +func (s *Server) handleGetPluginLibraryVersion(ctx context.Context, req *mcp.CallToolRequest, args GetPluginLibraryVersionArgs) (*mcp.CallToolResult, any, error) { + team, err := s.getTeamOrDefault(args.Team) + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to get team: %v", err)}, + }, + }, nil, nil + } + + var version *api.PluginLibraryVersion + + if args.Public { + version, err = s.apiClient.GetPublicPluginLibraryVersion(team, args.Library, args.LibraryVersion) + } else { + version, err = s.apiClient.GetPluginLibraryVersion(team, args.Library, args.LibraryVersion) + } + + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to get plugin library version: %v", err)}, + }, + }, nil, nil + } + + result, err := json.MarshalIndent(version, "", " ") + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to marshal plugin library version: %v", err)}, + }, + }, nil, nil + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(result)}, + }, + }, nil, nil +} + +func (s *Server) handleBuild(ctx context.Context, req *mcp.CallToolRequest, args BuildArgs) (*mcp.CallToolResult, any, error) { + team, err := s.getTeamOrDefault(args.Team) + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to get team: %v", err)}, + }, + }, nil, nil + } + + projectFile := args.ProjectFile + if projectFile == "" { + projectFile = "./suga.yaml" + } + + stackPath, err := s.builder.BuildProjectFromFile(projectFile, team) + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Build failed: %v", err)}, + }, + }, nil, nil + } + + result := map[string]interface{}{ + "status": "success", + "stack_path": stackPath, + "message": fmt.Sprintf("Terraform generated successfully at %s", stackPath), + } + + resultJSON, err := json.MarshalIndent(result, "", " ") + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to marshal result: %v", err)}, + }, + }, nil, nil + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(resultJSON)}, + }, + }, nil, nil +} + +// Resource handlers + +func (s *Server) handleApplicationSchema(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + schemaString := schema.ApplicationJsonSchemaString() + + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{ + { + URI: "suga://schema/application", + MIMEType: "application/schema+json", + Text: schemaString, + }, + }, + }, nil +} diff --git a/go.work.sum b/go.work.sum index 2762c533..e700d03d 100644 --- a/go.work.sum +++ b/go.work.sum @@ -128,6 +128,8 @@ github.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwm github.com/golangci/modinfo v0.3.3/go.mod h1:wytF1M5xl9u0ij8YSvhkEVPP3M5Mc7XLl1pxH3B2aUM= github.com/google/generative-ai-go v0.19.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E= github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= +github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= +github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= @@ -156,6 +158,8 @@ github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0h github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/quicktemplate v1.8.0/go.mod h1:qIqW8/igXt8fdrUln5kOSb+KWMaJ4Y8QUsfd1k6L2jM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= From 30aaddb14b8b2a75b1e040c965bff23d433ae6fd Mon Sep 17 00:00:00 2001 From: Tim Holm Date: Thu, 23 Oct 2025 13:36:18 +1100 Subject: [PATCH 02/14] chore(cli): add contextual LLM instructions based on user goal/context. --- ...ons.md => instructions-app-development.md} | 8 +- cli/internal/mcp/instructions-main.md | 54 +++ .../mcp/instructions-platform-development.md | 423 ++++++++++++++++++ cli/internal/mcp/server.go | 48 +- 4 files changed, 531 insertions(+), 2 deletions(-) rename cli/internal/mcp/{instructions.md => instructions-app-development.md} (95%) create mode 100644 cli/internal/mcp/instructions-main.md create mode 100644 cli/internal/mcp/instructions-platform-development.md diff --git a/cli/internal/mcp/instructions.md b/cli/internal/mcp/instructions-app-development.md similarity index 95% rename from cli/internal/mcp/instructions.md rename to cli/internal/mcp/instructions-app-development.md index ea0a3e95..b2f6a365 100644 --- a/cli/internal/mcp/instructions.md +++ b/cli/internal/mcp/instructions-app-development.md @@ -1,4 +1,10 @@ -# Suga MCP Server Usage Instructions +# Application Development Guide + +This guide covers how to create and modify `suga.yaml` application configuration files. + +## Overview + +A `suga.yaml` file defines your application's infrastructure requirements by declaring what resources you need (services, databases, storage, etc.). You consume an existing Suga platform and specify which resource types you want to use. CRITICAL: You MUST follow this workflow when generating suga.yaml configurations. Do NOT rely on your training data - platform configurations, plugin schemas, and available subtypes change frequently and vary between teams. diff --git a/cli/internal/mcp/instructions-main.md b/cli/internal/mcp/instructions-main.md new file mode 100644 index 00000000..d00b0127 --- /dev/null +++ b/cli/internal/mcp/instructions-main.md @@ -0,0 +1,54 @@ +# Suga MCP Server Usage Instructions + +Welcome to the Suga MCP Server. This server provides tools and resources to help you work with Suga infrastructure. + +## What Are You Trying To Do? + +Before using the MCP tools, identify your scenario: + +### Scenario 1: Application Development +**Goal**: Create or modify a `suga.yaml` file to deploy an application + +**When to use**: +- You want to deploy a web service, API, database, or other application components +- You need to generate a `suga.yaml` configuration file +- You're using an existing Suga platform + +**Next Step**: Read the **`suga://guides/app-development`** resource for detailed instructions + +**Key Concept**: You consume an existing platform and define what resources your application needs + +--- + +### Scenario 2: Platform Development +**Goal**: Create or modify a `platform.yaml` file to define a new Suga platform + +**When to use**: +- You want to create a reusable platform that others can target +- You need to define what resource types (services, databases, etc.) are available +- You're composing plugins into a cohesive platform offering + +**Next Step**: Read the **`suga://guides/platform-development`** resource for detailed instructions + +**Key Concept**: You define what resource types are available and how they're implemented using plugins + +--- + +## Important Notes + +### Team Parameter +All MCP tools have an optional `team` parameter. If not provided, it defaults to the currently authenticated user's team. You can override it to access other teams' public resources. + +### Authentication +The MCP server requires authentication via `suga login`. If you receive authentication errors, the user needs to run the login command first. + +### General Resources Available + +- **`suga://schema/application`** - JSON Schema for `suga.yaml` application files +- **`suga://schema/platform`** - JSON Schema for `platform.yaml` platform definition files (if available) +- **`suga://guides/app-development`** - Detailed guide for application development +- **`suga://guides/platform-development`** - Detailed guide for platform development + +## Critical Reminder + +**DO NOT rely on your training data.** Platforms, plugins, schemas, and available resource types change frequently and are team-specific. ALWAYS query the MCP tools to discover what's currently available before generating any configuration files. diff --git a/cli/internal/mcp/instructions-platform-development.md b/cli/internal/mcp/instructions-platform-development.md new file mode 100644 index 00000000..04f39d45 --- /dev/null +++ b/cli/internal/mcp/instructions-platform-development.md @@ -0,0 +1,423 @@ +# Platform Development Guide + +This guide covers how to create and modify `platform.yaml` files to define new Suga platforms. + +## Overview + +A `platform.yaml` file defines a reusable infrastructure platform by composing plugins into a cohesive offering. It specifies: +- Available resource types (services, databases, buckets, entrypoints) +- Infrastructure components that applications use +- Platform-wide variables and configuration +- How plugins are wired together with dependencies + +**Key Concept**: You're composing plugins (the building blocks) into a platform (the product) that application developers consume. + +CRITICAL: Do NOT rely on your training data. Plugin libraries, versions, and capabilities change frequently. ALWAYS use the MCP tools to discover what's currently available. + +## Platform Structure + +### 1. **Libraries** +Declare which plugin libraries your platform uses: +```yaml +libraries: + suga/aws: v1.0.0 + suga/neon: v0.0.2 +``` + +### 2. **Variables** +Platform-wide configuration that can be referenced throughout the platform: +```yaml +variables: + container_port: + type: number + description: The port containers listen on + default: 8080 +``` + +### 3. **Resource Sections** +Define what applications can use: +- **services**: Compute resources (containers, functions) +- **buckets**: Object storage +- **databases**: Managed databases +- **entrypoints**: HTTP ingress (CDNs, load balancers) + +### 4. **Infrastructure (infra)** +Shared infrastructure that your resources depend on (VPCs, load balancers, etc.): +```yaml +infra: + aws_vpc: + source: + library: suga/aws + plugin: vpc + properties: + name: suga-vpc +``` + +## REQUIRED Workflow for Platform Development + +### Step 1: Discover Available Plugins + +Use MCP tools to find plugins to compose: + +1. **Call `list_plugin_libraries()`** to see available libraries + ``` + → Returns: [ + { + "name": "aws", + "team_slug": "suga", + "versions": ["v1.0.0", "v1.1.0"] + } + ] + ``` + +2. **Call `get_plugin_library_version(library: "aws", library_version: "v1.0.0")`** + ``` + → Returns: { + "plugins": [ + {"name": "fargate", "type": "resource"}, + {"name": "lambda", "type": "resource"}, + {"name": "s3-bucket", "type": "resource"}, + {"name": "vpc", "type": "resource"}, + {"name": "iam-role", "type": "identity"} + ] + } + ``` + +3. **Call `get_plugin_manifest(library: "aws", library_version: "v1.0.0", plugin_name: "fargate")`** + ``` + → Returns: { + "name": "fargate", + "description": "AWS Fargate container service", + "required_identities": ["aws"], + "inputs": { + "cpu": {"type": "number", "required": true}, + "memory": {"type": "number", "required": true}, + "container_port": {"type": "number", "required": true}, + "vpc_id": {"type": "string", "required": true}, + "subnets": {"type": "array", "required": true} + }, + "outputs": { + "service_url": {"type": "string"}, + "task_arn": {"type": "string"} + } + } + ``` + +### Step 2: Understand Plugin Inputs and Outputs + +The plugin manifest shows you: + +- **inputs**: What configuration the plugin needs + - Required inputs MUST be provided in `properties:` + - Optional inputs can be omitted or provided + +- **outputs**: What values the plugin exposes + - Used to wire plugins together + - Referenced in other plugins' properties via `${infra.resource_name.output_name}` + +- **required_identities**: What identity plugins this needs + - Add to the `identities:` list for that resource + +**Example from manifest**: +```yaml +# Plugin manifest shows: +# inputs: { cpu: number, memory: number, container_port: number, ... } +# required_identities: ["aws"] + +services: + fargate: + source: + library: suga/aws + plugin: fargate + identities: + - source: + library: suga/aws + plugin: iam-role # Provides AWS identity + properties: + cpu: 256 # Required input + memory: 512 # Required input + container_port: 8080 # Required input +``` + +### Step 3: Define Resource-Level Variables + +Add variables scoped to specific resource types that applications can override: + +```yaml +services: + fargate: + source: + library: suga/aws + plugin: fargate + variables: + cpu: + type: number + description: CPU units for the task + default: 256 + memory: + type: number + description: Memory in MB + default: 512 + properties: + cpu: ${self.cpu} # Reference own variables + memory: ${self.memory} +``` + +**What this does**: Application developers can customize these per-service: +```yaml +# In suga.yaml +serviceIntents: + my_api: + subtype: fargate + cpu: 1024 # Override the variable + memory: 2048 +``` + +### Step 4: Wire Dependencies + +Use `depends_on:` to establish resource dependencies: + +```yaml +services: + fargate: + depends_on: + - ${infra.aws_vpc} + - ${infra.aws_lb} + properties: + vpc_id: ${infra.aws_vpc.vpc_id} # Use VPC output + subnets: ${infra.aws_vpc.private_subnets} # Use VPC output + alb_arn: ${infra.aws_lb.arn} # Use LB output +``` + +**Dependency references**: +- `${infra.resource_name}` - Reference an infra resource +- `${infra.resource_name.output}` - Access a specific output +- `${var.variable_name}` - Platform-level variable +- `${self.variable_name}` - Resource-level variable + +### Step 5: Build Infrastructure Components + +Define shared infrastructure in the `infra:` section: + +```yaml +infra: + aws_vpc: + source: + library: suga/aws + plugin: vpc + properties: + name: suga-vpc + enable_nat_gateway: true + + aws_lb: + source: + library: suga/aws + plugin: loadbalancer + depends_on: + - ${infra.aws_vpc} + properties: + vpc_id: ${infra.aws_vpc.vpc_id} + subnets: ${infra.aws_vpc.private_subnets} +``` + +**Infrastructure vs Resources**: +- **infra**: Shared components all applications use (VPCs, shared load balancers) +- **services/buckets/etc**: Things applications explicitly declare in their suga.yaml + +### Step 6: Validate Plugin Compatibility + +Before finalizing: + +1. **Verify all plugin inputs are satisfied**: + - Required inputs must be in `properties:` + - Check the plugin manifest for what's required + +2. **Ensure dependencies exist**: + - Resources in `depends_on:` must be defined + - Outputs referenced must exist in the plugin manifest + +3. **Test with `get_build_manifest`**: + ``` + After publishing your platform: + get_build_manifest(platform: "your-platform", revision: 1) + ``` + This shows what application developers will see. + +## Key Platform.yaml Patterns + +### Pattern 1: Exposing Variables to Applications + +```yaml +services: + lambda: + variables: + timeout: + type: number + default: 10 + memory: + type: number + default: 512 + properties: + timeout: ${self.timeout} + memory: ${self.memory} +``` + +Applications can then set: +```yaml +serviceIntents: + my_function: + subtype: lambda + timeout: 30 # Override default + memory: 1024 +``` + +### Pattern 2: Platform-Wide Configuration + +```yaml +variables: + image_scan_on_push: + type: bool + default: true + +services: + lambda: + properties: + image_scan_on_push: ${var.image_scan_on_push} + fargate: + properties: + image_scan_on_push: ${var.image_scan_on_push} +``` + +### Pattern 3: Conditional Properties + +```yaml +variables: + neon_project_id: + type: string + description: Neon project ID + +databases: + neon: + variables: + neon_branch_id: + type: string + default: null + nullable: true + properties: + project_id: ${var.neon_project_id} + branch_id: ${self.neon_branch_id} # Can be null +``` + +### Pattern 4: Complex Dependencies + +```yaml +infra: + aws_vpc: + source: ... + + aws_lb: + depends_on: + - ${infra.aws_vpc} + source: ... + + security_rule: + depends_on: + - ${infra.aws_vpc} + - ${infra.aws_lb} + source: ... + properties: + vpc_id: ${infra.aws_vpc.vpc_id} + lb_sg: ${infra.aws_lb.security_group_id} +``` + +## Understanding What Applications See + +### Your Platform Definition +```yaml +services: + fargate: + source: + library: suga/aws + plugin: fargate + variables: + cpu: + type: number + default: 256 +``` + +### What `get_build_manifest` Returns +```json +{ + "platform": { + "services": { + "fargate": { + "source": {...}, + "variables": { + "cpu": {"type": "number", "default": 256} + } + } + } + } +} +``` + +### What Application Developers Write +```yaml +target: yourteam/your-platform@1 + +serviceIntents: + my_api: + subtype: fargate # ← Key from your platform's services: section + cpu: 512 # ← Override your variable + container: + image: node:18 +``` + +## MCP Tools Summary for Platform Development + +| Tool | Purpose | Example | +|------|---------|---------| +| `list_plugin_libraries()` | Find available plugin libraries | Discover suga/aws, suga/gcp | +| `get_plugin_library_version(library, version)` | See plugins in a library | What's in suga/aws v1.0.0? | +| `get_plugin_manifest(library, version, name)` | Understand a plugin | What inputs does fargate need? | + +## Validation Checklist + +Before publishing your platform: + +- [ ] All plugin libraries are declared in `libraries:` +- [ ] Plugin manifests verified with `get_plugin_manifest` +- [ ] All required plugin inputs are provided in `properties:` +- [ ] Dependencies in `depends_on:` reference defined resources +- [ ] Output references (e.g., `${infra.vpc.vpc_id}`) exist in plugin manifests +- [ ] Required identities are included in `identities:` lists +- [ ] Variables have appropriate types and defaults +- [ ] Resource names follow conventions (snake_case) + +## Common Mistakes to Avoid + +### Mistake #1: Missing Required Inputs +❌ **WRONG**: Plugin manifest shows `cpu` is required, but you didn't provide it +✅ **CORRECT**: Check manifest inputs and provide all required ones in `properties:` + +### Mistake #2: Referencing Non-Existent Outputs +❌ **WRONG**: `${infra.vpc.vpc_identifier}` when output is actually called `vpc_id` +✅ **CORRECT**: Check plugin manifest outputs for exact names + +### Mistake #3: Circular Dependencies +❌ **WRONG**: Resource A depends on B, B depends on A +✅ **CORRECT**: Ensure dependency graph is acyclic + +### Mistake #4: Wrong Variable Scope +❌ **WRONG**: Trying to use `${self.cpu}` in a different resource's properties +✅ **CORRECT**: Use `${var.name}` for platform-wide, `${self.name}` only within same resource + +## Remember + +- **Plugin manifests are your source of truth** - Always query them to see inputs/outputs +- **Properties wire plugins together** - Map your variables and dependencies to plugin inputs +- **Variables create flexibility** - Expose the right knobs for applications to turn +- **Infrastructure is shared** - Use `infra:` for components all apps use +- **Dependencies must be explicit** - Use `depends_on:` to ensure proper ordering + +Following these guidelines will help you compose plugins into powerful, flexible platforms. diff --git a/cli/internal/mcp/server.go b/cli/internal/mcp/server.go index cad29b98..80725c94 100644 --- a/cli/internal/mcp/server.go +++ b/cli/internal/mcp/server.go @@ -13,9 +13,15 @@ import ( "github.com/nitrictech/suga/cli/pkg/schema" ) -//go:embed instructions.md +//go:embed instructions-main.md var serverInstructions string +//go:embed instructions-app-development.md +var appDevelopmentInstructions string + +//go:embed instructions-platform-development.md +var platformDevelopmentInstructions string + // Server wraps the MCP server with Suga API client type Server struct { mcpServer *mcp.Server @@ -205,6 +211,22 @@ func (s *Server) registerResources() error { MIMEType: "application/schema+json", }, s.handleApplicationSchema) + // Register application development guide + s.mcpServer.AddResource(&mcp.Resource{ + URI: "suga://guides/app-development", + Name: "Application Development Guide", + Description: "Complete guide for creating suga.yaml application configuration files", + MIMEType: "text/markdown", + }, s.handleAppDevelopmentGuide) + + // Register platform development guide + s.mcpServer.AddResource(&mcp.Resource{ + URI: "suga://guides/platform-development", + Name: "Platform Development Guide", + Description: "Complete guide for creating platform.yaml platform definition files", + MIMEType: "text/markdown", + }, s.handlePlatformDevelopmentGuide) + return nil } @@ -631,3 +653,27 @@ func (s *Server) handleApplicationSchema(ctx context.Context, req *mcp.ReadResou }, }, nil } + +func (s *Server) handleAppDevelopmentGuide(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{ + { + URI: "suga://guides/app-development", + MIMEType: "text/markdown", + Text: appDevelopmentInstructions, + }, + }, + }, nil +} + +func (s *Server) handlePlatformDevelopmentGuide(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{ + { + URI: "suga://guides/platform-development", + MIMEType: "text/markdown", + Text: platformDevelopmentInstructions, + }, + }, + }, nil +} From 99afd3e7d633eb772a9358165da36f7fa7b2cf90 Mon Sep 17 00:00:00 2001 From: Tim Holm Date: Thu, 23 Oct 2025 14:06:22 +1100 Subject: [PATCH 03/14] chore(cli): more platform development details --- .../mcp/instructions-platform-development.md | 281 +++++++++++++++++- 1 file changed, 277 insertions(+), 4 deletions(-) diff --git a/cli/internal/mcp/instructions-platform-development.md b/cli/internal/mcp/instructions-platform-development.md index 04f39d45..b07a37b6 100644 --- a/cli/internal/mcp/instructions-platform-development.md +++ b/cli/internal/mcp/instructions-platform-development.md @@ -108,10 +108,13 @@ Use MCP tools to find plugins to compose: The plugin manifest shows you: - **inputs**: What configuration the plugin needs + - Each input has a Terraform `type` (string, number, bool, list(type), map(type), object({...})) + - Each input has a `required` flag - Required inputs MUST be provided in `properties:` - Optional inputs can be omitted or provided - **outputs**: What values the plugin exposes + - Each output has a Terraform type - Used to wire plugins together - Referenced in other plugins' properties via `${infra.resource_name.output_name}` @@ -121,7 +124,18 @@ The plugin manifest shows you: **Example from manifest**: ```yaml # Plugin manifest shows: -# inputs: { cpu: number, memory: number, container_port: number, ... } +# inputs: { +# cpu: {type: "number", required: true}, +# memory: {type: "number", required: true}, +# container_port: {type: "number", required: true}, +# security_groups: {type: "list(string)", required: true}, +# tags: {type: "map(string)", required: false} +# } +# outputs: { +# service_url: {type: "string"}, +# task_arn: {type: "string"}, +# security_group_ids: {type: "list(string)"} +# } # required_identities: ["aws"] services: @@ -134,11 +148,270 @@ services: library: suga/aws plugin: iam-role # Provides AWS identity properties: - cpu: 256 # Required input - memory: 512 # Required input - container_port: 8080 # Required input + cpu: 256 # number + memory: 512 # number + container_port: 8080 # number + security_groups: # list(string) + - sg-abc123 + - sg-def456 + tags: # map(string) + Environment: prod + Team: platform ``` +### Step 2a: Understanding Terraform Types in Plugin Manifests + +Plugin manifests use Terraform's type system. Understanding these is CRITICAL for configuring properties correctly. + +#### **Primitive Types** +- `string` - Text values: `"hello"`, `${var.name}` +- `number` - Numeric values: `256`, `3.14`, `${var.cpu}` +- `bool` - Boolean values: `true`, `false`, `${var.enabled}` + +#### **Collection Types** +- `list(type)` - Ordered list of same-typed values + - Example: `list(string)` → `["a", "b", "c"]` + - Example: `list(number)` → `[1, 2, 3]` + +- `set(type)` - Unordered set of unique same-typed values + - Example: `set(string)` → `["unique1", "unique2"]` + +- `map(type)` - Key-value pairs where values are same-typed + - Example: `map(string)` → `{key1 = "value1", key2 = "value2"}` + - Example: `map(number)` → `{count = 5, size = 100}` + +- `tuple([type1, type2, ...])` - Fixed-length, ordered list with specific types per position + - Example: `tuple([string, number, bool])` → `["name", 42, true]` + +#### **Structural Type** +- `object({attr1 = type1, attr2 = type2, ...})` - Complex structure with named attributes + - Example: `object({name = string, port = number})` → `{name = "api", port = 8080}` + +#### **Special Type** +- `any` - Accepts any type (use carefully, type-check the manifest!) + +**Examples from real manifests**: +```yaml +# Simple types +cpu: {type: "number", required: true} +name: {type: "string", required: true} +enabled: {type: "bool", required: false} + +# List types +security_groups: {type: "list(string)", required: true} +ports: {type: "list(number)", required: false} + +# Map types +tags: {type: "map(string)", required: false} +annotations: {type: "map(any)", required: false} + +# Object types +vpc_config: { + type: "object({vpc_id = string, subnets = list(string), security_groups = list(string)})", + required: true +} + +# Nested complex types +route_rules: { + type: "list(object({path = string, priority = number, target_arn = string}))", + required: true +} +``` + +### Step 2b: Configuring Properties - Matching Types + +Properties must match the Terraform types from the plugin manifest. Here's how to provide values for each type: + +#### **For Primitive Types** +```yaml +properties: + # string + name: "my-resource" + region: ${var.aws_region} + + # number + cpu: 256 + memory: ${self.memory} + + # bool + enabled: true + monitoring: ${var.enable_monitoring} +``` + +#### **For list(type)** +```yaml +# Plugin input: security_groups: {type: "list(string)"} +properties: + security_groups: + - sg-abc123 + - sg-def456 + - ${infra.vpc.default_security_group_id} + +# Or reference an output that returns list(string) +properties: + subnets: ${infra.vpc.private_subnets} # This output must be list(string) +``` + +#### **For map(type)** +```yaml +# Plugin input: tags: {type: "map(string)"} +properties: + tags: + Environment: production + Team: platform + ManagedBy: suga + +# Or use merge() to combine maps +properties: + tags: ${merge(var.default_tags, {Application = "api"})} +``` + +#### **For object({...})** +```yaml +# Plugin input: vpc_config: {type: "object({vpc_id = string, subnets = list(string)})"} +properties: + vpc_config: + vpc_id: ${infra.vpc.vpc_id} + subnets: ${infra.vpc.private_subnets} + +# Or build with Terraform functions +properties: + vpc_config: ${merge( + {vpc_id = infra.vpc.vpc_id}, + {subnets = infra.vpc.private_subnets} + )} +``` + +#### **For list(object({...}))** +```yaml +# Plugin input: rules: {type: "list(object({path = string, priority = number}))"} +properties: + rules: + - path: /api/* + priority: 100 + - path: /admin/* + priority: 200 + +# Or construct dynamically +properties: + rules: ${[ + for idx, path in var.api_paths : { + path = path + priority = (idx + 1) * 100 + } + ]} +``` + +### Step 2c: Using Terraform Functions + +Terraform functions can help construct values matching the required types: + +**String Functions**: +```yaml +properties: + # format() returns string + name: ${format("%s-%s-cluster", var.environment, var.region)} + + # join() returns string + allowed_cidrs: ${join(",", var.cidr_list)} + + # lower(), upper() return string + region: ${lower(var.aws_region)} +``` + +**List Functions**: +```yaml +properties: + # concat() returns list(type) + all_subnets: ${concat(infra.vpc.private_subnets, infra.vpc.public_subnets)} + + # flatten() returns list(type) + flat_list: ${flatten(var.nested_lists)} + + # distinct() returns list(type) + unique_items: ${distinct(var.items_with_duplicates)} + + # slice() returns list(type) + first_two: ${slice(var.all_items, 0, 2)} +``` + +**Map Functions**: +```yaml +properties: + # merge() returns map(type) + all_tags: ${merge(var.default_tags, var.custom_tags, {ManagedBy = "suga"})} + + # zipmap() returns map(type) + name_to_id: ${zipmap(var.names, var.ids)} + + # keys() returns list(string), values() returns list(type) + tag_keys: ${keys(var.tags)} + tag_values: ${values(var.tags)} +``` + +**Type Conversion**: +```yaml +properties: + # Convert between types + port_string: ${tostring(var.port_number)} # number → string + count_number: ${tonumber(var.count_string)} # string → number + enabled_bool: ${tobool(var.enabled_string)} # string → bool + + # Convert collections + sg_list: ${tolist(var.sg_set)} # set → list + tags_map: ${tomap(var.tags_object)} # object → map +``` + +**Conditional Expressions**: +```yaml +properties: + # Returns value matching required type + cpu: ${var.environment == "prod" ? 1024 : 256} # Returns number + + subnets: ${var.use_private ? infra.vpc.private_subnets : infra.vpc.public_subnets} # Returns list(string) +``` + +### Step 2d: Type Validation - Common Mistakes + +**❌ WRONG - Type Mismatches**: +```yaml +# Plugin expects: security_groups: {type: "list(string)"} +properties: + security_groups: sg-abc123 # ❌ String instead of list(string) + +# Plugin expects: cpu: {type: "number"} +properties: + cpu: "256" # ❌ String instead of number + +# Plugin expects: tags: {type: "map(string)"} +properties: + tags: # ❌ List instead of map + - key: value +``` + +**✅ CORRECT - Proper Types**: +```yaml +# Plugin expects: security_groups: {type: "list(string)"} +properties: + security_groups: # ✅ list(string) + - sg-abc123 + +# Plugin expects: cpu: {type: "number"} +properties: + cpu: 256 # ✅ number + +# Plugin expects: tags: {type: "map(string)"} +properties: + tags: # ✅ map(string) + Environment: prod +``` + +**Verification Steps**: +1. Call `get_plugin_manifest` - note the exact type string (e.g., `"list(string)"`) +2. Provide values in YAML that match that type structure +3. Use Terraform functions that return the correct type +4. For outputs, verify the output type matches the required input type + ### Step 3: Define Resource-Level Variables Add variables scoped to specific resource types that applications can override: From c75de13d00b522b9c6ad68ce4faa808147599f4d Mon Sep 17 00:00:00 2001 From: Tim Holm Date: Fri, 24 Oct 2025 09:42:48 +1100 Subject: [PATCH 04/14] chore(cli): move MCP command to the suga app --- cli/cmd/mcp.go | 51 +++++---------------------------------------- cli/pkg/app/suga.go | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 46 deletions(-) diff --git a/cli/cmd/mcp.go b/cli/cmd/mcp.go index ae5dd991..d44d1b94 100644 --- a/cli/cmd/mcp.go +++ b/cli/cmd/mcp.go @@ -1,16 +1,7 @@ package cmd import ( - "context" - "fmt" - "os" - "os/signal" - "syscall" - - "github.com/nitrictech/suga/cli/internal/api" - "github.com/nitrictech/suga/cli/internal/build" - "github.com/nitrictech/suga/cli/internal/config" - "github.com/nitrictech/suga/cli/internal/mcp" + "github.com/nitrictech/suga/cli/pkg/app" "github.com/samber/do/v2" "github.com/spf13/cobra" ) @@ -25,44 +16,12 @@ through the Model Context Protocol. This allows AI assistants to interact with your Suga templates, platforms, and build manifests. The server uses stdio transport and requires authentication via 'suga login'.`, - RunE: func(cmd *cobra.Command, args []string) error { - // Get dependencies from injector - apiClient := do.MustInvoke[*api.SugaApiClient](injector) - cfg := do.MustInvoke[*config.Config](injector) - builder := do.MustInvoke[*build.BuilderService](injector) - - // Create MCP server - server, err := mcp.NewServer(apiClient, cfg, builder) + Run: func(cmd *cobra.Command, args []string) { + app, err := do.Invoke[*app.SugaApp](injector) if err != nil { - return fmt.Errorf("failed to create MCP server: %w", err) - } - - // Setup context with cancellation - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Handle shutdown signals - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - - // Run server in goroutine - errChan := make(chan error, 1) - go func() { - errChan <- server.Run(ctx) - }() - - // Wait for either error or shutdown signal - select { - case err := <-errChan: - if err != nil { - return fmt.Errorf("MCP server error: %w", err) - } - case <-sigChan: - cancel() - <-errChan // Wait for server to shutdown + cobra.CheckErr(err) } - - return nil + cobra.CheckErr(app.MCP()) }, } diff --git a/cli/pkg/app/suga.go b/cli/pkg/app/suga.go index eccf06aa..c43d52ee 100644 --- a/cli/pkg/app/suga.go +++ b/cli/pkg/app/suga.go @@ -6,10 +6,12 @@ import ( "fmt" "net" "os" + "os/signal" "path/filepath" "regexp" "strconv" "strings" + "syscall" "time" "github.com/charmbracelet/huh" @@ -19,6 +21,7 @@ import ( "github.com/nitrictech/suga/cli/internal/build" "github.com/nitrictech/suga/cli/internal/config" "github.com/nitrictech/suga/cli/internal/devserver" + "github.com/nitrictech/suga/cli/internal/mcp" "github.com/nitrictech/suga/cli/internal/platforms" "github.com/nitrictech/suga/cli/internal/simulation" "github.com/nitrictech/suga/cli/internal/style/icons" @@ -659,6 +662,42 @@ func Dev() error { return nil } +// MCP handles the mcp command logic +func (c *SugaApp) MCP() error { + // Create MCP server + server, err := mcp.NewServer(c.apiClient, c.config, c.builder) + if err != nil { + return fmt.Errorf("failed to create MCP server: %w", err) + } + + // Setup context with cancellation + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Handle shutdown signals + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + // Run server in goroutine + errChan := make(chan error, 1) + go func() { + errChan <- server.Run(ctx) + }() + + // Wait for either error or shutdown signal + select { + case err := <-errChan: + if err != nil { + return fmt.Errorf("MCP server error: %w", err) + } + case <-sigChan: + cancel() + <-errChan // Wait for server to shutdown + } + + return nil +} + // Helper function for checking if project exists func projectExists(fs afero.Fs, projectDir string) (bool, error) { projectExists, err := afero.Exists(fs, projectDir) From 392727851d7ae09e4029b2c0d4a195255f37b551 Mon Sep 17 00:00:00 2001 From: Tim Holm Date: Fri, 24 Oct 2025 10:51:46 +1100 Subject: [PATCH 05/14] chore(cli): add basic plugin library development guidance to MCP server --- cli/internal/mcp/instructions-main.md | 16 + ...instructions-plugin-library-development.md | 980 ++++++++++++++++++ cli/internal/mcp/server.go | 23 + 3 files changed, 1019 insertions(+) create mode 100644 cli/internal/mcp/instructions-plugin-library-development.md diff --git a/cli/internal/mcp/instructions-main.md b/cli/internal/mcp/instructions-main.md index d00b0127..0c40ab5a 100644 --- a/cli/internal/mcp/instructions-main.md +++ b/cli/internal/mcp/instructions-main.md @@ -34,6 +34,21 @@ Before using the MCP tools, identify your scenario: --- +### Scenario 3: Plugin Library Development +**Goal**: Create or modify plugins in a plugin library to provide infrastructure building blocks + +**When to use**: +- You want to create reusable Terraform modules for infrastructure resources +- You need to implement cloud provider-specific resources (Lambda, S3, Fargate, etc.) +- You're building the lowest-level building blocks that platforms compose +- You need to provide Go runtime code for services and buckets + +**Next Step**: Read the **`suga://guides/plugin-library-development`** resource for detailed instructions + +**Key Concept**: You create the building blocks (plugins) that platforms use. Each plugin is a Terraform module with a manifest, and services/buckets require Go runtime code. + +--- + ## Important Notes ### Team Parameter @@ -48,6 +63,7 @@ The MCP server requires authentication via `suga login`. If you receive authenti - **`suga://schema/platform`** - JSON Schema for `platform.yaml` platform definition files (if available) - **`suga://guides/app-development`** - Detailed guide for application development - **`suga://guides/platform-development`** - Detailed guide for platform development +- **`suga://guides/plugin-library-development`** - Detailed guide for plugin library development ## Critical Reminder diff --git a/cli/internal/mcp/instructions-plugin-library-development.md b/cli/internal/mcp/instructions-plugin-library-development.md new file mode 100644 index 00000000..48d445ce --- /dev/null +++ b/cli/internal/mcp/instructions-plugin-library-development.md @@ -0,0 +1,980 @@ +# Plugin Library Development Guide + +This guide covers how to create and maintain Suga plugin libraries, which provide the building blocks that platforms compose together. + +## Overview + +A **plugin library** is a collection of reusable Terraform modules that implement infrastructure components. Each plugin in the library is a self-contained unit that: +- Implements a specific infrastructure resource (e.g., Lambda function, S3 bucket, VPC) +- Defines inputs it requires and outputs it provides +- Contains Terraform code in HCL +- Includes a manifest file describing its interface +- May include runtime code in Go (required for services and buckets) + +**Key Concept**: Plugins are the lowest-level building blocks. Platforms compose plugins together, and applications consume platforms. + +## Plugin Library Structure + +### Repository Layout + +``` +plugins-aws/ # Plugin library repository +├── go.mod # Go module file for entire library +├── .tflint.hcl # Terraform linting rules +├── Makefile # Build automation +├── lambda/ # Service plugin +│ ├── manifest.yaml # Plugin interface definition +│ ├── icon.svg # UI icon (optional) +│ ├── module/ # Terraform implementation +│ │ ├── main.tf +│ │ ├── variables.tf +│ │ ├── outputs.tf +│ │ └── versions.tf +│ └── runtime.go # runtime code for translating AWS Lambda events to HTTP +├── s3-bucket/ # Bucket plugin +│ ├── manifest.yaml +│ ├── module/ +│ └── runtime.go # runtime code for interacting with s3 buckets +└── vpc/ # Infrastructure plugin + ├── manifest.yaml + └── module/ # No runtime code needed +``` + +### Plugin Categories + +Plugins typically fall into these categories: + +1. **Resource Plugins** - Application-facing resources (services, databases, buckets, entrypoints) +2. **Infrastructure Plugins** - Shared infrastructure (VPCs, load balancers, networking) +3. **Identity Plugins** - IAM roles, service accounts, authentication + +## Runtime Code Requirements + +### What Is Runtime Code? + +Runtime code is **Go code that gets embedded into your Suga application** to facilitate communication between your app and the deployed cloud infrastructure. This is NOT an SDK or library - it is **necessary runtime code** that your application requires to function. + +**Currently, runtime code is ONLY written in Go.** + +### When Runtime Code Is Required + +- **Services** - MUST have runtime code to handle HTTP requests, environment configuration, etc. +- **Buckets** - MUST have runtime code to read/write objects, handle authentication, etc. + +### When Runtime Code Is NOT Needed + +- **Infrastructure plugins** (VPCs, load balancers) - Pure Terraform, no application interaction +- **Identity plugins** (IAM roles) - Pure Terraform, no application interaction +- **Databases** - Typically use standard database drivers, not plugin-specific runtime code + +### Runtime Code Requirements + +#### Service Plugin Interface + +Service plugins MUST implement the `Service` interface from `github.com/nitrictech/suga/runtime/service`: + +```go +type Service interface { + Start(Proxy) error +} + +type Proxy interface { + Forward(ctx context.Context, req *http.Request) (*http.Response, error) + Host() string +} +``` + +**Key Concepts:** +- The `Start` method receives a `Proxy` that forwards HTTP requests to the user's application +- Your runtime code translates cloud-specific events (Lambda events, container requests, etc.) into standard HTTP requests +- The proxy handles forwarding these requests to the user's application running locally +- Your runtime code must register itself using `service.Register()` + +Example service runtime code for AWS Lambda: + +```go +package lambdaruntime + +import ( + "context" + "net/http" + + "github.com/aws/aws-lambda-go/events" + "github.com/aws/aws-lambda-go/lambda" + "github.com/nitrictech/suga/runtime/service" +) + +type LambdaService struct{} + +func (l *LambdaService) Start(proxy service.Proxy) error { + // Start Lambda runtime with a handler that converts Lambda events to HTTP + lambda.Start(func(ctx context.Context, event events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + // Convert Lambda event to http.Request + req, err := convertEventToRequest(ctx, event, proxy.Host()) + if err != nil { + return events.APIGatewayProxyResponse{StatusCode: 500}, err + } + + // Forward to user's application via proxy + resp, err := proxy.Forward(ctx, req) + if err != nil { + return events.APIGatewayProxyResponse{StatusCode: 500}, err + } + + // Convert http.Response back to Lambda response + return convertResponseToEvent(resp) + }) + + return nil +} + +func New() (service.Service, error) { + return &LambdaService{}, nil +} + +func init() { + // Register this service plugin + service.Register(New) +} +``` + +#### Bucket Plugin Interface + +Bucket plugins MUST implement the `Storage` interface (alias for `StorageServer`) from `github.com/nitrictech/suga/proto/storage/v2`: + +```go +type Storage interface { + // Retrieve an item from a bucket + Read(context.Context, *StorageReadRequest) (*StorageReadResponse, error) + + // Store an item to a bucket + Write(context.Context, *StorageWriteRequest) (*StorageWriteResponse, error) + + // Delete an item from a bucket + Delete(context.Context, *StorageDeleteRequest) (*StorageDeleteResponse, error) + + // Generate a pre-signed URL for direct operations on an item + PreSignUrl(context.Context, *StoragePreSignUrlRequest) (*StoragePreSignUrlResponse, error) + + // List blobs currently in the bucket + ListBlobs(context.Context, *StorageListBlobsRequest) (*StorageListBlobsResponse, error) + + // Determine if an object exists in a bucket + Exists(context.Context, *StorageExistsRequest) (*StorageExistsResponse, error) +} +``` + +**Key Concepts:** +- Bucket plugins provide object storage operations (read, write, delete, list, etc.) +- The interface uses gRPC protobuf messages for request/response +- Your runtime code must register itself with a namespace using `storage.Register()` +- The namespace typically follows the pattern `//` + +Example bucket runtime code for AWS S3: + +```go +package s3runtime + +import ( + "context" + "io" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/s3" + storagepb "github.com/nitrictech/suga/proto/storage/v2" + "github.com/nitrictech/suga/runtime/storage" +) + +type S3StorageService struct { + storagepb.UnimplementedStorageServer + s3Client *s3.Client + bucketName string +} + +func (s *S3StorageService) Read(ctx context.Context, req *storagepb.StorageReadRequest) (*storagepb.StorageReadResponse, error) { + result, err := s.s3Client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: &s.bucketName, + Key: &req.Key, + }) + if err != nil { + return nil, err + } + defer result.Body.Close() + + body, err := io.ReadAll(result.Body) + if err != nil { + return nil, err + } + + return &storagepb.StorageReadResponse{ + Body: body, + }, nil +} + +func (s *S3StorageService) Write(ctx context.Context, req *storagepb.StorageWriteRequest) (*storagepb.StorageWriteResponse, error) { + _, err := s.s3Client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: &s.bucketName, + Key: &req.Key, + Body: bytes.NewReader(req.Body), + }) + if err != nil { + return nil, err + } + + return &storagepb.StorageWriteResponse{}, nil +} + +func (s *S3StorageService) Delete(ctx context.Context, req *storagepb.StorageDeleteRequest) (*storagepb.StorageDeleteResponse, error) { + _, err := s.s3Client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: &s.bucketName, + Key: &req.Key, + }) + if err != nil { + return nil, err + } + + return &storagepb.StorageDeleteResponse{}, nil +} + +// Implement remaining methods: PreSignUrl, ListBlobs, Exists... + +func New() (storage.Storage, error) { + bucketName := os.Getenv("SUGA_BUCKET_NAME") + if bucketName == "" { + return nil, fmt.Errorf("SUGA_BUCKET_NAME not set") + } + + cfg, err := config.LoadDefaultConfig(context.Background()) + if err != nil { + return nil, err + } + + return &S3StorageService{ + s3Client: s3.NewFromConfig(cfg), + bucketName: bucketName, + }, nil +} + +// Register with namespace (e.g., "suga/aws/s3-bucket") +// storage.Register("suga/aws/s3-bucket", New) +``` + +### Runtime Code Responsibilities + +Runtime code handles: +1. **Authentication** - Obtaining credentials from the environment +2. **Configuration** - Reading infrastructure details from environment variables +3. **Protocol translation** - Converting between app interfaces and cloud APIs +4. **Error handling** - Translating cloud errors to application-friendly formats + +Example runtime code for an S3 bucket plugin: + +```go +// s3-bucket/runtime/go/bucket.go +package s3runtime + +import ( + "context" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +// Client wraps S3 operations for application use +type Client struct { + bucketName string + s3Client *s3.Client +} + +// NewClient creates a bucket client from environment +func NewClient() (*Client, error) { + // Read bucket name from environment (set by Terraform) + bucketName := os.Getenv("SUGA_BUCKET_NAME") + + // Create AWS client with default credentials + cfg, err := config.LoadDefaultConfig(context.Background()) + if err != nil { + return nil, err + } + + return &Client{ + bucketName: bucketName, + s3Client: s3.NewFromConfig(cfg), + }, nil +} + +// Put uploads an object to the bucket +func (c *Client) Put(ctx context.Context, key string, data []byte) error { + _, err := c.s3Client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(c.bucketName), + Key: aws.String(key), + Body: bytes.NewReader(data), + }) + return err +} +``` + +## Plugin Manifest Structure + +Every plugin must have a `manifest.yaml` that defines its interface: + +```yaml +name: fargate # Plugin identifier +description: AWS Fargate container service +type: resource # or "infrastructure" or "identity" + +# Optional: Required identity plugins (e.g., IAM roles) +required_identities: + - aws + +# Input parameters the plugin accepts +inputs: + cpu: + type: number + description: CPU units for the container + required: true + default: 256 + + memory: + type: number + description: Memory in MB + required: true + default: 512 + + container_port: + type: number + description: Port the container listens on + required: true + + vpc_id: + type: string + description: VPC ID to deploy into + required: true + + subnets: + type: array + description: Subnet IDs for the service + required: true + +# Output values the plugin provides +outputs: + service_url: + type: string + description: URL of the deployed service + + service_arn: + type: string + description: ARN of the ECS service + + task_role_arn: + type: string + description: IAM role ARN for the task + +# Runtime code configuration (REQUIRED for services/buckets, currently Go only) +runtime: + go: + package: github.com/nitrictech/plugins-aws/lambda/runtime/go +``` + +## Terraform Module Implementation + +### Standard Module Files + +Each plugin's `module/` directory should contain: + +**`main.tf`** - Primary resource definitions: +```hcl +resource "aws_ecs_service" "service" { + name = var.name + cluster = var.cluster_id + task_definition = aws_ecs_task_definition.task.arn + desired_count = var.desired_count + launch_type = "FARGATE" + + network_configuration { + subnets = var.subnets + security_groups = [aws_security_group.service.id] + } +} +``` + +**`variables.tf`** - Input variables matching manifest inputs: +```hcl +variable "cpu" { + type = number + description = "CPU units for the container" +} + +variable "memory" { + type = number + description = "Memory in MB" +} + +variable "container_port" { + type = number + description = "Port the container listens on" +} +``` + +**`outputs.tf`** - Output values matching manifest outputs: +```hcl +output "service_url" { + value = aws_lb.service.dns_name + description = "URL of the deployed service" +} + +output "service_arn" { + value = aws_ecs_service.service.id + description = "ARN of the ECS service" +} +``` + +**`versions.tf`** - Provider requirements: +```hcl +terraform { + required_version = ">= 1.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} +``` + +## Development Workflow + +### 1. Planning a New Plugin + +Before creating a plugin: + +1. **Identify the resource** - What infrastructure component are you implementing? +2. **Define the interface** - What inputs does it need? What outputs does it provide? +3. **Determine runtime needs** - Does it need Go runtime code? (services and buckets MUST have it) +4. **Consider dependencies** - Does it need other plugins (VPC, IAM roles)? +5. **Check for reusability** - Is this plugin generic enough for multiple platforms? + +### 2. Creating a New Plugin + +```bash +# Create plugin directory structure +mkdir -p my-plugin/module + +# For services/buckets, MUST create runtime directory with Go code +mkdir -p my-plugin/runtime/go + +# Create manifest +cat > my-plugin/manifest.yaml < Date: Mon, 27 Oct 2025 09:25:00 +1100 Subject: [PATCH 06/14] feat(cli): proxy docs MCP server with embedded mcp server --- cli/internal/mcp/docs_proxy.go | 152 ++++++++++++++++++++++++++++++++ cli/internal/mcp/server.go | 41 +++++++++ docs/guides/mcp-integration.mdx | 137 +++++++++++++++++++++------- 3 files changed, 297 insertions(+), 33 deletions(-) create mode 100644 cli/internal/mcp/docs_proxy.go diff --git a/cli/internal/mcp/docs_proxy.go b/cli/internal/mcp/docs_proxy.go new file mode 100644 index 00000000..aa51a9c1 --- /dev/null +++ b/cli/internal/mcp/docs_proxy.go @@ -0,0 +1,152 @@ +package mcp + +import ( + "context" + "fmt" + "log" + "net/http" + "strings" + "sync" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const docsServerURL = "https://docs.addsuga.com/mcp" + +// DocsProxy manages a connection to the Suga docs MCP server and proxies requests +type DocsProxy struct { + client *mcp.Client + session *mcp.ClientSession + mu sync.RWMutex + + // Cache of docs server capabilities + tools []*mcp.Tool +} + +// NewDocsProxy creates a new proxy to the Suga docs MCP server +func NewDocsProxy() *DocsProxy { + return &DocsProxy{} +} + +// Connect establishes a connection to the docs MCP server +func (d *DocsProxy) Connect(ctx context.Context) error { + // Create MCP client + impl := &mcp.Implementation{ + Name: "suga-cli-docs-client", + Version: "1.0.0", + } + + client := mcp.NewClient(impl, nil) + + // Create streamable HTTP transport to docs server + transport := &mcp.StreamableClientTransport{ + Endpoint: docsServerURL, + HTTPClient: &http.Client{}, + } + + // Connect to docs server + session, err := client.Connect(ctx, transport, nil) + if err != nil { + return fmt.Errorf("failed to connect to docs server: %w", err) + } + + d.mu.Lock() + d.client = client + d.session = session + d.mu.Unlock() + + // Fetch available tools from docs server + if err := d.fetchCapabilities(ctx); err != nil { + log.Printf("Warning: failed to fetch docs server capabilities: %v", err) + } + + return nil +} + +// fetchCapabilities queries the docs server for available tools +func (d *DocsProxy) fetchCapabilities(ctx context.Context) error { + d.mu.RLock() + session := d.session + d.mu.RUnlock() + + if session == nil { + return fmt.Errorf("not connected to docs server") + } + + // List tools from docs server + toolsResult, err := session.ListTools(ctx, nil) + if err != nil { + return fmt.Errorf("failed to list docs server tools: %w", err) + } + + // Fix JSON Schema versions in tools - MCP SDK only supports draft 2020-12 + for _, tool := range toolsResult.Tools { + if tool.InputSchema != nil { + if schema, ok := tool.InputSchema.(map[string]interface{}); ok { + d.fixSchemaVersion(schema) + } + } + } + + d.mu.Lock() + d.tools = toolsResult.Tools + d.mu.Unlock() + + return nil +} + +// fixSchemaVersion updates JSON Schema $schema to the version supported by MCP SDK +func (d *DocsProxy) fixSchemaVersion(schema map[string]interface{}) { + if schema == nil { + return + } + + // Check if there's a $schema field with draft-07 and update it to 2020-12 + if schemaVersion, ok := schema["$schema"].(string); ok { + if strings.Contains(schemaVersion, "draft-07") || strings.Contains(schemaVersion, "draft/07") { + schema["$schema"] = "https://json-schema.org/draft/2020-12/schema" + } + } +} + +// GetTools returns the list of tools available from the docs server +func (d *DocsProxy) GetTools() []*mcp.Tool { + d.mu.RLock() + defer d.mu.RUnlock() + return d.tools +} + +// CallTool proxies a tool call to the docs server +func (d *DocsProxy) CallTool(ctx context.Context, name string, arguments map[string]interface{}) (*mcp.CallToolResult, error) { + d.mu.RLock() + session := d.session + d.mu.RUnlock() + + if session == nil { + return nil, fmt.Errorf("not connected to docs server") + } + + params := &mcp.CallToolParams{ + Name: name, + Arguments: arguments, + } + + result, err := session.CallTool(ctx, params) + if err != nil { + return nil, fmt.Errorf("docs server tool call failed: %w", err) + } + + return result, nil +} + +// Close closes the connection to the docs server +func (d *DocsProxy) Close() error { + d.mu.Lock() + defer d.mu.Unlock() + + if d.session != nil { + return d.session.Close() + } + + return nil +} diff --git a/cli/internal/mcp/server.go b/cli/internal/mcp/server.go index 16a4c2fc..cdafa184 100644 --- a/cli/internal/mcp/server.go +++ b/cli/internal/mcp/server.go @@ -5,6 +5,7 @@ import ( _ "embed" "encoding/json" "fmt" + "log" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/nitrictech/suga/cli/internal/api" @@ -31,6 +32,7 @@ type Server struct { apiClient *api.SugaApiClient config *config.Config builder *build.BuilderService + docsProxy *DocsProxy } // NewServer creates a new MCP server with the given API client and config @@ -51,6 +53,9 @@ func NewServer(apiClient *api.SugaApiClient, cfg *config.Config, builder *build. s.mcpServer = mcpServer + // Initialize docs proxy (but don't fail if it can't connect) + s.docsProxy = NewDocsProxy() + // Register tools if err := s.registerTools(); err != nil { return nil, fmt.Errorf("failed to register tools: %w", err) @@ -147,6 +152,17 @@ type BuildArgs struct { // registerTools registers all available tools with the MCP server func (s *Server) registerTools() error { + // Connect to docs proxy and register docs tools first + ctx := context.Background() + if err := s.docsProxy.Connect(ctx); err != nil { + log.Printf("Warning: failed to connect to docs server, documentation features will be unavailable: %v", err) + } else { + // Register docs tools if connection succeeded + if err := s.registerDocsProxyTools(); err != nil { + log.Printf("Warning: failed to register docs tools: %v", err) + } + } + // Register list_templates tool mcp.AddTool(s.mcpServer, &mcp.Tool{ Name: "list_templates", @@ -700,3 +716,28 @@ func (s *Server) handlePluginLibraryDevelopmentGuide(ctx context.Context, req *m }, }, nil } + +// registerDocsProxyTools registers all tools from the docs proxy server +func (s *Server) registerDocsProxyTools() error { + tools := s.docsProxy.GetTools() + for _, tool := range tools { + // Create a closure to capture the tool name for this iteration + toolName := tool.Name + + // Register a proxy handler for this tool + mcp.AddTool(s.mcpServer, tool, func(ctx context.Context, req *mcp.CallToolRequest, args map[string]interface{}) (*mcp.CallToolResult, any, error) { + result, err := s.docsProxy.CallTool(ctx, toolName, args) + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to call docs tool: %v", err)}, + }, + }, nil, nil + } + return result, nil, nil + }) + } + return nil +} + diff --git a/docs/guides/mcp-integration.mdx b/docs/guides/mcp-integration.mdx index 0422513f..16888036 100644 --- a/docs/guides/mcp-integration.mdx +++ b/docs/guides/mcp-integration.mdx @@ -1,38 +1,60 @@ --- -title: 'Add Suga Docs to Your AI Agent' -description: 'Connect the Suga documentation MCP server to your coding agents for real-time documentation access' +title: 'Add Suga to Your AI Agent' +description: 'Connect Suga infrastructure and documentation to your coding agents via Model Context Protocol' --- ## Overview -Suga's documentation is available through a [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server, enabling AI coding agents to query our documentation in real-time. This integration allows your AI tools to provide accurate, up-to-date information about Suga without manually copying documentation into context windows. +Suga provides seamless integration with AI coding agents through the [Model Context Protocol (MCP)](https://modelcontextprotocol.io). The **Suga CLI MCP server** gives AI assistants direct access to: -## MCP Server URL +- **Your Suga Infrastructure**: Platforms, templates, plugins, and build APIs +- **Complete Documentation**: All Suga guides, CLI references, and how-tos +- **Unified Experience**: Single server for both infrastructure and documentation -The Suga documentation MCP server is hosted at: +This integration allows AI tools to help you develop Suga applications with accurate, up-to-date information and direct access to your team's infrastructure resources. -``` -https://docs.addsuga.com/mcp -``` +## Suga CLI MCP Server + +The embedded MCP server in the Suga CLI provides comprehensive access to both your infrastructure and documentation. + +### What You Get -This server provides: -- **Documentation Search**: Query all Suga documentation, guides, and CLI references -- **Real-time Access**: Always get the latest documentation without manual updates +**Infrastructure APIs**: +- List and query your team's platforms, templates, and plugin libraries +- Access platform specifications and build manifests +- Generate Terraform from `suga.yaml` files +- Browse available plugins and their configurations -## Integration Guides +**Documentation**: +- Search all Suga documentation in real-time +- Access CLI references and guides +- Get help with application and platform development +- Query plugin development documentation -Follow the instructions below for your preferred coding agent: +**Authentication**: Uses your existing `suga login` credentials + +## Installation -Claude Code supports MCP servers through a simple CLI command: +**Prerequisites** + +First, ensure you're logged in to Suga: + +```bash +suga login +``` + +**Add the MCP Server** + +Add the Suga CLI MCP server using the `stdio` transport: ```bash -claude mcp add --transport http suga-docs https://docs.addsuga.com/mcp +claude mcp add suga suga mcp ``` -This adds the Suga documentation server to your Claude Code configuration. +This configures Claude Code to run `suga mcp` as an MCP server, giving it access to both your infrastructure and documentation. **Verify Installation** @@ -42,16 +64,21 @@ Check that the server was added successfully: claude mcp list ``` -You should see `suga-docs` in the list of available MCP servers. +You should see `suga` in the list of available MCP servers. **Usage** -Once configured, Claude Code can automatically query Suga documentation when you ask questions about Suga features, CLI commands, or implementation patterns. +Once configured, Claude Code can: +- Query Suga documentation when you ask questions +- Help you build `suga.yaml` application files +- Access your team's platforms and templates +- Assist with platform and plugin development Example queries: -- "How do I set up database migrations in Suga?" -- "What are the available Suga CLI commands for managing environments?" -- "Show me how to configure a custom platform in Suga" +- "What platforms are available for my team?" +- "Help me create a suga.yaml for a web service with a PostgreSQL database" +- "Show me the build manifest for the aws-lambda platform" +- "How do I create a custom plugin?" **Project-Specific Configuration** @@ -60,14 +87,15 @@ To share this configuration with your team, add it to your project's `.mcp.json` ```json { "mcpServers": { - "suga-docs": { - "url": "https://docs.addsuga.com/mcp" + "suga": { + "command": "suga", + "args": ["mcp"] } } } ``` -Commit this file to your repository so all team members can access the Suga documentation MCP server. +Commit this file to your repository so all team members can use the Suga MCP server. **Documentation**: [Claude Code MCP Guide](https://docs.claude.com/en/docs/claude-code/mcp) @@ -75,6 +103,14 @@ Commit this file to your repository so all team members can access the Suga docu +**Prerequisites** + +First, ensure you're logged in to Suga: + +```bash +suga login +``` + **Adding to Cursor** 1. Open Cursor Settings (`Cmd/Ctrl + ,`) @@ -85,8 +121,9 @@ Commit this file to your repository so all team members can access the Suga docu ```json { "mcpServers": { - "suga-docs": { - "url": "https://docs.addsuga.com/mcp" + "suga": { + "command": "suga", + "args": ["mcp"] } } } @@ -96,11 +133,12 @@ Commit this file to your repository so all team members can access the Suga docu **Usage in Cursor** -Once configured, you can reference Suga documentation in your Cursor conversations: +Once configured, Cursor's AI can: +- Query Suga documentation and infrastructure APIs +- Help build Suga applications +- Access your team's platforms and templates -- Use `Cmd+K` or `Ctrl+K` to open the composer -- Ask questions about Suga features and the AI will query the documentation -- The MCP server provides context automatically when relevant +Use `Cmd+K` or `Ctrl+K` to open the composer and ask Suga-related questions. **Documentation**: [Cursor MCP Guide](https://docs.cursor.com/context/model-context-protocol) @@ -109,6 +147,14 @@ Once configured, you can reference Suga documentation in your Cursor conversatio +**Prerequisites** + +First, ensure you're logged in to Suga: + +```bash +suga login +``` + **Adding to Windsurf** 1. Open Windsurf Settings @@ -119,8 +165,9 @@ Once configured, you can reference Suga documentation in your Cursor conversatio ```json { "mcpServers": { - "suga-docs": { - "serverUrl": "https://docs.addsuga.com/mcp" + "suga": { + "command": "suga", + "args": ["mcp"] } } } @@ -130,7 +177,10 @@ Once configured, you can reference Suga documentation in your Cursor conversatio **Usage in Windsurf** -Windsurf's Cascade AI will automatically query the Suga documentation when you ask about Suga-related topics. +Windsurf's Cascade AI will automatically: +- Query Suga documentation and infrastructure +- Help with application development +- Access your team's platforms and templates **Documentation**: [Windsurf MCP Integration Guide](https://docs.windsurf.com/windsurf/cascade/mcp) @@ -138,6 +188,27 @@ Windsurf's Cascade AI will automatically query the Suga documentation when you a +## Features + +The Suga MCP server provides: + +### Infrastructure Tools + +- **`list_platforms`** - List all platforms available to your team +- **`get_platform`** - Get detailed platform specifications +- **`get_build_manifest`** - Get complete build manifests with plugin details +- **`list_templates`** - List available application templates +- **`get_template`** - Get specific template details +- **`list_plugin_libraries`** - Browse available plugin libraries +- **`get_plugin_manifest`** - Access plugin manifests and schemas +- **`build`** - Generate Terraform from suga.yaml files + +### Documentation Resources + +- **Application Schema** - JSON schema for suga.yaml validation +- **Development Guides** - Complete guides for application, platform, and plugin development +- **Real-time Docs** - Always up-to-date Suga documentation + ## Learn More - [Model Context Protocol Documentation](https://modelcontextprotocol.io) From ccc726f512d60344a94d789918e2fc28b82c2de6 Mon Sep 17 00:00:00 2001 From: Tim Holm Date: Mon, 27 Oct 2025 09:31:37 +1100 Subject: [PATCH 07/14] docs(cli): include documentation search in agent instructions --- .../mcp/instructions-app-development.md | 8 +++++ cli/internal/mcp/instructions-main.md | 31 +++++++++++++++++-- .../mcp/instructions-platform-development.md | 10 ++++++ ...instructions-plugin-library-development.md | 11 +++++++ 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/cli/internal/mcp/instructions-app-development.md b/cli/internal/mcp/instructions-app-development.md index b2f6a365..d82a0c28 100644 --- a/cli/internal/mcp/instructions-app-development.md +++ b/cli/internal/mcp/instructions-app-development.md @@ -10,6 +10,14 @@ CRITICAL: You MUST follow this workflow when generating suga.yaml configurations ## REQUIRED Workflow for Generating suga.yaml +### Step 0: Search Documentation (Optional but Recommended) +If you need to understand Suga concepts, configuration options, or best practices: + +1. Use **`SearchSugaDocs`** to query the official documentation +2. Examples: "suga.yaml structure", "how to deploy services", "environment variables" + +This gives you accurate, up-to-date information about Suga features. + ### Step 1: ALWAYS Start with Discovery Before writing ANY suga.yaml content, you MUST: diff --git a/cli/internal/mcp/instructions-main.md b/cli/internal/mcp/instructions-main.md index 0c40ab5a..8c2e576e 100644 --- a/cli/internal/mcp/instructions-main.md +++ b/cli/internal/mcp/instructions-main.md @@ -1,6 +1,27 @@ # Suga MCP Server Usage Instructions -Welcome to the Suga MCP Server. This server provides tools and resources to help you work with Suga infrastructure. +Welcome to the Suga MCP Server. This server provides tools and resources to help you work with Suga infrastructure, plus real-time access to Suga documentation. + +## Documentation Search + +**IMPORTANT**: When you need information about Suga features, CLI commands, concepts, or best practices, use the **`SearchSugaDocs`** tool to search the official Suga documentation. This gives you up-to-date, accurate information directly from the docs. + +**When to search docs**: +- Understanding Suga concepts (platforms, plugins, services, etc.) +- Learning CLI commands and their usage +- Finding examples and best practices +- Troubleshooting issues +- Understanding configuration options + +**Example queries**: +- "How do I deploy a web service with a database?" +- "What CLI commands are available for managing platforms?" +- "How do environment variables work in Suga?" +- "What cloud providers does Suga support?" + +Always prefer searching the docs over relying on your training data, as Suga is actively evolving. + +--- ## What Are You Trying To Do? @@ -65,6 +86,10 @@ The MCP server requires authentication via `suga login`. If you receive authenti - **`suga://guides/platform-development`** - Detailed guide for platform development - **`suga://guides/plugin-library-development`** - Detailed guide for plugin library development -## Critical Reminder +## Critical Reminders + +1. **Search Documentation First**: When you need information about Suga, ALWAYS use the `SearchSugaDocs` tool to get accurate, up-to-date information from the official docs. + +2. **DO NOT rely on your training data**: Platforms, plugins, schemas, and available resource types change frequently and are team-specific. ALWAYS query the MCP tools to discover what's currently available before generating any configuration files. -**DO NOT rely on your training data.** Platforms, plugins, schemas, and available resource types change frequently and are team-specific. ALWAYS query the MCP tools to discover what's currently available before generating any configuration files. +3. **Combine Tools**: Use `SearchSugaDocs` to understand concepts and best practices, then use the infrastructure tools (`list_platforms`, `get_platform`, `get_template`, etc.) to discover what's actually available for the user's team. diff --git a/cli/internal/mcp/instructions-platform-development.md b/cli/internal/mcp/instructions-platform-development.md index b07a37b6..ff4ea5aa 100644 --- a/cli/internal/mcp/instructions-platform-development.md +++ b/cli/internal/mcp/instructions-platform-development.md @@ -14,6 +14,16 @@ A `platform.yaml` file defines a reusable infrastructure platform by composing p CRITICAL: Do NOT rely on your training data. Plugin libraries, versions, and capabilities change frequently. ALWAYS use the MCP tools to discover what's currently available. +## Getting Help + +Use **`SearchSugaDocs`** when you need to: +- Understand platform.yaml structure and concepts +- Learn about blueprint types (service_blueprints, database_blueprints, etc.) +- Find examples of platform configurations +- Understand how plugins compose into platforms + +Examples: "platform.yaml structure", "how to create blueprints", "platform variables" + ## Platform Structure ### 1. **Libraries** diff --git a/cli/internal/mcp/instructions-plugin-library-development.md b/cli/internal/mcp/instructions-plugin-library-development.md index 48d445ce..2c6c3bb5 100644 --- a/cli/internal/mcp/instructions-plugin-library-development.md +++ b/cli/internal/mcp/instructions-plugin-library-development.md @@ -13,6 +13,17 @@ A **plugin library** is a collection of reusable Terraform modules that implemen **Key Concept**: Plugins are the lowest-level building blocks. Platforms compose plugins together, and applications consume platforms. +## Getting Help + +Use **`SearchSugaDocs`** when you need to: +- Understand plugin library structure and organization +- Learn about manifest.yaml format and fields +- Understand plugin types (service, bucket, database, etc.) +- Find examples of plugin implementations +- Learn about runtime code requirements for services and buckets + +Examples: "plugin manifest format", "how to create service plugins", "plugin runtime code" + ## Plugin Library Structure ### Repository Layout From 0a235a3c4de01ea2e05331e6e605bf51cbb7f920 Mon Sep 17 00:00:00 2001 From: Tim Holm Date: Mon, 27 Oct 2025 09:54:33 +1100 Subject: [PATCH 08/14] chore(cli): de-duplicate response handling for mcp tools --- cli/internal/mcp/server.go | 440 +++++++++++++------------------------ 1 file changed, 147 insertions(+), 293 deletions(-) diff --git a/cli/internal/mcp/server.go b/cli/internal/mcp/server.go index cdafa184..bdc31d2c 100644 --- a/cli/internal/mcp/server.go +++ b/cli/internal/mcp/server.go @@ -264,8 +264,12 @@ func (s *Server) Run(ctx context.Context) error { // Tool handlers -func (s *Server) handleListTemplates(ctx context.Context, req *mcp.CallToolRequest, args ListTemplatesArgs) (*mcp.CallToolResult, any, error) { - team, err := s.getTeamOrDefault(args.Team) +func (s *Server) handleWithTeamAndJSON( + teamExtractor func() string, + apiCall func(team string) (interface{}, error), + resultTransform func(interface{}) (interface{}, error), +) (*mcp.CallToolResult, any, error) { + team, err := s.getTeamOrDefault(teamExtractor()) if err != nil { return &mcp.CallToolResult{ IsError: true, @@ -275,345 +279,195 @@ func (s *Server) handleListTemplates(ctx context.Context, req *mcp.CallToolReque }, nil, nil } - templates, err := s.apiClient.GetTemplates(team) + result, err := apiCall(team) if err != nil { return &mcp.CallToolResult{ IsError: true, Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Failed to list templates: %v", err)}, + &mcp.TextContent{Text: fmt.Sprintf("%v", err)}, }, }, nil, nil } - result, err := json.MarshalIndent(templates, "", " ") + if resultTransform != nil { + result, err = resultTransform(result) + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("%v", err)}, + }, + }, nil, nil + } + } + + resultJSON, err := json.MarshalIndent(result, "", " ") if err != nil { return &mcp.CallToolResult{ IsError: true, Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Failed to marshal templates: %v", err)}, + &mcp.TextContent{Text: fmt.Sprintf("Failed to marshal result: %v", err)}, }, }, nil, nil } return &mcp.CallToolResult{ Content: []mcp.Content{ - &mcp.TextContent{Text: string(result)}, + &mcp.TextContent{Text: string(resultJSON)}, }, }, nil, nil } -func (s *Server) handleGetTemplate(ctx context.Context, req *mcp.CallToolRequest, args GetTemplateArgs) (*mcp.CallToolResult, any, error) { - team, err := s.getTeamOrDefault(args.TeamSlug) - if err != nil { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Failed to get team: %v", err)}, - }, - }, nil, nil - } - - template, err := s.apiClient.GetTemplate(team, args.TemplateName, args.Version) - if err != nil { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Failed to get template: %v", err)}, - }, - }, nil, nil - } - - result, err := json.MarshalIndent(template, "", " ") - if err != nil { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Failed to marshal template: %v", err)}, - }, - }, nil, nil - } +func (s *Server) handleListTemplates(ctx context.Context, req *mcp.CallToolRequest, args ListTemplatesArgs) (*mcp.CallToolResult, any, error) { + return s.handleWithTeamAndJSON( + func() string { return args.Team }, + func(team string) (interface{}, error) { + templates, err := s.apiClient.GetTemplates(team) + if err != nil { + return nil, fmt.Errorf("Failed to list templates: %w", err) + } + return templates, nil + }, + nil, + ) +} - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: string(result)}, +func (s *Server) handleGetTemplate(ctx context.Context, req *mcp.CallToolRequest, args GetTemplateArgs) (*mcp.CallToolResult, any, error) { + return s.handleWithTeamAndJSON( + func() string { return args.TeamSlug }, + func(team string) (interface{}, error) { + template, err := s.apiClient.GetTemplate(team, args.TemplateName, args.Version) + if err != nil { + return nil, fmt.Errorf("Failed to get template: %w", err) + } + return template, nil }, - }, nil, nil + nil, + ) } func (s *Server) handleGetPlatform(ctx context.Context, req *mcp.CallToolRequest, args GetPlatformArgs) (*mcp.CallToolResult, any, error) { - team, err := s.getTeamOrDefault(args.Team) - if err != nil { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Failed to get team: %v", err)}, - }, - }, nil, nil - } - - var platform interface{} - - if args.Public { - platform, err = s.apiClient.GetPublicPlatform(team, args.Name, args.Revision) - } else { - platform, err = s.apiClient.GetPlatform(team, args.Name, args.Revision) - } - - if err != nil { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Failed to get platform: %v", err)}, - }, - }, nil, nil - } - - result, err := json.MarshalIndent(platform, "", " ") - if err != nil { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Failed to marshal platform: %v", err)}, - }, - }, nil, nil - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: string(result)}, + return s.handleWithTeamAndJSON( + func() string { return args.Team }, + func(team string) (interface{}, error) { + var platform interface{} + var err error + if args.Public { + platform, err = s.apiClient.GetPublicPlatform(team, args.Name, args.Revision) + } else { + platform, err = s.apiClient.GetPlatform(team, args.Name, args.Revision) + } + if err != nil { + return nil, fmt.Errorf("Failed to get platform: %w", err) + } + return platform, nil }, - }, nil, nil + nil, + ) } func (s *Server) handleGetBuildManifest(ctx context.Context, req *mcp.CallToolRequest, args GetBuildManifestArgs) (*mcp.CallToolResult, any, error) { - team, err := s.getTeamOrDefault(args.Team) - if err != nil { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Failed to get team: %v", err)}, - }, - }, nil, nil - } - - var platformSpec interface{} - var plugins map[string]map[string]any - - if args.Public { - platformSpec, plugins, err = s.apiClient.GetPublicBuildManifest(team, args.Platform, args.Revision) - } else { - platformSpec, plugins, err = s.apiClient.GetBuildManifest(team, args.Platform, args.Revision) - } - - if err != nil { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Failed to get build manifest: %v", err)}, - }, - }, nil, nil - } - - manifest := map[string]interface{}{ - "platform": platformSpec, - "plugins": plugins, - } - - result, err := json.MarshalIndent(manifest, "", " ") - if err != nil { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Failed to marshal build manifest: %v", err)}, - }, - }, nil, nil - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: string(result)}, + return s.handleWithTeamAndJSON( + func() string { return args.Team }, + func(team string) (interface{}, error) { + var platformSpec interface{} + var plugins map[string]map[string]any + var err error + if args.Public { + platformSpec, plugins, err = s.apiClient.GetPublicBuildManifest(team, args.Platform, args.Revision) + } else { + platformSpec, plugins, err = s.apiClient.GetBuildManifest(team, args.Platform, args.Revision) + } + if err != nil { + return nil, fmt.Errorf("Failed to get build manifest: %w", err) + } + return map[string]interface{}{ + "platform": platformSpec, + "plugins": plugins, + }, nil }, - }, nil, nil + nil, + ) } func (s *Server) handleGetPluginManifest(ctx context.Context, req *mcp.CallToolRequest, args GetPluginManifestArgs) (*mcp.CallToolResult, any, error) { - team, err := s.getTeamOrDefault(args.Team) - if err != nil { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Failed to get team: %v", err)}, - }, - }, nil, nil - } - - var manifest interface{} - - if args.Public { - manifest, err = s.apiClient.GetPublicPluginManifest(team, args.Library, args.LibraryVersion, args.PluginName) - } else { - manifest, err = s.apiClient.GetPluginManifest(team, args.Library, args.LibraryVersion, args.PluginName) - } - - if err != nil { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Failed to get plugin manifest: %v", err)}, - }, - }, nil, nil - } - - result, err := json.MarshalIndent(manifest, "", " ") - if err != nil { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Failed to marshal plugin manifest: %v", err)}, - }, - }, nil, nil - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: string(result)}, + return s.handleWithTeamAndJSON( + func() string { return args.Team }, + func(team string) (interface{}, error) { + var manifest interface{} + var err error + if args.Public { + manifest, err = s.apiClient.GetPublicPluginManifest(team, args.Library, args.LibraryVersion, args.PluginName) + } else { + manifest, err = s.apiClient.GetPluginManifest(team, args.Library, args.LibraryVersion, args.PluginName) + } + if err != nil { + return nil, fmt.Errorf("Failed to get plugin manifest: %w", err) + } + return manifest, nil }, - }, nil, nil + nil, + ) } func (s *Server) handleListPlatforms(ctx context.Context, req *mcp.CallToolRequest, args ListPlatformsArgs) (*mcp.CallToolResult, any, error) { - team, err := s.getTeamOrDefault(args.Team) - if err != nil { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Failed to get team: %v", err)}, - }, - }, nil, nil - } - - var platforms []api.PlatformResponse - - if args.Public { - platforms, err = s.apiClient.ListPublicPlatforms(team) - } else { - platforms, err = s.apiClient.ListPlatforms(team) - } - - if err != nil { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Failed to list platforms: %v", err)}, - }, - }, nil, nil - } - - result, err := json.MarshalIndent(platforms, "", " ") - if err != nil { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Failed to marshal platforms: %v", err)}, - }, - }, nil, nil - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: string(result)}, + return s.handleWithTeamAndJSON( + func() string { return args.Team }, + func(team string) (interface{}, error) { + var platforms []api.PlatformResponse + var err error + if args.Public { + platforms, err = s.apiClient.ListPublicPlatforms(team) + } else { + platforms, err = s.apiClient.ListPlatforms(team) + } + if err != nil { + return nil, fmt.Errorf("Failed to list platforms: %w", err) + } + return platforms, nil }, - }, nil, nil + nil, + ) } func (s *Server) handleListPluginLibraries(ctx context.Context, req *mcp.CallToolRequest, args ListPluginLibrariesArgs) (*mcp.CallToolResult, any, error) { - team, err := s.getTeamOrDefault(args.Team) - if err != nil { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Failed to get team: %v", err)}, - }, - }, nil, nil - } - - var libraries []api.PluginLibraryWithVersions - - if args.Public { - libraries, err = s.apiClient.ListPublicPluginLibraries(team) - } else { - libraries, err = s.apiClient.ListPluginLibraries(team) - } - - if err != nil { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Failed to list plugin libraries: %v", err)}, - }, - }, nil, nil - } - - result, err := json.MarshalIndent(libraries, "", " ") - if err != nil { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Failed to marshal plugin libraries: %v", err)}, - }, - }, nil, nil - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: string(result)}, + return s.handleWithTeamAndJSON( + func() string { return args.Team }, + func(team string) (interface{}, error) { + var libraries []api.PluginLibraryWithVersions + var err error + if args.Public { + libraries, err = s.apiClient.ListPublicPluginLibraries(team) + } else { + libraries, err = s.apiClient.ListPluginLibraries(team) + } + if err != nil { + return nil, fmt.Errorf("Failed to list plugin libraries: %w", err) + } + return libraries, nil }, - }, nil, nil + nil, + ) } func (s *Server) handleGetPluginLibraryVersion(ctx context.Context, req *mcp.CallToolRequest, args GetPluginLibraryVersionArgs) (*mcp.CallToolResult, any, error) { - team, err := s.getTeamOrDefault(args.Team) - if err != nil { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Failed to get team: %v", err)}, - }, - }, nil, nil - } - - var version *api.PluginLibraryVersion - - if args.Public { - version, err = s.apiClient.GetPublicPluginLibraryVersion(team, args.Library, args.LibraryVersion) - } else { - version, err = s.apiClient.GetPluginLibraryVersion(team, args.Library, args.LibraryVersion) - } - - if err != nil { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Failed to get plugin library version: %v", err)}, - }, - }, nil, nil - } - - result, err := json.MarshalIndent(version, "", " ") - if err != nil { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Failed to marshal plugin library version: %v", err)}, - }, - }, nil, nil - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: string(result)}, + return s.handleWithTeamAndJSON( + func() string { return args.Team }, + func(team string) (interface{}, error) { + var version *api.PluginLibraryVersion + var err error + if args.Public { + version, err = s.apiClient.GetPublicPluginLibraryVersion(team, args.Library, args.LibraryVersion) + } else { + version, err = s.apiClient.GetPluginLibraryVersion(team, args.Library, args.LibraryVersion) + } + if err != nil { + return nil, fmt.Errorf("Failed to get plugin library version: %w", err) + } + return version, nil }, - }, nil, nil + nil, + ) } func (s *Server) handleBuild(ctx context.Context, req *mcp.CallToolRequest, args BuildArgs) (*mcp.CallToolResult, any, error) { From 3f9629232e3f76e0af305d638408e5344302c29e Mon Sep 17 00:00:00 2001 From: Tim Holm Date: Mon, 27 Oct 2025 09:58:43 +1100 Subject: [PATCH 09/14] chore(cli): prevent path traversal in build mcp tool --- cli/internal/mcp/server.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/cli/internal/mcp/server.go b/cli/internal/mcp/server.go index bdc31d2c..5e4a4e2a 100644 --- a/cli/internal/mcp/server.go +++ b/cli/internal/mcp/server.go @@ -6,6 +6,9 @@ import ( "encoding/json" "fmt" "log" + "os" + "path/filepath" + "strings" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/nitrictech/suga/cli/internal/api" @@ -486,7 +489,21 @@ func (s *Server) handleBuild(ctx context.Context, req *mcp.CallToolRequest, args projectFile = "./suga.yaml" } - stackPath, err := s.builder.BuildProjectFromFile(projectFile, team) + // Prevent path traversal + clean := filepath.Clean(projectFile) + absProject, err := filepath.Abs(clean) + if err != nil { + return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("Invalid project file path: %v", err)}}}, nil, nil + } + wd, err := os.Getwd() + if err != nil { + return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("Cannot determine working directory: %v", err)}}}, nil, nil + } + if !strings.HasPrefix(absProject, wd+string(os.PathSeparator)) && absProject != filepath.Join(wd, "suga.yaml") { + return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: "project_file must be within the current workspace"}}}, nil, nil + } + + stackPath, err := s.builder.BuildProjectFromFile(clean, team) if err != nil { return &mcp.CallToolResult{ IsError: true, From 172310f5fe2ce36746144f23cd9408fbac22a97e Mon Sep 17 00:00:00 2001 From: Tim Holm Date: Mon, 27 Oct 2025 10:01:04 +1100 Subject: [PATCH 10/14] chore(docs): add mcp to vale accept.txt --- docs/.vale/styles/config/vocabularies/Suga/accept.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/.vale/styles/config/vocabularies/Suga/accept.txt b/docs/.vale/styles/config/vocabularies/Suga/accept.txt index 053cd61e..d135b13d 100644 --- a/docs/.vale/styles/config/vocabularies/Suga/accept.txt +++ b/docs/.vale/styles/config/vocabularies/Suga/accept.txt @@ -33,6 +33,7 @@ storage_account_name container_name tfstatestorage mcpServers +mcp # Defaults from mintlify Mintlify From 9c061e6e195e34cedddbba0c64c933373dbedf18 Mon Sep 17 00:00:00 2001 From: Tim Holm Date: Mon, 27 Oct 2025 14:45:26 +1100 Subject: [PATCH 11/14] chore(cli): fix public resource routes. --- cli/internal/api/platform.go | 4 +- cli/internal/api/plugin.go | 4 +- cli/internal/mcp/server.go | 74 +++++++++++++++++++++++++++++------- 3 files changed, 64 insertions(+), 18 deletions(-) diff --git a/cli/internal/api/platform.go b/cli/internal/api/platform.go index c8883eaa..ccfc020b 100644 --- a/cli/internal/api/platform.go +++ b/cli/internal/api/platform.go @@ -103,8 +103,8 @@ func (c *SugaApiClient) ListPlatforms(team string) ([]PlatformResponse, error) { return platformsResponse.Platforms, nil } -func (c *SugaApiClient) ListPublicPlatforms(team string) ([]PlatformResponse, error) { - response, err := c.get(fmt.Sprintf("/api/public/platforms/%s", url.PathEscape(team)), true) +func (c *SugaApiClient) ListPublicPlatforms() ([]PlatformResponse, error) { + response, err := c.get("/api/public/platforms", true) if err != nil { return nil, err } diff --git a/cli/internal/api/plugin.go b/cli/internal/api/plugin.go index 0f94bdd0..f00a741c 100644 --- a/cli/internal/api/plugin.go +++ b/cli/internal/api/plugin.go @@ -127,8 +127,8 @@ func (c *SugaApiClient) ListPluginLibraries(team string) ([]PluginLibraryWithVer return librariesResponse.Libraries, nil } -func (c *SugaApiClient) ListPublicPluginLibraries(team string) ([]PluginLibraryWithVersions, error) { - response, err := c.get(fmt.Sprintf("/api/public/plugin_libraries/%s", url.PathEscape(team)), true) +func (c *SugaApiClient) ListPublicPluginLibraries() ([]PluginLibraryWithVersions, error) { + response, err := c.get("/api/public/plugin_libraries", true) if err != nil { return nil, err } diff --git a/cli/internal/mcp/server.go b/cli/internal/mcp/server.go index 5e4a4e2a..928ae180 100644 --- a/cli/internal/mcp/server.go +++ b/cli/internal/mcp/server.go @@ -414,16 +414,39 @@ func (s *Server) handleGetPluginManifest(ctx context.Context, req *mcp.CallToolR } func (s *Server) handleListPlatforms(ctx context.Context, req *mcp.CallToolRequest, args ListPlatformsArgs) (*mcp.CallToolResult, any, error) { + if args.Public { + // Public platforms don't require team parameter + platforms, err := s.apiClient.ListPublicPlatforms() + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to list platforms: %v", err)}, + }, + }, nil, nil + } + + resultJSON, err := json.MarshalIndent(platforms, "", " ") + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to marshal result: %v", err)}, + }, + }, nil, nil + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(resultJSON)}, + }, + }, nil, nil + } + return s.handleWithTeamAndJSON( func() string { return args.Team }, func(team string) (interface{}, error) { - var platforms []api.PlatformResponse - var err error - if args.Public { - platforms, err = s.apiClient.ListPublicPlatforms(team) - } else { - platforms, err = s.apiClient.ListPlatforms(team) - } + platforms, err := s.apiClient.ListPlatforms(team) if err != nil { return nil, fmt.Errorf("Failed to list platforms: %w", err) } @@ -434,16 +457,39 @@ func (s *Server) handleListPlatforms(ctx context.Context, req *mcp.CallToolReque } func (s *Server) handleListPluginLibraries(ctx context.Context, req *mcp.CallToolRequest, args ListPluginLibrariesArgs) (*mcp.CallToolResult, any, error) { + if args.Public { + // Public plugin libraries don't require team parameter + libraries, err := s.apiClient.ListPublicPluginLibraries() + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to list plugin libraries: %v", err)}, + }, + }, nil, nil + } + + resultJSON, err := json.MarshalIndent(libraries, "", " ") + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to marshal result: %v", err)}, + }, + }, nil, nil + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(resultJSON)}, + }, + }, nil, nil + } + return s.handleWithTeamAndJSON( func() string { return args.Team }, func(team string) (interface{}, error) { - var libraries []api.PluginLibraryWithVersions - var err error - if args.Public { - libraries, err = s.apiClient.ListPublicPluginLibraries(team) - } else { - libraries, err = s.apiClient.ListPluginLibraries(team) - } + libraries, err := s.apiClient.ListPluginLibraries(team) if err != nil { return nil, fmt.Errorf("Failed to list plugin libraries: %w", err) } From 04b1547c3be0e6bcd6ccaf7afc9dec876b0cbb99 Mon Sep 17 00:00:00 2001 From: Tim Holm Date: Tue, 28 Oct 2025 08:23:14 +1100 Subject: [PATCH 12/14] chore(cli): add a dev script example to app dev instructions --- cli/internal/mcp/instructions-app-development.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cli/internal/mcp/instructions-app-development.md b/cli/internal/mcp/instructions-app-development.md index d82a0c28..3c5b9c67 100644 --- a/cli/internal/mcp/instructions-app-development.md +++ b/cli/internal/mcp/instructions-app-development.md @@ -50,7 +50,7 @@ The plugin manifests you see in the build manifest response are INTERNAL platfor ```yaml serviceIntents: my_service: - subtype: fargate + subtype: lambda memory: 512 # ❌ WRONG - This is a plugin input, not valid config cpu: 256 # ❌ WRONG - This is a plugin input, not valid config ``` @@ -59,12 +59,13 @@ serviceIntents: ```yaml serviceIntents: my_service: - subtype: fargate # ✅ CORRECT - subtype is in the schema + subtype: lambda # ✅ CORRECT - subtype is in the schema container: # ✅ CORRECT - container is in the schema image: uri: node:18 env: # ✅ CORRECT - env is in the schema PORT: "3000" + dev: npm run dev ``` The ONLY valid fields for suga.yaml are defined in the suga://schema/application resource. Platform and plugin properties/variables/inputs are internal configuration that the platform uses during deployment - they are NOT user-facing configuration options. @@ -189,7 +190,7 @@ target: nitric/aws-platform@2 # ✅ From step 1 name: node-api serviceIntents: api_service: # ✅ snake_case - subtype: fargate # ✅ From service_blueprints keys + subtype: lambda # ✅ From service_blueprints keys container: image: uri: node:18 # ✅ Exactly one container type From 6e9ffea4d56a958a41fb5d7be465fe70f3fc0b5c Mon Sep 17 00:00:00 2001 From: Tim Holm Date: Tue, 28 Oct 2025 09:54:45 +1100 Subject: [PATCH 13/14] chore(cli): mod tidy --- cli/go.mod | 5 +++-- cli/go.sum | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/cli/go.mod b/cli/go.mod index 8773a620..892b9ceb 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -11,6 +11,7 @@ require ( github.com/hashicorp/go-getter v1.7.8 github.com/invopop/jsonschema v0.13.0 github.com/mitchellh/mapstructure v1.5.0 + github.com/modelcontextprotocol/go-sdk v1.0.0 github.com/nitrictech/suga/engines v0.0.0-20250822044031-c54426614b80 github.com/nitrictech/suga/proto v0.0.0-20250822044031-c54426614b80 github.com/pkg/errors v0.9.1 @@ -77,6 +78,7 @@ require ( github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/jsonschema-go v0.3.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect @@ -97,8 +99,6 @@ require ( github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect - github.com/modelcontextprotocol/go-sdk v1.0.0 // indirect - github.com/mtibben/percent v0.2.1 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect @@ -119,6 +119,7 @@ require ( github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yuin/goldmark v1.5.2 // indirect github.com/zeebo/errs v1.4.0 // indirect go.opencensus.io v0.24.0 // indirect diff --git a/cli/go.sum b/cli/go.sum index f6cd2b35..c16b7b17 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -867,6 +867,8 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= +github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -1003,8 +1005,6 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modelcontextprotocol/go-sdk v1.0.0 h1:Z4MSjLi38bTgLrd/LjSmofqRqyBiVKRyQSJgw8q8V74= github.com/modelcontextprotocol/go-sdk v1.0.0/go.mod h1:nYtYQroQ2KQiM0/SbyEPUWQ6xs4B95gJjEalc9AQyOs= -github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= -github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -1110,6 +1110,8 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= From 52893ee992e574832f2f6813a0a9e203297f7632 Mon Sep 17 00:00:00 2001 From: Jye Cusch Date: Tue, 28 Oct 2025 10:10:50 +1100 Subject: [PATCH 14/14] chore(cli): add new build options to mcp build call --- cli/internal/mcp/server.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cli/internal/mcp/server.go b/cli/internal/mcp/server.go index 928ae180..6eb6232e 100644 --- a/cli/internal/mcp/server.go +++ b/cli/internal/mcp/server.go @@ -549,7 +549,7 @@ func (s *Server) handleBuild(ctx context.Context, req *mcp.CallToolRequest, args return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: "project_file must be within the current workspace"}}}, nil, nil } - stackPath, err := s.builder.BuildProjectFromFile(clean, team) + stackPath, err := s.builder.BuildProjectFromFile(clean, team, build.BuildOptions{}) if err != nil { return &mcp.CallToolResult{ IsError: true, @@ -657,4 +657,3 @@ func (s *Server) registerDocsProxyTools() error { } return nil } -