Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,11 +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 `<Tool>Input`/`<Tool>Output` structs and their `Schema()` methods, then `new<Tool>Tool()` returning the `*mcp.Tool`, then the `handle<Tool>` 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: 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.
### One File Per Tool

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 `<Tool>Input`/`<Tool>Output` structs and their `Schema()` methods.
2. `new<Tool>Tool()`, returning the `*mcp.Tool`.
3. The `handle<Tool>` 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

Expand Down
70 changes: 54 additions & 16 deletions internal/cmd/mcp_get_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -55,17 +55,17 @@ 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

`

serviceListJSON := `{
"annotations": {
"idempotentHint": false,
"openWorldHint": true,
"openWorldHint": false,
"readOnlyHint": true,
"title": "List Database Services"
},
Expand Down Expand Up @@ -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"
}
},
Expand All @@ -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"
}
},
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/mcp/db_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
}
Expand Down
2 changes: 1 addition & 1 deletion internal/mcp/db_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
}
Expand Down
2 changes: 1 addition & 1 deletion internal/mcp/service_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,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",
},
}
Expand Down
5 changes: 2 additions & 3 deletions internal/mcp/service_fork.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,8 @@ 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"].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."
Expand Down Expand Up @@ -98,7 +97,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",
},
}
Expand Down
6 changes: 4 additions & 2 deletions internal/mcp/service_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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",
},
}
Expand Down
23 changes: 18 additions & 5 deletions internal/mcp/service_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,24 +27,37 @@ 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)"`
ServiceID string `json:"id"`
Name string `json:"name"`
Status string `json:"status" jsonschema:"Service status (e.g., READY, PAUSED, CONFIGURING, UPGRADING)"`
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
}

Expand All @@ -58,7 +71,7 @@ func newServiceListTool() *mcp.Tool {
OutputSchema: ServiceListOutput{}.Schema(),
Annotations: &mcp.ToolAnnotations{
ReadOnlyHint: true,
OpenWorldHint: new(true),
OpenWorldHint: new(false),
Title: "List Database Services",
},
}
Expand Down
12 changes: 7 additions & 5 deletions internal/mcp/service_logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,22 +36,24 @@ 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
}

// 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 {
Expand All @@ -67,7 +69,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",
},
}
Expand Down
7 changes: 5 additions & 2 deletions internal/mcp/service_metrics_series.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,18 @@ 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)
schema.Properties["bucket_seconds"].Examples = []any{60, 300, 3600}

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
}
Expand Down
Loading