diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..ab1f416
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,10 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Ignored default folder with query files
+/queries/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
+# Editor-based HTTP Client requests
+/httpRequests/
diff --git a/.idea/go-api.iml b/.idea/go-api.iml
new file mode 100644
index 0000000..5e764c4
--- /dev/null
+++ b/.idea/go-api.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml
new file mode 100644
index 0000000..d7202f0
--- /dev/null
+++ b/.idea/go.imports.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml
new file mode 100644
index 0000000..6725e8b
--- /dev/null
+++ b/.idea/material_theme_project_new.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..90dee70
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,4 @@
+
+
+ {}
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..ddba8d8
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/authentik.go b/authentik.go
index 7344bca..fa08217 100644
--- a/authentik.go
+++ b/authentik.go
@@ -5,6 +5,7 @@ package api
import (
"context"
"net/http"
+ "slices"
"strings"
"sync"
@@ -43,12 +44,7 @@ type AuthentikUser struct {
// HasGroup reports whether the user belongs to the named group.
func (u *AuthentikUser) HasGroup(group string) bool {
- for _, g := range u.Groups {
- if g == group {
- return true
- }
- }
- return false
+ return slices.Contains(u.Groups, group)
}
// authentikUserKey is the Gin context key used to store the authenticated user.
@@ -112,10 +108,10 @@ func validateJWT(ctx context.Context, cfg AuthentikConfig, rawToken string) (*Au
var claims struct {
PreferredUsername string `json:"preferred_username"`
- Email string `json:"email"`
- Name string `json:"name"`
- Sub string `json:"sub"`
- Groups []string `json:"groups"`
+ Email string `json:"email"`
+ Name string `json:"name"`
+ Sub string `json:"sub"`
+ Groups []string `json:"groups"`
}
if err := idToken.Claims(&claims); err != nil {
return nil, err
diff --git a/cache.go b/cache.go
index 54b5511..d032346 100644
--- a/cache.go
+++ b/cache.go
@@ -4,6 +4,7 @@ package api
import (
"bytes"
+ "maps"
"net/http"
"sync"
"time"
@@ -113,9 +114,7 @@ func cacheMiddleware(store *cacheStore, ttl time.Duration) gin.HandlerFunc {
status := cw.ResponseWriter.Status()
if status >= 200 && status < 300 {
headers := make(http.Header)
- for k, vals := range cw.ResponseWriter.Header() {
- headers[k] = vals
- }
+ maps.Copy(headers, cw.ResponseWriter.Header())
store.set(key, &cacheEntry{
status: status,
headers: headers,
diff --git a/docs/plans/2026-02-21-openapi-spec-gen.md b/docs/plans/2026-02-21-openapi-spec-gen.md
new file mode 100644
index 0000000..17574de
--- /dev/null
+++ b/docs/plans/2026-02-21-openapi-spec-gen.md
@@ -0,0 +1,366 @@
+# go-api Phase 3: OpenAPI Spec Generation + SDK Codegen
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Turn MCP tool definitions into REST endpoints with auto-generated OpenAPI 3.1 specs and multi-language SDK codegen.
+
+**Architecture:** Runtime OpenAPI generation (NOT swaggo annotations) — routes are dynamic via RouteGroup, Response[T] generics break swaggo, and MCP tools already carry JSON Schema at runtime. A `ToolBridge` converts tool descriptors into RouteGroup + OpenAPI metadata. A `SpecBuilder` constructs the full OpenAPI 3.1 spec from all registered groups. SDK codegen wraps `openapi-generator-cli`.
+
+**Tech Stack:** go-api (Gin), OpenAPI 3.1, openapi-generator-cli, go-ai MCP SDK
+
+---
+
+## Context
+
+Phase 1+2 complete (143 tests, 21 With*() options). The "missing link" is: MCP tools are only accessible via JSON-RPC. This phase creates a REST bridge so any HTTP client (or generated SDK) can call the same services. The pipeline: **MCP tool → REST endpoint → OpenAPI spec → SDK codegen**.
+
+Current swagger.go serves an empty spec (`paths: {}`). swaggo annotations don't exist anywhere and can't work with dynamic RouteGroups or generics. The solution is runtime spec generation.
+
+## Critical Files
+
+- `swagger.go` — Refactor from empty hardcoded spec to SpecBuilder
+- `group.go` — Add DescribableGroup interface
+- `api.go` — Pass groups to registerSwagger, Engine struct changes
+- `response.go` — Response[T], Error, Meta structs (read-only reference for envelope schema)
+- `options.go` — WithSwagger already exists, no changes needed
+- `go-ai/mcp/mcp.go` — Tool registration with typed Input/Output structs
+- `go-ai/mcp/subsystem.go` — Subsystem interface
+
+## Wave 1: OpenAPI Runtime Spec (go-api only)
+
+### Task 1: DescribableGroup interface + RouteDescription
+
+**Files:**
+- Modify: `group.go`
+- Test: `group_test.go` (existing — add new tests)
+
+Add a `DescribableGroup` interface that RouteGroups can optionally implement to provide OpenAPI metadata. This is the opt-in contract for spec generation.
+
+```go
+// DescribableGroup extends RouteGroup with OpenAPI metadata.
+// RouteGroups that implement this will have their endpoints
+// included in the generated OpenAPI specification.
+type DescribableGroup interface {
+ RouteGroup
+ Describe() []RouteDescription
+}
+
+// RouteDescription describes a single endpoint for OpenAPI generation.
+type RouteDescription struct {
+ Method string // HTTP method: GET, POST, PUT, DELETE, PATCH
+ Path string // Path relative to BasePath, e.g. "/generate"
+ Summary string // Short summary
+ Description string // Long description
+ Tags []string // OpenAPI tags for grouping
+ RequestBody map[string]any // JSON Schema for request body (nil for GET)
+ Response map[string]any // JSON Schema for success response data
+}
+```
+
+**Tests (5):**
+- `TestDescribableGroup_Good_ImplementsRouteGroup` — A struct implementing both interfaces compiles and works
+- `TestDescribableGroup_Good_DescribeReturnsRoutes` — Verify Describe() returns correct RouteDescription list
+- `TestDescribableGroup_Good_EmptyDescribe` — Empty Describe() is valid (no endpoints documented)
+- `TestDescribableGroup_Good_MultipleVerbs` — GET, POST, DELETE on same base path
+- `TestDescribableGroup_Bad_NilSchemas` — RequestBody and Response can be nil
+
+### Task 2: ToolBridge — tool descriptors to REST endpoints
+
+**Files:**
+- Create: `bridge.go`
+- Create: `bridge_test.go`
+
+The `ToolBridge` converts tool descriptors into a RouteGroup that also implements DescribableGroup. It creates `POST /{tool_name}` endpoints for each registered tool.
+
+```go
+// ToolDescriptor describes a tool that can be exposed as a REST endpoint.
+type ToolDescriptor struct {
+ Name string // Tool name, e.g. "file_read" (becomes POST path)
+ Description string // Human-readable description
+ Group string // OpenAPI tag group, e.g. "files"
+ InputSchema map[string]any // JSON Schema for request body
+ OutputSchema map[string]any // JSON Schema for response data (optional)
+}
+
+// ToolBridge converts tool descriptors into REST endpoints and OpenAPI paths.
+// It implements both RouteGroup and DescribableGroup.
+type ToolBridge struct {
+ basePath string
+ tools []boundTool
+}
+
+type boundTool struct {
+ descriptor ToolDescriptor
+ handler gin.HandlerFunc
+}
+
+func NewToolBridge(basePath string) *ToolBridge
+
+// Add registers a tool with its HTTP handler.
+func (b *ToolBridge) Add(desc ToolDescriptor, handler gin.HandlerFunc)
+
+// RouteGroup implementation
+func (b *ToolBridge) Name() string
+func (b *ToolBridge) BasePath() string
+func (b *ToolBridge) RegisterRoutes(rg *gin.RouterGroup)
+
+// DescribableGroup implementation
+func (b *ToolBridge) Describe() []RouteDescription
+
+// Tools returns registered tool descriptors.
+func (b *ToolBridge) Tools() []ToolDescriptor
+```
+
+**Tests (6):**
+- `TestToolBridge_Good_RegisterAndServe` — Add 2 tools, verify POST endpoints respond
+- `TestToolBridge_Good_BasePath` — Custom base path `/api/v1/tools` works
+- `TestToolBridge_Good_Describe` — Describe() returns correct RouteDescription for each tool
+- `TestToolBridge_Good_ToolsAccessor` — Tools() returns all registered descriptors
+- `TestToolBridge_Bad_EmptyBridge` — Bridge with no tools is valid, RegisterRoutes is no-op
+- `TestToolBridge_Good_IntegrationWithEngine` — Register bridge with Engine, verify routes accessible via Handler()
+
+### Task 3: SpecBuilder — OpenAPI 3.1 spec generation
+
+**Files:**
+- Create: `openapi.go`
+- Create: `openapi_test.go`
+
+Builds a complete OpenAPI 3.1 JSON document from registered RouteGroups. Groups implementing DescribableGroup contribute their endpoint metadata. All responses are wrapped in the Response[T] envelope.
+
+```go
+// SpecBuilder constructs an OpenAPI 3.1 spec from registered RouteGroups.
+type SpecBuilder struct {
+ Title string
+ Description string
+ Version string
+}
+
+// Build generates the complete OpenAPI 3.1 JSON spec.
+// Groups implementing DescribableGroup contribute endpoint documentation.
+// Other groups are listed as tags only.
+func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error)
+```
+
+Key behaviours:
+- Always includes `GET /health` (built-in)
+- For each DescribableGroup, generates path items from Describe()
+- Wraps all response schemas in the Response[T] envelope (success/data/error/meta)
+- Uses OpenAPI 3.1 (compatible with JSON Schema 2020-12 from MCP SDK)
+- Tags derived from RouteGroup.Name()
+- Output is valid, parseable OpenAPI 3.1 JSON
+
+**Tests (6):**
+- `TestSpecBuilder_Good_EmptyGroups` — Produces valid spec with just /health
+- `TestSpecBuilder_Good_WithDescribableGroup` — Paths populated from Describe()
+- `TestSpecBuilder_Good_EnvelopeWrapping` — Response schema wraps data in Response[T] envelope
+- `TestSpecBuilder_Good_NonDescribableGroup` — Non-describable groups appear as tags only
+- `TestSpecBuilder_Good_ToolBridgeIntegration` — ToolBridge + SpecBuilder produces correct tool endpoints
+- `TestSpecBuilder_Bad_InfoFields` — Title, description, version appear in output
+
+### Task 4: Refactor swagger.go to use SpecBuilder
+
+**Files:**
+- Modify: `swagger.go`
+- Modify: `api.go` (pass groups to registerSwagger)
+- Modify: `swagger_test.go` (update tests)
+
+Replace the hardcoded empty JSON spec with SpecBuilder-generated spec. The swaggerSpec struct now wraps SpecBuilder and caches the result via sync.Once. `registerSwagger` receives the engine's groups.
+
+```go
+type swaggerSpec struct {
+ builder *SpecBuilder
+ groups []RouteGroup
+ once sync.Once
+ doc string
+}
+
+func (s *swaggerSpec) ReadDoc() string {
+ s.once.Do(func() {
+ data, _ := s.builder.Build(s.groups)
+ s.doc = string(data)
+ })
+ return s.doc
+}
+
+func registerSwagger(g *gin.Engine, title, description, version string, groups []RouteGroup)
+```
+
+In `api.go`, change `build()`:
+```go
+// Mount Swagger UI if enabled.
+if e.swaggerEnabled {
+ registerSwagger(r, e.swaggerTitle, e.swaggerDesc, e.swaggerVersion, e.groups)
+}
+```
+
+**Tests (5):**
+- `TestSwagger_Good_SpecNotEmpty` — GET /swagger/doc.json returns spec with /health path
+- `TestSwagger_Good_WithToolBridge` — Register ToolBridge, verify tool endpoints appear in spec
+- `TestSwagger_Good_CachesSpec` — Multiple calls return same cached spec
+- `TestSwagger_Good_InfoFromOptions` — Title/desc/version from WithSwagger() appear in spec
+- `TestSwagger_Good_ValidOpenAPI` — Output parses as valid JSON with correct openapi version field
+
+### Task 5: SpecExporter — export spec to file/stdout
+
+**Files:**
+- Create: `export.go`
+- Create: `export_test.go`
+
+A helper that exports the generated spec to a file or io.Writer. Supports JSON and YAML output. This is what `core api spec` will call.
+
+```go
+// ExportSpec generates the OpenAPI spec and writes it to w.
+// Format can be "json" or "yaml".
+func ExportSpec(w io.Writer, format string, builder *SpecBuilder, groups []RouteGroup) error
+
+// ExportSpecToFile writes the spec to the given path.
+func ExportSpecToFile(path, format string, builder *SpecBuilder, groups []RouteGroup) error
+```
+
+**Tests (5):**
+- `TestExportSpec_Good_JSON` — Writes valid JSON to buffer
+- `TestExportSpec_Good_YAML` — Writes valid YAML to buffer
+- `TestExportSpec_Bad_InvalidFormat` — Returns error for unknown format
+- `TestExportSpecToFile_Good_CreatesFile` — Writes file to temp dir
+- `TestExportSpec_Good_WithToolBridge` — Full pipeline: tools → bridge → spec → export
+
+## Wave 2: MCP-to-REST Bridge (go-ai)
+
+### Task 6: Tool registry in go-ai Service
+
+**Files:**
+- Modify: `go-ai/mcp/mcp.go` — Add ToolRecord and registry
+- Create: `go-ai/mcp/registry.go` — ToolRecord type and helpers
+- Create: `go-ai/mcp/registry_test.go`
+
+The MCP SDK's `mcp.Server.tools` is unexported. We maintain a parallel registry in `Service` that records tool metadata (name, description, input/output Go types) as tools are registered. This becomes the source of truth for both MCP and REST.
+
+```go
+// ToolRecord captures metadata about a registered MCP tool.
+type ToolRecord struct {
+ Name string
+ Description string
+ Group string // Subsystem group name
+ InputSchema map[string]any // JSON Schema from Go struct tags
+ OutputSchema map[string]any
+ Handler any // The original handler func for type assertion
+}
+
+// Tools returns all recorded tool metadata.
+func (s *Service) Tools() []ToolRecord
+```
+
+Modify `registerTools` and subsystem registration to also record tools. Use `jsonschema-go` (already a transitive dep from MCP SDK) to extract schemas from the typed Input/Output parameters.
+
+**Tests (5):**
+- `TestToolRegistry_Good_RecordsTools` — After New(), Tools() returns all registered tools
+- `TestToolRegistry_Good_IncludesSubsystems` — Subsystem tools included in registry
+- `TestToolRegistry_Good_SchemaExtraction` — InputSchema contains correct properties from struct tags
+- `TestToolRegistry_Good_ToolCount` — Count matches expected (10 built-in + subsystem tools)
+- `TestToolRegistry_Bad_EmptyService` — Service with no subsystems has only built-in tools
+
+### Task 7: BridgeToAPI — MCP tools to go-api ToolBridge
+
+**Files:**
+- Create: `go-ai/mcp/bridge.go`
+- Create: `go-ai/mcp/bridge_test.go`
+
+Converts MCP tool records into go-api ToolBridge entries. Each tool becomes a POST endpoint. The handler unmarshals JSON from request body, calls the business logic, and wraps the response in `api.OK()`/`api.Fail()`.
+
+```go
+// BridgeToAPI populates a go-api ToolBridge from recorded MCP tools.
+// Each tool becomes a POST endpoint at /{tool_name}.
+func BridgeToAPI(svc *Service, bridge *api.ToolBridge) error
+```
+
+**Tests (5):**
+- `TestBridgeToAPI_Good_FileRead` — Bridge file_read, POST JSON body, get Response[ReadFileOutput]
+- `TestBridgeToAPI_Good_AllTools` — All tools from registry appear in bridge
+- `TestBridgeToAPI_Good_DescribableGroup` — Bridge implements DescribableGroup, Describe() returns correct metadata
+- `TestBridgeToAPI_Bad_InvalidJSON` — POST malformed JSON returns 400 with error envelope
+- `TestBridgeToAPI_Good_EndToEnd` — New service → BridgeToAPI → register with Engine → GET /swagger/doc.json shows all tools
+
+## Wave 3: SDK Codegen + CLI
+
+### Task 8: Codegen wrapper
+
+**Files:**
+- Create: `codegen.go`
+- Create: `codegen_test.go`
+
+Wraps `openapi-generator-cli` for multi-language SDK generation. Checks if the tool is available and provides clear installation instructions if missing.
+
+```go
+// SDKGenerator wraps openapi-generator-cli for SDK generation.
+type SDKGenerator struct {
+ SpecPath string
+ OutputDir string
+ PackageName string
+}
+
+// Generate creates an SDK for the given language.
+// Supported: "go", "typescript-fetch", "python", "java", "csharp"
+func (g *SDKGenerator) Generate(ctx context.Context, language string) error
+
+// Available checks if openapi-generator-cli is installed.
+func (g *SDKGenerator) Available() bool
+
+// SupportedLanguages returns the list of supported SDK languages.
+func SupportedLanguages() []string
+```
+
+**Tests (5):**
+- `TestSDKGenerator_Good_CommandConstruction` — Verify correct CLI arguments without executing
+- `TestSDKGenerator_Good_SupportedLanguages` — Returns expected language list
+- `TestSDKGenerator_Bad_UnsupportedLanguage` — Returns error for "brainfuck"
+- `TestSDKGenerator_Bad_MissingSpec` — Returns error when spec file doesn't exist
+- `TestSDKGenerator_Good_OutputDirCreated` — Creates output directory if missing
+
+### Task 9: CLI commands — `core api spec` and `core api sdk`
+
+**Files:**
+- Create: `cmd/core-cli/api.go` (or appropriate location in CLI tree)
+- Create test file alongside
+
+New `api` command group in the core CLI:
+
+```
+core api spec # Export OpenAPI spec JSON to stdout
+core api spec --output spec.json # Write to file
+core api spec --format yaml # YAML format
+
+core api sdk --lang go # Generate Go SDK
+core api sdk --lang typescript-fetch,python --output ./sdk/
+core api sdk --spec spec.json --lang go --output ./sdk/go
+```
+
+This depends on understanding the CLI registration pattern. The commands create an MCP service, bridge tools, build spec, and either export or generate SDKs.
+
+**Tests (4):**
+- `TestAPISpecCmd_Good_JSON` — Outputs valid JSON to stdout
+- `TestAPISpecCmd_Good_YAML` — Outputs valid YAML with --format yaml
+- `TestAPISDKCmd_Bad_NoLang` — Returns error without --lang flag
+- `TestAPISDKCmd_Good_ValidatesLanguage` — Rejects unsupported language with helpful message
+
+## Verification
+
+After all tasks are complete:
+
+1. **Unit tests**: `go test ./...` — all pass
+2. **go-ai tests**: `cd ../go-ai && go test ./...` — all pass
+3. **Spec roundtrip**: Register a ToolBridge with sample tools, build Engine with WithSwagger, GET `/swagger/doc.json` → verify paths are populated, not empty
+4. **Export**: `ExportSpec` to temp file in JSON and YAML, verify both parse correctly
+5. **SDK smoke test** (if openapi-generator-cli installed): Generate Go client from exported spec, verify it compiles
+
+## Dependency Sequencing
+
+```
+Task 1 (DescribableGroup) ← Task 2 (ToolBridge) ← Task 3 (SpecBuilder)
+Task 3 ← Task 4 (swagger refactor)
+Task 3 ← Task 5 (ExportSpec)
+Task 2 ← Task 6 (Tool registry) ← Task 7 (BridgeToAPI)
+Task 5 ← Task 8 (Codegen)
+Task 5 + Task 7 ← Task 9 (CLI commands)
+```
+
+Wave 1 (Tasks 1-5) is self-contained in go-api. Wave 2 (Tasks 6-7) bridges go-ai. Wave 3 (Tasks 8-9) adds codegen and CLI.
diff --git a/docs/plans/valiant-hatching-wave.md b/docs/plans/valiant-hatching-wave.md
new file mode 100644
index 0000000..dfe7b74
--- /dev/null
+++ b/docs/plans/valiant-hatching-wave.md
@@ -0,0 +1,366 @@
+# go-api Phase 3: OpenAPI Spec Generation + SDK Codegen
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Turn MCP tool definitions into REST endpoints with auto-generated OpenAPI 3.1 specs and multi-language SDK codegen.
+
+**Architecture:** Runtime OpenAPI generation (NOT swaggo annotations) — routes are dynamic via RouteGroup, Response[T] generics break swaggo, and MCP tools already carry JSON Schema at runtime. A `ToolBridge` converts tool descriptors into RouteGroup + OpenAPI metadata. A `SpecBuilder` constructs the full OpenAPI 3.1 spec from all registered groups. SDK codegen wraps `openapi-generator-cli`.
+
+**Tech Stack:** go-api (Gin), OpenAPI 3.1, openapi-generator-cli, go-ai MCP SDK
+
+---
+
+## Context
+
+Phase 1+2 complete (143 tests, 21 With*() options). The "missing link" is: MCP tools are only accessible via JSON-RPC. This phase creates a REST bridge so any HTTP client (or generated SDK) can call the same services. The pipeline: **MCP tool → REST endpoint → OpenAPI spec → SDK codegen**.
+
+Current swagger.go serves an empty spec (`paths: {}`). swaggo annotations don't exist anywhere and can't work with dynamic RouteGroups or generics. The solution is runtime spec generation.
+
+## Critical Files
+
+- `/Users/snider/Code/go-api/swagger.go` — Refactor from empty hardcoded spec to SpecBuilder
+- `/Users/snider/Code/go-api/group.go` — Add DescribableGroup interface
+- `/Users/snider/Code/go-api/api.go` — Pass groups to registerSwagger, Engine struct changes
+- `/Users/snider/Code/go-api/response.go` — Response[T], Error, Meta structs (read-only reference for envelope schema)
+- `/Users/snider/Code/go-api/options.go` — WithSwagger already exists, no changes needed
+- `/Users/snider/Code/go-ai/mcp/mcp.go` — Tool registration with typed Input/Output structs
+- `/Users/snider/Code/go-ai/mcp/subsystem.go` — Subsystem interface
+
+## Wave 1: OpenAPI Runtime Spec (go-api only)
+
+### Task 1: DescribableGroup interface + RouteDescription
+
+**Files:**
+- Modify: `/Users/snider/Code/go-api/group.go`
+- Test: `/Users/snider/Code/go-api/group_test.go` (existing — add new tests)
+
+Add a `DescribableGroup` interface that RouteGroups can optionally implement to provide OpenAPI metadata. This is the opt-in contract for spec generation.
+
+```go
+// DescribableGroup extends RouteGroup with OpenAPI metadata.
+// RouteGroups that implement this will have their endpoints
+// included in the generated OpenAPI specification.
+type DescribableGroup interface {
+ RouteGroup
+ Describe() []RouteDescription
+}
+
+// RouteDescription describes a single endpoint for OpenAPI generation.
+type RouteDescription struct {
+ Method string // HTTP method: GET, POST, PUT, DELETE, PATCH
+ Path string // Path relative to BasePath, e.g. "/generate"
+ Summary string // Short summary
+ Description string // Long description
+ Tags []string // OpenAPI tags for grouping
+ RequestBody map[string]any // JSON Schema for request body (nil for GET)
+ Response map[string]any // JSON Schema for success response data
+}
+```
+
+**Tests (5):**
+- `TestDescribableGroup_Good_ImplementsRouteGroup` — A struct implementing both interfaces compiles and works
+- `TestDescribableGroup_Good_DescribeReturnsRoutes` — Verify Describe() returns correct RouteDescription list
+- `TestDescribableGroup_Good_EmptyDescribe` — Empty Describe() is valid (no endpoints documented)
+- `TestDescribableGroup_Good_MultipleVerbs` — GET, POST, DELETE on same base path
+- `TestDescribableGroup_Bad_NilSchemas` — RequestBody and Response can be nil
+
+### Task 2: ToolBridge — tool descriptors to REST endpoints
+
+**Files:**
+- Create: `/Users/snider/Code/go-api/bridge.go`
+- Create: `/Users/snider/Code/go-api/bridge_test.go`
+
+The `ToolBridge` converts tool descriptors into a RouteGroup that also implements DescribableGroup. It creates `POST /{tool_name}` endpoints for each registered tool.
+
+```go
+// ToolDescriptor describes a tool that can be exposed as a REST endpoint.
+type ToolDescriptor struct {
+ Name string // Tool name, e.g. "file_read" (becomes POST path)
+ Description string // Human-readable description
+ Group string // OpenAPI tag group, e.g. "files"
+ InputSchema map[string]any // JSON Schema for request body
+ OutputSchema map[string]any // JSON Schema for response data (optional)
+}
+
+// ToolBridge converts tool descriptors into REST endpoints and OpenAPI paths.
+// It implements both RouteGroup and DescribableGroup.
+type ToolBridge struct {
+ basePath string
+ tools []boundTool
+}
+
+type boundTool struct {
+ descriptor ToolDescriptor
+ handler gin.HandlerFunc
+}
+
+func NewToolBridge(basePath string) *ToolBridge
+
+// Add registers a tool with its HTTP handler.
+func (b *ToolBridge) Add(desc ToolDescriptor, handler gin.HandlerFunc)
+
+// RouteGroup implementation
+func (b *ToolBridge) Name() string
+func (b *ToolBridge) BasePath() string
+func (b *ToolBridge) RegisterRoutes(rg *gin.RouterGroup)
+
+// DescribableGroup implementation
+func (b *ToolBridge) Describe() []RouteDescription
+
+// Tools returns registered tool descriptors.
+func (b *ToolBridge) Tools() []ToolDescriptor
+```
+
+**Tests (6):**
+- `TestToolBridge_Good_RegisterAndServe` — Add 2 tools, verify POST endpoints respond
+- `TestToolBridge_Good_BasePath` — Custom base path `/api/v1/tools` works
+- `TestToolBridge_Good_Describe` — Describe() returns correct RouteDescription for each tool
+- `TestToolBridge_Good_ToolsAccessor` — Tools() returns all registered descriptors
+- `TestToolBridge_Bad_EmptyBridge` — Bridge with no tools is valid, RegisterRoutes is no-op
+- `TestToolBridge_Good_IntegrationWithEngine` — Register bridge with Engine, verify routes accessible via Handler()
+
+### Task 3: SpecBuilder — OpenAPI 3.1 spec generation
+
+**Files:**
+- Create: `/Users/snider/Code/go-api/openapi.go`
+- Create: `/Users/snider/Code/go-api/openapi_test.go`
+
+Builds a complete OpenAPI 3.1 JSON document from registered RouteGroups. Groups implementing DescribableGroup contribute their endpoint metadata. All responses are wrapped in the Response[T] envelope.
+
+```go
+// SpecBuilder constructs an OpenAPI 3.1 spec from registered RouteGroups.
+type SpecBuilder struct {
+ Title string
+ Description string
+ Version string
+}
+
+// Build generates the complete OpenAPI 3.1 JSON spec.
+// Groups implementing DescribableGroup contribute endpoint documentation.
+// Other groups are listed as tags only.
+func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error)
+```
+
+Key behaviours:
+- Always includes `GET /health` (built-in)
+- For each DescribableGroup, generates path items from Describe()
+- Wraps all response schemas in the Response[T] envelope (success/data/error/meta)
+- Uses OpenAPI 3.1 (compatible with JSON Schema 2020-12 from MCP SDK)
+- Tags derived from RouteGroup.Name()
+- Output is valid, parseable OpenAPI 3.1 JSON
+
+**Tests (6):**
+- `TestSpecBuilder_Good_EmptyGroups` — Produces valid spec with just /health
+- `TestSpecBuilder_Good_WithDescribableGroup` — Paths populated from Describe()
+- `TestSpecBuilder_Good_EnvelopeWrapping` — Response schema wraps data in Response[T] envelope
+- `TestSpecBuilder_Good_NonDescribableGroup` — Non-describable groups appear as tags only
+- `TestSpecBuilder_Good_ToolBridgeIntegration` — ToolBridge + SpecBuilder produces correct tool endpoints
+- `TestSpecBuilder_Bad_InfoFields` — Title, description, version appear in output
+
+### Task 4: Refactor swagger.go to use SpecBuilder
+
+**Files:**
+- Modify: `/Users/snider/Code/go-api/swagger.go`
+- Modify: `/Users/snider/Code/go-api/api.go` (pass groups to registerSwagger)
+- Modify: `/Users/snider/Code/go-api/swagger_test.go` (update tests)
+
+Replace the hardcoded empty JSON spec with SpecBuilder-generated spec. The swaggerSpec struct now wraps SpecBuilder and caches the result via sync.Once. `registerSwagger` receives the engine's groups.
+
+```go
+type swaggerSpec struct {
+ builder *SpecBuilder
+ groups []RouteGroup
+ once sync.Once
+ doc string
+}
+
+func (s *swaggerSpec) ReadDoc() string {
+ s.once.Do(func() {
+ data, _ := s.builder.Build(s.groups)
+ s.doc = string(data)
+ })
+ return s.doc
+}
+
+func registerSwagger(g *gin.Engine, title, description, version string, groups []RouteGroup)
+```
+
+In `api.go`, change `build()`:
+```go
+// Mount Swagger UI if enabled.
+if e.swaggerEnabled {
+ registerSwagger(r, e.swaggerTitle, e.swaggerDesc, e.swaggerVersion, e.groups)
+}
+```
+
+**Tests (5):**
+- `TestSwagger_Good_SpecNotEmpty` — GET /swagger/doc.json returns spec with /health path
+- `TestSwagger_Good_WithToolBridge` — Register ToolBridge, verify tool endpoints appear in spec
+- `TestSwagger_Good_CachesSpec` — Multiple calls return same cached spec
+- `TestSwagger_Good_InfoFromOptions` — Title/desc/version from WithSwagger() appear in spec
+- `TestSwagger_Good_ValidOpenAPI` — Output parses as valid JSON with correct openapi version field
+
+### Task 5: SpecExporter — export spec to file/stdout
+
+**Files:**
+- Create: `/Users/snider/Code/go-api/export.go`
+- Create: `/Users/snider/Code/go-api/export_test.go`
+
+A helper that exports the generated spec to a file or io.Writer. Supports JSON and YAML output. This is what `core api spec` will call.
+
+```go
+// ExportSpec generates the OpenAPI spec and writes it to w.
+// Format can be "json" or "yaml".
+func ExportSpec(w io.Writer, format string, builder *SpecBuilder, groups []RouteGroup) error
+
+// ExportSpecToFile writes the spec to the given path.
+func ExportSpecToFile(path, format string, builder *SpecBuilder, groups []RouteGroup) error
+```
+
+**Tests (5):**
+- `TestExportSpec_Good_JSON` — Writes valid JSON to buffer
+- `TestExportSpec_Good_YAML` — Writes valid YAML to buffer
+- `TestExportSpec_Bad_InvalidFormat` — Returns error for unknown format
+- `TestExportSpecToFile_Good_CreatesFile` — Writes file to temp dir
+- `TestExportSpec_Good_WithToolBridge` — Full pipeline: tools → bridge → spec → export
+
+## Wave 2: MCP-to-REST Bridge (go-ai)
+
+### Task 6: Tool registry in go-ai Service
+
+**Files:**
+- Modify: `/Users/snider/Code/go-ai/mcp/mcp.go` — Add ToolRecord and registry
+- Create: `/Users/snider/Code/go-ai/mcp/registry.go` — ToolRecord type and helpers
+- Create: `/Users/snider/Code/go-ai/mcp/registry_test.go`
+
+The MCP SDK's `mcp.Server.tools` is unexported. We maintain a parallel registry in `Service` that records tool metadata (name, description, input/output Go types) as tools are registered. This becomes the source of truth for both MCP and REST.
+
+```go
+// ToolRecord captures metadata about a registered MCP tool.
+type ToolRecord struct {
+ Name string
+ Description string
+ Group string // Subsystem group name
+ InputSchema map[string]any // JSON Schema from Go struct tags
+ OutputSchema map[string]any
+ Handler any // The original handler func for type assertion
+}
+
+// Tools returns all recorded tool metadata.
+func (s *Service) Tools() []ToolRecord
+```
+
+Modify `registerTools` and subsystem registration to also record tools. Use `jsonschema-go` (already a transitive dep from MCP SDK) to extract schemas from the typed Input/Output parameters.
+
+**Tests (5):**
+- `TestToolRegistry_Good_RecordsTools` — After New(), Tools() returns all registered tools
+- `TestToolRegistry_Good_IncludesSubsystems` — Subsystem tools included in registry
+- `TestToolRegistry_Good_SchemaExtraction` — InputSchema contains correct properties from struct tags
+- `TestToolRegistry_Good_ToolCount` — Count matches expected (10 built-in + subsystem tools)
+- `TestToolRegistry_Bad_EmptyService` — Service with no subsystems has only built-in tools
+
+### Task 7: BridgeToAPI — MCP tools to go-api ToolBridge
+
+**Files:**
+- Create: `/Users/snider/Code/go-ai/mcp/bridge.go`
+- Create: `/Users/snider/Code/go-ai/mcp/bridge_test.go`
+
+Converts MCP tool records into go-api ToolBridge entries. Each tool becomes a POST endpoint. The handler unmarshals JSON from request body, calls the business logic, and wraps the response in `api.OK()`/`api.Fail()`.
+
+```go
+// BridgeToAPI populates a go-api ToolBridge from recorded MCP tools.
+// Each tool becomes a POST endpoint at /{tool_name}.
+func BridgeToAPI(svc *Service, bridge *api.ToolBridge) error
+```
+
+**Tests (5):**
+- `TestBridgeToAPI_Good_FileRead` — Bridge file_read, POST JSON body, get Response[ReadFileOutput]
+- `TestBridgeToAPI_Good_AllTools` — All tools from registry appear in bridge
+- `TestBridgeToAPI_Good_DescribableGroup` — Bridge implements DescribableGroup, Describe() returns correct metadata
+- `TestBridgeToAPI_Bad_InvalidJSON` — POST malformed JSON returns 400 with error envelope
+- `TestBridgeToAPI_Good_EndToEnd` — New service → BridgeToAPI → register with Engine → GET /swagger/doc.json shows all tools
+
+## Wave 3: SDK Codegen + CLI
+
+### Task 8: Codegen wrapper
+
+**Files:**
+- Create: `/Users/snider/Code/go-api/codegen.go`
+- Create: `/Users/snider/Code/go-api/codegen_test.go`
+
+Wraps `openapi-generator-cli` for multi-language SDK generation. Checks if the tool is available and provides clear installation instructions if missing.
+
+```go
+// SDKGenerator wraps openapi-generator-cli for SDK generation.
+type SDKGenerator struct {
+ SpecPath string
+ OutputDir string
+ PackageName string
+}
+
+// Generate creates an SDK for the given language.
+// Supported: "go", "typescript-fetch", "python", "java", "csharp"
+func (g *SDKGenerator) Generate(ctx context.Context, language string) error
+
+// Available checks if openapi-generator-cli is installed.
+func (g *SDKGenerator) Available() bool
+
+// SupportedLanguages returns the list of supported SDK languages.
+func SupportedLanguages() []string
+```
+
+**Tests (5):**
+- `TestSDKGenerator_Good_CommandConstruction` — Verify correct CLI arguments without executing
+- `TestSDKGenerator_Good_SupportedLanguages` — Returns expected language list
+- `TestSDKGenerator_Bad_UnsupportedLanguage` — Returns error for "brainfuck"
+- `TestSDKGenerator_Bad_MissingSpec` — Returns error when spec file doesn't exist
+- `TestSDKGenerator_Good_OutputDirCreated` — Creates output directory if missing
+
+### Task 9: CLI commands — `core api spec` and `core api sdk`
+
+**Files:**
+- Create: `/Users/snider/Code/host-uk/core/cmd/core-cli/api.go` (or appropriate location in CLI tree)
+- Create test file alongside
+
+New `api` command group in the core CLI:
+
+```
+core api spec # Export OpenAPI spec JSON to stdout
+core api spec --output spec.json # Write to file
+core api spec --format yaml # YAML format
+
+core api sdk --lang go # Generate Go SDK
+core api sdk --lang typescript-fetch,python --output ./sdk/
+core api sdk --spec spec.json --lang go --output ./sdk/go
+```
+
+This depends on understanding the CLI registration pattern. The commands create an MCP service, bridge tools, build spec, and either export or generate SDKs.
+
+**Tests (4):**
+- `TestAPISpecCmd_Good_JSON` — Outputs valid JSON to stdout
+- `TestAPISpecCmd_Good_YAML` — Outputs valid YAML with --format yaml
+- `TestAPISDKCmd_Bad_NoLang` — Returns error without --lang flag
+- `TestAPISDKCmd_Good_ValidatesLanguage` — Rejects unsupported language with helpful message
+
+## Verification
+
+After all tasks are complete:
+
+1. **Unit tests**: `cd /Users/snider/Code/go-api && go test ./...` — all pass
+2. **go-ai tests**: `cd /Users/snider/Code/go-ai && go test ./...` — all pass
+3. **Spec roundtrip**: Register a ToolBridge with sample tools, build Engine with WithSwagger, GET `/swagger/doc.json` → verify paths are populated, not empty
+4. **Export**: `ExportSpec` to temp file in JSON and YAML, verify both parse correctly
+5. **SDK smoke test** (if openapi-generator-cli installed): Generate Go client from exported spec, verify it compiles
+
+## Dependency Sequencing
+
+```
+Task 1 (DescribableGroup) ← Task 2 (ToolBridge) ← Task 3 (SpecBuilder)
+Task 3 ← Task 4 (swagger refactor)
+Task 3 ← Task 5 (ExportSpec)
+Task 2 ← Task 6 (Tool registry) ← Task 7 (BridgeToAPI)
+Task 5 ← Task 8 (Codegen)
+Task 5 + Task 7 ← Task 9 (CLI commands)
+```
+
+Wave 1 (Tasks 1-5) is self-contained in go-api. Wave 2 (Tasks 6-7) bridges go-ai. Wave 3 (Tasks 8-9) adds codegen and CLI.
diff --git a/options.go b/options.go
index 820b6a3..bdf3f66 100644
--- a/options.go
+++ b/options.go
@@ -6,6 +6,7 @@ import (
"compress/gzip"
"log/slog"
"net/http"
+ "slices"
"time"
"github.com/99designs/gqlgen/graphql"
@@ -63,11 +64,8 @@ func WithCORS(allowOrigins ...string) Option {
MaxAge: 12 * time.Hour,
}
- for _, o := range allowOrigins {
- if o == "*" {
- cfg.AllowAllOrigins = true
- break
- }
+ if slices.Contains(allowOrigins, "*") {
+ cfg.AllowAllOrigins = true
}
if !cfg.AllowAllOrigins {
cfg.AllowOrigins = allowOrigins
@@ -156,12 +154,12 @@ func WithExpvar() Option {
func WithSecure() Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, secure.New(secure.Config{
- STSSeconds: 31536000,
- STSIncludeSubdomains: true,
- FrameDeny: true,
- ContentTypeNosniff: true,
- ReferrerPolicy: "strict-origin-when-cross-origin",
- IsDevelopment: false,
+ STSSeconds: 31536000,
+ STSIncludeSubdomains: true,
+ FrameDeny: true,
+ ContentTypeNosniff: true,
+ ReferrerPolicy: "strict-origin-when-cross-origin",
+ IsDevelopment: false,
}))
}
}
diff --git a/sse_test.go b/sse_test.go
index 3cba62a..4618104 100644
--- a/sse_test.go
+++ b/sse_test.go
@@ -81,11 +81,11 @@ func TestWithSSE_Good_ReceivesPublishedEvent(t *testing.T) {
defer close(done)
for scanner.Scan() {
line := scanner.Text()
- if strings.HasPrefix(line, "event: ") {
- eventLine = strings.TrimPrefix(line, "event: ")
+ if after, ok := strings.CutPrefix(line, "event: "); ok {
+ eventLine = after
}
- if strings.HasPrefix(line, "data: ") {
- dataLine = strings.TrimPrefix(line, "data: ")
+ if after, ok := strings.CutPrefix(line, "data: "); ok {
+ dataLine = after
return
}
}
@@ -144,8 +144,8 @@ func TestWithSSE_Good_ChannelFiltering(t *testing.T) {
defer close(done)
for scanner.Scan() {
line := scanner.Text()
- if strings.HasPrefix(line, "event: ") {
- eventLine = strings.TrimPrefix(line, "event: ")
+ if after, ok := strings.CutPrefix(line, "event: "); ok {
+ eventLine = after
// Read past the data and blank line.
scanner.Scan() // data line
return
@@ -246,8 +246,8 @@ func TestWithSSE_Good_MultipleClients(t *testing.T) {
go func() {
for scanner.Scan() {
line := scanner.Text()
- if strings.HasPrefix(line, "event: ") {
- done <- strings.TrimPrefix(line, "event: ")
+ if after, ok := strings.CutPrefix(line, "event: "); ok {
+ done <- after
return
}
}