From 94e35d1845a89855f456872286c96528cb456991 Mon Sep 17 00:00:00 2001 From: Nathan Cochran Date: Mon, 14 Sep 2026 13:22:38 -0400 Subject: [PATCH 1/4] Remove examples from MCP tool fields with an enum --- CLAUDE.md | 2 +- internal/mcp/service_fork.go | 1 - internal/mcp/service_metrics_series.go | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a9e90a1a..4216ff72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -189,7 +189,7 @@ The server exposes two kinds of tools: native Tiger tools for service management **One file per tool**, named to match the tool (`service_create` → `service_create.go`), laid out in this order: the `Input`/`Output` structs and their `Schema()` methods, then `newTool()` returning the `*mcp.Tool`, then the `handle` handler method on `*Server`, then helpers used only by that tool. Registration lives in `server.go` (`registerServiceTools`, `registerDatabaseTools`), so adding a tool means one new file plus one `addTool` line. Shared schema helpers and API-to-output conversion live in `utils.go`. -**Tool schemas.** Generate the base schema from the input struct with `util.Must(jsonschema.For[Input](nil))`, then enhance it in the `Schema()` method: add a description and `Examples` to every field, and use the JSON Schema properties (`Default`, `Minimum`/`Maximum`, `Enum`, `Pattern`, `MaxLength`, …) both to document values for AI assistants and to reject invalid arguments before they reach the handler. Accessing a property that doesn't exist in the generated schema panics at startup, which keeps the schema and the struct in sync. Fields without `omitempty`/`omitzero` are **required**; an optional field with a struct (non-pointer) type must use `omitzero`, because `omitempty` has no effect on struct values and the field would silently stay required. +**Tool schemas.** Generate the base schema from the input struct with `util.Must(jsonschema.For[Input](nil))`, then enhance it in the `Schema()` method: add a description to every field, and use the JSON Schema properties (`Default`, `Minimum`/`Maximum`, `Enum`, `Pattern`, `MaxLength`, …) both to document values for AI assistants and to reject invalid arguments before they reach the handler. Every field gets `Examples` too, except one with an `Enum`: the enum already lists every legal value, so examples there are redundant (point at a preferred value with `Default` instead). Accessing a property that doesn't exist in the generated schema panics at startup, which keeps the schema and the struct in sync. Fields without `omitempty`/`omitzero` are **required**; an optional field with a struct (non-pointer) type must use `omitzero`, because `omitempty` has no effect on struct values and the field would silently stay required. ## Read-Only Mode diff --git a/internal/mcp/service_fork.go b/internal/mcp/service_fork.go index 843dcf07..ea506e6a 100644 --- a/internal/mcp/service_fork.go +++ b/internal/mcp/service_fork.go @@ -38,7 +38,6 @@ func (ServiceForkInput) Schema() *jsonschema.Schema { schema.Properties["fork_strategy"].Description = "Fork strategy: 'NOW' creates fork at current state, 'LAST_SNAPSHOT' uses last existing snapshot (faster), 'PITR' allows point-in-time recovery to specific timestamp (requires target_time parameter)" schema.Properties["fork_strategy"].Enum = []any{api.ForkStrategyNOW, api.ForkStrategyLASTSNAPSHOT, api.ForkStrategyPITR} - schema.Properties["fork_strategy"].Examples = []any{api.ForkStrategyNOW, api.ForkStrategyLASTSNAPSHOT} schema.Properties["target_time"].Description = "Target timestamp for point-in-time recovery (RFC3339 format, e.g., '2025-01-15T10:30:00Z'). Only used when fork_strategy is 'PITR'." schema.Properties["target_time"].Examples = []any{"2025-01-15T10:30:00Z", "2024-12-01T00:00:00Z"} diff --git a/internal/mcp/service_metrics_series.go b/internal/mcp/service_metrics_series.go index e69ab834..e9348bf5 100644 --- a/internal/mcp/service_metrics_series.go +++ b/internal/mcp/service_metrics_series.go @@ -63,7 +63,6 @@ func (ServiceMetricsSeriesInput) Schema() *jsonschema.Schema { schema.Properties["fn"].Description = "Aggregation function applied per bucket. Not accepted on these metrics (returns INVALID_REQUEST): timescale_cloud_system_cpu_total_millicores, timescale_cloud_system_cpu_usage_millicores, timescale_cloud_system_disk_io_read_bytes, timescale_cloud_system_disk_io_read_ops, timescale_cloud_system_disk_io_total_bytes, timescale_cloud_system_disk_io_total_ops, timescale_cloud_system_disk_io_write_bytes, timescale_cloud_system_disk_io_write_ops, timescale_cloud_system_disk_usage_bytes, timescale_cloud_system_memory_total_bytes, timescale_cloud_system_memory_usage_bytes, timescale_cloud_database_qps, timescale_cloud_database_num_connections, timescale_cloud_database_job_duration_usecs, timescale_cloud_database_job_success. When omitted, the server picks a sensible default for the metric (typically LAST)." schema.Properties["fn"].Enum = []any{"RATE", "INCREASE", "SUM", "AVG", "MIN", "MAX", "COUNT", "P50", "P90", "P99", "LAST"} - schema.Properties["fn"].Examples = []any{"RATE"} return schema } From 35fb4ab3e50b5509602e93000aafba1dcdeb8c00 Mon Sep 17 00:00:00 2001 From: Nathan Cochran Date: Mon, 14 Sep 2026 13:57:14 -0400 Subject: [PATCH 2/4] Improve MCP section of CLAUDE.md, fix OpenWorldHint and Examples --- CLAUDE.md | 4 +- internal/cmd/mcp_get_test.go | 70 +++++++++++++++++++------ internal/mcp/db_query.go | 2 +- internal/mcp/db_schema.go | 2 +- internal/mcp/service_create.go | 2 +- internal/mcp/service_fork.go | 4 +- internal/mcp/service_get.go | 6 ++- internal/mcp/service_list.go | 10 ++-- internal/mcp/service_logs.go | 6 +-- internal/mcp/service_metrics_series.go | 6 ++- internal/mcp/service_resize.go | 2 +- internal/mcp/service_start.go | 2 +- internal/mcp/service_stop.go | 2 +- internal/mcp/service_update_password.go | 2 +- internal/mcp/utils.go | 15 ++++-- 15 files changed, 97 insertions(+), 38 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4216ff72..16eea2ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -189,7 +189,9 @@ The server exposes two kinds of tools: native Tiger tools for service management **One file per tool**, named to match the tool (`service_create` → `service_create.go`), laid out in this order: the `Input`/`Output` structs and their `Schema()` methods, then `newTool()` returning the `*mcp.Tool`, then the `handle` handler method on `*Server`, then helpers used only by that tool. Registration lives in `server.go` (`registerServiceTools`, `registerDatabaseTools`), so adding a tool means one new file plus one `addTool` line. Shared schema helpers and API-to-output conversion live in `utils.go`. -**Tool schemas.** Generate the base schema from the input struct with `util.Must(jsonschema.For[Input](nil))`, then enhance it in the `Schema()` method: add a description to every field, and use the JSON Schema properties (`Default`, `Minimum`/`Maximum`, `Enum`, `Pattern`, `MaxLength`, …) both to document values for AI assistants and to reject invalid arguments before they reach the handler. Every field gets `Examples` too, except one with an `Enum`: the enum already lists every legal value, so examples there are redundant (point at a preferred value with `Default` instead). Accessing a property that doesn't exist in the generated schema panics at startup, which keeps the schema and the struct in sync. Fields without `omitempty`/`omitzero` are **required**; an optional field with a struct (non-pointer) type must use `omitzero`, because `omitempty` has no effect on struct values and the field would silently stay required. +**Tool schemas.** Generate the base schema from the input struct with `util.Must(jsonschema.For[Input](nil))`, then enhance it in the `Schema()` method: give every field a description, and use the JSON Schema properties (`Default`, `Minimum`/`Maximum`, `Enum`, `Pattern`, `MaxLength`, …) both to document values for AI assistants and to reject invalid arguments before they reach the handler. Illustrative values never go inline in a description string — not in input schemas and not in the `jsonschema` struct tags of output types. A field whose values form a fixed, exhaustive set gets an `Enum` and nothing more; every other field gets `Examples`, and a preferred value is signalled with `Default`. The MCP SDK applies schema defaults to the raw arguments before unmarshaling them into the input struct, so a handler reads a defaulted field directly and never re-applies the default itself. Accessing a property that doesn't exist in the generated schema panics at startup, which keeps the schema and the struct in sync. Fields without `omitempty`/`omitzero` are **required**; an optional field with a struct (non-pointer) type must use `omitzero`, because `omitempty` has no effect on struct values and the field would silently stay required. + +**Annotations.** Every tool sets `Annotations`: read-only tools set `ReadOnlyHint`, write tools set `DestructiveHint` and `IdempotentHint`, and all of them set `OpenWorldHint` to false — the tools act only on the Tiger Cloud API and the user's own services, a closed domain rather than an open world of arbitrary external entities. ## Read-Only Mode diff --git a/internal/cmd/mcp_get_test.go b/internal/cmd/mcp_get_test.go index 68eb4a8b..02b2fb4f 100644 --- a/internal/cmd/mcp_get_test.go +++ b/internal/cmd/mcp_get_test.go @@ -7,7 +7,7 @@ import ( func TestMCPGetCmd(t *testing.T) { // service_get exercises the full text layout: annotation tags, description, // a Parameters section, and a nested Output schema. - serviceGetText := `Get Service Details [read-only] [open-world] + serviceGetText := `Get Service Details [read-only] Tool name: service_get @@ -31,16 +31,16 @@ Output: • region (required): string • replicas (required): integer - Number of HA replicas (0=single node/no HA, 1+=HA enabled) • resources: object, null - • cpu: string - CPU allocation (e.g., '0.5 cores', '1 core') - • memory: string - Memory allocation (e.g., '2 GB', '4 GB') - • status (required): string - Service status (e.g., READY, PAUSED, CONFIGURING, UPGRADING) + • cpu: string - CPU allocation + • memory: string - Memory allocation + • status (required): string - Service status • type (required): string ` // service_list takes no parameters, so its text output has no Parameters // section. - serviceListText := `List Database Services [read-only] [open-world] + serviceListText := `List Database Services [read-only] Tool name: service_list @@ -55,9 +55,9 @@ Output: • name (required): string • region (required): string • resources: object, null - • cpu: string - CPU allocation (e.g., '0.5 cores', '1 core') - • memory: string - Memory allocation (e.g., '2 GB', '4 GB') - • status (required): string - Service status (e.g., READY, PAUSED, CONFIGURING, UPGRADING) + • cpu: string - CPU allocation + • memory: string - Memory allocation + • status (required): string - Service status • type (required): string ` @@ -65,7 +65,7 @@ Output: serviceListJSON := `{ "annotations": { "idempotentHint": false, - "openWorldHint": true, + "openWorldHint": false, "readOnlyHint": true, "title": "List Database Services" }, @@ -103,11 +103,21 @@ Output: "additionalProperties": false, "properties": { "cpu": { - "description": "CPU allocation (e.g., '0.5 cores', '1 core')", + "description": "CPU allocation", + "examples": [ + "0.5 cores", + "1 core", + "4 cores" + ], "type": "string" }, "memory": { - "description": "Memory allocation (e.g., '2 GB', '4 GB')", + "description": "Memory allocation", + "examples": [ + "2 GB", + "4 GB", + "16 GB" + ], "type": "string" } }, @@ -117,10 +127,21 @@ Output: ] }, "status": { - "description": "Service status (e.g., READY, PAUSED, CONFIGURING, UPGRADING)", + "description": "Service status", + "examples": [ + "READY", + "PAUSED", + "CONFIGURING", + "UPGRADING" + ], "type": "string" }, "type": { + "enum": [ + "TIMESCALEDB", + "POSTGRES", + "VECTOR" + ], "type": "string" } }, @@ -151,7 +172,7 @@ Output: serviceListYAML := `annotations: idempotentHint: false - openWorldHint: true + openWorldHint: false readOnlyHint: true title: List Database Services description: List all database services in your Tiger Cloud project. Returns services with status, type, region, and resource allocation. @@ -182,18 +203,35 @@ outputSchema: additionalProperties: false properties: cpu: - description: CPU allocation (e.g., '0.5 cores', '1 core') + description: CPU allocation + examples: + - 0.5 cores + - 1 core + - 4 cores type: string memory: - description: Memory allocation (e.g., '2 GB', '4 GB') + description: Memory allocation + examples: + - 2 GB + - 4 GB + - 16 GB type: string type: - "null" - object status: - description: Service status (e.g., READY, PAUSED, CONFIGURING, UPGRADING) + description: Service status + examples: + - READY + - PAUSED + - CONFIGURING + - UPGRADING type: string type: + enum: + - TIMESCALEDB + - POSTGRES + - VECTOR type: string required: - id diff --git a/internal/mcp/db_query.go b/internal/mcp/db_query.go index dec98375..160ec3bd 100644 --- a/internal/mcp/db_query.go +++ b/internal/mcp/db_query.go @@ -131,7 +131,7 @@ WARNING: Can execute any SQL statement including INSERT, UPDATE, DELETE, and DDL ReadOnlyHint: false, DestructiveHint: new(true), // Can execute destructive SQL IdempotentHint: false, // Queries may have side effects - OpenWorldHint: new(true), + OpenWorldHint: new(false), Title: "Execute SQL Query", }, } diff --git a/internal/mcp/db_schema.go b/internal/mcp/db_schema.go index 8de6908a..a561e6b8 100644 --- a/internal/mcp/db_schema.go +++ b/internal/mcp/db_schema.go @@ -82,7 +82,7 @@ By default only user-facing schemas and objects are shown; view/routine definiti OutputSchema: DBSchemaOutput{}.Schema(), Annotations: &mcp.ToolAnnotations{ ReadOnlyHint: true, - OpenWorldHint: new(true), + OpenWorldHint: new(false), Title: "Show Database Schema", }, } diff --git a/internal/mcp/service_create.go b/internal/mcp/service_create.go index 7ad11f76..fdbb20e0 100644 --- a/internal/mcp/service_create.go +++ b/internal/mcp/service_create.go @@ -90,7 +90,7 @@ WARNING: Creates billable resources.`, ReadOnlyHint: false, DestructiveHint: new(false), // Creates resources but doesn't modify existing IdempotentHint: false, // Creating with same name creates multiple services (name is not unique) - OpenWorldHint: new(true), + OpenWorldHint: new(false), Title: "Create Database Service", }, } diff --git a/internal/mcp/service_fork.go b/internal/mcp/service_fork.go index ea506e6a..e09cf26a 100644 --- a/internal/mcp/service_fork.go +++ b/internal/mcp/service_fork.go @@ -39,7 +39,7 @@ func (ServiceForkInput) Schema() *jsonschema.Schema { schema.Properties["fork_strategy"].Description = "Fork strategy: 'NOW' creates fork at current state, 'LAST_SNAPSHOT' uses last existing snapshot (faster), 'PITR' allows point-in-time recovery to specific timestamp (requires target_time parameter)" schema.Properties["fork_strategy"].Enum = []any{api.ForkStrategyNOW, api.ForkStrategyLASTSNAPSHOT, api.ForkStrategyPITR} - schema.Properties["target_time"].Description = "Target timestamp for point-in-time recovery (RFC3339 format, e.g., '2025-01-15T10:30:00Z'). Only used when fork_strategy is 'PITR'." + schema.Properties["target_time"].Description = "Target timestamp for point-in-time recovery (RFC3339 format). Only used when fork_strategy is 'PITR'." schema.Properties["target_time"].Examples = []any{"2025-01-15T10:30:00Z", "2024-12-01T00:00:00Z"} schema.Properties["cpu_memory"].Description = "CPU and memory allocation combination. Choose from the available configurations. If not specified, inherits from source service." @@ -92,7 +92,7 @@ WARNING: Creates billable resources.`, ReadOnlyHint: false, DestructiveHint: new(false), // Creates resources but doesn't modify existing IdempotentHint: false, // Forking same service multiple times creates multiple forks - OpenWorldHint: new(true), + OpenWorldHint: new(false), Title: "Fork Database Service", }, } diff --git a/internal/mcp/service_get.go b/internal/mcp/service_get.go index 419ad994..d6d19c4f 100644 --- a/internal/mcp/service_get.go +++ b/internal/mcp/service_get.go @@ -33,7 +33,9 @@ type ServiceGetOutput struct { } func (ServiceGetOutput) Schema() *jsonschema.Schema { - return util.Must(jsonschema.For[ServiceGetOutput](nil)) + schema := util.Must(jsonschema.For[ServiceGetOutput](nil)) + schema.Properties["service"] = ServiceDetail{}.Schema() + return schema } func newServiceGetTool() *mcp.Tool { @@ -46,7 +48,7 @@ func newServiceGetTool() *mcp.Tool { OutputSchema: ServiceGetOutput{}.Schema(), Annotations: &mcp.ToolAnnotations{ ReadOnlyHint: true, - OpenWorldHint: new(true), + OpenWorldHint: new(false), Title: "Get Service Details", }, } diff --git a/internal/mcp/service_list.go b/internal/mcp/service_list.go index ca6b01bd..7bda55f6 100644 --- a/internal/mcp/service_list.go +++ b/internal/mcp/service_list.go @@ -27,14 +27,16 @@ type ServiceListOutput struct { } func (ServiceListOutput) Schema() *jsonschema.Schema { - return util.Must(jsonschema.For[ServiceListOutput](nil)) + schema := util.Must(jsonschema.For[ServiceListOutput](nil)) + schema.Properties["services"].Items = ServiceInfo{}.Schema() + return schema } // ServiceInfo represents simplified service information for MCP output type ServiceInfo struct { ServiceID string `json:"id" jsonschema:"Service identifier (10-character alphanumeric string)"` Name string `json:"name"` - Status string `json:"status" jsonschema:"Service status (e.g., READY, PAUSED, CONFIGURING, UPGRADING)"` + Status string `json:"status" jsonschema:"Service status"` Type string `json:"type"` Region string `json:"region"` Created string `json:"created,omitempty"` @@ -44,7 +46,9 @@ type ServiceInfo struct { func (ServiceInfo) Schema() *jsonschema.Schema { schema := util.Must(jsonschema.For[ServiceInfo](nil)) + schema.Properties["status"].Examples = []any{"READY", "PAUSED", "CONFIGURING", "UPGRADING"} schema.Properties["type"].Enum = util.AnySlice(validServiceTypes()) + setResourceInfoSchemaProperties(schema.Properties["resources"]) return schema } @@ -58,7 +62,7 @@ func newServiceListTool() *mcp.Tool { OutputSchema: ServiceListOutput{}.Schema(), Annotations: &mcp.ToolAnnotations{ ReadOnlyHint: true, - OpenWorldHint: new(true), + OpenWorldHint: new(false), Title: "List Database Services", }, } diff --git a/internal/mcp/service_logs.go b/internal/mcp/service_logs.go index 2dcf0240..0637a99b 100644 --- a/internal/mcp/service_logs.go +++ b/internal/mcp/service_logs.go @@ -36,10 +36,10 @@ func (ServiceLogsInput) Schema() *jsonschema.Schema { schema.Properties["tail"].Minimum = new(1.0) schema.Properties["tail"].Examples = []any{50, 100, 1000} - schema.Properties["since"].Description = "Fetch logs after this timestamp (RFC3339 format, e.g., '2024-01-15T09:00:00Z'). If not provided, only the tail parameter limits how far back logs are fetched." + schema.Properties["since"].Description = "Fetch logs after this timestamp (RFC3339 format). If not provided, only the tail parameter limits how far back logs are fetched." schema.Properties["since"].Examples = []any{"2024-01-15T09:00:00Z", "2025-01-16T08:00:00Z"} - schema.Properties["until"].Description = "Fetch logs before this timestamp (RFC3339 format, e.g., '2024-01-15T10:00:00Z'). If not provided, fetches logs up to the current time." + schema.Properties["until"].Description = "Fetch logs before this timestamp (RFC3339 format). If not provided, fetches logs up to the current time." schema.Properties["until"].Examples = []any{"2024-01-15T10:00:00Z", "2025-01-16T08:30:00Z"} return schema @@ -67,7 +67,7 @@ Supports filtering by time (via since/until parameters) and node (for services w OutputSchema: ServiceLogsOutput{}.Schema(), Annotations: &mcp.ToolAnnotations{ ReadOnlyHint: true, - OpenWorldHint: new(true), + OpenWorldHint: new(false), Title: "Get Service Logs", }, } diff --git a/internal/mcp/service_metrics_series.go b/internal/mcp/service_metrics_series.go index e9348bf5..c4ab341e 100644 --- a/internal/mcp/service_metrics_series.go +++ b/internal/mcp/service_metrics_series.go @@ -55,7 +55,11 @@ func (ServiceMetricsSeriesInput) Schema() *jsonschema.Schema { schema.Properties["role"].Description = "Convenience filter for the 'role' label. Omit to include all roles. Equivalent to passing {key:\"role\", value:\"primary\"|\"replica\"} via filters." schema.Properties["role"].Enum = []any{"PRIMARY", "REPLICA"} - schema.Properties["filters"].Description = "Arbitrary label filters applied to the series query. Recognized label names depend on the metric (e.g. 'role', 'ordinal', 'job_id')." + schema.Properties["filters"].Description = "Arbitrary label filters applied to the series query. Recognized label names depend on the metric." + schema.Properties["filters"].Examples = []any{ + []MetricLabelFilterInput{{Key: "ordinal", Value: "0"}}, + []MetricLabelFilterInput{{Key: "job_id", Value: "1000"}}, + } schema.Properties["bucket_seconds"].Description = "Aggregation bucket size in seconds. Optional — when omitted, the server picks a default matched to the window (roughly 1m for windows up to 1h, 1h for up to 30d, 1d beyond that). Minimum 60s." schema.Properties["bucket_seconds"].Minimum = new(60.0) diff --git a/internal/mcp/service_resize.go b/internal/mcp/service_resize.go index 463c6ba3..5fc74e5c 100644 --- a/internal/mcp/service_resize.go +++ b/internal/mcp/service_resize.go @@ -63,7 +63,7 @@ WARNING: Creates billable resource changes. Increasing resources will increase c ReadOnlyHint: false, DestructiveHint: new(false), // Not destructive, just modifies resources IdempotentHint: true, // Can resize to same size multiple times - OpenWorldHint: new(true), + OpenWorldHint: new(false), Title: "Resize Database Service", }, } diff --git a/internal/mcp/service_start.go b/internal/mcp/service_start.go index d860582e..dcb19230 100644 --- a/internal/mcp/service_start.go +++ b/internal/mcp/service_start.go @@ -55,7 +55,7 @@ This operation starts a service that is currently in a stopped/paused state. The ReadOnlyHint: false, DestructiveHint: new(false), // Starting a service cannot really break anything IdempotentHint: true, // Starting an already-started service is safe (but returns an error) - OpenWorldHint: new(true), + OpenWorldHint: new(false), Title: "Start Database Service", }, } diff --git a/internal/mcp/service_stop.go b/internal/mcp/service_stop.go index 5b4ea5eb..c4c4e7bf 100644 --- a/internal/mcp/service_stop.go +++ b/internal/mcp/service_stop.go @@ -55,7 +55,7 @@ This operation stops a service that is currently running. The service will trans ReadOnlyHint: false, DestructiveHint: new(true), // Stopping a service breaks existing connections and could cause app downtime IdempotentHint: true, // Stopping an already-stopped service is safe (but returns an error) - OpenWorldHint: new(true), + OpenWorldHint: new(false), Title: "Stop Database Service", }, } diff --git a/internal/mcp/service_update_password.go b/internal/mcp/service_update_password.go index 94c625c3..cd5a1d34 100644 --- a/internal/mcp/service_update_password.go +++ b/internal/mcp/service_update_password.go @@ -53,7 +53,7 @@ func newServiceUpdatePasswordTool() *mcp.Tool { ReadOnlyHint: false, DestructiveHint: new(true), // Modifies authentication credentials IdempotentHint: true, // Same password can be set multiple times - OpenWorldHint: new(true), + OpenWorldHint: new(false), Title: "Update Service Password", }, } diff --git a/internal/mcp/utils.go b/internal/mcp/utils.go index fb3b6bd8..0d975b1c 100644 --- a/internal/mcp/utils.go +++ b/internal/mcp/utils.go @@ -49,15 +49,22 @@ func setWithPasswordSchemaProperties(schema *jsonschema.Schema) { // ResourceInfo represents resource allocation information type ResourceInfo struct { - CPU string `json:"cpu,omitempty" jsonschema:"CPU allocation (e.g., '0.5 cores', '1 core')"` - Memory string `json:"memory,omitempty" jsonschema:"Memory allocation (e.g., '2 GB', '4 GB')"` + CPU string `json:"cpu,omitempty" jsonschema:"CPU allocation"` + Memory string `json:"memory,omitempty" jsonschema:"Memory allocation"` +} + +// setResourceInfoSchemaProperties enhances the schema of a ResourceInfo +// property nested inside a service output schema. +func setResourceInfoSchemaProperties(schema *jsonschema.Schema) { + schema.Properties["cpu"].Examples = []any{"0.5 cores", "1 core", "4 cores"} + schema.Properties["memory"].Examples = []any{"2 GB", "4 GB", "16 GB"} } // ServiceDetail represents detailed service information type ServiceDetail struct { ServiceID string `json:"id" jsonschema:"Service identifier (10-character alphanumeric string)"` Name string `json:"name"` - Status string `json:"status" jsonschema:"Service status (e.g., READY, PAUSED, CONFIGURING, UPGRADING)"` + Status string `json:"status" jsonschema:"Service status"` Type string `json:"type"` Region string `json:"region"` Created string `json:"created,omitempty"` @@ -72,7 +79,9 @@ type ServiceDetail struct { func (ServiceDetail) Schema() *jsonschema.Schema { schema := util.Must(jsonschema.For[ServiceDetail](nil)) + schema.Properties["status"].Examples = []any{"READY", "PAUSED", "CONFIGURING", "UPGRADING"} schema.Properties["type"].Enum = util.AnySlice(validServiceTypes()) + setResourceInfoSchemaProperties(schema.Properties["resources"]) return schema } From 4fca5ea08b0a83f88282c56a90741f943edffcd1 Mon Sep 17 00:00:00 2001 From: Nathan Cochran Date: Mon, 14 Sep 2026 14:09:42 -0400 Subject: [PATCH 3/4] Move MCP tool descriptions out of struct tags --- CLAUDE.md | 2 +- internal/mcp/service_list.go | 15 +++++++++++--- internal/mcp/service_logs.go | 6 ++++-- internal/mcp/service_resize.go | 7 +++++-- internal/mcp/service_start.go | 6 ++++-- internal/mcp/service_stop.go | 6 ++++-- internal/mcp/utils.go | 38 +++++++++++++++++++++++++--------- 7 files changed, 58 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 16eea2ab..3e50e702 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -189,7 +189,7 @@ The server exposes two kinds of tools: native Tiger tools for service management **One file per tool**, named to match the tool (`service_create` → `service_create.go`), laid out in this order: the `Input`/`Output` structs and their `Schema()` methods, then `newTool()` returning the `*mcp.Tool`, then the `handle` handler method on `*Server`, then helpers used only by that tool. Registration lives in `server.go` (`registerServiceTools`, `registerDatabaseTools`), so adding a tool means one new file plus one `addTool` line. Shared schema helpers and API-to-output conversion live in `utils.go`. -**Tool schemas.** Generate the base schema from the input struct with `util.Must(jsonschema.For[Input](nil))`, then enhance it in the `Schema()` method: give every field a description, and use the JSON Schema properties (`Default`, `Minimum`/`Maximum`, `Enum`, `Pattern`, `MaxLength`, …) both to document values for AI assistants and to reject invalid arguments before they reach the handler. Illustrative values never go inline in a description string — not in input schemas and not in the `jsonschema` struct tags of output types. A field whose values form a fixed, exhaustive set gets an `Enum` and nothing more; every other field gets `Examples`, and a preferred value is signalled with `Default`. The MCP SDK applies schema defaults to the raw arguments before unmarshaling them into the input struct, so a handler reads a defaulted field directly and never re-applies the default itself. Accessing a property that doesn't exist in the generated schema panics at startup, which keeps the schema and the struct in sync. Fields without `omitempty`/`omitzero` are **required**; an optional field with a struct (non-pointer) type must use `omitzero`, because `omitempty` has no effect on struct values and the field would silently stay required. +**Tool schemas.** Generate the base schema from the input struct with `util.Must(jsonschema.For[Input](nil))`, then enhance it in the `Schema()` method: set a `Description` on every field there — never through `jsonschema` struct tags, so all schema detail for input and output types alike lives in one place — and use the JSON Schema properties (`Default`, `Minimum`/`Maximum`, `Enum`, `Pattern`, `MaxLength`, …) both to document values for AI assistants and to reject invalid arguments before they reach the handler. `jsonschema.For` doesn't call the `Schema()` methods of nested types, so a nested output type's `Schema()` (or, for a property that must stay nullable, its `set*SchemaProperties` helper) is wired in explicitly by the enclosing type's `Schema()` — `ServiceListOutput` is the model. Illustrative values never go inline in a description string. A field whose values form a fixed, exhaustive set gets an `Enum` and nothing more; every other field gets `Examples`, and a preferred value is signalled with `Default`. The MCP SDK applies schema defaults to the raw arguments before unmarshaling them into the input struct, so a handler reads a defaulted field directly and never re-applies the default itself. Accessing a property that doesn't exist in the generated schema panics at startup, which keeps the schema and the struct in sync. Fields without `omitempty`/`omitzero` are **required**; an optional field with a struct (non-pointer) type must use `omitzero`, because `omitempty` has no effect on struct values and the field would silently stay required. **Annotations.** Every tool sets `Annotations`: read-only tools set `ReadOnlyHint`, write tools set `DestructiveHint` and `IdempotentHint`, and all of them set `OpenWorldHint` to false — the tools act only on the Tiger Cloud API and the user's own services, a closed domain rather than an open world of arbitrary external entities. diff --git a/internal/mcp/service_list.go b/internal/mcp/service_list.go index 7bda55f6..e2b57195 100644 --- a/internal/mcp/service_list.go +++ b/internal/mcp/service_list.go @@ -34,21 +34,30 @@ func (ServiceListOutput) Schema() *jsonschema.Schema { // ServiceInfo represents simplified service information for MCP output type ServiceInfo struct { - ServiceID string `json:"id" jsonschema:"Service identifier (10-character alphanumeric string)"` + ServiceID string `json:"id"` Name string `json:"name"` - Status string `json:"status" jsonschema:"Service status"` + Status string `json:"status"` Type string `json:"type"` Region string `json:"region"` Created string `json:"created,omitempty"` - Environment string `json:"environment" jsonschema:"Environment tag (DEV or PROD). Under read_only=prod, services tagged PROD cannot be modified."` + Environment string `json:"environment"` Resources *ResourceInfo `json:"resources,omitempty"` } func (ServiceInfo) Schema() *jsonschema.Schema { schema := util.Must(jsonschema.For[ServiceInfo](nil)) + + schema.Properties["id"].Description = "Service identifier (10-character alphanumeric string)" + + schema.Properties["status"].Description = "Service status" schema.Properties["status"].Examples = []any{"READY", "PAUSED", "CONFIGURING", "UPGRADING"} + schema.Properties["type"].Enum = util.AnySlice(validServiceTypes()) + + schema.Properties["environment"].Description = "Environment tag (DEV or PROD). Under read_only=prod, services tagged PROD cannot be modified." + setResourceInfoSchemaProperties(schema.Properties["resources"]) + return schema } diff --git a/internal/mcp/service_logs.go b/internal/mcp/service_logs.go index 0637a99b..156250be 100644 --- a/internal/mcp/service_logs.go +++ b/internal/mcp/service_logs.go @@ -47,11 +47,13 @@ func (ServiceLogsInput) Schema() *jsonschema.Schema { // ServiceLogsOutput represents output for service_logs type ServiceLogsOutput struct { - Logs []string `json:"logs" jsonschema:"Log lines ordered from oldest to newest. Each line is prefixed with an RFC3339 timestamp followed by the log message."` + Logs []string `json:"logs"` } func (ServiceLogsOutput) Schema() *jsonschema.Schema { - return util.Must(jsonschema.For[ServiceLogsOutput](nil)) + schema := util.Must(jsonschema.For[ServiceLogsOutput](nil)) + schema.Properties["logs"].Description = "Log lines ordered from oldest to newest. Each line is prefixed with an RFC3339 timestamp followed by the log message." + return schema } func newServiceLogsTool() *mcp.Tool { diff --git a/internal/mcp/service_resize.go b/internal/mcp/service_resize.go index 5fc74e5c..f748da70 100644 --- a/internal/mcp/service_resize.go +++ b/internal/mcp/service_resize.go @@ -38,13 +38,16 @@ func (ServiceResizeInput) Schema() *jsonschema.Schema { // ServiceResizeOutput represents output for service_resize type ServiceResizeOutput struct { - Status string `json:"status" jsonschema:"Current service status after resize operation"` + Status string `json:"status"` Resources *ResourceInfo `json:"resources,omitempty"` Message string `json:"message"` } func (ServiceResizeOutput) Schema() *jsonschema.Schema { - return util.Must(jsonschema.For[ServiceResizeOutput](nil)) + schema := util.Must(jsonschema.For[ServiceResizeOutput](nil)) + schema.Properties["status"].Description = "Current service status after resize operation" + setResourceInfoSchemaProperties(schema.Properties["resources"]) + return schema } func newServiceResizeTool() *mcp.Tool { diff --git a/internal/mcp/service_start.go b/internal/mcp/service_start.go index dcb19230..37236915 100644 --- a/internal/mcp/service_start.go +++ b/internal/mcp/service_start.go @@ -34,12 +34,14 @@ func (ServiceStartInput) Schema() *jsonschema.Schema { // ServiceStartOutput represents output for service_start type ServiceStartOutput struct { - Status string `json:"status" jsonschema:"Current service status after start operation"` + Status string `json:"status"` Message string `json:"message"` } func (ServiceStartOutput) Schema() *jsonschema.Schema { - return util.Must(jsonschema.For[ServiceStartOutput](nil)) + schema := util.Must(jsonschema.For[ServiceStartOutput](nil)) + schema.Properties["status"].Description = "Current service status after start operation" + return schema } func newServiceStartTool() *mcp.Tool { diff --git a/internal/mcp/service_stop.go b/internal/mcp/service_stop.go index c4c4e7bf..089e1100 100644 --- a/internal/mcp/service_stop.go +++ b/internal/mcp/service_stop.go @@ -34,12 +34,14 @@ func (ServiceStopInput) Schema() *jsonschema.Schema { // ServiceStopOutput represents output for service_stop type ServiceStopOutput struct { - Status string `json:"status" jsonschema:"Current service status after stop operation"` + Status string `json:"status"` Message string `json:"message"` } func (ServiceStopOutput) Schema() *jsonschema.Schema { - return util.Must(jsonschema.For[ServiceStopOutput](nil)) + schema := util.Must(jsonschema.For[ServiceStopOutput](nil)) + schema.Properties["status"].Description = "Current service status after stop operation" + return schema } func newServiceStopTool() *mcp.Tool { diff --git a/internal/mcp/utils.go b/internal/mcp/utils.go index 0d975b1c..b5ec9a0e 100644 --- a/internal/mcp/utils.go +++ b/internal/mcp/utils.go @@ -49,39 +49,57 @@ func setWithPasswordSchemaProperties(schema *jsonschema.Schema) { // ResourceInfo represents resource allocation information type ResourceInfo struct { - CPU string `json:"cpu,omitempty" jsonschema:"CPU allocation"` - Memory string `json:"memory,omitempty" jsonschema:"Memory allocation"` + CPU string `json:"cpu,omitempty"` + Memory string `json:"memory,omitempty"` } // setResourceInfoSchemaProperties enhances the schema of a ResourceInfo // property nested inside a service output schema. func setResourceInfoSchemaProperties(schema *jsonschema.Schema) { + schema.Properties["cpu"].Description = "CPU allocation" schema.Properties["cpu"].Examples = []any{"0.5 cores", "1 core", "4 cores"} + + schema.Properties["memory"].Description = "Memory allocation" schema.Properties["memory"].Examples = []any{"2 GB", "4 GB", "16 GB"} } // ServiceDetail represents detailed service information type ServiceDetail struct { - ServiceID string `json:"id" jsonschema:"Service identifier (10-character alphanumeric string)"` + ServiceID string `json:"id"` Name string `json:"name"` - Status string `json:"status" jsonschema:"Service status"` + Status string `json:"status"` Type string `json:"type"` Region string `json:"region"` Created string `json:"created,omitempty"` - Environment string `json:"environment" jsonschema:"Environment tag (DEV or PROD). Under read_only=prod, services tagged PROD cannot be modified."` + Environment string `json:"environment"` Resources *ResourceInfo `json:"resources,omitempty"` - Replicas int `json:"replicas" jsonschema:"Number of HA replicas (0=single node/no HA, 1+=HA enabled)"` - DirectEndpoint string `json:"direct_endpoint,omitempty" jsonschema:"Direct database connection endpoint"` - PoolerEndpoint string `json:"pooler_endpoint,omitempty" jsonschema:"Connection pooler endpoint"` - Password string `json:"password,omitempty" jsonschema:"Password for tsdbadmin user (only included if with_password=true)"` - ConnectionString string `json:"connection_string" jsonschema:"PostgreSQL connection string (password embedded only if with_password=true)"` + Replicas int `json:"replicas"` + DirectEndpoint string `json:"direct_endpoint,omitempty"` + PoolerEndpoint string `json:"pooler_endpoint,omitempty"` + Password string `json:"password,omitempty"` + ConnectionString string `json:"connection_string"` } func (ServiceDetail) Schema() *jsonschema.Schema { schema := util.Must(jsonschema.For[ServiceDetail](nil)) + + schema.Properties["id"].Description = "Service identifier (10-character alphanumeric string)" + + schema.Properties["status"].Description = "Service status" schema.Properties["status"].Examples = []any{"READY", "PAUSED", "CONFIGURING", "UPGRADING"} + schema.Properties["type"].Enum = util.AnySlice(validServiceTypes()) + + schema.Properties["environment"].Description = "Environment tag (DEV or PROD). Under read_only=prod, services tagged PROD cannot be modified." + setResourceInfoSchemaProperties(schema.Properties["resources"]) + + schema.Properties["replicas"].Description = "Number of HA replicas (0=single node/no HA, 1+=HA enabled)" + schema.Properties["direct_endpoint"].Description = "Direct database connection endpoint" + schema.Properties["pooler_endpoint"].Description = "Connection pooler endpoint" + schema.Properties["password"].Description = "Password for tsdbadmin user (only included if with_password=true)" + schema.Properties["connection_string"].Description = "PostgreSQL connection string (password embedded only if with_password=true)" + return schema } From d4f29b682518ffcf3ca855f11b51df1b43fd31d4 Mon Sep 17 00:00:00 2001 From: Nathan Cochran Date: Mon, 14 Sep 2026 14:20:36 -0400 Subject: [PATCH 4/4] Improve CLAUDE.md --- CLAUDE.md | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3e50e702..39283e05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -185,13 +185,36 @@ Only the MCP server logs. `newLogger(w)` (`internal/cmd/logger_helper.go`) point The server exposes two kinds of tools: native Tiger tools for service management and database operations (one file per tool), and documentation tools proxied from a remote docs MCP server (`proxy.go`). -**Server state.** `NewServer(ctx, app, logger)` takes the already-loaded `*common.App` and keeps it on the `Server` along with the logger. The experimental gate and the docs-proxy settings are read once here at startup (a client must restart the server to pick up changes to those), as are read-only mode's startup decisions — which write tools get registered, and the warning in the server instructions. The analytics middleware, though, reloads the App on every request, so handlers see current config and credentials — including the live `read_only` value in the per-call gates (see [Read-Only Mode](#read-only-mode)). Handlers therefore never load anything themselves — they read `s.app.GetAll()`/`GetClient()`/`GetConfig()` and log via `s.logger`. +### Server State -**One file per tool**, named to match the tool (`service_create` → `service_create.go`), laid out in this order: the `Input`/`Output` structs and their `Schema()` methods, then `newTool()` returning the `*mcp.Tool`, then the `handle` handler method on `*Server`, then helpers used only by that tool. Registration lives in `server.go` (`registerServiceTools`, `registerDatabaseTools`), so adding a tool means one new file plus one `addTool` line. Shared schema helpers and API-to-output conversion live in `utils.go`. +`NewServer(ctx, app, logger)` takes the already-loaded `*common.App` and keeps it on the `Server` along with the logger. The experimental gate and the docs-proxy settings are read once here at startup (a client must restart the server to pick up changes to those), as are read-only mode's startup decisions — which write tools get registered, and the warning in the server instructions. The analytics middleware, though, reloads the App on every request, so handlers see current config and credentials — including the live `read_only` value in the per-call gates (see [Read-Only Mode](#read-only-mode)). Handlers therefore never load anything themselves — they read `s.app.GetAll()`/`GetClient()`/`GetConfig()` and log via `s.logger`. -**Tool schemas.** Generate the base schema from the input struct with `util.Must(jsonschema.For[Input](nil))`, then enhance it in the `Schema()` method: set a `Description` on every field there — never through `jsonschema` struct tags, so all schema detail for input and output types alike lives in one place — and use the JSON Schema properties (`Default`, `Minimum`/`Maximum`, `Enum`, `Pattern`, `MaxLength`, …) both to document values for AI assistants and to reject invalid arguments before they reach the handler. `jsonschema.For` doesn't call the `Schema()` methods of nested types, so a nested output type's `Schema()` (or, for a property that must stay nullable, its `set*SchemaProperties` helper) is wired in explicitly by the enclosing type's `Schema()` — `ServiceListOutput` is the model. Illustrative values never go inline in a description string. A field whose values form a fixed, exhaustive set gets an `Enum` and nothing more; every other field gets `Examples`, and a preferred value is signalled with `Default`. The MCP SDK applies schema defaults to the raw arguments before unmarshaling them into the input struct, so a handler reads a defaulted field directly and never re-applies the default itself. Accessing a property that doesn't exist in the generated schema panics at startup, which keeps the schema and the struct in sync. Fields without `omitempty`/`omitzero` are **required**; an optional field with a struct (non-pointer) type must use `omitzero`, because `omitempty` has no effect on struct values and the field would silently stay required. +### One File Per Tool -**Annotations.** Every tool sets `Annotations`: read-only tools set `ReadOnlyHint`, write tools set `DestructiveHint` and `IdempotentHint`, and all of them set `OpenWorldHint` to false — the tools act only on the Tiger Cloud API and the user's own services, a closed domain rather than an open world of arbitrary external entities. +Every tool gets its own file in `internal/mcp/`, named to match the tool (`service_create` → `service_create.go`), laid out in this order: + +1. The `Input`/`Output` structs and their `Schema()` methods. +2. `newTool()`, returning the `*mcp.Tool`. +3. The `handle` handler method on `*Server`. +4. Helpers used only by that tool. + +Registration lives in `server.go` (`registerServiceTools`, `registerDatabaseTools`), so adding a tool means one new file plus one `addTool` line. Shared schema helpers and API-to-output conversion live in `utils.go`. + +### Tool Schemas + +Generate the base schema from the struct with `util.Must(jsonschema.For[Input](nil))`, then enhance it in the type's `Schema()` method: + +- Set a `Description` on every field there, never through `jsonschema` struct tags, so all schema detail for input and output types alike lives in one place. +- Use the JSON Schema properties (`Default`, `Minimum`/`Maximum`, `Enum`, `Pattern`, `MaxLength`, …) both to document values for AI assistants and to reject invalid arguments before they reach the handler. +- Illustrative values never go inline in a description string. A field whose values form a fixed, exhaustive set gets an `Enum` and nothing more; every other field gets `Examples`, and a preferred value is signalled with `Default`. +- The MCP SDK applies schema defaults to the raw arguments before unmarshaling them into the input struct, so a handler reads a defaulted field directly and never re-applies the default itself. +- `jsonschema.For` doesn't call the `Schema()` methods of nested types, so a nested output type's `Schema()` (or, for a property that must stay nullable, its `set*SchemaProperties` helper) is wired in explicitly by the enclosing type's `Schema()` — `ServiceListOutput` is the model. +- Accessing a property that doesn't exist in the generated schema panics at startup, which keeps the schema and the struct in sync. +- Fields without `omitempty`/`omitzero` are **required**. An optional field with a struct (non-pointer) type must use `omitzero`, because `omitempty` has no effect on struct values and the field would silently stay required. + +### Annotations + +Every tool sets `Annotations`: read-only tools set `ReadOnlyHint`, write tools set `DestructiveHint` and `IdempotentHint`, and all of them set `OpenWorldHint` to false — the tools act only on the Tiger Cloud API and the user's own services, a closed domain rather than an open world of arbitrary external entities. ## Read-Only Mode