diff --git a/README.md b/README.md index af972e4..fffacf7 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,24 @@ # roteiro-agent -MCP (Model Context Protocol) server for Roteiro, a spatial data platform. Enables AI agents (Claude Desktop, VS Code, Cursor) to work with geospatial datasets, run geoprocessing operations, execute SQL, and more. +MCP server for Cairn's current public API. -## Installation +The agent now exposes a smaller, explicit tool surface built around the stable workflows: -```bash -go install github.com/i-norden/roteiro-agent@latest -``` +- datasets and collection queries +- uploads and remote dataset intake +- celestial body metadata and recipe execution +- unified vector operations and async jobs +- ad hoc and saved pipelines +- SQL query control plane +- projects and workspace state +- published map management + +Legacy tools for `/api/process`, raster processing, catalog browsing, STAC import, routing, geocoding, and the old `map_api` catch-all have been removed. -Or build from source: +## Install ```bash -git clone https://github.com/i-norden/roteiro-agent -cd roteiro-agent -go build -o roteiro-agent . +go install github.com/i-norden/roteiro-agent@latest ``` ## Usage @@ -22,149 +27,29 @@ go build -o roteiro-agent . roteiro-agent --server-url http://localhost:8080 --api-key Roteiro_abc123 --project-id 42 ``` -The server communicates via JSON-RPC 2.0 over stdio (stdin/stdout), following the MCP specification. +The server speaks JSON-RPC 2.0 over stdio and follows the MCP protocol. -### Environment variables +## Environment Variables | Variable | Flag | Description | |----------|------|-------------| -| `ROTEIRO_SERVER_URL` | `--server-url` | Roteiro server base URL | -| `ROTEIRO_API_KEY` | `--api-key` | Roteiro API key | -| `ROTEIRO_SESSION_COOKIE` | `--session-cookie` | Session cookie (alternative to API key) | -| `ROTEIRO_PROJECT_ID` | `--project-id` | Optional default project scope sent as `X-Project-ID` | - -When `--project-id` or `ROTEIRO_PROJECT_ID` is set, the agent scopes compatible requests to that project by default. Individual tool calls can also override the scope with a `project_id` argument. - -## MCP Client Configuration - -### Claude Desktop - -Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: - -```json -{ - "mcpServers": { - "roteiro": { - "command": "roteiro-agent", - "args": ["--server-url", "https://your-roteiro-instance.com", "--api-key", "roteiro_abc123"] - } - } -} -``` - -### VS Code (Copilot) - -Add to `.vscode/mcp.json`: - -```json -{ - "servers": { - "roteiro": { - "command": "roteiro-agent", - "args": ["--server-url", "http://localhost:8080", "--api-key", "roteiro_abc123"] - } - } -} -``` - -### Claude Code - -Add to `.mcp.json`: - -```json -{ - "mcpServers": { - "roteiro": { - "command": "roteiro-agent", - "args": ["--server-url", "http://localhost:8080", "--api-key", "roteiro_abc123"] - } - } -} -``` - -## Available Tools - -| Tool | Description | -|------|-------------| -| `list_datasets` | List all registered datasets | -| `get_dataset_info` | Dataset schema, CRS, bounds, feature count | -| `get_dataset_schema` | Field names and types | -| `get_dataset_profile` | Statistical profile of a dataset | -| `query_features` | Query with bbox, bbox CRS, response CRS, CQL2 filter, limit, properties | -| `get_feature` | Single feature by ID | -| `upload_dataset` | Upload a spatial data file, optionally naming it and attaching it to a project | -| `run_process` | Single synchronous geoprocessing operation | -| `run_raster_process` | Generic synchronous raster processing via file-path inputs | -| `preflight_process` | Validate and normalize a processing request | -| `submit_process_job` | Submit an async processing job | -| `submit_process_batch` | Submit dependent async processing jobs | -| `list_process_jobs` | List async processing jobs | -| `get_process_job` | Inspect an async processing job | -| `cancel_process_job` | Cancel an async processing job | -| `rerun_process_job` | Re-submit an async processing job | -| `run_pipeline` | Multi-step geoprocessing pipeline | -| `convert_format` | Convert between formats (GeoJSON, Shapefile, etc.) | -| `diff_datasets` | Compare two dataset versions | -| `execute_sql` | Run PostGIS SQL query | -| `list_spatial_tables` | List spatial tables in the database | -| `get_duckdb_info` | DuckDB SQL engine status/capabilities | -| `list_duckdb_datasets` | Datasets available to DuckDB SQL | -| `geocode` | Address to coordinates | -| `reverse_geocode` | Coordinates to address | -| `compute_route` | Driving/walking route computation | -| `compute_isochrone` | Travel-time isochrone polygons | -| `compute_route_matrix` | Origin-destination time/distance matrix | -| `compute_service_area` | Distance-based service area polygons | -| `list_operations` | Available geoprocessing operations | -| `list_analysis_operations` | Available advanced analysis operations | -| `browse_catalog` | Browse the built-in data catalog | -| `browse_catalog_enhanced` | Browse enhanced catalog with filters | -| `get_catalog_entry` | Get enhanced catalog entry by ID | -| `list_catalog_categories` | List catalog categories | -| `list_catalog_tags` | List catalog tags | -| `import_from_catalog` | Import a dataset from the data catalog, optionally into a project | -| `browse_stac_catalog` | Browse a remote STAC catalog | -| `browse_stac_collections` | List collections in a remote STAC catalog | -| `browse_stac_items` | List items in a remote STAC collection | -| `import_stac_asset` | Import a STAC asset as a local dataset, optionally with namespace/catalog metadata and project attachment | -| `search_stac` | Search local STAC with bbox/datetime/CQL2 filters | -| `map_api` | Allowlisted map endpoint access (publish/unpublish/stats/embed config, raster metadata/JSON analysis/export ops including contour/viewshed/profile/KDE/slope/aspect, geodesic area/length, raster classification via k-means/ISODATA/max-likelihood/random-forest, OGC feature edit ops). Mutations require `confirm=true`. | - -Use `list_operations` for the live vector-processing catalog. Raster operations do not currently have a live catalog endpoint, so `run_raster_process` documents the current backend families directly: terrain, hydrology, distance/cost, spectral/change, classification, and raster-vector conversion. For dataset-name-based raster JSON routes, `map_api` now also exposes contour, viewshed, profile, KDE, slope, and aspect. Geodesic area/length and raster classification (k-means, ISODATA, maximum-likelihood, random-forest) are also available via `map_api`. - -## Example Workflows - -**"Show me all parks larger than 10 acres near downtown"** -1. Agent calls `list_datasets` to find the parks dataset -2. Agent calls `query_features` with a CQL2 filter: `area_acres > 10` and bbox around downtown -3. Returns matching parks as GeoJSON features - -**"Buffer all schools by 1km and find which residential zones intersect"** -1. Agent calls `run_pipeline` with two steps: - - Buffer "schools" by 1000m - - Spatial join the buffer result with "residential_zones" -2. Returns the intersection result - -**"What's the average building height per neighborhood?"** -1. Agent calls `execute_sql` with PostGIS SQL: - ```sql - SELECT n.name, AVG(b.height) as avg_height - FROM neighborhoods n - JOIN buildings b ON ST_Intersects(n.geom, b.geom) - GROUP BY n.name ORDER BY avg_height DESC - ``` - -**"Import building footprints from a STAC catalog and calculate total area"** -1. Agent calls `browse_stac_collections` to discover available collections -2. Agent calls `browse_stac_items` to preview the buildings collection -3. Agent calls `import_stac_asset` to download and register the data -4. Agent calls `execute_sql` to calculate total building area with PostGIS - -**"Find open data about transportation in our catalog"** -1. Agent calls `browse_catalog` with search="transportation" -2. Agent calls `import_from_catalog` to import the desired dataset -3. Agent calls `get_dataset_info` to inspect the imported data - -## License - -MIT +| `ROTEIRO_SERVER_URL` | `--server-url` | Cairn server base URL | +| `ROTEIRO_API_KEY` | `--api-key` | API key | +| `ROTEIRO_SESSION_COOKIE` | `--session-cookie` | Session cookie alternative | +| `ROTEIRO_PROJECT_ID` | `--project-id` | Default project scope sent as `X-Project-ID` | + +## Tool Groups + +- data: `list_datasets`, `get_dataset_info`, `query_features`, `get_feature`, `create_feature`, `update_feature`, `delete_feature`, `upload_dataset`, `import_source` +- celestial: `get_scene_manifest`, `list_bodies`, `get_body`, `get_body_recipes`, `execute_body_recipe` +- operations: `list_operations`, `preflight_operation`, `run_operation`, `submit_operation_job`, `submit_operation_batch`, `list_operation_jobs`, `get_operation_job`, `cancel_operation_job`, `rerun_operation_job` +- pipelines: `list_pipeline_operations`, `run_pipeline`, `list_pipelines`, `get_pipeline`, `create_pipeline`, `update_pipeline`, `delete_pipeline`, `duplicate_pipeline`, `execute_saved_pipeline`, `list_pipeline_runs`, `get_pipeline_run` +- SQL: `list_query_engines`, `get_query_engine_info`, `list_query_datasets`, `execute_sql`, `save_sql_result` +- projects: `list_projects`, `get_project`, `create_project`, `update_project`, `delete_project`, `get_project_workspace`, `set_project_workspace` +- publishing: `publish_map`, `list_published_maps`, `delete_published_map`, `get_published_map_stats`, `update_map_embed_config` + +## Notes + +- Most tools accept project scoping through the agent's global `--project-id` or a per-call `project_id` override. +- `upload_dataset` and `import_source` both support `body_id` so tenant-defined celestial bodies flow through the intake path. +- SQL tools operate against Cairn's engine-aware control plane, so `engine` is required where applicable. diff --git a/SKILL.md b/SKILL.md index 3447a8f..da5aaf6 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,123 +1,50 @@ -# Roteiro Spatial Platform — Agent Guide +# Cairn Agent Guide -## What is Roteiro? +## Shape -Roteiro is a full-featured spatial data platform. It stores, processes, and serves geospatial datasets. Think of it as a self-hosted GIS server with a REST API. +`roteiro-agent` is a narrow MCP wrapper around Cairn's current stable workflows. Prefer the explicit tools over inventing raw REST calls. -## Authentication +## Core Workflows -All requests require either an API key (`X-API-Key` header) or a session cookie. The roteiro-agent MCP server handles this automatically — you just need to provide credentials when starting it. +### Data discovery and query -## Key Concepts +- Start with `list_datasets`. +- Use `get_dataset_info` before writing filters or SQL. +- Use `query_features` for bounded inspection and `get_feature` for a single record. -- **Dataset**: A named collection of spatial features (points, lines, polygons). Can be GeoJSON, Shapefile, GeoPackage, etc. -- **Collection**: OGC API term for a dataset. Used interchangeably. -- **Feature**: A single geographic entity with geometry and properties (attributes). -- **CQL2**: Common Query Language v2 — a standard for filtering features by attributes and spatial relationships. -- **Pipeline**: A chain of geoprocessing operations where each step's output feeds the next. +### Intake -## Working with Data +- Use `upload_dataset` for local files. +- Use `import_source` for remote URLs or catalog-backed sources. +- Set `body_id` when the dataset belongs to Earth, Moon, Mars, or a tenant-defined body. -### Discovering datasets +### Celestial bodies -Start with `list_datasets` to see what's available. Use `get_dataset_info` to drill into a specific dataset's schema, CRS, extent, and feature count. Use `get_dataset_schema` for just the field types, or `get_dataset_profile` for statistical summaries. +- Use `get_scene_manifest` to inspect the current body-aware scene configuration. +- Use `list_bodies`, `get_body`, and `get_body_recipes` to discover body metadata. +- Use `execute_body_recipe` to trigger a configured recipe source for a body. -### Querying features +### Operations and pipelines -Use `query_features` with: -- `bbox`: spatial bounding box filter (`west,south,east,north`) -- `bbox_crs`: optional CRS for the bbox coordinates -- `crs`: optional CRS for returned geometries -- `filter`: CQL2 expression (e.g. `population > 10000 AND status = 'active'`) -- `limit`: max features (default 10, use higher values carefully) -- `properties`: comma-separated list of properties to include (reduces response size) -- `sortby`: property to sort by (prefix with `-` for descending) +- Call `list_operations` first. +- Use `preflight_operation` before expensive operations. +- Use `run_operation` for synchronous work and the `*_operation_job` tools for async work. +- Use `run_pipeline` for ad hoc multi-step chains. +- Use saved pipeline tools only when the user is clearly asking about persisted workflows. -Most data-management tools also accept an optional `project_id` argument. Use it when the same user has access to multiple projects or when the agent is started without a global `--project-id`. +### SQL -### SQL queries +- Use `list_query_engines` and `get_query_engine_info` to discover available engines. +- Use `execute_sql` for analysis and `save_sql_result` when the result should become a dataset. +- Always specify `engine`. -Use `execute_sql` for complex spatial queries. Roteiro exposes PostGIS, so all spatial functions are available: -- `ST_Area`, `ST_Length`, `ST_Distance` — measurements -- `ST_Buffer`, `ST_Intersection`, `ST_Union` — geometry operations -- `ST_Intersects`, `ST_Contains`, `ST_Within` — spatial predicates -- `ST_Transform` — coordinate system transformation +### Projects and publishing -Queries must be SELECT-only (read-only). +- Use project tools for workspace state and basic lifecycle. +- Use publish tools for public map links and embed configuration. -## Geoprocessing Operations +## Guardrails -Use `preflight_process` to validate and normalize a request first. Use `run_process` for synchronous execution, `submit_process_job` or `submit_process_batch` for async execution, and `run_pipeline` for chains. - -Always call `list_operations` first to fetch the live server operation catalog and parameter names. The server now returns rich metadata including category, UI availability, projected-CRS requirements, and typed parameter definitions. - -Important parameter names for common ops: -- `geodesic_buffer` uses metric `distance` in meters -- `clip` uses `mask` -- `sjoin` uses `right` and `predicate` -- `reproject` uses `from_crs` and `to_crs` -- `dissolve` uses `group_by` - -Async process jobs expose queue state, phase, progress, and failure metadata via the `/api/process/jobs*` endpoints. - -Use `list_analysis_operations` for advanced analysis catalog endpoints under `/api/analysis/operations`. - -The async process workflow is available through `submit_process_job`, `submit_process_batch`, `list_process_jobs`, `get_process_job`, `cancel_process_job`, and `rerun_process_job`. - -For raster analysis, use `run_raster_process` with file paths when you need the generic `/api/raster/process` endpoint. Typical operation families include terrain, hydrology, distance/cost, spectral/change, classification, and raster-vector conversion. - -For registered raster datasets and JSON-returning raster endpoints, use `map_api` with operations such as `get_raster_info`, `get_raster_stats`, `get_raster_histogram`, `get_raster_dimensions`, `get_raster_values`, `raster_zonal_stats`, `export_raster_band`, `raster_contour`, `raster_viewshed`, `raster_profile`, and `raster_kde`. - -## Data Catalog & STAC - -Roteiro includes a built-in data catalog and supports importing from remote STAC (SpatioTemporal Asset Catalog) servers. - -### Built-in catalog - -Use `browse_catalog` to discover datasets available for import. Filter by `search` (text) or `category`. Use `import_from_catalog` with a `catalog_id` to download and register a dataset. Pass `project_id` when the imported dataset should be attached to a specific workspace project. - -### Remote STAC catalogs - -For external data sources: -1. `browse_stac_catalog` — inspect a remote STAC catalog by URL -2. `browse_stac_collections` — list available collections -3. `browse_stac_items` — preview items with optional `bbox` and `datetime` filters -4. `import_stac_asset` — download an asset URL and register it as a local dataset; optionally include `namespace`, `collection`, `catalog_url`, and `project_id` - -### Local STAC search - -Use `search_stac` to search Roteiro's own STAC endpoint with spatial (`bbox`), temporal (`datetime`), collection, and CQL2 (`filter`) criteria. - -## Tips for Effective Use - -1. **Start with discovery**: Always `list_datasets` first to understand what's available. -2. **Use small limits**: Default to `limit=10` when exploring. Increase only when needed. -3. **Prefer SQL for analytics**: For aggregations, joins, and complex spatial queries, `execute_sql` is more efficient than fetching features and computing client-side. -4. **Chain operations with pipelines**: Instead of running operations one by one, use `run_pipeline` to chain them in a single request. -5. **Check schemas before querying**: Use `get_dataset_schema` to see available fields before writing CQL2 filters or SQL. - -## Common Patterns - -### Find features near a point -```sql -SELECT * FROM parks -WHERE ST_DWithin(geom, ST_SetSRID(ST_MakePoint(-73.97, 40.77), 4326), 0.01) -LIMIT 20 -``` - -### Aggregate by region -```sql -SELECT r.name, COUNT(p.*) as count, SUM(ST_Area(p.geom::geography)) as total_area_m2 -FROM regions r JOIN parcels p ON ST_Intersects(r.geom, p.geom) -GROUP BY r.name ORDER BY count DESC -``` - -### Buffer and intersect -```json -{ - "steps": [ - {"operation": "buffer", "input": "schools", "params": {"distance": 1000}}, - {"operation": "intersect", "params": {"mask": "residential_zones"}} - ] -} -``` +- Keep feature and query requests bounded with `limit` unless the user explicitly needs a large result. +- Prefer the explicit MCP tools here over legacy routes like `/api/process`, `/api/query/sql`, `/api/catalog`, `/api/stac`, or the old `map_api` wrapper. +- Do not assume Earth-only data. Carry `body_id` or body slug context through the workflow when the task is celestial. diff --git a/mcp/client.go b/mcp/client.go index 8612896..9849e66 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -1,5 +1,5 @@ // Package mcp implements an MCP (Model Context Protocol) server that exposes -// Roteiro's spatial data platform to AI agents. +// Cairn's stable public API to AI agents. package mcp import ( @@ -17,7 +17,7 @@ import ( "time" ) -// Client is a thin HTTP wrapper for the Roteiro REST API. +// Client is a thin HTTP wrapper for the Cairn REST API. type Client struct { BaseURL string APIKey string @@ -71,45 +71,21 @@ func (c *Client) WithProjectID(projectID string) *Client { return &clone } -func (c *Client) get(path string, query url.Values) ([]byte, int, error) { - u := c.BaseURL + path - if len(query) > 0 { - u += "?" + query.Encode() - } - req, err := http.NewRequest("GET", u, nil) - if err != nil { - return nil, 0, err - } - return c.do(req) -} - -func (c *Client) postJSON(path string, payload interface{}) ([]byte, int, error) { - data, err := json.Marshal(payload) - if err != nil { - return nil, 0, err - } - req, err := http.NewRequest("POST", c.BaseURL+path, bytes.NewReader(data)) - if err != nil { - return nil, 0, err - } - req.Header.Set("Content-Type", "application/json") - return c.do(req) -} - -func (c *Client) callJSON(method, path string, payload interface{}, query map[string]string) ([]byte, int, error) { +func (c *Client) request(method, path string, payload interface{}, query map[string]string, headers map[string]string, expected ...int) (json.RawMessage, error) { if !strings.HasPrefix(path, "/") { - return nil, 0, fmt.Errorf("path must start with '/': %s", path) + return nil, fmt.Errorf("path must start with '/': %s", path) } + u := c.BaseURL + path if len(query) > 0 { q := url.Values{} - for k, v := range query { - if k != "" && v != "" { - q.Set(k, v) + for key, value := range query { + if key != "" && value != "" { + q.Set(key, value) } } - if enc := q.Encode(); enc != "" { - u += "?" + enc + if encoded := q.Encode(); encoded != "" { + u += "?" + encoded } } @@ -117,722 +93,316 @@ func (c *Client) callJSON(method, path string, payload interface{}, query map[st if payload != nil { data, err := json.Marshal(payload) if err != nil { - return nil, 0, err + return nil, err } body = bytes.NewReader(data) } req, err := http.NewRequest(method, u, body) if err != nil { - return nil, 0, err + return nil, err } if payload != nil { req.Header.Set("Content-Type", "application/json") } - return c.do(req) -} - -// APIRequest sends a constrained API request used by allowlisted MCP tools. -func (c *Client) APIRequest(method, path string, payload interface{}, query map[string]string) (json.RawMessage, error) { - body, code, err := c.callJSON(method, path, payload, query) - if err != nil { - return nil, err - } - if code < 200 || code >= 300 { - return nil, fmt.Errorf("%s %s returned %d: %s", method, path, code, truncate(body, 500)) - } - if len(body) == 0 { - return json.RawMessage(`{"status":"ok"}`), nil - } - return json.RawMessage(body), nil -} - -// ListDatasets calls GET /datasets. -func (c *Client) ListDatasets() (json.RawMessage, error) { - body, code, err := c.get("/datasets", nil) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /datasets returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil -} - -// GetCollection calls GET /collections/{id}. -func (c *Client) GetCollection(id string) (json.RawMessage, error) { - body, code, err := c.get("/collections/"+url.PathEscape(id), nil) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /collections/%s returned %d: %s", id, code, truncate(body, 500)) + for key, value := range headers { + if strings.TrimSpace(key) != "" && strings.TrimSpace(value) != "" { + req.Header.Set(key, value) + } } - return json.RawMessage(body), nil -} -// QueryFeatures calls GET /collections/{id}/items with optional query params. -func (c *Client) QueryFeatures(id string, params map[string]string) (json.RawMessage, error) { - q := url.Values{} - for k, v := range params { - q.Set(k, v) - } - body, code, err := c.get("/collections/"+url.PathEscape(id)+"/items", q) + respBody, code, err := c.do(req) if err != nil { return nil, err } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /collections/%s/items returned %d: %s", id, code, truncate(body, 500)) - } - return json.RawMessage(body), nil -} - -// GetFeature calls GET /collections/{id}/items/{fid}. -func (c *Client) GetFeature(collectionID, featureID string) (json.RawMessage, error) { - path := fmt.Sprintf("/collections/%s/items/%s", url.PathEscape(collectionID), url.PathEscape(featureID)) - body, code, err := c.get(path, nil) - if err != nil { - return nil, err + if len(expected) == 0 { + if code < 200 || code >= 300 { + return nil, fmt.Errorf("%s %s returned %d: %s", method, path, code, truncate(respBody, 500)) + } + } else { + matched := false + for _, want := range expected { + if code == want { + matched = true + break + } + } + if !matched { + return nil, fmt.Errorf("%s %s returned %d: %s", method, path, code, truncate(respBody, 500)) + } } - if code != http.StatusOK { - return nil, fmt.Errorf("GET %s returned %d: %s", path, code, truncate(body, 500)) + if len(respBody) == 0 { + return json.RawMessage(`{"status":"ok"}`), nil } - return json.RawMessage(body), nil + return json.RawMessage(respBody), nil } -// UploadMultipart calls POST {path} with a multipart file upload. -func (c *Client) UploadMultipart(path, filePath, fieldName string, extraFields map[string]string) (json.RawMessage, error) { - f, err := os.Open(filePath) +func (c *Client) UploadFile(filePath, name, projectID, bodyID string) (json.RawMessage, error) { + file, err := os.Open(filePath) if err != nil { return nil, fmt.Errorf("open file: %w", err) } - defer f.Close() + defer file.Close() var buf bytes.Buffer - w := multipart.NewWriter(&buf) - part, err := w.CreateFormFile(fieldName, filepath.Base(filePath)) + writer := multipart.NewWriter(&buf) + part, err := writer.CreateFormFile("file", filepath.Base(filePath)) if err != nil { return nil, err } - if _, err := io.Copy(part, f); err != nil { + if _, err := io.Copy(part, file); err != nil { return nil, err } - for key, value := range extraFields { - if strings.TrimSpace(key) == "" || strings.TrimSpace(value) == "" { - continue + if strings.TrimSpace(name) != "" { + if err := writer.WriteField("name", name); err != nil { + return nil, err } - if err := w.WriteField(key, value); err != nil { + } + if strings.TrimSpace(projectID) != "" { + if err := writer.WriteField("project_id", projectID); err != nil { return nil, err } } - if err := w.Close(); err != nil { + if strings.TrimSpace(bodyID) != "" { + if err := writer.WriteField("body_id", bodyID); err != nil { + return nil, err + } + } + if err := writer.Close(); err != nil { return nil, err } - req, err := http.NewRequest("POST", c.BaseURL+path, &buf) + req, err := http.NewRequest("POST", c.BaseURL+"/upload", &buf) if err != nil { return nil, err } - req.Header.Set("Content-Type", w.FormDataContentType()) + req.Header.Set("Content-Type", writer.FormDataContentType()) body, code, err := c.do(req) if err != nil { return nil, err } - if code != http.StatusOK && code != http.StatusCreated { - return nil, fmt.Errorf("POST %s returned %d: %s", path, code, truncate(body, 500)) + if code != http.StatusOK && code != http.StatusCreated && code != http.StatusAccepted { + return nil, fmt.Errorf("POST /upload returned %d: %s", code, truncate(body, 500)) } return json.RawMessage(body), nil } -// UploadFile calls POST /upload with a multipart file upload. -func (c *Client) UploadFile(filePath, name, projectID string) (json.RawMessage, error) { - extraFields := map[string]string{} - if strings.TrimSpace(name) != "" { - extraFields["name"] = name - } - if strings.TrimSpace(projectID) != "" { - extraFields["project_id"] = projectID - } - return c.UploadMultipart("/upload", filePath, "file", extraFields) +func (c *Client) ListDatasets() (json.RawMessage, error) { + return c.request("GET", "/datasets", nil, nil, nil, http.StatusOK) } -// RunProcess calls POST /api/process. -func (c *Client) RunProcess(payload interface{}) (json.RawMessage, error) { - body, code, err := c.postJSON("/api/process", payload) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("POST /api/process returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) GetCollection(id string) (json.RawMessage, error) { + return c.request("GET", "/collections/"+url.PathEscape(id), nil, nil, nil, http.StatusOK) } -// RunRasterProcess calls POST /api/raster/process. -func (c *Client) RunRasterProcess(payload interface{}) (json.RawMessage, error) { - body, code, err := c.postJSON("/api/raster/process", payload) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("POST /api/raster/process returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) QueryFeatures(id string, params map[string]string) (json.RawMessage, error) { + return c.request("GET", "/collections/"+url.PathEscape(id)+"/items", nil, params, nil, http.StatusOK) } -// PreflightProcess calls POST /api/process/preflight. -func (c *Client) PreflightProcess(payload interface{}) (json.RawMessage, error) { - body, code, err := c.postJSON("/api/process/preflight", payload) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("POST /api/process/preflight returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) GetFeature(collectionID, featureID string) (json.RawMessage, error) { + path := fmt.Sprintf("/collections/%s/items/%s", url.PathEscape(collectionID), url.PathEscape(featureID)) + return c.request("GET", path, nil, nil, nil, http.StatusOK) } -// SubmitProcessJob calls POST /api/process/jobs. -func (c *Client) SubmitProcessJob(payload interface{}) (json.RawMessage, error) { - body, code, err := c.postJSON("/api/process/jobs", payload) - if err != nil { - return nil, err - } - if code != http.StatusAccepted { - return nil, fmt.Errorf("POST /api/process/jobs returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) CreateFeature(collectionID string, feature interface{}) (json.RawMessage, error) { + path := fmt.Sprintf("/collections/%s/items", url.PathEscape(collectionID)) + return c.request("POST", path, feature, nil, map[string]string{"Content-Type": "application/geo+json"}, http.StatusOK, http.StatusCreated) } -// SubmitProcessBatch calls POST /api/process/jobs/batch. -func (c *Client) SubmitProcessBatch(payload interface{}) (json.RawMessage, error) { - body, code, err := c.postJSON("/api/process/jobs/batch", payload) - if err != nil { - return nil, err - } - if code != http.StatusAccepted { - return nil, fmt.Errorf("POST /api/process/jobs/batch returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) UpdateFeature(collectionID, featureID string, feature interface{}) (json.RawMessage, error) { + path := fmt.Sprintf("/collections/%s/items/%s", url.PathEscape(collectionID), url.PathEscape(featureID)) + return c.request("PUT", path, feature, nil, map[string]string{"Content-Type": "application/geo+json"}, http.StatusOK) } -// ListProcessJobs calls GET /api/process/jobs. -func (c *Client) ListProcessJobs(params map[string]string) (json.RawMessage, error) { - q := url.Values{} - for k, v := range params { - if v != "" { - q.Set(k, v) - } - } - body, code, err := c.get("/api/process/jobs", q) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /api/process/jobs returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) DeleteFeature(collectionID, featureID string) (json.RawMessage, error) { + path := fmt.Sprintf("/collections/%s/items/%s", url.PathEscape(collectionID), url.PathEscape(featureID)) + return c.request("DELETE", path, nil, nil, nil, http.StatusNoContent) } -// GetProcessJob calls GET /api/process/jobs/{id}. -func (c *Client) GetProcessJob(id string) (json.RawMessage, error) { - body, code, err := c.get("/api/process/jobs/"+url.PathEscape(id), nil) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /api/process/jobs/%s returned %d: %s", id, code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) GetDatasetSchema(name string) (json.RawMessage, error) { + return c.request("GET", "/api/v1/datasets/"+url.PathEscape(name)+"/schema", nil, nil, nil, http.StatusOK) } -// CancelProcessJob calls DELETE /api/process/jobs/{id}. -func (c *Client) CancelProcessJob(id string) (json.RawMessage, error) { - body, code, err := c.callJSON("DELETE", "/api/process/jobs/"+url.PathEscape(id), nil, nil) - if err != nil { - return nil, err - } - if code != http.StatusNoContent { - return nil, fmt.Errorf("DELETE /api/process/jobs/%s returned %d: %s", id, code, truncate(body, 500)) - } - return json.RawMessage(`{"status":"cancelled"}`), nil +func (c *Client) GetDatasetProfile(name string) (json.RawMessage, error) { + return c.request("GET", "/api/v1/datasets/"+url.PathEscape(name)+"/profile", nil, nil, nil, http.StatusOK) } -// RerunProcessJob calls POST /api/process/jobs/{id}/rerun. -func (c *Client) RerunProcessJob(id string) (json.RawMessage, error) { - body, code, err := c.postJSON("/api/process/jobs/"+url.PathEscape(id)+"/rerun", map[string]interface{}{}) - if err != nil { - return nil, err - } - if code != http.StatusAccepted { - return nil, fmt.Errorf("POST /api/process/jobs/%s/rerun returned %d: %s", id, code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) ImportSource(payload interface{}) (json.RawMessage, error) { + return c.request("POST", "/api/v1/datasets/import-source", payload, nil, nil, http.StatusOK, http.StatusCreated, http.StatusAccepted) } -// RunPipeline calls POST /api/pipeline. -func (c *Client) RunPipeline(payload interface{}) (json.RawMessage, error) { - body, code, err := c.postJSON("/api/pipeline", payload) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("POST /api/pipeline returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) GetSceneManifest() (json.RawMessage, error) { + return c.request("GET", "/api/v1/scene-manifest", nil, nil, nil, http.StatusOK) } -// ListPipelineTemplates calls GET /api/pipelines/templates. -func (c *Client) ListPipelineTemplates() (json.RawMessage, error) { - body, code, err := c.get("/api/pipelines/templates", nil) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /api/pipelines/templates returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) ListBodies() (json.RawMessage, error) { + return c.request("GET", "/api/v1/bodies", nil, nil, nil, http.StatusOK) } -// ListPipelines calls GET /api/pipelines. -func (c *Client) ListPipelines() (json.RawMessage, error) { - body, code, err := c.get("/api/pipelines", nil) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /api/pipelines returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) GetBody(slug string) (json.RawMessage, error) { + return c.request("GET", "/api/v1/bodies/"+url.PathEscape(slug), nil, nil, nil, http.StatusOK) } -// GetPipeline calls GET /api/pipelines/{id}. -func (c *Client) GetPipeline(id string) (json.RawMessage, error) { - body, code, err := c.get("/api/pipelines/"+url.PathEscape(id), nil) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /api/pipelines/%s returned %d: %s", id, code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) GetBodyRecipes(slug string) (json.RawMessage, error) { + return c.request("GET", "/api/v1/bodies/"+url.PathEscape(slug)+"/recipes", nil, nil, nil, http.StatusOK) } -// CreatePipeline calls POST /api/pipelines. -func (c *Client) CreatePipeline(payload interface{}) (json.RawMessage, error) { - body, code, err := c.postJSON("/api/pipelines", payload) - if err != nil { - return nil, err - } - if code != http.StatusCreated { - return nil, fmt.Errorf("POST /api/pipelines returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) ExecuteBodyRecipe(slug, sourceID string) (json.RawMessage, error) { + path := fmt.Sprintf("/api/v1/bodies/%s/recipes/%s/execute", url.PathEscape(slug), url.PathEscape(sourceID)) + return c.request("POST", path, map[string]interface{}{}, nil, nil, http.StatusOK) } -// UpdatePipeline calls PUT /api/pipelines/{id}. -func (c *Client) UpdatePipeline(id string, payload interface{}) (json.RawMessage, error) { - body, code, err := c.callJSON("PUT", "/api/pipelines/"+url.PathEscape(id), payload, nil) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("PUT /api/pipelines/%s returned %d: %s", id, code, truncate(body, 500)) +func (c *Client) ListOperations(domain string) (json.RawMessage, error) { + query := map[string]string{} + if strings.TrimSpace(domain) != "" { + query["domain"] = domain } - return json.RawMessage(body), nil + return c.request("GET", "/api/v1/ops", nil, query, nil, http.StatusOK) } -// DeletePipeline calls DELETE /api/pipelines/{id}. -func (c *Client) DeletePipeline(id string) (json.RawMessage, error) { - body, code, err := c.callJSON("DELETE", "/api/pipelines/"+url.PathEscape(id), nil, nil) - if err != nil { - return nil, err - } - if code != http.StatusNoContent { - return nil, fmt.Errorf("DELETE /api/pipelines/%s returned %d: %s", id, code, truncate(body, 500)) - } - return json.RawMessage(`{"status":"deleted"}`), nil +func (c *Client) PreflightOperation(payload interface{}) (json.RawMessage, error) { + return c.request("POST", "/api/v1/ops/preflight", payload, nil, nil, http.StatusOK) } -// DuplicatePipeline calls POST /api/pipelines/{id}/duplicate. -func (c *Client) DuplicatePipeline(id string) (json.RawMessage, error) { - body, code, err := c.postJSON("/api/pipelines/"+url.PathEscape(id)+"/duplicate", map[string]interface{}{}) - if err != nil { - return nil, err - } - if code != http.StatusCreated { - return nil, fmt.Errorf("POST /api/pipelines/%s/duplicate returned %d: %s", id, code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) RunOperation(operation string, payload interface{}) (json.RawMessage, error) { + return c.request("POST", "/api/v1/ops/"+url.PathEscape(operation), payload, nil, nil, http.StatusOK) } -// ExecutePipeline calls POST /api/pipelines/{id}/execute. -func (c *Client) ExecutePipeline(id string) (json.RawMessage, error) { - body, code, err := c.postJSON("/api/pipelines/"+url.PathEscape(id)+"/execute", map[string]interface{}{}) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("POST /api/pipelines/%s/execute returned %d: %s", id, code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) SubmitOperationJob(operation string, payload interface{}) (json.RawMessage, error) { + path := "/api/v1/ops/by-operation/" + url.PathEscape(operation) + "/jobs" + return c.request("POST", path, payload, nil, nil, http.StatusOK, http.StatusAccepted) } -// ConvertFormat calls POST /api/convert. -func (c *Client) ConvertFormat(payload interface{}) (json.RawMessage, error) { - body, code, err := c.postJSON("/api/convert", payload) - if err != nil { - return nil, err - } - if code != http.StatusOK && code != http.StatusCreated { - return nil, fmt.Errorf("POST /api/convert returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) SubmitOperationBatch(payload interface{}) (json.RawMessage, error) { + return c.request("POST", "/api/v1/ops/jobs/batch", payload, nil, nil, http.StatusOK, http.StatusAccepted) } -// DiffDatasets calls POST /api/diff. -func (c *Client) DiffDatasets(payload interface{}) (json.RawMessage, error) { - body, code, err := c.postJSON("/api/diff", payload) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("POST /api/diff returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) ListOperationJobs(params map[string]string) (json.RawMessage, error) { + return c.request("GET", "/api/v1/ops/jobs", nil, params, nil, http.StatusOK) } -// ExecuteSQL calls POST /api/query/sql. -func (c *Client) ExecuteSQL(query string) (json.RawMessage, error) { - body, code, err := c.postJSON("/api/query/sql", map[string]string{"sql": query}) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("POST SQL query endpoint returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) GetOperationJob(id string) (json.RawMessage, error) { + return c.request("GET", "/api/v1/ops/jobs/"+url.PathEscape(id), nil, nil, nil, http.StatusOK) } -// ListSpatialTables calls GET /api/query/sql/datasets. -func (c *Client) ListSpatialTables() (json.RawMessage, error) { - body, code, err := c.get("/api/query/sql/datasets", nil) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET SQL tables endpoint returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) CancelOperationJob(id string) (json.RawMessage, error) { + return c.request("DELETE", "/api/v1/ops/jobs/"+url.PathEscape(id), nil, nil, nil, http.StatusNoContent) } -// GetDuckDBInfo calls GET /api/query/sql/info. -func (c *Client) GetDuckDBInfo() (json.RawMessage, error) { - body, code, err := c.get("/api/query/sql/info", nil) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /api/query/sql/info returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) RerunOperationJob(id string) (json.RawMessage, error) { + return c.request("POST", "/api/v1/ops/jobs/"+url.PathEscape(id)+"/rerun", map[string]interface{}{}, nil, nil, http.StatusOK, http.StatusAccepted) } -// ListDuckDBDatasets calls GET /api/query/sql/datasets. -func (c *Client) ListDuckDBDatasets() (json.RawMessage, error) { - body, code, err := c.get("/api/query/sql/datasets", nil) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /api/query/sql/datasets returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) ListPipelineOperations() (json.RawMessage, error) { + return c.request("GET", "/api/v1/pipeline/operations", nil, nil, nil, http.StatusOK) } -// Geocode calls GET /api/geocode. -func (c *Client) Geocode(address string) (json.RawMessage, error) { - body, code, err := c.get("/api/geocode", url.Values{"q": {address}}) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /api/geocode returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) RunPipeline(payload interface{}) (json.RawMessage, error) { + return c.request("POST", "/api/v1/pipeline", payload, nil, nil, http.StatusOK) } -// ReverseGeocode calls GET /api/geocode/reverse. -func (c *Client) ReverseGeocode(lat, lon string) (json.RawMessage, error) { - body, code, err := c.get("/api/geocode/reverse", url.Values{"lat": {lat}, "lon": {lon}}) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /api/geocode/reverse returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) ListPipelines() (json.RawMessage, error) { + return c.request("GET", "/api/v1/pipelines", nil, nil, nil, http.StatusOK) } -// ComputeRoute calls POST /api/route. -func (c *Client) ComputeRoute(payload interface{}) (json.RawMessage, error) { - body, code, err := c.postJSON("/api/route", payload) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("POST /api/route returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) GetPipeline(id string) (json.RawMessage, error) { + return c.request("GET", "/api/v1/pipelines/"+url.PathEscape(id), nil, nil, nil, http.StatusOK) } -// ComputeIsochrone calls POST /api/route/isochrone. -func (c *Client) ComputeIsochrone(payload interface{}) (json.RawMessage, error) { - body, code, err := c.postJSON("/api/route/isochrone", payload) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("POST /api/route/isochrone returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) CreatePipeline(payload interface{}) (json.RawMessage, error) { + return c.request("POST", "/api/v1/pipelines", payload, nil, nil, http.StatusOK, http.StatusCreated) } -// ComputeRouteMatrix calls POST /api/route/matrix. -func (c *Client) ComputeRouteMatrix(payload interface{}) (json.RawMessage, error) { - body, code, err := c.postJSON("/api/route/matrix", payload) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("POST /api/route/matrix returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) UpdatePipeline(id string, payload interface{}) (json.RawMessage, error) { + return c.request("PUT", "/api/v1/pipelines/"+url.PathEscape(id), payload, nil, nil, http.StatusOK) } -// ComputeServiceArea calls POST /api/route/service-area. -func (c *Client) ComputeServiceArea(payload interface{}) (json.RawMessage, error) { - body, code, err := c.postJSON("/api/route/service-area", payload) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("POST /api/route/service-area returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) DeletePipeline(id string) (json.RawMessage, error) { + return c.request("DELETE", "/api/v1/pipelines/"+url.PathEscape(id), nil, nil, nil, http.StatusNoContent) } -// ListOperations calls GET /api/operations. -func (c *Client) ListOperations() (json.RawMessage, error) { - body, code, err := c.get("/api/operations", nil) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /api/operations returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) DuplicatePipeline(id string) (json.RawMessage, error) { + return c.request("POST", "/api/v1/pipelines/"+url.PathEscape(id)+"/duplicate", map[string]interface{}{}, nil, nil, http.StatusOK, http.StatusCreated) } -// ListAnalysisOperations calls GET /api/analysis/operations. -func (c *Client) ListAnalysisOperations() (json.RawMessage, error) { - body, code, err := c.get("/api/analysis/operations", nil) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /api/analysis/operations returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) ExecutePipeline(id string) (json.RawMessage, error) { + return c.request("POST", "/api/v1/pipelines/"+url.PathEscape(id)+"/execute", map[string]interface{}{}, nil, nil, http.StatusOK) } -// GetDatasetSchema calls GET /api/datasets/{name}/schema. -func (c *Client) GetDatasetSchema(name string) (json.RawMessage, error) { - body, code, err := c.get("/api/datasets/"+url.PathEscape(name)+"/schema", nil) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /api/datasets/%s/schema returned %d: %s", name, code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) ListPipelineRuns(id string) (json.RawMessage, error) { + return c.request("GET", "/api/v1/pipelines/"+url.PathEscape(id)+"/runs", nil, nil, nil, http.StatusOK) } -// GetDatasetProfile calls GET /api/datasets/{name}/profile. -func (c *Client) GetDatasetProfile(name string) (json.RawMessage, error) { - body, code, err := c.get("/api/datasets/"+url.PathEscape(name)+"/profile", nil) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /api/datasets/%s/profile returned %d: %s", name, code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) GetPipelineRun(id string) (json.RawMessage, error) { + return c.request("GET", "/api/v1/pipeline-runs/"+url.PathEscape(id), nil, nil, nil, http.StatusOK) } -// BrowseCatalog calls GET /api/catalog. -func (c *Client) BrowseCatalog(params map[string]string) (json.RawMessage, error) { - q := url.Values{} - for k, v := range params { - if v != "" { - q.Set(k, v) - } - } - body, code, err := c.get("/api/catalog", q) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /api/catalog returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) ListQueryEngines() (json.RawMessage, error) { + return c.request("GET", "/api/v1/query/engines", nil, nil, nil, http.StatusOK) } -// BrowseEnhancedCatalog calls GET /api/catalog/enhanced. -func (c *Client) BrowseEnhancedCatalog(params map[string]string) (json.RawMessage, error) { - q := url.Values{} - for k, v := range params { - if v != "" { - q.Set(k, v) - } - } - body, code, err := c.get("/api/catalog/enhanced", q) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /api/catalog/enhanced returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) GetQueryEngineInfo(engine string) (json.RawMessage, error) { + return c.request("GET", "/api/v1/query/sql/info", nil, map[string]string{"engine": engine}, nil, http.StatusOK) } -// GetCatalogEntry calls GET /api/catalog/enhanced/{id}. -func (c *Client) GetCatalogEntry(id string) (json.RawMessage, error) { - body, code, err := c.get("/api/catalog/enhanced/"+url.PathEscape(id), nil) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /api/catalog/enhanced/%s returned %d: %s", id, code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) ListQueryDatasets(engine string) (json.RawMessage, error) { + return c.request("GET", "/api/v1/query/sql/datasets", nil, map[string]string{"engine": engine}, nil, http.StatusOK) } -// ListCatalogCategories calls GET /api/catalog/categories. -func (c *Client) ListCatalogCategories() (json.RawMessage, error) { - body, code, err := c.get("/api/catalog/categories", nil) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /api/catalog/categories returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) ExecuteSQL(engine string, payload interface{}) (json.RawMessage, error) { + return c.request("POST", "/api/v1/query/sql", payload, map[string]string{"engine": engine}, nil, http.StatusOK) } -// ListCatalogTags calls GET /api/catalog/tags. -func (c *Client) ListCatalogTags(limit string) (json.RawMessage, error) { - q := url.Values{} - if limit != "" { - q.Set("limit", limit) - } - body, code, err := c.get("/api/catalog/tags", q) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /api/catalog/tags returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) SaveSQLResult(engine string, payload interface{}) (json.RawMessage, error) { + return c.request("POST", "/api/v1/query/sql/save", payload, map[string]string{"engine": engine}, nil, http.StatusCreated) } -// ImportFromCatalog calls POST /api/catalog/import. -func (c *Client) ImportFromCatalog(catalogID, projectID string) (json.RawMessage, error) { - payload := map[string]interface{}{"catalog_id": catalogID} - if strings.TrimSpace(projectID) != "" { - payload["project_id"] = projectIDJSONValue(projectID) - } - body, code, err := c.postJSON("/api/catalog/import", payload) - if err != nil { - return nil, err - } - if code != http.StatusOK && code != http.StatusCreated && code != http.StatusAccepted { - return nil, fmt.Errorf("POST /api/catalog/import returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) ListProjects() (json.RawMessage, error) { + return c.request("GET", "/api/v1/projects", nil, nil, nil, http.StatusOK) } -// BrowseSTACCatalog calls GET /api/stac/remote. -func (c *Client) BrowseSTACCatalog(catalogURL string) (json.RawMessage, error) { - body, code, err := c.get("/api/stac/remote", url.Values{"url": {catalogURL}}) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /api/stac/remote returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) GetProject(id string) (json.RawMessage, error) { + return c.request("GET", "/api/v1/projects/"+url.PathEscape(id), nil, nil, nil, http.StatusOK) } -// BrowseSTACCollections calls GET /api/stac/remote/collections. -func (c *Client) BrowseSTACCollections(catalogURL string) (json.RawMessage, error) { - body, code, err := c.get("/api/stac/remote/collections", url.Values{"url": {catalogURL}}) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /api/stac/remote/collections returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) CreateProject(payload interface{}) (json.RawMessage, error) { + return c.request("POST", "/api/v1/projects", payload, nil, nil, http.StatusOK, http.StatusCreated) } -// BrowseSTACItems calls GET /api/stac/remote/items. -func (c *Client) BrowseSTACItems(collectionURL string, params map[string]string) (json.RawMessage, error) { - q := url.Values{"url": {collectionURL}} - for k, v := range params { - if v != "" { - q.Set(k, v) - } - } - body, code, err := c.get("/api/stac/remote/items", q) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /api/stac/remote/items returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) UpdateProject(id string, payload interface{}) (json.RawMessage, error) { + return c.request("PUT", "/api/v1/projects/"+url.PathEscape(id), payload, nil, nil, http.StatusOK) } -// ImportSTACAsset calls POST /api/stac/import. -func (c *Client) ImportSTACAsset(payload map[string]interface{}) (json.RawMessage, error) { - body, code, err := c.postJSON("/api/stac/import", payload) - if err != nil { - return nil, err - } - if code != http.StatusOK && code != http.StatusCreated && code != http.StatusAccepted { - return nil, fmt.Errorf("POST /api/stac/import returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil +func (c *Client) DeleteProject(id string) (json.RawMessage, error) { + return c.request("DELETE", "/api/v1/projects/"+url.PathEscape(id), nil, nil, nil, http.StatusNoContent) } -func buildSTACImportPayload(assetURL, name, format, projectID string, extras map[string]string) map[string]interface{} { - payload := map[string]interface{}{ - "asset_url": assetURL, - "name": name, - } - if format != "" { - payload["format"] = format - } - if strings.TrimSpace(projectID) != "" { - payload["project_id"] = projectIDJSONValue(projectID) - } - for key, value := range extras { - if strings.TrimSpace(value) != "" { - payload[key] = value - } - } - return payload +func (c *Client) GetProjectWorkspace(id string) (json.RawMessage, error) { + return c.request("GET", "/api/v1/projects/"+url.PathEscape(id)+"/workspace", nil, nil, nil, http.StatusOK) +} + +func (c *Client) SetProjectWorkspace(id string, payload interface{}) (json.RawMessage, error) { + return c.request("PUT", "/api/v1/projects/"+url.PathEscape(id)+"/workspace", payload, nil, nil, http.StatusOK) +} + +func (c *Client) PublishMap(payload interface{}) (json.RawMessage, error) { + return c.request("POST", "/api/v1/maps/publish", payload, nil, nil, http.StatusOK, http.StatusCreated) +} + +func (c *Client) ListPublishedMaps() (json.RawMessage, error) { + return c.request("GET", "/api/v1/maps/published", nil, nil, nil, http.StatusOK) +} + +func (c *Client) DeletePublishedMap(token string) (json.RawMessage, error) { + return c.request("DELETE", "/api/v1/maps/published/"+url.PathEscape(token), nil, nil, nil, http.StatusNoContent) +} + +func (c *Client) GetPublishedMapStats(token string) (json.RawMessage, error) { + return c.request("GET", "/api/v1/maps/published/"+url.PathEscape(token)+"/stats", nil, nil, nil, http.StatusOK) +} + +func (c *Client) UpdateMapEmbedConfig(token string, payload interface{}) (json.RawMessage, error) { + return c.request("PUT", "/api/v1/maps/published/"+url.PathEscape(token)+"/embed-config", payload, nil, nil, http.StatusOK) } func projectIDJSONValue(projectID string) interface{} { @@ -846,24 +416,6 @@ func projectIDJSONValue(projectID string) interface{} { return projectID } -// SearchSTAC calls GET /stac/search. -func (c *Client) SearchSTAC(params map[string]string) (json.RawMessage, error) { - q := url.Values{} - for k, v := range params { - if v != "" { - q.Set(k, v) - } - } - body, code, err := c.get("/stac/search", q) - if err != nil { - return nil, err - } - if code != http.StatusOK { - return nil, fmt.Errorf("GET /stac/search returned %d: %s", code, truncate(body, 500)) - } - return json.RawMessage(body), nil -} - func truncate(b []byte, n int) string { s := string(b) if len(s) > n { diff --git a/mcp/client_test.go b/mcp/client_test.go index 86f1fd9..8d5038d 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -1,59 +1,62 @@ package mcp import ( + "io" "net/http" "net/http/httptest" + "os" + "path/filepath" + "strings" "testing" ) -func TestListSpatialTablesPrefersCurrentDuckDBEndpoint(t *testing.T) { - tablesCalled := false - datasetsCalled := false +func TestListQueryDatasetsUsesEngineAwareEndpoint(t *testing.T) { + t.Helper() + var gotPath string + var gotEngine string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/query/sql/datasets": - datasetsCalled = true - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`[{"name":"roads"}]`)) - case "/api/sql/tables": - tablesCalled = true - http.Error(w, `{"error":"legacy route should not be called first"}`, http.StatusGone) - default: - http.NotFound(w, r) - } + gotPath = r.URL.Path + gotEngine = r.URL.Query().Get("engine") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"name":"roads"}]`)) })) defer srv.Close() client := NewClient(srv.URL, "") client.HTTPClient = srv.Client() - body, err := client.ListSpatialTables() + body, err := client.ListQueryDatasets("duckdb") if err != nil { - t.Fatalf("ListSpatialTables error: %v", err) + t.Fatalf("ListQueryDatasets error: %v", err) } - if !datasetsCalled { - t.Fatal("expected current /api/query/sql/datasets route to be used") + if gotPath != "/api/v1/query/sql/datasets" { + t.Fatalf("path = %q, want %q", gotPath, "/api/v1/query/sql/datasets") } - if tablesCalled { - t.Fatal("legacy /api/sql/tables route should not be called when the current endpoint succeeds") + if gotEngine != "duckdb" { + t.Fatalf("engine = %q, want %q", gotEngine, "duckdb") } if string(body) != `[{"name":"roads"}]` { t.Fatalf("unexpected body: %s", body) } } -func TestListSpatialTablesDoesNotFallbackToLegacyEndpoint(t *testing.T) { - tablesCalled := false +func TestExecuteSQLUsesCurrentEndpointOnly(t *testing.T) { + legacyCalled := false srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { - case "/api/query/sql/datasets": + case "/api/v1/query/sql": + if got := r.URL.Query().Get("engine"); got != "duckdb" { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"wrong engine"}`)) + return + } http.NotFound(w, r) - case "/api/sql/tables": - tablesCalled = true + case "/api/query/sql": + legacyCalled = true w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`[{"name":"roads"}]`)) + _, _ = w.Write([]byte(`{"rows":[{"value":1}]}`)) default: http.NotFound(w, r) } @@ -63,40 +66,139 @@ func TestListSpatialTablesDoesNotFallbackToLegacyEndpoint(t *testing.T) { client := NewClient(srv.URL, "") client.HTTPClient = srv.Client() - _, err := client.ListSpatialTables() + _, err := client.ExecuteSQL("duckdb", map[string]interface{}{"sql": "SELECT 1"}) if err == nil { - t.Fatal("expected ListSpatialTables to return an error when the current endpoint is unavailable") + t.Fatal("expected ExecuteSQL to fail when the current endpoint is unavailable") } - if tablesCalled { - t.Fatal("legacy /api/sql/tables route should not be called") + if legacyCalled { + t.Fatal("legacy /api/query/sql route should not be called") } } -func TestExecuteSQLDoesNotFallbackToLegacyEndpoint(t *testing.T) { - legacyCalled := false - +func TestDeleteFeatureSynthesizesOKResponseOnNoContent(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/query/sql": - http.NotFound(w, r) - case "/api/sql/query": - legacyCalled = true - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"rows":[{"value":1}]}`)) - default: + if r.Method != http.MethodDelete { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if r.URL.Path != "/collections/roads/items/f-1" { http.NotFound(w, r) + return } + w.WriteHeader(http.StatusNoContent) })) defer srv.Close() client := NewClient(srv.URL, "") client.HTTPClient = srv.Client() - _, err := client.ExecuteSQL("SELECT 1") - if err == nil { - t.Fatal("expected ExecuteSQL to return an error when the current endpoint is unavailable") + body, err := client.DeleteFeature("roads", "f-1") + if err != nil { + t.Fatalf("DeleteFeature error: %v", err) } - if legacyCalled { - t.Fatal("legacy /api/sql/query route should not be called") + if string(body) != `{"status":"ok"}` { + t.Fatalf("body = %q, want ok status JSON", body) + } +} + +func TestUploadFileIncludesProjectAndBodyID(t *testing.T) { + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "roads.geojson") + if err := os.WriteFile(filePath, []byte(`{"type":"FeatureCollection","features":[]}`), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + var gotProjectHeader string + var gotName string + var gotProjectID string + var gotBodyID string + var gotFileName string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotProjectHeader = r.Header.Get("X-Project-ID") + reader, err := r.MultipartReader() + if err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(err.Error())) + return + } + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(err.Error())) + return + } + data, _ := io.ReadAll(part) + switch part.FormName() { + case "name": + gotName = string(data) + case "project_id": + gotProjectID = string(data) + case "body_id": + gotBodyID = string(data) + case "file": + gotFileName = part.FileName() + } + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"roads"}`)) + })) + defer srv.Close() + + client := NewClient(srv.URL, "") + client.HTTPClient = srv.Client() + client.ProjectID = "42" + + body, err := client.UploadFile(filePath, "roads", "42", "mars") + if err != nil { + t.Fatalf("UploadFile error: %v", err) + } + if gotProjectHeader != "42" { + t.Fatalf("X-Project-ID = %q, want 42", gotProjectHeader) + } + if gotName != "roads" { + t.Fatalf("name = %q, want roads", gotName) + } + if gotProjectID != "42" { + t.Fatalf("project_id = %q, want 42", gotProjectID) + } + if gotBodyID != "mars" { + t.Fatalf("body_id = %q, want mars", gotBodyID) + } + if !strings.HasSuffix(gotFileName, "roads.geojson") { + t.Fatalf("file name = %q, want roads.geojson", gotFileName) + } + if string(body) != `{"name":"roads"}` { + t.Fatalf("unexpected body: %s", body) + } +} + +func TestUploadFileSendsMultipartContentType(t *testing.T) { + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "roads.geojson") + if err := os.WriteFile(filePath, []byte(`{"type":"FeatureCollection","features":[]}`), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + var contentType string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + contentType = r.Header.Get("Content-Type") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + client := NewClient(srv.URL, "") + client.HTTPClient = srv.Client() + + if _, err := client.UploadFile(filePath, "", "", ""); err != nil { + t.Fatalf("UploadFile error: %v", err) + } + if !strings.HasPrefix(contentType, "multipart/form-data;") { + t.Fatalf("content type = %q, want multipart form-data", contentType) } } diff --git a/mcp/handlers.go b/mcp/handlers.go index 3e5a73b..87026b1 100644 --- a/mcp/handlers.go +++ b/mcp/handlers.go @@ -1,9 +1,9 @@ package mcp import ( + "bytes" "encoding/json" "fmt" - "net/url" "strconv" "strings" ) @@ -32,36 +32,52 @@ func HandleToolCall(client *Client, name string, args json.RawMessage) (string, return handleListDatasets(client) case "get_dataset_info": return handleGetDatasetInfo(client, params) - case "get_dataset_schema": - return handleGetDatasetSchema(client, params) - case "get_dataset_profile": - return handleGetDatasetProfile(client, params) case "query_features": return handleQueryFeatures(client, params) case "get_feature": return handleGetFeature(client, params) + case "create_feature": + return handleCreateFeature(client, params) + case "update_feature": + return handleUpdateFeature(client, params) + case "delete_feature": + return handleDeleteFeature(client, params) case "upload_dataset": return handleUploadDataset(client, params) - case "run_process": - return handleRunProcess(client, params) - case "run_raster_process": - return handleRunRasterProcess(client, params) - case "preflight_process": - return handlePreflightProcess(client, params) - case "submit_process_job": - return handleSubmitProcessJob(client, params) - case "submit_process_batch": - return handleSubmitProcessBatch(client, params) - case "list_process_jobs": - return handleListProcessJobs(client, params) - case "get_process_job": - return handleGetProcessJob(client, params) - case "cancel_process_job": - return handleCancelProcessJob(client, params) - case "rerun_process_job": - return handleRerunProcessJob(client, params) - case "list_pipeline_templates": - return handleListPipelineTemplates(client) + case "import_source": + return handleImportSource(client, params) + case "get_scene_manifest": + return handleGetSceneManifest(client) + case "list_bodies": + return handleListBodies(client) + case "get_body": + return handleGetBody(client, params) + case "get_body_recipes": + return handleGetBodyRecipes(client, params) + case "execute_body_recipe": + return handleExecuteBodyRecipe(client, params) + case "list_operations": + return handleListOperations(client, params) + case "preflight_operation": + return handlePreflightOperation(client, params) + case "run_operation": + return handleRunOperation(client, params) + case "submit_operation_job": + return handleSubmitOperationJob(client, params) + case "submit_operation_batch": + return handleSubmitOperationBatch(client, params) + case "list_operation_jobs": + return handleListOperationJobs(client, params) + case "get_operation_job": + return handleGetOperationJob(client, params) + case "cancel_operation_job": + return handleCancelOperationJob(client, params) + case "rerun_operation_job": + return handleRerunOperationJob(client, params) + case "list_pipeline_operations": + return handleListPipelineOperations(client) + case "run_pipeline": + return handleRunPipeline(client, params) case "list_pipelines": return handleListPipelines(client) case "get_pipeline": @@ -76,102 +92,49 @@ func HandleToolCall(client *Client, name string, args json.RawMessage) (string, return handleDuplicatePipeline(client, params) case "execute_saved_pipeline": return handleExecuteSavedPipeline(client, params) - case "run_pipeline": - return handleRunPipeline(client, params) - case "convert_format": - return handleConvertFormat(client, params) - case "diff_datasets": - return handleDiffDatasets(client, params) + case "list_pipeline_runs": + return handleListPipelineRuns(client, params) + case "get_pipeline_run": + return handleGetPipelineRun(client, params) + case "list_query_engines": + return handleListQueryEngines(client) + case "get_query_engine_info": + return handleGetQueryEngineInfo(client, params) + case "list_query_datasets": + return handleListQueryDatasets(client, params) case "execute_sql": return handleExecuteSQL(client, params) - case "list_spatial_tables": - return handleListSpatialTables(client) - case "get_duckdb_info": - return handleGetDuckDBInfo(client) - case "list_duckdb_datasets": - return handleListDuckDBDatasets(client) - case "geocode": - return handleGeocode(client, params) - case "reverse_geocode": - return handleReverseGeocode(client, params) - case "compute_route": - return handleComputeRoute(client, params) - case "compute_isochrone": - return handleComputeIsochrone(client, params) - case "compute_route_matrix": - return handleComputeRouteMatrix(client, params) - case "compute_service_area": - return handleComputeServiceArea(client, params) - case "list_operations": - return handleListOperations(client) - case "list_analysis_operations": - return handleListAnalysisOperations(client) - case "browse_catalog": - return handleBrowseCatalog(client, params) - case "browse_catalog_enhanced": - return handleBrowseEnhancedCatalog(client, params) - case "get_catalog_entry": - return handleGetCatalogEntry(client, params) - case "list_catalog_categories": - return handleListCatalogCategories(client) - case "list_catalog_tags": - return handleListCatalogTags(client, params) - case "import_from_catalog": - return handleImportFromCatalog(client, params) - case "browse_stac_catalog": - return handleBrowseSTACCatalog(client, params) - case "browse_stac_collections": - return handleBrowseSTACCollections(client, params) - case "browse_stac_items": - return handleBrowseSTACItems(client, params) - case "import_stac_asset": - return handleImportSTACAsset(client, params) - case "search_stac": - return handleSearchSTAC(client, params) - case "map_api": - return handleMapAPI(client, params) + case "save_sql_result": + return handleSaveSQLResult(client, params) + case "list_projects": + return handleListProjects(client) + case "get_project": + return handleGetProject(client, params) + case "create_project": + return handleCreateProject(client, params) + case "update_project": + return handleUpdateProject(client, params) + case "delete_project": + return handleDeleteProject(client, params) + case "get_project_workspace": + return handleGetProjectWorkspace(client, params) + case "set_project_workspace": + return handleSetProjectWorkspace(client, params) + case "publish_map": + return handlePublishMap(client, params) + case "list_published_maps": + return handleListPublishedMaps(client) + case "delete_published_map": + return handleDeletePublishedMap(client, params) + case "get_published_map_stats": + return handleGetPublishedMapStats(client, params) + case "update_map_embed_config": + return handleUpdateMapEmbedConfig(client, params) default: return "", fmt.Errorf("unknown tool: %s", name) } } -type apiOperation struct { - Method string - Path string - Mutating bool -} - -var mapOperations = map[string]apiOperation{ - "publish_map": {Method: "POST", Path: "/api/maps/publish", Mutating: true}, - "list_published_maps": {Method: "GET", Path: "/api/maps/published"}, - "unpublish_map": {Method: "DELETE", Path: "/api/maps/published/{token}", Mutating: true}, - "get_published_map_stats": {Method: "GET", Path: "/api/maps/published/{token}/stats"}, - "update_map_embed_config": {Method: "PUT", Path: "/api/maps/published/{token}/embed-config", Mutating: true}, - "get_public_map": {Method: "GET", Path: "/public/maps/{token}"}, - "get_raster_info": {Method: "GET", Path: "/raster/{name}/info"}, - "get_raster_stats": {Method: "GET", Path: "/raster/{name}/stats"}, - "get_raster_histogram": {Method: "GET", Path: "/raster/{name}/histogram"}, - "get_raster_dimensions": {Method: "GET", Path: "/raster/{name}/dimensions"}, - "get_raster_values": {Method: "GET", Path: "/raster/{name}/values"}, - "raster_zonal_stats": {Method: "POST", Path: "/raster/{name}/zonal-stats"}, - "export_raster_band": {Method: "POST", Path: "/raster/{name}/export"}, - "raster_contour": {Method: "POST", Path: "/raster/{name}/contour"}, - "raster_viewshed": {Method: "POST", Path: "/raster/{name}/viewshed"}, - "raster_profile": {Method: "POST", Path: "/raster/{name}/profile"}, - "raster_kde": {Method: "POST", Path: "/api/raster/kde"}, - "raster_slope": {Method: "POST", Path: "/raster/{name}/slope"}, - "raster_aspect": {Method: "POST", Path: "/raster/{name}/aspect"}, - "geodesic_area": {Method: "POST", Path: "/api/geodesic/area"}, - "geodesic_length": {Method: "POST", Path: "/api/geodesic/length"}, - "classify_kmeans": {Method: "POST", Path: "/api/raster/classify/kmeans"}, - "classify_isodata": {Method: "POST", Path: "/api/raster/classify/isodata"}, - "classify_ml": {Method: "POST", Path: "/api/raster/classify/ml"}, - "classify_rf": {Method: "POST", Path: "/api/raster/classify/rf"}, - "create_feature": {Method: "POST", Path: "/collections/{collection_id}/items", Mutating: true}, - "update_feature": {Method: "PUT", Path: "/collections/{collection_id}/items/{feature_id}", Mutating: true}, - "delete_feature": {Method: "DELETE", Path: "/collections/{collection_id}/items/{feature_id}", Mutating: true}, -} - func handleListDatasets(client *Client) (string, error) { data, err := client.ListDatasets() if err != nil { @@ -181,78 +144,137 @@ func handleListDatasets(client *Client) (string, error) { } func handleGetDatasetInfo(client *Client, params map[string]interface{}) (string, error) { - id, err := requireString(params, "collection_id") + name, err := requireStringLike(params, "name") if err != nil { return "", err } - data, err := client.GetCollection(id) + collection, err := client.GetCollection(name) if err != nil { return "", err } - return formatJSON(data), nil + schema, err := client.GetDatasetSchema(name) + if err != nil { + return "", err + } + profile, err := client.GetDatasetProfile(name) + if err != nil { + return "", err + } + combined := map[string]interface{}{ + "collection": mustJSONObject(collection), + "schema": mustJSONObject(schema), + "profile": mustJSONObject(profile), + } + return formatJSON(combined), nil } -func handleGetDatasetSchema(client *Client, params map[string]interface{}) (string, error) { - name, err := requireString(params, "name") +func handleQueryFeatures(client *Client, params map[string]interface{}) (string, error) { + id, err := requireStringLike(params, "collection_id") if err != nil { return "", err } - data, err := client.GetDatasetSchema(name) + query := map[string]string{} + for _, key := range []string{"bbox", "filter", "datetime", "limit", "offset", "cursor"} { + if value, ok := params[key]; ok { + text, err := stringify(value) + if err != nil { + return "", fmt.Errorf("%s: %w", key, err) + } + if text != "" { + query[key] = text + } + } + } + if value, ok := params["bbox_crs"]; ok { + text, err := stringify(value) + if err != nil { + return "", fmt.Errorf("bbox_crs: %w", err) + } + if text != "" { + query["bbox-crs"] = text + } + } + if value, ok := params["crs"]; ok { + text, err := stringify(value) + if err != nil { + return "", fmt.Errorf("crs: %w", err) + } + if text != "" { + query["crs"] = text + } + } + if _, ok := query["limit"]; !ok { + query["limit"] = "10" + } + data, err := client.QueryFeatures(id, query) if err != nil { return "", err } return formatJSON(data), nil } -func handleGetDatasetProfile(client *Client, params map[string]interface{}) (string, error) { - name, err := requireString(params, "name") +func handleGetFeature(client *Client, params map[string]interface{}) (string, error) { + collectionID, err := requireStringLike(params, "collection_id") + if err != nil { + return "", err + } + featureID, err := requireStringLike(params, "feature_id") if err != nil { return "", err } - data, err := client.GetDatasetProfile(name) + data, err := client.GetFeature(collectionID, featureID) if err != nil { return "", err } return formatJSON(data), nil } -func handleQueryFeatures(client *Client, params map[string]interface{}) (string, error) { - id, err := requireString(params, "collection_id") +func handleCreateFeature(client *Client, params map[string]interface{}) (string, error) { + collectionID, err := requireStringLike(params, "collection_id") if err != nil { return "", err } - qp := stringLikeParams(params, "bbox", "filter", "datetime", "limit", "offset", "properties", "sortby") - if v, ok := params["bbox_crs"]; ok { - if s, err := stringify(v); err == nil && s != "" { - qp["bbox-crs"] = s - } + feature, err := requireObject(params, "feature") + if err != nil { + return "", err } - if v, ok := params["crs"]; ok { - if s, err := stringify(v); err == nil && s != "" { - qp["crs"] = s - } + data, err := client.CreateFeature(collectionID, feature) + if err != nil { + return "", err + } + return formatJSON(data), nil +} + +func handleUpdateFeature(client *Client, params map[string]interface{}) (string, error) { + collectionID, err := requireStringLike(params, "collection_id") + if err != nil { + return "", err + } + featureID, err := requireStringLike(params, "feature_id") + if err != nil { + return "", err } - // Default to a reasonable limit to avoid dumping huge responses. - if _, ok := qp["limit"]; !ok { - qp["limit"] = "10" + feature, err := requireObject(params, "feature") + if err != nil { + return "", err } - data, err := client.QueryFeatures(id, qp) + data, err := client.UpdateFeature(collectionID, featureID, feature) if err != nil { return "", err } return formatJSON(data), nil } -func handleGetFeature(client *Client, params map[string]interface{}) (string, error) { - collID, err := requireString(params, "collection_id") +func handleDeleteFeature(client *Client, params map[string]interface{}) (string, error) { + collectionID, err := requireStringLike(params, "collection_id") if err != nil { return "", err } - fid, err := requireString(params, "feature_id") + featureID, err := requireStringLike(params, "feature_id") if err != nil { return "", err } - data, err := client.GetFeature(collID, fid) + data, err := client.DeleteFeature(collectionID, featureID) if err != nil { return "", err } @@ -260,132 +282,244 @@ func handleGetFeature(client *Client, params map[string]interface{}) (string, er } func handleUploadDataset(client *Client, params map[string]interface{}) (string, error) { - path, err := requireString(params, "file_path") + filePath, err := requireStringLike(params, "file_path") if err != nil { return "", err } name, _ := optionalStringLike(params, "name") - data, err := client.UploadFile(path, name, client.ProjectID) + bodyID, _ := optionalStringLike(params, "body_id") + data, err := client.UploadFile(filePath, name, client.ProjectID, bodyID) + if err != nil { + return "", err + } + return formatJSON(data), nil +} + +func handleImportSource(client *Client, params map[string]interface{}) (string, error) { + payload, err := normalizeImportSourcePayload(params, client.ProjectID) + if err != nil { + return "", err + } + data, err := client.ImportSource(payload) + if err != nil { + return "", err + } + return formatJSON(data), nil +} + +func handleGetSceneManifest(client *Client) (string, error) { + data, err := client.GetSceneManifest() + if err != nil { + return "", err + } + return formatJSON(data), nil +} + +func handleListBodies(client *Client) (string, error) { + data, err := client.ListBodies() if err != nil { return "", err } return formatJSON(data), nil } -func handleRunProcess(client *Client, params map[string]interface{}) (string, error) { - normalizeProcessPayload(params, client.ProjectID) - data, err := client.RunProcess(params) +func handleGetBody(client *Client, params map[string]interface{}) (string, error) { + slug, err := requireStringLike(params, "slug") + if err != nil { + return "", err + } + data, err := client.GetBody(slug) if err != nil { return "", err } return formatJSON(data), nil } -func handleRunRasterProcess(client *Client, params map[string]interface{}) (string, error) { - normalizeRasterProcessPayload(params) - data, err := client.RunRasterProcess(params) +func handleGetBodyRecipes(client *Client, params map[string]interface{}) (string, error) { + slug, err := requireStringLike(params, "slug") + if err != nil { + return "", err + } + data, err := client.GetBodyRecipes(slug) if err != nil { return "", err } return formatJSON(data), nil } -func handlePreflightProcess(client *Client, params map[string]interface{}) (string, error) { - normalizeProcessPayload(params, client.ProjectID) - data, err := client.PreflightProcess(params) +func handleExecuteBodyRecipe(client *Client, params map[string]interface{}) (string, error) { + slug, err := requireStringLike(params, "slug") + if err != nil { + return "", err + } + sourceID, err := requireStringLike(params, "source_id") + if err != nil { + return "", err + } + data, err := client.ExecuteBodyRecipe(slug, sourceID) if err != nil { return "", err } return formatJSON(data), nil } -func handleSubmitProcessJob(client *Client, params map[string]interface{}) (string, error) { - normalizeProcessPayload(params, client.ProjectID) - data, err := client.SubmitProcessJob(params) +func handleListOperations(client *Client, params map[string]interface{}) (string, error) { + domain, _ := optionalStringLike(params, "domain") + data, err := client.ListOperations(domain) if err != nil { return "", err } return formatJSON(data), nil } -func handleSubmitProcessBatch(client *Client, params map[string]interface{}) (string, error) { - jobs, ok := params["jobs"].([]interface{}) - if !ok || len(jobs) == 0 { - return "", fmt.Errorf("parameter jobs must be a non-empty array") +func handlePreflightOperation(client *Client, params map[string]interface{}) (string, error) { + payload, operation, err := normalizeOperationPayload(params, client.ProjectID) + if err != nil { + return "", err + } + payload["operation"] = operation + data, err := client.PreflightOperation(payload) + if err != nil { + return "", err } - for i, item := range jobs { + return formatJSON(data), nil +} + +func handleRunOperation(client *Client, params map[string]interface{}) (string, error) { + payload, operation, err := normalizeOperationPayload(params, client.ProjectID) + if err != nil { + return "", err + } + data, err := client.RunOperation(operation, payload) + if err != nil { + return "", err + } + return formatJSON(data), nil +} + +func handleSubmitOperationJob(client *Client, params map[string]interface{}) (string, error) { + payload, operation, err := normalizeOperationPayload(params, client.ProjectID) + if err != nil { + return "", err + } + data, err := client.SubmitOperationJob(operation, payload) + if err != nil { + return "", err + } + return formatJSON(data), nil +} + +func handleSubmitOperationBatch(client *Client, params map[string]interface{}) (string, error) { + rawJobs, err := requireArray(params, "jobs") + if err != nil { + return "", err + } + jobs := make([]map[string]interface{}, 0, len(rawJobs)) + for i, item := range rawJobs { job, ok := item.(map[string]interface{}) if !ok { return "", fmt.Errorf("jobs[%d] must be an object", i) } - req, ok := job["request"].(map[string]interface{}) - if !ok { - return "", fmt.Errorf("jobs[%d].request must be an object", i) + normalized := map[string]interface{}{} + if clientID, err := optionalStringLike(job, "client_id"); err == nil && clientID != "" { + normalized["client_id"] = clientID + } + if dependsOn, ok := job["depends_on"]; ok { + normalized["depends_on"] = dependsOn + } + requestObject, err := requireObject(job, "request") + if err != nil { + return "", fmt.Errorf("jobs[%d]: %w", i, err) + } + payload, operation, err := normalizeOperationPayload(requestObject, client.ProjectID) + if err != nil { + return "", fmt.Errorf("jobs[%d]: %w", i, err) + } + normalized["request"] = map[string]interface{}{ + "operation": operation, + } + for key, value := range payload { + normalized["request"].(map[string]interface{})[key] = value } - normalizeProcessPayload(req, client.ProjectID) + jobs = append(jobs, normalized) } - data, err := client.SubmitProcessBatch(params) + data, err := client.SubmitOperationBatch(map[string]interface{}{"jobs": jobs}) if err != nil { return "", err } return formatJSON(data), nil } -func handleListProcessJobs(client *Client, params map[string]interface{}) (string, error) { - qp := map[string]string{} +func handleListOperationJobs(client *Client, params map[string]interface{}) (string, error) { + query := map[string]string{} for _, key := range []string{"status", "search", "limit", "offset"} { - if v, ok := params[key]; ok { - s, err := stringify(v) - if err == nil && s != "" { - qp[key] = s + if value, ok := params[key]; ok { + text, err := stringify(value) + if err != nil { + return "", fmt.Errorf("%s: %w", key, err) + } + if text != "" { + query[key] = text } } } - data, err := client.ListProcessJobs(qp) + data, err := client.ListOperationJobs(query) if err != nil { return "", err } return formatJSON(data), nil } -func handleGetProcessJob(client *Client, params map[string]interface{}) (string, error) { - jobID, err := requireString(params, "job_id") +func handleGetOperationJob(client *Client, params map[string]interface{}) (string, error) { + jobID, err := requireStringLike(params, "job_id") if err != nil { return "", err } - data, err := client.GetProcessJob(jobID) + data, err := client.GetOperationJob(jobID) if err != nil { return "", err } return formatJSON(data), nil } -func handleCancelProcessJob(client *Client, params map[string]interface{}) (string, error) { - jobID, err := requireString(params, "job_id") +func handleCancelOperationJob(client *Client, params map[string]interface{}) (string, error) { + jobID, err := requireStringLike(params, "job_id") if err != nil { return "", err } - data, err := client.CancelProcessJob(jobID) + data, err := client.CancelOperationJob(jobID) if err != nil { return "", err } return formatJSON(data), nil } -func handleRerunProcessJob(client *Client, params map[string]interface{}) (string, error) { - jobID, err := requireString(params, "job_id") +func handleRerunOperationJob(client *Client, params map[string]interface{}) (string, error) { + jobID, err := requireStringLike(params, "job_id") + if err != nil { + return "", err + } + data, err := client.RerunOperationJob(jobID) if err != nil { return "", err } - data, err := client.RerunProcessJob(jobID) + return formatJSON(data), nil +} + +func handleListPipelineOperations(client *Client) (string, error) { + data, err := client.ListPipelineOperations() if err != nil { return "", err } return formatJSON(data), nil } -func handleListPipelineTemplates(client *Client) (string, error) { - data, err := client.ListPipelineTemplates() +func handleRunPipeline(client *Client, params map[string]interface{}) (string, error) { + payload, err := normalizePipelinePayload(params) + if err != nil { + return "", err + } + data, err := client.RunPipeline(payload) if err != nil { return "", err } @@ -401,7 +535,7 @@ func handleListPipelines(client *Client) (string, error) { } func handleGetPipeline(client *Client, params map[string]interface{}) (string, error) { - pipelineID, err := requireString(params, "pipeline_id") + pipelineID, err := requireStringLike(params, "pipeline_id") if err != nil { return "", err } @@ -413,15 +547,13 @@ func handleGetPipeline(client *Client, params map[string]interface{}) (string, e } func handleCreatePipeline(client *Client, params map[string]interface{}) (string, error) { - name, err := requireString(params, "name") + name, err := requireStringLike(params, "name") if err != nil { return "", err } - payload := map[string]interface{}{ - "name": name, - } - if desc, err := optionalStringLike(params, "description"); err == nil && desc != "" { - payload["description"] = desc + payload := map[string]interface{}{"name": name} + if description, err := optionalStringLike(params, "description"); err == nil && description != "" { + payload["description"] = description } if graph, ok := params["graph"]; ok { payload["graph"] = graph @@ -437,11 +569,11 @@ func handleCreatePipeline(client *Client, params map[string]interface{}) (string } func handleUpdatePipeline(client *Client, params map[string]interface{}) (string, error) { - pipelineID, err := requireString(params, "pipeline_id") + pipelineID, err := requireStringLike(params, "pipeline_id") if err != nil { return "", err } - name, err := requireString(params, "name") + name, err := requireStringLike(params, "name") if err != nil { return "", err } @@ -449,12 +581,9 @@ func handleUpdatePipeline(client *Client, params map[string]interface{}) (string if err != nil { return "", err } - payload := map[string]interface{}{ - "name": name, - "version": version, - } - if desc, err := optionalStringLike(params, "description"); err == nil && desc != "" { - payload["description"] = desc + payload := map[string]interface{}{"name": name, "version": version} + if description, err := optionalStringLike(params, "description"); err == nil && description != "" { + payload["description"] = description } if graph, ok := params["graph"]; ok { payload["graph"] = graph @@ -470,7 +599,7 @@ func handleUpdatePipeline(client *Client, params map[string]interface{}) (string } func handleDeletePipeline(client *Client, params map[string]interface{}) (string, error) { - pipelineID, err := requireString(params, "pipeline_id") + pipelineID, err := requireStringLike(params, "pipeline_id") if err != nil { return "", err } @@ -482,7 +611,7 @@ func handleDeletePipeline(client *Client, params map[string]interface{}) (string } func handleDuplicatePipeline(client *Client, params map[string]interface{}) (string, error) { - pipelineID, err := requireString(params, "pipeline_id") + pipelineID, err := requireStringLike(params, "pipeline_id") if err != nil { return "", err } @@ -494,7 +623,7 @@ func handleDuplicatePipeline(client *Client, params map[string]interface{}) (str } func handleExecuteSavedPipeline(client *Client, params map[string]interface{}) (string, error) { - pipelineID, err := requireString(params, "pipeline_id") + pipelineID, err := requireStringLike(params, "pipeline_id") if err != nil { return "", err } @@ -505,682 +634,509 @@ func handleExecuteSavedPipeline(client *Client, params map[string]interface{}) ( return formatJSON(data), nil } -func handleRunPipeline(client *Client, params map[string]interface{}) (string, error) { - if _, ok := params["output_name"]; !ok { - if out, ok := params["output"].(string); ok && out != "" { - params["output_name"] = out - } - } - if _, ok := params["input"]; !ok { - if steps, ok := params["steps"].([]interface{}); ok && len(steps) > 0 { - if step0, ok := steps[0].(map[string]interface{}); ok { - if in, ok := step0["input"].(string); ok && in != "" { - params["input"] = in - } - } - } +func handleListPipelineRuns(client *Client, params map[string]interface{}) (string, error) { + pipelineID, err := requireStringLike(params, "pipeline_id") + if err != nil { + return "", err } - ensureProjectID(params, client.ProjectID) - data, err := client.RunPipeline(params) + data, err := client.ListPipelineRuns(pipelineID) if err != nil { return "", err } return formatJSON(data), nil } -func handleConvertFormat(client *Client, params map[string]interface{}) (string, error) { - if _, ok := params["output_format"]; !ok { - if format, ok := params["format"].(string); ok && format != "" { - params["output_format"] = format - } - } - if _, ok := params["output_name"]; !ok { - if out, ok := params["output"].(string); ok && out != "" { - params["output_name"] = out - } +func handleGetPipelineRun(client *Client, params map[string]interface{}) (string, error) { + runID, err := requireStringLike(params, "run_id") + if err != nil { + return "", err } - ensureProjectID(params, client.ProjectID) - data, err := client.ConvertFormat(params) + data, err := client.GetPipelineRun(runID) if err != nil { return "", err } return formatJSON(data), nil } -func normalizeProcessPayload(params map[string]interface{}, projectID string) { - if _, ok := params["output_name"]; !ok { - if out, ok := params["output"].(string); ok && out != "" { - params["output_name"] = out - } - } - if _, ok := params["output_format"]; !ok { - if format, ok := params["format"].(string); ok && format != "" { - params["output_format"] = format - } - } - if _, ok := params["params"]; !ok || params["params"] == nil { - params["params"] = map[string]interface{}{} - } - ensureProjectID(params, projectID) -} - -func normalizeRasterProcessPayload(params map[string]interface{}) { - if _, ok := params["params"]; !ok || params["params"] == nil { - params["params"] = map[string]interface{}{} - } -} - -func handleDiffDatasets(client *Client, params map[string]interface{}) (string, error) { - if _, ok := params["left"]; !ok { - if base, ok := params["base"].(string); ok && base != "" { - params["left"] = base - } - } - if _, ok := params["right"]; !ok { - if cmp, ok := params["compare"].(string); ok && cmp != "" { - params["right"] = cmp - } - } - data, err := client.DiffDatasets(params) +func handleListQueryEngines(client *Client) (string, error) { + data, err := client.ListQueryEngines() if err != nil { return "", err } return formatJSON(data), nil } -func handleExecuteSQL(client *Client, params map[string]interface{}) (string, error) { - query, err := requireString(params, "query") +func handleGetQueryEngineInfo(client *Client, params map[string]interface{}) (string, error) { + engine, err := requireStringLike(params, "engine") if err != nil { return "", err } - data, err := client.ExecuteSQL(query) + data, err := client.GetQueryEngineInfo(engine) if err != nil { return "", err } return formatJSON(data), nil } -func handleListSpatialTables(client *Client) (string, error) { - data, err := client.ListSpatialTables() +func handleListQueryDatasets(client *Client, params map[string]interface{}) (string, error) { + engine, err := requireStringLike(params, "engine") if err != nil { return "", err } - return formatJSON(data), nil -} - -func handleGetDuckDBInfo(client *Client) (string, error) { - data, err := client.GetDuckDBInfo() + data, err := client.ListQueryDatasets(engine) if err != nil { return "", err } return formatJSON(data), nil } -func handleListDuckDBDatasets(client *Client) (string, error) { - data, err := client.ListDuckDBDatasets() +func handleExecuteSQL(client *Client, params map[string]interface{}) (string, error) { + engine, err := requireStringLike(params, "engine") if err != nil { return "", err } - return formatJSON(data), nil -} - -func handleGeocode(client *Client, params map[string]interface{}) (string, error) { - addr, err := requireString(params, "address") + payload, err := normalizeSQLExecutePayload(params) if err != nil { return "", err } - data, err := client.Geocode(addr) + data, err := client.ExecuteSQL(engine, payload) if err != nil { return "", err } return formatJSON(data), nil } -func handleReverseGeocode(client *Client, params map[string]interface{}) (string, error) { - lat, err := requireStringLike(params, "lat") +func handleSaveSQLResult(client *Client, params map[string]interface{}) (string, error) { + engine, err := requireStringLike(params, "engine") if err != nil { return "", err } - lon, err := requireStringLike(params, "lon") + payload, err := normalizeSQLSavePayload(params) if err != nil { return "", err } - data, err := client.ReverseGeocode(lat, lon) + ensureProjectID(payload, client.ProjectID) + data, err := client.SaveSQLResult(engine, payload) if err != nil { return "", err } return formatJSON(data), nil } -func handleComputeRoute(client *Client, params map[string]interface{}) (string, error) { - origin, hasOrigin := params["origin"] - destination, hasDestination := params["destination"] - if hasOrigin && hasDestination { - waypoints := make([][2]float64, 0, 2) - pt, err := parseRoutePoint(origin) - if err != nil { - return "", fmt.Errorf("origin: %w", err) - } - waypoints = append(waypoints, pt) - - if raw, ok := params["waypoints"].([]interface{}); ok { - for i, wp := range raw { - pt, err := parseRoutePoint(wp) - if err != nil { - return "", fmt.Errorf("waypoints[%d]: %w", i, err) - } - waypoints = append(waypoints, pt) - } - } - - pt, err = parseRoutePoint(destination) - if err != nil { - return "", fmt.Errorf("destination: %w", err) - } - waypoints = append(waypoints, pt) - params["waypoints"] = waypoints - } - data, err := client.ComputeRoute(params) +func handleListProjects(client *Client) (string, error) { + data, err := client.ListProjects() if err != nil { return "", err } return formatJSON(data), nil } -func handleComputeIsochrone(client *Client, params map[string]interface{}) (string, error) { - if origin, ok := params["origin"]; ok { - pt, err := parseRoutePoint(origin) - if err != nil { - return "", fmt.Errorf("origin: %w", err) - } - params["lng"] = pt[0] - params["lat"] = pt[1] - } - data, err := client.ComputeIsochrone(params) +func handleGetProject(client *Client, params map[string]interface{}) (string, error) { + projectID, err := requireStringLike(params, "project_id") if err != nil { return "", err } - return formatJSON(data), nil -} - -func handleComputeRouteMatrix(client *Client, params map[string]interface{}) (string, error) { - if raw, ok := params["origins"].([]interface{}); ok { - pts, err := parseRoutePoints(raw) - if err != nil { - return "", fmt.Errorf("origins: %w", err) - } - params["origins"] = pts - } - if raw, ok := params["destinations"].([]interface{}); ok { - pts, err := parseRoutePoints(raw) - if err != nil { - return "", fmt.Errorf("destinations: %w", err) - } - params["destinations"] = pts - } - data, err := client.ComputeRouteMatrix(params) + data, err := client.GetProject(projectID) if err != nil { return "", err } return formatJSON(data), nil } -func handleComputeServiceArea(client *Client, params map[string]interface{}) (string, error) { - if origin, ok := params["origin"]; ok { - pt, err := parseRoutePoint(origin) - if err != nil { - return "", fmt.Errorf("origin: %w", err) - } - params["lng"] = pt[0] - params["lat"] = pt[1] - } - data, err := client.ComputeServiceArea(params) +func handleCreateProject(client *Client, params map[string]interface{}) (string, error) { + name, err := requireStringLike(params, "name") if err != nil { return "", err } - return formatJSON(data), nil -} - -func handleListOperations(client *Client) (string, error) { - data, err := client.ListOperations() + payload := map[string]interface{}{"name": name} + if description, err := optionalStringLike(params, "description"); err == nil && description != "" { + payload["description"] = description + } + data, err := client.CreateProject(payload) if err != nil { return "", err } return formatJSON(data), nil } -func handleListAnalysisOperations(client *Client) (string, error) { - data, err := client.ListAnalysisOperations() +func handleUpdateProject(client *Client, params map[string]interface{}) (string, error) { + projectID, err := requireStringLike(params, "project_id") if err != nil { return "", err } - return formatJSON(data), nil -} - -// requireString extracts a required string parameter. -func requireString(params map[string]interface{}, key string) (string, error) { - v, ok := params[key] - if !ok { - return "", fmt.Errorf("missing required parameter: %s", key) - } - s, ok := v.(string) - if !ok { - return "", fmt.Errorf("parameter %s must be a string", key) - } - if s == "" { - return "", fmt.Errorf("parameter %s must not be empty", key) + payload := map[string]interface{}{} + if name, err := optionalStringLike(params, "name"); err == nil && name != "" { + payload["name"] = name } - return s, nil -} - -// requireStringLike extracts a required parameter as a string, accepting strings and numbers. -func requireStringLike(params map[string]interface{}, key string) (string, error) { - v, ok := params[key] - if !ok { - return "", fmt.Errorf("missing required parameter: %s", key) + if description, ok := params["description"]; ok { + payload["description"] = description } - s, err := stringify(v) + data, err := client.UpdateProject(projectID, payload) if err != nil { - return "", fmt.Errorf("parameter %s must be a string or number", key) - } - if s == "" { - return "", fmt.Errorf("parameter %s must not be empty", key) + return "", err } - return s, nil + return formatJSON(data), nil } -func requireIntLike(params map[string]interface{}, key string) (int, error) { - s, err := requireStringLike(params, key) +func handleDeleteProject(client *Client, params map[string]interface{}) (string, error) { + projectID, err := requireStringLike(params, "project_id") if err != nil { - return 0, err + return "", err } - value, err := strconv.Atoi(s) + data, err := client.DeleteProject(projectID) if err != nil { - return 0, fmt.Errorf("parameter %s must be an integer", key) + return "", err } - return value, nil + return formatJSON(data), nil } -func parseRoutePoint(v interface{}) ([2]float64, error) { - if arr, ok := v.([]interface{}); ok { - if len(arr) != 2 { - return [2]float64{}, fmt.Errorf("array form must have 2 numbers [lon,lat]") - } - lon, err := parseFloat64(arr[0]) - if err != nil { - return [2]float64{}, fmt.Errorf("invalid lon") - } - lat, err := parseFloat64(arr[1]) - if err != nil { - return [2]float64{}, fmt.Errorf("invalid lat") - } - return [2]float64{lon, lat}, nil - } - m, ok := v.(map[string]interface{}) - if !ok { - return [2]float64{}, fmt.Errorf("must be an object with lat/lon") - } - latRaw, ok := m["lat"] - if !ok { - return [2]float64{}, fmt.Errorf("missing lat") - } - lonRaw, ok := m["lon"] - if !ok { - return [2]float64{}, fmt.Errorf("missing lon") - } - lat, err := parseFloat64(latRaw) +func handleGetProjectWorkspace(client *Client, params map[string]interface{}) (string, error) { + projectID, err := requireStringLike(params, "project_id") if err != nil { - return [2]float64{}, fmt.Errorf("invalid lat") + return "", err } - lon, err := parseFloat64(lonRaw) + data, err := client.GetProjectWorkspace(projectID) if err != nil { - return [2]float64{}, fmt.Errorf("invalid lon") - } - return [2]float64{lon, lat}, nil -} - -func parseRoutePoints(values []interface{}) ([][2]float64, error) { - pts := make([][2]float64, 0, len(values)) - for i, v := range values { - pt, err := parseRoutePoint(v) - if err != nil { - return nil, fmt.Errorf("index %d: %w", i, err) - } - pts = append(pts, pt) + return "", err } - return pts, nil + return formatJSON(data), nil } -func parseFloat64(v interface{}) (float64, error) { - s, err := stringify(v) +func handleSetProjectWorkspace(client *Client, params map[string]interface{}) (string, error) { + projectID, err := requireStringLike(params, "project_id") if err != nil { - return 0, err - } - return strconv.ParseFloat(s, 64) -} - -func stringify(v interface{}) (string, error) { - if s, ok := v.(string); ok { - return s, nil - } - if f, ok := v.(float64); ok { - return strconv.FormatFloat(f, 'f', -1, 64), nil - } - if b, ok := v.(bool); ok { - if b { - return "true", nil - } - return "false", nil - } - return "", fmt.Errorf("unsupported type") -} - -func optionalStringLike(params map[string]interface{}, key string) (string, error) { - v, ok := params[key] - if !ok || v == nil { - return "", nil - } - return stringify(v) -} - -func optionalProjectID(params map[string]interface{}) (string, error) { - v, ok := params["project_id"] - if !ok || v == nil { - return "", nil - } - switch raw := v.(type) { - case string: - return raw, nil - case float64: - return strconv.FormatFloat(raw, 'f', -1, 64), nil - default: - return "", fmt.Errorf("parameter project_id must be a string or number") - } -} - -func ensureProjectID(params map[string]interface{}, projectID string) { - projectID = strings.TrimSpace(projectID) - if projectID == "" { - return + return "", err } - if _, ok := params["project_id"]; ok { - return + mapState, ok := params["map_state"] + if !ok { + return "", fmt.Errorf("missing required parameter: map_state") } - params["project_id"] = projectIDJSONValue(projectID) -} - -func stringLikeParams(params map[string]interface{}, keys ...string) map[string]string { - qp := map[string]string{} - for _, key := range keys { - v, ok := params[key] - if !ok { - continue - } - s, err := stringify(v) - if err == nil && s != "" { - qp[key] = s - } + payload := map[string]interface{}{"map_state": mapState} + if layerStyles, ok := params["layer_styles"]; ok { + payload["layer_styles"] = layerStyles } - return qp -} - -func handleBrowseCatalog(client *Client, params map[string]interface{}) (string, error) { - qp := stringLikeParams(params, "search", "category", "limit", "offset") - data, err := client.BrowseCatalog(qp) + data, err := client.SetProjectWorkspace(projectID, payload) if err != nil { return "", err } return formatJSON(data), nil } -func handleBrowseEnhancedCatalog(client *Client, params map[string]interface{}) (string, error) { - qp := map[string]string{} - for _, key := range []string{"search", "category", "formats", "tags", "live_only", "sort", "order", "bbox", "limit", "offset"} { - if v, ok := params[key]; ok { - s, err := stringify(v) - if err == nil && s != "" { - qp[key] = s - } +func handlePublishMap(client *Client, params map[string]interface{}) (string, error) { + mapState, ok := params["map_state"] + if !ok { + return "", fmt.Errorf("missing required parameter: map_state") + } + payload := map[string]interface{}{"map_state": mapState} + for _, key := range []string{"title", "description", "expires_hours", "embed_config"} { + if value, ok := params[key]; ok { + payload[key] = value } } - data, err := client.BrowseEnhancedCatalog(qp) + data, err := client.PublishMap(payload) if err != nil { return "", err } return formatJSON(data), nil } -func handleGetCatalogEntry(client *Client, params map[string]interface{}) (string, error) { - id, err := requireString(params, "id") - if err != nil { - return "", err - } - data, err := client.GetCatalogEntry(id) +func handleListPublishedMaps(client *Client) (string, error) { + data, err := client.ListPublishedMaps() if err != nil { return "", err } return formatJSON(data), nil } -func handleListCatalogCategories(client *Client) (string, error) { - data, err := client.ListCatalogCategories() +func handleDeletePublishedMap(client *Client, params map[string]interface{}) (string, error) { + token, err := requireStringLike(params, "token") if err != nil { return "", err } - return formatJSON(data), nil -} - -func handleListCatalogTags(client *Client, params map[string]interface{}) (string, error) { - limit := "" - if v, ok := params["limit"]; ok { - if s, err := stringify(v); err == nil { - limit = s - } - } - data, err := client.ListCatalogTags(limit) + data, err := client.DeletePublishedMap(token) if err != nil { return "", err } return formatJSON(data), nil } -func handleImportFromCatalog(client *Client, params map[string]interface{}) (string, error) { - catalogID, err := requireString(params, "catalog_id") +func handleGetPublishedMapStats(client *Client, params map[string]interface{}) (string, error) { + token, err := requireStringLike(params, "token") if err != nil { return "", err } - data, err := client.ImportFromCatalog(catalogID, client.ProjectID) + data, err := client.GetPublishedMapStats(token) if err != nil { return "", err } return formatJSON(data), nil } -func handleBrowseSTACCatalog(client *Client, params map[string]interface{}) (string, error) { - catalogURL, err := requireString(params, "url") +func handleUpdateMapEmbedConfig(client *Client, params map[string]interface{}) (string, error) { + token, err := requireStringLike(params, "token") if err != nil { return "", err } - data, err := client.BrowseSTACCatalog(catalogURL) + embedConfig, err := requireObject(params, "embed_config") if err != nil { return "", err } - return formatJSON(data), nil -} - -func handleBrowseSTACCollections(client *Client, params map[string]interface{}) (string, error) { - catalogURL, err := requireString(params, "url") - if err != nil { - return "", err - } - data, err := client.BrowseSTACCollections(catalogURL) + data, err := client.UpdateMapEmbedConfig(token, embedConfig) if err != nil { return "", err } return formatJSON(data), nil } -func handleBrowseSTACItems(client *Client, params map[string]interface{}) (string, error) { - collURL, err := requireString(params, "url") +func normalizeImportSourcePayload(params map[string]interface{}, projectID string) (map[string]interface{}, error) { + name, err := requireStringLike(params, "name") if err != nil { - return "", err + return nil, err } - qp := stringLikeParams(params, "bbox", "datetime") - data, err := client.BrowseSTACItems(collURL, qp) + source, err := requireStringLike(params, "source") if err != nil { - return "", err + return nil, err } - return formatJSON(data), nil + payload := map[string]interface{}{"name": name, "source": source} + for _, key := range []string{"format", "crs", "body_id", "source_type", "catalog_url", "collection"} { + if value, ok := params[key]; ok { + payload[key] = value + } + } + ensureProjectID(payload, projectID) + return payload, nil } -func handleImportSTACAsset(client *Client, params map[string]interface{}) (string, error) { - assetURL, err := requireString(params, "asset_url") +func normalizeOperationPayload(params map[string]interface{}, projectID string) (map[string]interface{}, string, error) { + operation, err := requireStringLike(params, "operation") if err != nil { - return "", err + return nil, "", err } - name, err := requireString(params, "name") - if err != nil { - return "", err + payload := map[string]interface{}{} + for _, key := range []string{"input", "input_geojson", "params", "output_name", "output_format", "register"} { + if value, ok := params[key]; ok { + payload[key] = value + } } - format, _ := params["format"].(string) - namespace, _ := optionalStringLike(params, "namespace") - collection, _ := optionalStringLike(params, "collection") - catalogURL, _ := optionalStringLike(params, "catalog_url") - payload := buildSTACImportPayload(assetURL, name, format, client.ProjectID, map[string]string{ - "namespace": namespace, - "collection": collection, - "catalog_url": catalogURL, - }) - data, err := client.ImportSTACAsset(payload) - if err != nil { - return "", err + if _, ok := payload["output_name"]; !ok { + if alias, err := optionalStringLike(params, "output"); err == nil && alias != "" { + payload["output_name"] = alias + } } - return formatJSON(data), nil + if _, ok := payload["output_format"]; !ok { + if alias, err := optionalStringLike(params, "format"); err == nil && alias != "" { + payload["output_format"] = alias + } + } + if _, ok := payload["params"]; !ok { + payload["params"] = map[string]interface{}{} + } + ensureProjectID(payload, projectID) + return payload, operation, nil } -func handleSearchSTAC(client *Client, params map[string]interface{}) (string, error) { - qp := stringLikeParams(params, "bbox", "datetime", "collections", "limit", "filter") - data, err := client.SearchSTAC(qp) +func normalizePipelinePayload(params map[string]interface{}) (map[string]interface{}, error) { + payload := map[string]interface{}{} + if input, err := optionalStringLike(params, "input"); err == nil && input != "" { + payload["input"] = input + } + if inputGeoJSON, ok := params["input_geojson"]; ok { + payload["input_geojson"] = inputGeoJSON + } + steps, err := requireArray(params, "steps") if err != nil { - return "", err + return nil, err } - return formatJSON(data), nil + payload["steps"] = steps + if _, ok := params["output_name"]; ok { + payload["output_name"] = params["output_name"] + } else if alias, err := optionalStringLike(params, "output"); err == nil && alias != "" { + payload["output_name"] = alias + } + if register, ok := params["register"]; ok { + payload["register"] = register + } + return payload, nil } -func handleMapAPI(client *Client, params map[string]interface{}) (string, error) { - return handleScopedAPI(client, params, mapOperations, "map") +func normalizeSQLExecutePayload(params map[string]interface{}) (map[string]interface{}, error) { + sqlText, _ := optionalStringLike(params, "sql") + if sqlText == "" { + alias, _ := optionalStringLike(params, "query") + sqlText = alias + } + if strings.TrimSpace(sqlText) == "" { + return nil, fmt.Errorf("missing required parameter: sql") + } + payload := map[string]interface{}{"sql": sqlText} + for _, key := range []string{"limit", "timeout_sec", "format", "query_options"} { + if value, ok := params[key]; ok { + payload[key] = value + } + } + return payload, nil } -func handleScopedAPI(client *Client, params map[string]interface{}, ops map[string]apiOperation, scope string) (string, error) { - opName, err := requireString(params, "operation") +func normalizeSQLSavePayload(params map[string]interface{}) (map[string]interface{}, error) { + payload, err := normalizeSQLExecutePayload(params) if err != nil { - return "", err + return nil, err } - op, ok := ops[opName] - if !ok { - return "", fmt.Errorf("unknown %s operation: %s", scope, opName) + outputName, err := requireStringLike(params, "output_name") + if err != nil { + return nil, err } - if op.Mutating && !requireConfirm(params) { - return "", fmt.Errorf("%s operation %q mutates data; pass confirm=true to proceed", scope, opName) + payload["output_name"] = outputName + if geometryColumn, err := optionalStringLike(params, "geometry_column"); err == nil && geometryColumn != "" { + payload["geometry_column"] = geometryColumn } - path, err := interpolatePath(op.Path, params) - if err != nil { - return "", err + return payload, nil +} + +func formatJSON(value interface{}) string { + var raw []byte + switch v := value.(type) { + case json.RawMessage: + raw = v + case []byte: + raw = v + default: + data, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + raw = data } - query, err := extractQuery(params) - if err != nil { - return "", err + if len(raw) == 0 { + return "{}" } - body := extractBody(params) - if op.Method == "GET" || op.Method == "DELETE" { - body = nil + var pretty bytes.Buffer + if err := json.Indent(&pretty, raw, "", " "); err == nil { + return pretty.String() } + return string(raw) +} - data, err := client.APIRequest(op.Method, path, body, query) - if err != nil { - return "", err +func mustJSONObject(raw json.RawMessage) interface{} { + var value interface{} + if err := json.Unmarshal(raw, &value); err != nil { + return string(raw) } - return formatJSON(data), nil + return value } -func requireConfirm(params map[string]interface{}) bool { - v, ok := params["confirm"] - if !ok { - return false +func optionalProjectID(params map[string]interface{}) (string, error) { + value, ok := params["project_id"] + if !ok || value == nil { + return "", nil } - b, ok := v.(bool) - return ok && b + return stringify(value) } -func extractBody(params map[string]interface{}) interface{} { - if raw, ok := params["body"]; ok { - if _, ok := raw.(map[string]interface{}); ok { - return raw - } - if _, ok := raw.([]interface{}); ok { - return raw - } +func ensureProjectID(payload map[string]interface{}, projectID string) { + if strings.TrimSpace(projectID) == "" { + return } - return nil + if _, exists := payload["project_id"]; exists { + return + } + payload["project_id"] = projectIDJSONValue(projectID) } -func extractQuery(params map[string]interface{}) (map[string]string, error) { - raw, ok := params["query"] - if !ok { - return nil, nil +func requireObject(params map[string]interface{}, key string) (map[string]interface{}, error) { + value, ok := params[key] + if !ok || value == nil { + return nil, fmt.Errorf("missing required parameter: %s", key) } - m, ok := raw.(map[string]interface{}) + object, ok := value.(map[string]interface{}) if !ok { - return nil, fmt.Errorf("query must be an object of key/value pairs") - } - out := make(map[string]string, len(m)) - for k, v := range m { - if k == "" || v == nil { - continue - } - s, err := stringify(v) - if err != nil { - return nil, fmt.Errorf("query.%s must be string/number/bool", k) - } - out[k] = s + return nil, fmt.Errorf("parameter %s must be an object", key) } - return out, nil + return object, nil } -func interpolatePath(tpl string, params map[string]interface{}) (string, error) { - out := tpl - for { - start := strings.IndexByte(out, '{') - if start == -1 { - return out, nil - } - end := strings.IndexByte(out[start:], '}') - if end == -1 { - return "", fmt.Errorf("invalid operation path template: %s", tpl) - } - end += start - key := out[start+1 : end] - val, ok := params[key] - if !ok { - return "", fmt.Errorf("missing required path parameter: %s", key) - } - s, err := stringify(val) - if err != nil || strings.TrimSpace(s) == "" { - return "", fmt.Errorf("invalid path parameter %s", key) - } - out = out[:start] + url.PathEscape(s) + out[end+1:] +func requireArray(params map[string]interface{}, key string) ([]interface{}, error) { + value, ok := params[key] + if !ok || value == nil { + return nil, fmt.Errorf("missing required parameter: %s", key) } + array, ok := value.([]interface{}) + if !ok || len(array) == 0 { + return nil, fmt.Errorf("parameter %s must be a non-empty array", key) + } + return array, nil } -// formatJSON pretty-prints JSON for readability in agent responses. -func formatJSON(data json.RawMessage) string { - var out json.RawMessage - if err := json.Unmarshal(data, &out); err != nil { - return string(data) +func requireStringLike(params map[string]interface{}, key string) (string, error) { + value, ok := params[key] + if !ok || value == nil { + return "", fmt.Errorf("missing required parameter: %s", key) } - pretty, err := json.MarshalIndent(out, "", " ") + text, err := stringify(value) if err != nil { - return string(data) + return "", fmt.Errorf("parameter %s: %w", key, err) + } + if strings.TrimSpace(text) == "" { + return "", fmt.Errorf("parameter %s must not be empty", key) + } + return text, nil +} + +func optionalStringLike(params map[string]interface{}, key string) (string, error) { + value, ok := params[key] + if !ok || value == nil { + return "", nil + } + return stringify(value) +} + +func requireIntLike(params map[string]interface{}, key string) (int64, error) { + value, ok := params[key] + if !ok || value == nil { + return 0, fmt.Errorf("missing required parameter: %s", key) + } + switch v := value.(type) { + case float64: + return int64(v), nil + case int: + return int64(v), nil + case int64: + return v, nil + case json.Number: + return v.Int64() + case string: + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return 0, fmt.Errorf("parameter %s must be an integer", key) + } + return n, nil + default: + return 0, fmt.Errorf("parameter %s must be an integer", key) + } +} + +func stringify(value interface{}) (string, error) { + switch v := value.(type) { + case string: + return strings.TrimSpace(v), nil + case float64: + return strconv.FormatInt(int64(v), 10), nil + case float32: + return strconv.FormatFloat(float64(v), 'f', -1, 32), nil + case int: + return strconv.Itoa(v), nil + case int64: + return strconv.FormatInt(v, 10), nil + case int32: + return strconv.FormatInt(int64(v), 10), nil + case json.Number: + return v.String(), nil + case bool: + if v { + return "true", nil + } + return "false", nil + default: + return "", fmt.Errorf("expected string-like value") } - return string(pretty) } diff --git a/mcp/handlers_test.go b/mcp/handlers_test.go index 9c0b4b1..9245ed1 100644 --- a/mcp/handlers_test.go +++ b/mcp/handlers_test.go @@ -1,341 +1,90 @@ package mcp import ( - "bytes" "encoding/json" - "fmt" - "net/http" - "reflect" "strings" "testing" ) -func TestRunWithIOParseError(t *testing.T) { - srv := testServer(t, http.NotFoundHandler()) - - var out bytes.Buffer - if err := srv.RunWithIO(strings.NewReader("{not-json}\n"), &out); err != nil { - t.Fatalf("RunWithIO error: %v", err) - } - - var resp jsonRPCResponse - if err := json.Unmarshal(out.Bytes(), &resp); err != nil { - t.Fatalf("unmarshal response error: %v\nraw: %s", err, out.String()) - } - if resp.Error == nil { - t.Fatal("expected parse error response") - } - if resp.Error.Code != -32700 { - t.Fatalf("error code = %d, want -32700", resp.Error.Code) - } -} - -func TestRunWithIOInitializedNotificationHasNoResponse(t *testing.T) { - srv := testServer(t, http.NotFoundHandler()) - req := `{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}` + "\n" - - var out bytes.Buffer - if err := srv.RunWithIO(strings.NewReader(req), &out); err != nil { - t.Fatalf("RunWithIO error: %v", err) - } - if out.Len() != 0 { - t.Fatalf("expected no response, got %q", out.String()) - } -} - -func TestToolsCallQueryFeaturesAcceptsNumericPagination(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("GET /collections/{id}/items", func(w http.ResponseWriter, r *http.Request) { - if got := r.URL.Query().Get("limit"); got != "5" { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(w, `{"error":"limit=%s"}`, got) - return - } - if got := r.URL.Query().Get("offset"); got != "2" { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(w, `{"error":"offset=%s"}`, got) - return - } - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"type":"FeatureCollection","features":[]}`) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 101, map[string]interface{}{ - "name": "query_features", - "arguments": map[string]interface{}{ - "collection_id": "buildings", - "limit": 5.0, - "offset": 2.0, - }, - }) - - if resp.Error != nil { - t.Fatalf("unexpected JSON-RPC error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) - if isErr, _ := result["isError"].(bool); isErr { - t.Fatalf("expected success result, got error: %+v", result) - } -} - -func TestToolsCallBrowseCatalogAcceptsNumericPagination(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("GET /api/catalog", func(w http.ResponseWriter, r *http.Request) { - if got := r.URL.Query().Get("limit"); got != "25" { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(w, `{"error":"limit=%s"}`, got) - return - } - if got := r.URL.Query().Get("offset"); got != "10" { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(w, `{"error":"offset=%s"}`, got) - return - } - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `[{"id":"dataset-1"}]`) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 102, map[string]interface{}{ - "name": "browse_catalog", - "arguments": map[string]interface{}{ - "search": "roads", - "limit": 25.0, - "offset": 10.0, - }, - }) - - if resp.Error != nil { - t.Fatalf("unexpected JSON-RPC error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) - if isErr, _ := result["isError"].(bool); isErr { - t.Fatalf("expected success result, got error: %+v", result) - } -} - -func TestToolsCallSearchSTACAcceptsNumericLimit(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("GET /stac/search", func(w http.ResponseWriter, r *http.Request) { - if got := r.URL.Query().Get("limit"); got != "3" { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(w, `{"error":"limit=%s"}`, got) - return - } - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"features":[]}`) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 103, map[string]interface{}{ - "name": "search_stac", - "arguments": map[string]interface{}{ - "collections": "imagery", - "limit": 3.0, - }, - }) - - if resp.Error != nil { - t.Fatalf("unexpected JSON-RPC error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) - if isErr, _ := result["isError"].(bool); isErr { - t.Fatalf("expected success result, got error: %+v", result) +func TestNormalizeImportSourcePayload(t *testing.T) { + payload, err := normalizeImportSourcePayload(map[string]interface{}{ + "name": "mola-dem", + "source": "https://example.test/mola.tif", + "format": "tif", + "body_id": "mars", + "source_type": "remote_url", + }, "42") + if err != nil { + t.Fatalf("normalizeImportSourcePayload error: %v", err) } -} - -func TestToolsCallMapAPIMutationRequiresConfirm(t *testing.T) { - srv := testServer(t, http.NotFoundHandler()) - - resp := sendRequest(t, srv, "tools/call", 104, map[string]interface{}{ - "name": "map_api", - "arguments": map[string]interface{}{ - "operation": "publish_map", - "body": map[string]interface{}{"map_id": "abc"}, - }, - }) - if resp.Error != nil { - t.Fatalf("unexpected JSON-RPC error: %v", resp.Error) + if got := payload["name"]; got != "mola-dem" { + t.Fatalf("name = %v, want mola-dem", got) } - result, _ := resp.Result.(map[string]interface{}) - if isErr, _ := result["isError"].(bool); !isErr { - t.Fatalf("expected error result, got: %+v", result) + if got := payload["source"]; got != "https://example.test/mola.tif" { + t.Fatalf("source = %v, want remote URL", got) } - content, _ := result["content"].([]interface{}) - first, _ := content[0].(map[string]interface{}) - text, _ := first["text"].(string) - if !strings.Contains(text, "confirm=true") { - t.Fatalf("error text = %q, want confirm hint", text) + if got := payload["body_id"]; got != "mars" { + t.Fatalf("body_id = %v, want mars", got) } -} - -func TestToolsCallMapAPIRejectsInvalidQueryObject(t *testing.T) { - srv := testServer(t, http.NotFoundHandler()) - - resp := sendRequest(t, srv, "tools/call", 105, map[string]interface{}{ - "name": "map_api", - "arguments": map[string]interface{}{ - "operation": "get_raster_values", - "name": "elevation", - "query": "x=1", - }, - }) - - if resp.Error != nil { - t.Fatalf("unexpected JSON-RPC error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) - if isErr, _ := result["isError"].(bool); !isErr { - t.Fatalf("expected error result, got: %+v", result) - } - content, _ := result["content"].([]interface{}) - first, _ := content[0].(map[string]interface{}) - text, _ := first["text"].(string) - if !strings.Contains(text, "query must be an object") { - t.Fatalf("error text = %q, want invalid query message", text) + if got := payload["project_id"]; got != int64(42) { + t.Fatalf("project_id = %v, want 42", got) } } -func TestToolsCallMapAPIMissingPathParameter(t *testing.T) { - srv := testServer(t, http.NotFoundHandler()) - - resp := sendRequest(t, srv, "tools/call", 106, map[string]interface{}{ - "name": "map_api", - "arguments": map[string]interface{}{ - "operation": "get_public_map", - }, - }) - - if resp.Error != nil { - t.Fatalf("unexpected JSON-RPC error: %v", resp.Error) +func TestNormalizeOperationPayloadMapsCompatibilityAliases(t *testing.T) { + payload, operation, err := normalizeOperationPayload(map[string]interface{}{ + "operation": "buffer", + "input": "roads", + "output": "roads_buffered", + "format": "parquet", + }, "7") + if err != nil { + t.Fatalf("normalizeOperationPayload error: %v", err) } - result, _ := resp.Result.(map[string]interface{}) - if isErr, _ := result["isError"].(bool); !isErr { - t.Fatalf("expected error result, got: %+v", result) + if operation != "buffer" { + t.Fatalf("operation = %q, want buffer", operation) } - content, _ := result["content"].([]interface{}) - first, _ := content[0].(map[string]interface{}) - text, _ := first["text"].(string) - if !strings.Contains(text, "missing required path parameter: token") { - t.Fatalf("error text = %q, want missing path parameter", text) + if got := payload["output_name"]; got != "roads_buffered" { + t.Fatalf("output_name = %v, want roads_buffered", got) } -} - -func TestParseRoutePoint(t *testing.T) { - t.Run("object", func(t *testing.T) { - got, err := parseRoutePoint(map[string]interface{}{"lat": 39.1, "lon": -86.2}) - if err != nil { - t.Fatalf("parseRoutePoint returned error: %v", err) - } - want := [2]float64{-86.2, 39.1} - if got != want { - t.Fatalf("point = %#v, want %#v", got, want) - } - }) - - t.Run("array", func(t *testing.T) { - got, err := parseRoutePoint([]interface{}{-86.2, 39.1}) - if err != nil { - t.Fatalf("parseRoutePoint returned error: %v", err) - } - want := [2]float64{-86.2, 39.1} - if got != want { - t.Fatalf("point = %#v, want %#v", got, want) - } - }) - - t.Run("error", func(t *testing.T) { - _, err := parseRoutePoint(map[string]interface{}{"lon": -86.2}) - if err == nil || !strings.Contains(err.Error(), "missing lat") { - t.Fatalf("error = %v, want missing lat", err) - } - }) -} - -func TestParseRoutePoints(t *testing.T) { - got, err := parseRoutePoints([]interface{}{ - map[string]interface{}{"lat": 39.0, "lon": -86.0}, - []interface{}{-86.1, 39.1}, - }) - if err != nil { - t.Fatalf("parseRoutePoints returned error: %v", err) + if got := payload["output_format"]; got != "parquet" { + t.Fatalf("output_format = %v, want parquet", got) } - want := [][2]float64{{-86.0, 39.0}, {-86.1, 39.1}} - if !reflect.DeepEqual(got, want) { - t.Fatalf("points = %#v, want %#v", got, want) + if _, ok := payload["params"].(map[string]interface{}); !ok { + t.Fatalf("params = %#v, want initialized params object", payload["params"]) } - - _, err = parseRoutePoints([]interface{}{map[string]interface{}{"lat": 39.0}}) - if err == nil || !strings.Contains(err.Error(), "index 0") { - t.Fatalf("error = %v, want indexed parse error", err) + if got := payload["project_id"]; got != int64(7) { + t.Fatalf("project_id = %v, want 7", got) } } -func TestExtractQuery(t *testing.T) { - got, err := extractQuery(map[string]interface{}{ - "query": map[string]interface{}{ - "limit": 5.0, - "exact": true, - "dataset": "roads", - "empty": nil, - }, +func TestNormalizeSQLPayloads(t *testing.T) { + execPayload, err := normalizeSQLExecutePayload(map[string]interface{}{ + "query": "SELECT 1", + "limit": 5.0, + "timeout_sec": 10.0, }) if err != nil { - t.Fatalf("extractQuery returned error: %v", err) + t.Fatalf("normalizeSQLExecutePayload error: %v", err) } - want := map[string]string{ - "limit": "5", - "exact": "true", - "dataset": "roads", - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("query = %#v, want %#v", got, want) + if got := execPayload["sql"]; got != "SELECT 1" { + t.Fatalf("sql = %v, want SELECT 1", got) } - _, err = extractQuery(map[string]interface{}{"query": "bad"}) - if err == nil || !strings.Contains(err.Error(), "query must be an object") { - t.Fatalf("error = %v, want query object error", err) - } -} - -func TestInterpolatePath(t *testing.T) { - got, err := interpolatePath("/collections/{collection_id}/items/{feature_id}", map[string]interface{}{ - "collection_id": "roads/2024", - "feature_id": "abc 123", + savePayload, err := normalizeSQLSavePayload(map[string]interface{}{ + "sql": "SELECT * FROM roads", + "output_name": "roads_export", + "geometry_column": "geom", }) if err != nil { - t.Fatalf("interpolatePath returned error: %v", err) + t.Fatalf("normalizeSQLSavePayload error: %v", err) } - if got != "/collections/roads%2F2024/items/abc%20123" { - t.Fatalf("path = %q, want %q", got, "/collections/roads%2F2024/items/abc%20123") + if got := savePayload["output_name"]; got != "roads_export" { + t.Fatalf("output_name = %v, want roads_export", got) } - - _, err = interpolatePath("/collections/{collection_id}", map[string]interface{}{}) - if err == nil || !strings.Contains(err.Error(), "missing required path parameter: collection_id") { - t.Fatalf("error = %v, want missing path parameter", err) - } -} - -func TestNormalizeProcessPayload(t *testing.T) { - params := map[string]interface{}{ - "output": "buffered_roads", - "format": "parquet", - } - normalizeProcessPayload(params, "42") - - if got := params["output_name"]; got != "buffered_roads" { - t.Fatalf("output_name = %v, want buffered_roads", got) - } - if got := params["output_format"]; got != "parquet" { - t.Fatalf("output_format = %v, want parquet", got) - } - if _, ok := params["params"].(map[string]interface{}); !ok { - t.Fatalf("params = %#v, want initialized params object", params["params"]) - } - if got := params["project_id"]; got != int64(42) { - t.Fatalf("project_id = %v, want 42", got) + if got := savePayload["geometry_column"]; got != "geom" { + t.Fatalf("geometry_column = %v, want geom", got) } } @@ -372,27 +121,27 @@ func TestFormatJSON(t *testing.T) { } } -func TestHandleSubmitProcessBatchValidation(t *testing.T) { +func TestHandleSubmitOperationBatchValidation(t *testing.T) { client := NewClient("https://example.test", "") - _, err := handleSubmitProcessBatch(client, map[string]interface{}{}) - if err == nil || !strings.Contains(err.Error(), "parameter jobs must be a non-empty array") { - t.Fatalf("error = %v, want jobs array error", err) + _, err := handleSubmitOperationBatch(client, map[string]interface{}{}) + if err == nil || !strings.Contains(err.Error(), "missing required parameter: jobs") { + t.Fatalf("error = %v, want missing jobs error", err) } - _, err = handleSubmitProcessBatch(client, map[string]interface{}{ + _, err = handleSubmitOperationBatch(client, map[string]interface{}{ "jobs": []interface{}{"bad"}, }) if err == nil || !strings.Contains(err.Error(), "jobs[0] must be an object") { t.Fatalf("error = %v, want job object error", err) } - _, err = handleSubmitProcessBatch(client, map[string]interface{}{ + _, err = handleSubmitOperationBatch(client, map[string]interface{}{ "jobs": []interface{}{ map[string]interface{}{"request": "bad"}, }, }) - if err == nil || !strings.Contains(err.Error(), "jobs[0].request must be an object") { + if err == nil || !strings.Contains(err.Error(), "jobs[0]: parameter request must be an object") { t.Fatalf("error = %v, want request object error", err) } } diff --git a/mcp/server_test.go b/mcp/server_test.go index 7863180..3274d04 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -12,7 +12,6 @@ import ( "testing" ) -// testServer creates a test Roteiro API server and returns a connected MCP server. func testServer(t *testing.T, handler http.Handler) *Server { t.Helper() ts := httptest.NewServer(handler) @@ -68,7 +67,7 @@ func TestInitialize(t *testing.T) { } } -func TestToolsList(t *testing.T) { +func TestToolsListCurrentSurface(t *testing.T) { srv := testServer(t, http.NotFoundHandler()) resp := sendRequest(t, srv, "tools/list", 2, nil) @@ -84,151 +83,96 @@ func TestToolsList(t *testing.T) { t.Fatal("tools is not an array") } if len(tools) == 0 { - t.Error("tools list should not be empty") + t.Fatal("tools list should not be empty") } - // Verify expected tools are present. - toolNames := make(map[string]bool) - for _, t := range tools { - m, _ := t.(map[string]interface{}) + toolNames := make(map[string]bool, len(tools)) + for _, item := range tools { + m, _ := item.(map[string]interface{}) name, _ := m["name"].(string) toolNames[name] = true } + for _, want := range []string{ - "list_datasets", "get_dataset_info", "query_features", "get_feature", - "upload_dataset", "run_process", "run_raster_process", "preflight_process", "submit_process_job", - "submit_process_batch", "list_process_jobs", "get_process_job", - "cancel_process_job", "rerun_process_job", "list_pipeline_templates", "list_pipelines", - "get_pipeline", "create_pipeline", "update_pipeline", "delete_pipeline", - "duplicate_pipeline", "execute_saved_pipeline", "run_pipeline", "convert_format", - "diff_datasets", "execute_sql", "list_spatial_tables", "get_duckdb_info", - "list_duckdb_datasets", "geocode", "reverse_geocode", "compute_route", - "compute_isochrone", "compute_route_matrix", "compute_service_area", - "list_operations", "list_analysis_operations", "browse_catalog", "browse_catalog_enhanced", - "get_catalog_entry", "list_catalog_categories", "list_catalog_tags", "import_from_catalog", "browse_stac_catalog", - "browse_stac_collections", "browse_stac_items", "import_stac_asset", - "search_stac", + "list_datasets", + "import_source", + "get_scene_manifest", + "list_bodies", + "execute_body_recipe", + "list_operations", + "submit_operation_job", + "run_pipeline", + "list_query_engines", + "execute_sql", + "save_sql_result", + "list_projects", + "set_project_workspace", + "publish_map", + "update_map_embed_config", } { if !toolNames[want] { t.Errorf("missing tool: %s", want) } } -} - -func TestToolsCall_ListAnalysisOperations(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("GET /api/analysis/operations", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"operations":[{"id":"topology","name":"Topology Analysis"}]}`) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 22, map[string]interface{}{ - "name": "list_analysis_operations", - "arguments": map[string]interface{}{}, - }) - - if resp.Error != nil { - t.Fatalf("unexpected error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) - content, _ := result["content"].([]interface{}) - first, _ := content[0].(map[string]interface{}) - text, _ := first["text"].(string) - if !strings.Contains(text, "Topology Analysis") { - t.Errorf("response should contain operation name, got: %s", text) - } -} -func TestToolsCall_ListDatasets(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("GET /datasets", func(w http.ResponseWriter, r *http.Request) { - // Verify API key is passed. - if r.Header.Get("X-API-Key") != "test-key" { - w.WriteHeader(http.StatusUnauthorized) - return + for _, removed := range []string{ + "run_process", + "run_raster_process", + "convert_format", + "diff_datasets", + "browse_catalog", + "search_stac", + "map_api", + } { + if toolNames[removed] { + t.Errorf("legacy tool should have been removed: %s", removed) } - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `[{"name":"parks","format":"GeoJSON","feature_count":42}]`) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 3, map[string]interface{}{ - "name": "list_datasets", - "arguments": map[string]interface{}{}, - }) - - if resp.Error != nil { - t.Fatalf("unexpected error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) - content, _ := result["content"].([]interface{}) - if len(content) == 0 { - t.Fatal("expected content") - } - first, _ := content[0].(map[string]interface{}) - text, _ := first["text"].(string) - if !strings.Contains(text, "parks") { - t.Errorf("response should contain 'parks', got: %s", text) } } -func TestToolsCall_QueryFeatures(t *testing.T) { +func TestToolsCallQueryFeaturesAcceptsNumericPagination(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("GET /collections/{id}/items", func(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - if id != "buildings" { - w.WriteHeader(http.StatusNotFound) - return - } - if got := r.Header.Get("X-Project-ID"); got != "42" { + if got := r.URL.Query().Get("limit"); got != "5" { w.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(w, `{"error":"project=%s"}`, got) + fmt.Fprintf(w, `{"error":"limit=%s"}`, got) return } - if got := r.URL.Query().Get("bbox-crs"); got != "EPSG:4326" { + if got := r.URL.Query().Get("offset"); got != "2" { w.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(w, `{"error":"bbox-crs=%s"}`, got) + fmt.Fprintf(w, `{"error":"offset=%s"}`, got) return } - if got := r.URL.Query().Get("crs"); got != "EPSG:3857" { + if got := r.Header.Get("X-Project-ID"); got != "42" { w.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(w, `{"error":"crs=%s"}`, got) + fmt.Fprintf(w, `{"error":"project=%s"}`, got) return } - limit := r.URL.Query().Get("limit") - if limit == "" { - limit = "10" - } w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"type":"FeatureCollection","features":[],"numberMatched":0,"limit":%s}`, limit) + fmt.Fprint(w, `{"type":"FeatureCollection","features":[]}`) }) srv := testServer(t, mux) - resp := sendRequest(t, srv, "tools/call", 4, map[string]interface{}{ + resp := sendRequest(t, srv, "tools/call", 101, map[string]interface{}{ "name": "query_features", "arguments": map[string]interface{}{ "collection_id": "buildings", - "bbox_crs": "EPSG:4326", - "crs": "EPSG:3857", + "limit": 5.0, + "offset": 2.0, "project_id": 42.0, - "limit": "5", }, }) if resp.Error != nil { - t.Fatalf("unexpected error: %v", resp.Error) + t.Fatalf("unexpected JSON-RPC error: %v", resp.Error) } result, _ := resp.Result.(map[string]interface{}) - content, _ := result["content"].([]interface{}) - first, _ := content[0].(map[string]interface{}) - text, _ := first["text"].(string) - if !strings.Contains(text, "FeatureCollection") { - t.Errorf("response should contain 'FeatureCollection', got: %s", text) + if isErr, _ := result["isError"].(bool); isErr { + t.Fatalf("expected success result, got error: %+v", result) } } -func TestToolsCall_UploadDatasetScoped(t *testing.T) { +func TestToolsCallUploadDatasetIncludesBodyID(t *testing.T) { tmpDir := t.TempDir() filePath := filepath.Join(tmpDir, "roads.geojson") if err := os.WriteFile(filePath, []byte(`{"type":"FeatureCollection","features":[]}`), 0o600); err != nil { @@ -247,27 +191,23 @@ func TestToolsCall_UploadDatasetScoped(t *testing.T) { fmt.Fprintf(w, `{"error":"parse=%v"}`, err) return } - if got := r.FormValue("name"); got != "roads" { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(w, `{"error":"name=%s"}`, got) - return - } - if got := r.FormValue("project_id"); got != "42" { + if got := r.FormValue("body_id"); got != "mars" { w.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(w, `{"error":"project_id=%s"}`, got) + fmt.Fprintf(w, `{"error":"body_id=%s"}`, got) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) - fmt.Fprint(w, `{"name":"roads","path":"/tmp/roads.geojson","format":"geojson"}`) + fmt.Fprint(w, `{"name":"roads","body_id":"mars"}`) }) srv := testServer(t, mux) - resp := sendRequest(t, srv, "tools/call", 240, map[string]interface{}{ + resp := sendRequest(t, srv, "tools/call", 102, map[string]interface{}{ "name": "upload_dataset", "arguments": map[string]interface{}{ "file_path": filePath, "name": "roads", + "body_id": "mars", "project_id": 42.0, }, }) @@ -279,204 +219,33 @@ func TestToolsCall_UploadDatasetScoped(t *testing.T) { content, _ := result["content"].([]interface{}) first, _ := content[0].(map[string]interface{}) text, _ := first["text"].(string) - if !strings.Contains(text, "roads") { - t.Errorf("response should contain dataset name, got: %s", text) - } -} - -func TestToolsCall_ExecuteSQL(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("POST /api/query/sql", func(w http.ResponseWriter, r *http.Request) { - var body struct { - SQL string `json:"sql"` - } - json.NewDecoder(r.Body).Decode(&body) - w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"columns":["count"],"rows":[[42]],"sql":"%s"}`, body.SQL) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 5, map[string]interface{}{ - "name": "execute_sql", - "arguments": map[string]interface{}{ - "query": "SELECT count(*) FROM parks", - }, - }) - - if resp.Error != nil { - t.Fatalf("unexpected error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) - isErr, _ := result["isError"].(bool) - if isErr { - t.Error("should not be an error") - } - content, _ := result["content"].([]interface{}) - first, _ := content[0].(map[string]interface{}) - text, _ := first["text"].(string) - if !strings.Contains(text, "42") { - t.Errorf("response should contain '42', got: %s", text) - } -} - -func TestToolsCall_ConvertFormat_MapsFormatToOutputFormat(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("POST /api/convert", func(w http.ResponseWriter, r *http.Request) { - var body map[string]interface{} - json.NewDecoder(r.Body).Decode(&body) - if body["output_format"] != "parquet" { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, `{"error":"missing output_format"}`) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - fmt.Fprint(w, `{"message":"conversion complete"}`) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 13, map[string]interface{}{ - "name": "convert_format", - "arguments": map[string]interface{}{ - "input": "parks", - "format": "parquet", - }, - }) - - if resp.Error != nil { - t.Fatalf("unexpected error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) - isErr, _ := result["isError"].(bool) - if isErr { - t.Fatalf("expected success result, got error: %+v", result) - } -} - -func TestToolsCall_DiffDatasets_MapsBaseCompare(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("POST /api/diff", func(w http.ResponseWriter, r *http.Request) { - var body map[string]interface{} - json.NewDecoder(r.Body).Decode(&body) - if body["left"] != "v1" || body["right"] != "v2" { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, `{"error":"missing left/right"}`) - return - } - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"added":1,"removed":0,"modified":2}`) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 14, map[string]interface{}{ - "name": "diff_datasets", - "arguments": map[string]interface{}{ - "base": "v1", - "compare": "v2", - }, - }) - - if resp.Error != nil { - t.Fatalf("unexpected error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) - isErr, _ := result["isError"].(bool) - if isErr { - t.Fatalf("expected success result, got error: %+v", result) + if !strings.Contains(text, "mars") { + t.Fatalf("response should contain body_id, got: %s", text) } } -func TestToolsCall_ComputeRoute_MapsOriginDestination(t *testing.T) { +func TestToolsCallExecuteSQLUsesEngineAwareRoute(t *testing.T) { mux := http.NewServeMux() - mux.HandleFunc("POST /api/route", func(w http.ResponseWriter, r *http.Request) { - var body struct { - Waypoints [][2]float64 `json:"waypoints"` - } - json.NewDecoder(r.Body).Decode(&body) - if len(body.Waypoints) != 2 { + mux.HandleFunc("POST /api/v1/query/sql", func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("engine"); got != "duckdb" { w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, `{"error":"expected 2 waypoints"}`) + fmt.Fprintf(w, `{"error":"engine=%s"}`, got) return } - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"distance":1000,"duration":120}`) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 15, map[string]interface{}{ - "name": "compute_route", - "arguments": map[string]interface{}{ - "origin": map[string]interface{}{"lat": 39.0, "lon": -86.0}, - "destination": map[string]interface{}{"lat": 39.1, "lon": -86.1}, - }, - }) - - if resp.Error != nil { - t.Fatalf("unexpected error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) - isErr, _ := result["isError"].(bool) - if isErr { - t.Fatalf("expected success result, got error: %+v", result) - } -} - -func TestToolsCall_ComputeRouteMatrix_MapsPoints(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("POST /api/route/matrix", func(w http.ResponseWriter, r *http.Request) { var body struct { - Origins [][2]float64 `json:"origins"` - Destinations [][2]float64 `json:"destinations"` - } - json.NewDecoder(r.Body).Decode(&body) - if len(body.Origins) != 1 || len(body.Destinations) != 1 { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, `{"error":"expected origins/destinations"}`) - return - } - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"durations":[[120]],"distances":[[1000]]}`) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 16, map[string]interface{}{ - "name": "compute_route_matrix", - "arguments": map[string]interface{}{ - "origins": []interface{}{map[string]interface{}{"lat": 39.0, "lon": -86.0}}, - "destinations": []interface{}{map[string]interface{}{"lat": 39.1, "lon": -86.1}}, - }, - }) - - if resp.Error != nil { - t.Fatalf("unexpected error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) - isErr, _ := result["isError"].(bool) - if isErr { - t.Fatalf("expected success result, got error: %+v", result) - } -} - -func TestToolsCall_ComputeIsochrone_MapsOrigin(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("POST /api/route/isochrone", func(w http.ResponseWriter, r *http.Request) { - var body map[string]interface{} - json.NewDecoder(r.Body).Decode(&body) - if body["lng"] == nil || body["lat"] == nil { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, `{"error":"missing lng/lat"}`) - return + SQL string `json:"sql"` } + _ = json.NewDecoder(r.Body).Decode(&body) w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"type":"FeatureCollection","features":[]}`) + fmt.Fprintf(w, `{"columns":["count"],"rows":[[42]],"sql":%q}`, body.SQL) }) srv := testServer(t, mux) - resp := sendRequest(t, srv, "tools/call", 17, map[string]interface{}{ - "name": "compute_isochrone", + resp := sendRequest(t, srv, "tools/call", 103, map[string]interface{}{ + "name": "execute_sql", "arguments": map[string]interface{}{ - "origin": map[string]interface{}{"lat": 39.0, "lon": -86.0}, - "minutes": []interface{}{10.0, 20.0}, + "engine": "duckdb", + "query": "SELECT count(*) FROM parks", }, }) @@ -488,52 +257,25 @@ func TestToolsCall_ComputeIsochrone_MapsOrigin(t *testing.T) { if isErr { t.Fatalf("expected success result, got error: %+v", result) } -} - -func TestToolsCall_GetDuckDBInfo(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("GET /api/query/sql/info", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"status":"available"}`) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 18, map[string]interface{}{ - "name": "get_duckdb_info", - "arguments": map[string]interface{}{}, - }) - - if resp.Error != nil { - t.Fatalf("unexpected error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) content, _ := result["content"].([]interface{}) first, _ := content[0].(map[string]interface{}) text, _ := first["text"].(string) - if !strings.Contains(text, "available") { - t.Errorf("response should contain duckdb status, got: %s", text) + if !strings.Contains(text, "SELECT count(*) FROM parks") { + t.Fatalf("response should contain SQL text, got: %s", text) } } -func TestToolsCall_BrowseEnhancedCatalog(t *testing.T) { +func TestToolsCallListBodies(t *testing.T) { mux := http.NewServeMux() - mux.HandleFunc("GET /api/catalog/enhanced", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("live_only") != "true" { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, `{"error":"missing live_only"}`) - return - } + mux.HandleFunc("GET /api/v1/bodies", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `[{"id":"us-census","name":"US Census"}]`) + fmt.Fprint(w, `[{"slug":"earth"},{"slug":"mars"}]`) }) srv := testServer(t, mux) - resp := sendRequest(t, srv, "tools/call", 19, map[string]interface{}{ - "name": "browse_catalog_enhanced", - "arguments": map[string]interface{}{ - "search": "census", - "live_only": true, - }, + resp := sendRequest(t, srv, "tools/call", 104, map[string]interface{}{ + "name": "list_bodies", + "arguments": map[string]interface{}{}, }) if resp.Error != nil { @@ -543,15 +285,15 @@ func TestToolsCall_BrowseEnhancedCatalog(t *testing.T) { content, _ := result["content"].([]interface{}) first, _ := content[0].(map[string]interface{}) text, _ := first["text"].(string) - if !strings.Contains(text, "us-census") { - t.Errorf("response should contain 'us-census', got: %s", text) + if !strings.Contains(text, "mars") { + t.Fatalf("response should contain mars, got: %s", text) } } -func TestToolsCall_UnknownTool(t *testing.T) { +func TestToolsCallUnknownTool(t *testing.T) { srv := testServer(t, http.NotFoundHandler()) - resp := sendRequest(t, srv, "tools/call", 6, map[string]interface{}{ + resp := sendRequest(t, srv, "tools/call", 105, map[string]interface{}{ "name": "nonexistent_tool", "arguments": map[string]interface{}{}, }) @@ -562,14 +304,13 @@ func TestToolsCall_UnknownTool(t *testing.T) { result, _ := resp.Result.(map[string]interface{}) isErr, _ := result["isError"].(bool) if !isErr { - t.Error("should be an error result") + t.Fatal("should be an error result") } } func TestPing(t *testing.T) { srv := testServer(t, http.NotFoundHandler()) - resp := sendRequest(t, srv, "ping", 7, nil) - + resp := sendRequest(t, srv, "ping", 106, nil) if resp.Error != nil { t.Fatalf("unexpected error: %v", resp.Error) } @@ -577,20 +318,19 @@ func TestPing(t *testing.T) { func TestUnknownMethod(t *testing.T) { srv := testServer(t, http.NotFoundHandler()) - resp := sendRequest(t, srv, "nonexistent/method", 8, nil) - + resp := sendRequest(t, srv, "nonexistent/method", 107, nil) if resp.Error == nil { t.Fatal("expected an error for unknown method") } if resp.Error.Code != -32601 { - t.Errorf("error code = %d, want -32601", resp.Error.Code) + t.Fatalf("error code = %d, want -32601", resp.Error.Code) } } -func TestToolsCall_MissingRequiredParam(t *testing.T) { +func TestToolsCallMissingRequiredParam(t *testing.T) { srv := testServer(t, http.NotFoundHandler()) - resp := sendRequest(t, srv, "tools/call", 9, map[string]interface{}{ + resp := sendRequest(t, srv, "tools/call", 108, map[string]interface{}{ "name": "get_dataset_info", "arguments": map[string]interface{}{}, }) @@ -598,548 +338,12 @@ func TestToolsCall_MissingRequiredParam(t *testing.T) { result, _ := resp.Result.(map[string]interface{}) isErr, _ := result["isError"].(bool) if !isErr { - t.Error("should be an error when required param is missing") - } - content, _ := result["content"].([]interface{}) - first, _ := content[0].(map[string]interface{}) - text, _ := first["text"].(string) - if !strings.Contains(text, "collection_id") { - t.Errorf("error should mention missing param, got: %s", text) - } -} - -func TestToolsCall_BrowseCatalog(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("GET /api/catalog", func(w http.ResponseWriter, r *http.Request) { - search := r.URL.Query().Get("search") - w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `[{"id":"us-census","name":"US Census","category":"boundaries","search":"%s"}]`, search) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 11, map[string]interface{}{ - "name": "browse_catalog", - "arguments": map[string]interface{}{ - "search": "census", - }, - }) - - if resp.Error != nil { - t.Fatalf("unexpected error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) - content, _ := result["content"].([]interface{}) - first, _ := content[0].(map[string]interface{}) - text, _ := first["text"].(string) - if !strings.Contains(text, "us-census") { - t.Errorf("response should contain 'us-census', got: %s", text) - } -} - -func TestToolsCall_ImportSTACAsset(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("POST /api/stac/import", func(w http.ResponseWriter, r *http.Request) { - var body struct { - AssetURL string `json:"asset_url"` - Name string `json:"name"` - Format string `json:"format"` - Namespace string `json:"namespace"` - Collection string `json:"collection"` - CatalogURL string `json:"catalog_url"` - ProjectID int64 `json:"project_id"` - } - json.NewDecoder(r.Body).Decode(&body) - if body.ProjectID != 42 || body.Namespace != "demo" || body.Collection != "buildings" || body.CatalogURL != "https://example.com/stac" { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(w, `{"error":"bad-body"}`) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusAccepted) - fmt.Fprintf(w, `{"name":"%s","path":"data/%s.geojson","format":"geojson"}`, body.Name, body.Name) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 12, map[string]interface{}{ - "name": "import_stac_asset", - "arguments": map[string]interface{}{ - "asset_url": "https://example.com/buildings.geojson", - "name": "buildings", - "namespace": "demo", - "collection": "buildings", - "catalog_url": "https://example.com/stac", - "project_id": 42.0, - }, - }) - - if resp.Error != nil { - t.Fatalf("unexpected error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) - content, _ := result["content"].([]interface{}) - first, _ := content[0].(map[string]interface{}) - text, _ := first["text"].(string) - if !strings.Contains(text, "buildings") { - t.Errorf("response should contain 'buildings', got: %s", text) + t.Fatal("should be an error when required param is missing") } -} - -func TestToolsCall_ImportFromCatalogScoped(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("POST /api/catalog/import", func(w http.ResponseWriter, r *http.Request) { - var body map[string]interface{} - json.NewDecoder(r.Body).Decode(&body) - if body["catalog_id"] != "catalog-123" { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, `{"error":"missing catalog_id"}`) - return - } - if body["project_id"] != float64(42) { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, `{"error":"missing project_id"}`) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusAccepted) - fmt.Fprint(w, `{"name":"roads","status":"pending"}`) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 241, map[string]interface{}{ - "name": "import_from_catalog", - "arguments": map[string]interface{}{ - "catalog_id": "catalog-123", - "project_id": 42.0, - }, - }) - - if resp.Error != nil { - t.Fatalf("unexpected error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) - content, _ := result["content"].([]interface{}) - first, _ := content[0].(map[string]interface{}) - text, _ := first["text"].(string) - if !strings.Contains(text, `"status": "pending"`) { - t.Errorf("response should contain pending status, got: %s", text) - } -} - -func TestToolsCall_RunProcess(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("POST /api/process", func(w http.ResponseWriter, r *http.Request) { - var body map[string]interface{} - json.NewDecoder(r.Body).Decode(&body) - if body["output_name"] != "parks_buffered" { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, `{"error":"missing output_name"}`) - return - } - if body["output_format"] != "parquet" { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, `{"error":"missing output_format"}`) - return - } - if body["project_id"] != float64(42) { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, `{"error":"missing project_id"}`) - return - } - w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"output":"buffered_%s","feature_count":10}`, body["input"]) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 10, map[string]interface{}{ - "name": "run_process", - "arguments": map[string]interface{}{ - "operation": "buffer", - "input": "parks", - "params": map[string]interface{}{"distance": 500}, - "output": "parks_buffered", - "format": "parquet", - "project_id": 42.0, - }, - }) - - if resp.Error != nil { - t.Fatalf("unexpected error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) - content, _ := result["content"].([]interface{}) - first, _ := content[0].(map[string]interface{}) - text, _ := first["text"].(string) - if !strings.Contains(text, "buffered_parks") { - t.Errorf("response should contain output name, got: %s", text) - } -} - -func TestToolsCall_PreflightProcess(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("POST /api/process/preflight", func(w http.ResponseWriter, r *http.Request) { - var body map[string]interface{} - json.NewDecoder(r.Body).Decode(&body) - if body["output_name"] != "parks_buffered" { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, `{"error":"missing output_name"}`) - return - } - if body["project_id"] != float64(42) { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, `{"error":"missing project_id"}`) - return - } - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"valid":true,"resolved_params":{"distance":500}}`) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 23, map[string]interface{}{ - "name": "preflight_process", - "arguments": map[string]interface{}{ - "operation": "buffer", - "input": "parks", - "params": map[string]interface{}{"distance": 500}, - "output": "parks_buffered", - "project_id": 42.0, - }, - }) - - if resp.Error != nil { - t.Fatalf("unexpected error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) - content, _ := result["content"].([]interface{}) - first, _ := content[0].(map[string]interface{}) - text, _ := first["text"].(string) - if !strings.Contains(text, `"valid": true`) { - t.Errorf("response should contain valid preflight, got: %s", text) - } -} - -func TestToolsCall_RunRasterProcess(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("POST /api/raster/process", func(w http.ResponseWriter, r *http.Request) { - var body map[string]interface{} - json.NewDecoder(r.Body).Decode(&body) - if body["operation"] != "slope" { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, `{"error":"missing operation"}`) - return - } - if _, ok := body["params"]; !ok { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, `{"error":"missing params"}`) - return - } - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"width":2,"height":2,"data":[1,2,3,4]}`) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 231, map[string]interface{}{ - "name": "run_raster_process", - "arguments": map[string]interface{}{ - "operation": "slope", - "input_path": "/data/dem.tif", - }, - }) - - if resp.Error != nil { - t.Fatalf("unexpected error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) - content, _ := result["content"].([]interface{}) - first, _ := content[0].(map[string]interface{}) - text, _ := first["text"].(string) - if !strings.Contains(text, `"width": 2`) { - t.Errorf("response should contain raster result, got: %s", text) - } -} - -func TestToolsCall_MapAPIExportRasterBand(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("POST /raster/{name}/export", func(w http.ResponseWriter, r *http.Request) { - if r.PathValue("name") != "elevation" { - w.WriteHeader(http.StatusNotFound) - return - } - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"message":"exported to /tmp/elevation.tif"}`) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 232, map[string]interface{}{ - "name": "map_api", - "arguments": map[string]interface{}{ - "operation": "export_raster_band", - "name": "elevation", - "body": map[string]interface{}{ - "output_path": "elevation.tif", - "band": 0, - }, - }, - }) - - if resp.Error != nil { - t.Fatalf("unexpected error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) - content, _ := result["content"].([]interface{}) - first, _ := content[0].(map[string]interface{}) - text, _ := first["text"].(string) - if !strings.Contains(text, "exported to /tmp/elevation.tif") { - t.Errorf("response should contain export message, got: %s", text) - } -} - -func TestToolsCall_MapAPIContour(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("POST /raster/{name}/contour", func(w http.ResponseWriter, r *http.Request) { - if r.PathValue("name") != "elevation" { - w.WriteHeader(http.StatusNotFound) - return - } - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"type":"FeatureCollection","features":[]}`) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 233, map[string]interface{}{ - "name": "map_api", - "arguments": map[string]interface{}{ - "operation": "raster_contour", - "name": "elevation", - "body": map[string]interface{}{ - "interval": 10, - }, - }, - }) - - if resp.Error != nil { - t.Fatalf("unexpected error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) - content, _ := result["content"].([]interface{}) - first, _ := content[0].(map[string]interface{}) - text, _ := first["text"].(string) - if !strings.Contains(text, "FeatureCollection") { - t.Errorf("response should contain contour GeoJSON, got: %s", text) - } -} - -func TestToolsCall_MapAPIKDE(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("POST /api/raster/kde", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"width":2,"height":2,"data":[0.1,0.2,0.3,0.4]}`) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 234, map[string]interface{}{ - "name": "map_api", - "arguments": map[string]interface{}{ - "operation": "raster_kde", - "body": map[string]interface{}{ - "dataset": "points", - "bandwidth": 50, - }, - }, - }) - - if resp.Error != nil { - t.Fatalf("unexpected error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) - content, _ := result["content"].([]interface{}) - first, _ := content[0].(map[string]interface{}) - text, _ := first["text"].(string) - if !strings.Contains(text, `"width": 2`) { - t.Errorf("response should contain kde grid, got: %s", text) - } -} - -func TestToolsCall_SubmitProcessJob(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("POST /api/process/jobs", func(w http.ResponseWriter, r *http.Request) { - var body map[string]interface{} - json.NewDecoder(r.Body).Decode(&body) - if body["output_name"] != "parks_buffered" { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, `{"error":"missing output_name"}`) - return - } - if body["project_id"] != float64(42) { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, `{"error":"missing project_id"}`) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusAccepted) - fmt.Fprint(w, `{"id":"job_123","status":"queued"}`) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 24, map[string]interface{}{ - "name": "submit_process_job", - "arguments": map[string]interface{}{ - "operation": "buffer", - "input": "parks", - "params": map[string]interface{}{"distance": 500}, - "output": "parks_buffered", - "project_id": 42.0, - }, - }) - - if resp.Error != nil { - t.Fatalf("unexpected error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) - content, _ := result["content"].([]interface{}) - first, _ := content[0].(map[string]interface{}) - text, _ := first["text"].(string) - if !strings.Contains(text, "job_123") { - t.Errorf("response should contain job id, got: %s", text) - } -} - -func TestToolsCall_CreatePipeline(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("POST /api/pipelines", func(w http.ResponseWriter, r *http.Request) { - var body map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode request: %v", err) - } - if body["name"] != "Suitability model" { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, `{"error":"missing name"}`) - return - } - if _, ok := body["graph"]; !ok { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, `{"error":"missing graph"}`) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - fmt.Fprint(w, `{"id":"pipe_123","name":"Suitability model","version":1}`) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 241, map[string]interface{}{ - "name": "create_pipeline", - "arguments": map[string]interface{}{ - "name": "Suitability model", - "description": "Buffer and clip", - "graph": map[string]interface{}{ - "nodes": []interface{}{map[string]interface{}{"id": "n1"}}, - "edges": []interface{}{}, - }, - }, - }) - - if resp.Error != nil { - t.Fatalf("unexpected error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) - content, _ := result["content"].([]interface{}) - first, _ := content[0].(map[string]interface{}) - text, _ := first["text"].(string) - if !strings.Contains(text, "pipe_123") { - t.Errorf("response should contain pipeline id, got: %s", text) - } -} - -func TestToolsCall_ExecuteSavedPipeline(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("POST /api/pipelines/{id}/execute", func(w http.ResponseWriter, r *http.Request) { - if r.PathValue("id") != "pipe_123" { - w.WriteHeader(http.StatusNotFound) - return - } - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"pipeline_id":"pipe_123","status":"submitted","node_count":1,"edge_count":0}`) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 242, map[string]interface{}{ - "name": "execute_saved_pipeline", - "arguments": map[string]interface{}{ - "pipeline_id": "pipe_123", - }, - }) - - if resp.Error != nil { - t.Fatalf("unexpected error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) - content, _ := result["content"].([]interface{}) - first, _ := content[0].(map[string]interface{}) - text, _ := first["text"].(string) - if !strings.Contains(text, `"status": "submitted"`) { - t.Errorf("response should contain submitted status, got: %s", text) - } -} - -func TestToolsCall_ListProcessJobs(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("GET /api/process/jobs", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("status") != "queued" || r.URL.Query().Get("limit") != "25" { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, `{"error":"missing filters"}`) - return - } - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `[{"id":"job_123","status":"queued"}]`) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 25, map[string]interface{}{ - "name": "list_process_jobs", - "arguments": map[string]interface{}{ - "status": "queued", - "limit": 25.0, - }, - }) - - if resp.Error != nil { - t.Fatalf("unexpected error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) - content, _ := result["content"].([]interface{}) - first, _ := content[0].(map[string]interface{}) - text, _ := first["text"].(string) - if !strings.Contains(text, "job_123") { - t.Errorf("response should contain job id, got: %s", text) - } -} - -func TestToolsCall_CancelProcessJob(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("DELETE /api/process/jobs/{id}", func(w http.ResponseWriter, r *http.Request) { - if r.PathValue("id") != "job_123" { - w.WriteHeader(http.StatusNotFound) - return - } - w.WriteHeader(http.StatusNoContent) - }) - srv := testServer(t, mux) - - resp := sendRequest(t, srv, "tools/call", 26, map[string]interface{}{ - "name": "cancel_process_job", - "arguments": map[string]interface{}{ - "job_id": "job_123", - }, - }) - - if resp.Error != nil { - t.Fatalf("unexpected error: %v", resp.Error) - } - result, _ := resp.Result.(map[string]interface{}) content, _ := result["content"].([]interface{}) first, _ := content[0].(map[string]interface{}) text, _ := first["text"].(string) - if !strings.Contains(text, "cancelled") { - t.Errorf("response should contain cancellation status, got: %s", text) + if !strings.Contains(text, "name") { + t.Fatalf("error should mention missing param, got: %s", text) } } diff --git a/mcp/tools.go b/mcp/tools.go index d7107b3..7ffc57d 100644 --- a/mcp/tools.go +++ b/mcp/tools.go @@ -24,685 +24,298 @@ type PropertySchema struct { Default interface{} `json:"default,omitempty"` } +func tool(name, description string, properties map[string]PropertySchema, required ...string) Tool { + return Tool{ + Name: name, + Description: description, + InputSchema: InputSchema{ + Type: "object", + Properties: properties, + Required: required, + }, + } +} + +func stringProp(description string) PropertySchema { + return PropertySchema{Type: "string", Description: description} +} + +func boolProp(description string) PropertySchema { + return PropertySchema{Type: "boolean", Description: description} +} + +func numberProp(description string) PropertySchema { + return PropertySchema{Type: "number", Description: description} +} + +func objectProp(description string) PropertySchema { + return PropertySchema{Type: "object", Description: description} +} + +func arrayProp(description string, itemType string) PropertySchema { + return PropertySchema{Type: "array", Description: description, Items: &PropertySchema{Type: itemType}} +} + // AllTools returns the complete list of MCP tools that this server exposes. func AllTools() []Tool { return []Tool{ - { - Name: "list_datasets", - Description: "List all datasets registered in Roteiro with their names, formats, feature counts, and geometry types. Optionally scope the listing to a project.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "project_id": {Type: "string", Description: "Optional project scope override. Also configurable globally via --project-id."}, - }, - }, - }, - { - Name: "get_dataset_info", - Description: "Get detailed information about a dataset including its schema (field names and types), CRS, bounds, feature count, and geometry type.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "collection_id": {Type: "string", Description: "The dataset/collection identifier."}, - "project_id": {Type: "string", Description: "Optional project scope override. Also configurable globally via --project-id."}, - }, - Required: []string{"collection_id"}, - }, - }, - { - Name: "get_dataset_schema", - Description: "Get the field schema (column names, types) for a dataset. Useful for understanding what attributes are available before querying.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "name": {Type: "string", Description: "The dataset name."}, - }, - Required: []string{"name"}, - }, - }, - { - Name: "get_dataset_profile", - Description: "Get a statistical profile of a dataset including value distributions, min/max, and null counts for each field.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "name": {Type: "string", Description: "The dataset name."}, - }, - Required: []string{"name"}, - }, - }, - { - Name: "query_features", - Description: "Query features from a collection with optional spatial/attribute filters. Returns GeoJSON FeatureCollection. Use bbox for spatial filtering and filter for CQL2 attribute filtering.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "collection_id": {Type: "string", Description: "The collection identifier."}, - "bbox": {Type: "string", Description: "Bounding box filter as 'west,south,east,north' (EPSG:4326)."}, - "bbox_crs": {Type: "string", Description: "Optional CRS identifier for the bbox coordinates, forwarded as `bbox-crs`."}, - "crs": {Type: "string", Description: "Optional CRS identifier for returned geometries."}, - "filter": {Type: "string", Description: "CQL2 filter expression (e.g. \"population > 10000\")."}, - "datetime": {Type: "string", Description: "Temporal filter as RFC3339 instant or interval 'start/end'."}, - "limit": {Type: "string", Description: "Maximum number of features to return (default 10)."}, - "offset": {Type: "string", Description: "Number of features to skip for pagination."}, - "properties": {Type: "string", Description: "Comma-separated list of properties to include in the response."}, - "sortby": {Type: "string", Description: "Property to sort by, prefix with '-' for descending."}, - "project_id": {Type: "string", Description: "Optional project scope override. Also configurable globally via --project-id."}, - }, - Required: []string{"collection_id"}, - }, - }, - { - Name: "get_feature", - Description: "Get a single feature by its ID from a collection. Returns a GeoJSON Feature with all properties and geometry.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "collection_id": {Type: "string", Description: "The collection identifier."}, - "feature_id": {Type: "string", Description: "The feature identifier."}, - }, - Required: []string{"collection_id", "feature_id"}, - }, - }, - { - Name: "upload_dataset", - Description: "Upload a spatial data file (GeoJSON, Shapefile, GeoPackage, KML, CSV, etc.) to register it as a new dataset.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "file_path": {Type: "string", Description: "Local file path to the spatial data file to upload."}, - "name": {Type: "string", Description: "Optional dataset name. Defaults to the file stem if omitted."}, - "project_id": {Type: "string", Description: "Optional project to attach the uploaded dataset to. Also configurable globally via --project-id."}, - }, - Required: []string{"file_path"}, - }, - }, - { - Name: "run_process", - Description: "Run a single geoprocessing operation on a dataset via /api/process. Use list_operations first to discover the live operation catalog and parameter names on the connected server.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "operation": {Type: "string", Description: "The geoprocessing operation to run (e.g. 'buffer', 'clip', 'simplify')."}, - "input": {Type: "string", Description: "Input dataset name. Provide either 'input' or 'input_geojson'."}, - "input_geojson": { - Type: "object", - Description: "Inline GeoJSON input. Provide either 'input' or 'input_geojson'.", + tool("list_datasets", "List datasets registered in Cairn.", map[string]PropertySchema{ + "project_id": stringProp("Optional project scope override. Also configurable globally via --project-id."), + }), + tool("get_dataset_info", "Get combined collection, schema, and profile information for a dataset.", map[string]PropertySchema{ + "name": stringProp("Dataset or collection identifier."), + "project_id": stringProp("Optional project scope override. Also configurable globally via --project-id."), + }, "name"), + tool("query_features", "Query features from a collection with optional spatial and attribute filters.", map[string]PropertySchema{ + "collection_id": stringProp("Collection identifier."), + "bbox": stringProp("Bounding box filter as 'west,south,east,north'."), + "bbox_crs": stringProp("Optional CRS for the bbox coordinates, forwarded as bbox-crs."), + "crs": stringProp("Optional CRS identifier for returned geometries."), + "filter": stringProp("CQL2 filter expression."), + "datetime": stringProp("Temporal filter as RFC3339 instant or interval."), + "limit": stringProp("Maximum features to return. Defaults to 10."), + "offset": stringProp("Pagination offset."), + "cursor": stringProp("Opaque pagination cursor when supported by the backend."), + "project_id": stringProp("Optional project scope override. Also configurable globally via --project-id."), + }, "collection_id"), + tool("get_feature", "Fetch a single feature from a collection.", map[string]PropertySchema{ + "collection_id": stringProp("Collection identifier."), + "feature_id": stringProp("Feature identifier."), + "project_id": stringProp("Optional project scope override. Also configurable globally via --project-id."), + }, "collection_id", "feature_id"), + tool("create_feature", "Create a feature in a collection.", map[string]PropertySchema{ + "collection_id": stringProp("Collection identifier."), + "feature": objectProp("GeoJSON Feature payload."), + "project_id": stringProp("Optional project scope override. Also configurable globally via --project-id."), + }, "collection_id", "feature"), + tool("update_feature", "Replace an existing feature in a collection.", map[string]PropertySchema{ + "collection_id": stringProp("Collection identifier."), + "feature_id": stringProp("Feature identifier."), + "feature": objectProp("GeoJSON Feature payload."), + "project_id": stringProp("Optional project scope override. Also configurable globally via --project-id."), + }, "collection_id", "feature_id", "feature"), + tool("delete_feature", "Delete a feature from a collection.", map[string]PropertySchema{ + "collection_id": stringProp("Collection identifier."), + "feature_id": stringProp("Feature identifier."), + "project_id": stringProp("Optional project scope override. Also configurable globally via --project-id."), + }, "collection_id", "feature_id"), + tool("upload_dataset", "Upload a local geospatial file and register it as a dataset.", map[string]PropertySchema{ + "file_path": stringProp("Local file path to upload."), + "name": stringProp("Optional dataset name. Defaults to the file stem if omitted."), + "body_id": stringProp("Optional celestial body identifier for the dataset."), + "project_id": stringProp("Optional project override. Also configurable globally via --project-id."), + }, "file_path"), + tool("import_source", "Import a remote or catalog-backed source through the current dataset intake endpoint.", map[string]PropertySchema{ + "name": stringProp("Dataset name to register."), + "source": stringProp("Source URL or source reference."), + "format": stringProp("Optional format hint such as geojson, parquet, gpkg, tif, or csv."), + "crs": stringProp("Optional CRS hint."), + "body_id": stringProp("Optional celestial body identifier."), + "project_id": stringProp("Optional project override. Also configurable globally via --project-id."), + "source_type": stringProp("Optional source type such as remote_url."), + "catalog_url": stringProp("Optional source catalog URL for provenance."), + "collection": stringProp("Optional upstream collection identifier."), + }, "name", "source"), + tool("get_scene_manifest", "Fetch the current body-aware scene manifest.", map[string]PropertySchema{}), + tool("list_bodies", "List available celestial bodies for the current tenant.", map[string]PropertySchema{}), + tool("get_body", "Fetch a single celestial body definition by slug.", map[string]PropertySchema{ + "slug": stringProp("Body slug."), + }, "slug"), + tool("get_body_recipes", "List recipe sources configured for a celestial body.", map[string]PropertySchema{ + "slug": stringProp("Body slug."), + }, "slug"), + tool("execute_body_recipe", "Execute a configured recipe source for a celestial body.", map[string]PropertySchema{ + "slug": stringProp("Body slug."), + "source_id": stringProp("Recipe source identifier."), + }, "slug", "source_id"), + tool("list_operations", "List the current unified operation catalog.", map[string]PropertySchema{ + "domain": stringProp("Optional operation domain filter."), + }), + tool("preflight_operation", "Validate and normalize an operation request before execution.", map[string]PropertySchema{ + "operation": stringProp("Operation identifier."), + "input": stringProp("Input dataset name."), + "input_geojson": objectProp("Inline GeoJSON input."), + "params": objectProp("Operation-specific parameters."), + "output": stringProp("Compatibility alias for output_name."), + "output_name": stringProp("Optional output dataset name."), + "format": stringProp("Compatibility alias for output_format."), + "output_format": stringProp("Requested output format."), + "register": boolProp("Whether to register the result as a dataset."), + "project_id": stringProp("Optional project override. Also configurable globally via --project-id."), + }, "operation"), + tool("run_operation", "Run a synchronous unified operation.", map[string]PropertySchema{ + "operation": stringProp("Operation identifier."), + "input": stringProp("Input dataset name."), + "input_geojson": objectProp("Inline GeoJSON input."), + "params": objectProp("Operation-specific parameters."), + "output": stringProp("Compatibility alias for output_name."), + "output_name": stringProp("Optional output dataset name."), + "format": stringProp("Compatibility alias for output_format."), + "output_format": stringProp("Requested output format."), + "register": boolProp("Whether to register the result as a dataset."), + "project_id": stringProp("Optional project override. Also configurable globally via --project-id."), + }, "operation"), + tool("submit_operation_job", "Queue an asynchronous unified operation job.", map[string]PropertySchema{ + "operation": stringProp("Operation identifier."), + "input": stringProp("Input dataset name."), + "input_geojson": objectProp("Inline GeoJSON input."), + "params": objectProp("Operation-specific parameters."), + "output": stringProp("Compatibility alias for output_name."), + "output_name": stringProp("Optional output dataset name."), + "format": stringProp("Compatibility alias for output_format."), + "output_format": stringProp("Requested output format."), + "register": boolProp("Whether to register the result as a dataset."), + "project_id": stringProp("Optional project override. Also configurable globally via --project-id."), + }, "operation"), + tool("submit_operation_batch", "Submit a dependent batch of operation jobs.", map[string]PropertySchema{ + "jobs": { + Type: "array", + Description: "Array of batch jobs. Each item supports client_id, depends_on, and request.", + Items: &PropertySchema{ + Type: "object", + Properties: map[string]PropertySchema{ + "client_id": stringProp("Optional client-side identifier."), + "depends_on": arrayProp("Optional dependency references.", "string"), + "request": objectProp("Operation request payload matching submit_operation_job."), }, - "params": { - Type: "object", - Description: "Operation-specific parameters (e.g. {\"distance\": 500} for buffer, {\"tolerance\": 0.001} for simplify).", - }, - "output": {Type: "string", Description: "Compatibility alias for 'output_name'."}, - "output_name": {Type: "string", Description: "Output dataset name when registering or naming results."}, - "output_format": {Type: "string", Description: "Requested output format (for example 'geojson', 'parquet', or 'csv')."}, - "format": {Type: "string", Description: "Compatibility alias for 'output_format'."}, - "register": {Type: "boolean", Description: "Whether to register the result as a dataset."}, - "project_id": {Type: "string", Description: "Optional project scope override and output attachment target."}, - }, - Required: []string{"operation"}, - }, - }, - { - Name: "run_raster_process", - Description: "Run a generic raster processing operation via /api/raster/process using file paths. Current backend operations include terrain (slope, aspect, profile_curvature, plan_curvature, general_curvature), hydrology (fill, flow_direction, flow_accumulation, watershed, stream_order, snap_pour_point, basin_labels), distance/cost tools, spectral/change tools, classification, and raster-vector conversion.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "operation": {Type: "string", Description: "Raster operation name such as 'slope', 'fill', 'cost_distance', 'spectral_index', 'kmeans', 'raster_to_polygons', or 'rasterize'."}, - "input_path": {Type: "string", Description: "Input raster file path. Retrieve dataset paths with list_datasets or get_dataset_info when needed."}, - "output_path": {Type: "string", Description: "Optional output GeoTIFF path for file-writing operations."}, - "band": {Type: "string", Description: "Optional raster band index."}, - "params": {Type: "object", Description: "Operation-specific parameters, matching the backend raster analysis docs."}, }, - Required: []string{"operation"}, }, - }, - { - Name: "preflight_process", - Description: "Validate and normalize a processing request via /api/process/preflight before executing it. Useful for checking required params, CRS constraints, and normalized dataset references.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "operation": {Type: "string", Description: "The geoprocessing operation to validate."}, - "input": {Type: "string", Description: "Input dataset name. Provide either 'input' or 'input_geojson'."}, - "input_geojson": {Type: "object", Description: "Inline GeoJSON input. Provide either 'input' or 'input_geojson'."}, - "params": {Type: "object", Description: "Operation-specific parameters."}, - "output": {Type: "string", Description: "Compatibility alias for 'output_name'."}, - "output_name": {Type: "string", Description: "Optional output dataset name."}, - "output_format": {Type: "string", Description: "Requested output format."}, - "format": {Type: "string", Description: "Compatibility alias for 'output_format'."}, - "register": {Type: "boolean", Description: "Whether the eventual result should be registered as a dataset."}, - "project_id": {Type: "string", Description: "Optional project scope override and output attachment target."}, - }, - Required: []string{"operation"}, - }, - }, - { - Name: "submit_process_job", - Description: "Submit an asynchronous processing job via /api/process/jobs. Use this when the operation may take longer, or when preflight recommends async execution.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "operation": {Type: "string", Description: "The geoprocessing operation to queue."}, - "input": {Type: "string", Description: "Input dataset name. Provide either 'input' or 'input_geojson'."}, - "input_geojson": {Type: "object", Description: "Inline GeoJSON input. Provide either 'input' or 'input_geojson'."}, - "params": {Type: "object", Description: "Operation-specific parameters."}, - "output": {Type: "string", Description: "Compatibility alias for 'output_name'."}, - "output_name": {Type: "string", Description: "Optional output dataset name."}, - "output_format": {Type: "string", Description: "Requested output format."}, - "format": {Type: "string", Description: "Compatibility alias for 'output_format'."}, - "register": {Type: "boolean", Description: "Whether to register the result as a dataset."}, - "project_id": {Type: "string", Description: "Optional project scope override and output attachment target."}, - }, - Required: []string{"operation"}, - }, - }, - { - Name: "submit_process_batch", - Description: "Submit a dependent batch of asynchronous processing jobs via /api/process/jobs/batch.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "jobs": { - Type: "array", - Description: "Array of batch jobs. Each item supports client_id, depends_on, and request fields matching submit_process_job. Default project scope is inherited unless a request already sets project_id.", - Items: &PropertySchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "client_id": {Type: "string", Description: "Optional client-side identifier used for dependency references."}, - "depends_on": {Type: "array", Description: "Optional list of batch client IDs or job IDs this job depends on.", Items: &PropertySchema{Type: "string"}}, - "request": {Type: "object", Description: "Process request payload matching submit_process_job."}, - }, - }, + }, "jobs"), + tool("list_operation_jobs", "List asynchronous operation jobs.", map[string]PropertySchema{ + "status": stringProp("Optional status filter."), + "search": stringProp("Optional free-text search."), + "limit": stringProp("Maximum jobs to return."), + "offset": stringProp("Pagination offset."), + }), + tool("get_operation_job", "Fetch a queued operation job by ID.", map[string]PropertySchema{ + "job_id": stringProp("Operation job identifier."), + }, "job_id"), + tool("cancel_operation_job", "Cancel an operation job by ID.", map[string]PropertySchema{ + "job_id": stringProp("Operation job identifier."), + }, "job_id"), + tool("rerun_operation_job", "Re-submit a previous operation job.", map[string]PropertySchema{ + "job_id": stringProp("Operation job identifier."), + }, "job_id"), + tool("list_pipeline_operations", "List the current ad hoc pipeline operation catalog.", map[string]PropertySchema{}), + tool("run_pipeline", "Run an ad hoc multi-step pipeline.", map[string]PropertySchema{ + "input": stringProp("Input dataset name."), + "input_geojson": objectProp("Inline GeoJSON input."), + "steps": { + Type: "array", + Description: "Ordered pipeline steps.", + Items: &PropertySchema{ + Type: "object", + Properties: map[string]PropertySchema{ + "operation": stringProp("Operation identifier."), + "params": objectProp("Operation-specific parameters."), }, }, - Required: []string{"jobs"}, - }, - }, - { - Name: "list_process_jobs", - Description: "List asynchronous processing jobs via /api/process/jobs with optional filtering.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "status": {Type: "string", Description: "Optional job status filter: queued, processing, completed, failed, cancelled."}, - "search": {Type: "string", Description: "Optional substring match against operation or job metadata."}, - "limit": {Type: "string", Description: "Optional max jobs to return."}, - "offset": {Type: "string", Description: "Optional pagination offset."}, - }, - }, - }, - { - Name: "get_process_job", - Description: "Fetch a single asynchronous processing job by ID.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "job_id": {Type: "string", Description: "The async processing job ID."}, - }, - Required: []string{"job_id"}, - }, - }, - { - Name: "cancel_process_job", - Description: "Cancel an asynchronous processing job by ID.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "job_id": {Type: "string", Description: "The async processing job ID."}, - }, - Required: []string{"job_id"}, - }, - }, - { - Name: "rerun_process_job", - Description: "Re-submit a previous asynchronous processing job by ID.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "job_id": {Type: "string", Description: "The async processing job ID."}, - }, - Required: []string{"job_id"}, - }, - }, - { - Name: "list_pipeline_templates", - Description: "List persisted pipeline templates from Cairn's visual pipeline builder.", - InputSchema: InputSchema{Type: "object"}, - }, - { - Name: "list_pipelines", - Description: "List persisted pipelines for the current tenant.", - InputSchema: InputSchema{Type: "object"}, - }, - { - Name: "get_pipeline", - Description: "Fetch a persisted pipeline definition by ID.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "pipeline_id": {Type: "string", Description: "The persisted pipeline ID."}, - }, - Required: []string{"pipeline_id"}, - }, - }, - { - Name: "create_pipeline", - Description: "Create a persisted pipeline definition for the visual pipeline builder.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "name": {Type: "string", Description: "Pipeline name."}, - "description": {Type: "string", Description: "Optional pipeline description."}, - "graph": {Type: "object", Description: "Pipeline graph JSON payload."}, - "canvas": {Type: "object", Description: "Pipeline canvas layout JSON payload."}, - }, - Required: []string{"name"}, - }, - }, - { - Name: "update_pipeline", - Description: "Update a persisted pipeline definition. Requires the current version for optimistic concurrency.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "pipeline_id": {Type: "string", Description: "The persisted pipeline ID."}, - "name": {Type: "string", Description: "Pipeline name."}, - "description": {Type: "string", Description: "Optional pipeline description."}, - "graph": {Type: "object", Description: "Pipeline graph JSON payload."}, - "canvas": {Type: "object", Description: "Pipeline canvas layout JSON payload."}, - "version": {Type: "number", Description: "Current pipeline version from the latest read."}, - }, - Required: []string{"pipeline_id", "name", "version"}, - }, - }, - { - Name: "delete_pipeline", - Description: "Delete a persisted pipeline definition by ID.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "pipeline_id": {Type: "string", Description: "The persisted pipeline ID."}, - }, - Required: []string{"pipeline_id"}, - }, - }, - { - Name: "duplicate_pipeline", - Description: "Duplicate a persisted pipeline definition by ID.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "pipeline_id": {Type: "string", Description: "The persisted pipeline ID."}, - }, - Required: []string{"pipeline_id"}, - }, - }, - { - Name: "execute_saved_pipeline", - Description: "Submit a persisted pipeline definition for execution.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "pipeline_id": {Type: "string", Description: "The persisted pipeline ID."}, - }, - Required: []string{"pipeline_id"}, }, - }, - { - Name: "run_pipeline", - Description: "Run a multi-step geoprocessing pipeline. Each step's output feeds into the next step. Useful for chaining operations like buffer → clip → simplify.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "input": {Type: "string", Description: "Input dataset name."}, - "steps": { - Type: "array", - Description: "Array of pipeline steps, each with 'operation', 'input' (or uses previous output), and 'params'.", - Items: &PropertySchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "operation": {Type: "string", Description: "The geoprocessing operation."}, - "input": {Type: "string", Description: "Input dataset (optional if chaining from previous step)."}, - "params": {Type: "object", Description: "Operation-specific parameters."}, - "output": {Type: "string", Description: "Output dataset name (optional)."}, - }, - }, - }, - "output": {Type: "string", Description: "Output dataset name when registering results (optional)."}, - "register": {Type: "boolean", Description: "Persist the pipeline result as a dataset."}, - "project_id": {Type: "string", Description: "Optional project scope override. Also used when the pipeline registers a dataset."}, - }, - Required: []string{"input", "steps"}, - }, - }, - { - Name: "convert_format", - Description: "Convert a dataset between spatial data formats. Supported formats: geojson, shapefile, geopackage, kml, csv, flatgeobuf, parquet.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "input": {Type: "string", Description: "Input dataset name."}, - "format": {Type: "string", Description: "Target format (e.g. 'geojson', 'shapefile', 'geopackage', 'kml', 'csv', 'flatgeobuf', 'parquet')."}, - "output": {Type: "string", Description: "Optional output dataset name when registering results."}, - "register": {Type: "boolean", Description: "Persist converted output as a dataset."}, - "project_id": {Type: "string", Description: "Optional project to attach the converted dataset to."}, - }, - Required: []string{"input", "format"}, - }, - }, - { - Name: "diff_datasets", - Description: "Compare two dataset versions and show added, removed, and modified features.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "base": {Type: "string", Description: "Base dataset name (the 'before' version)."}, - "compare": {Type: "string", Description: "Compare dataset name (the 'after' version)."}, - "match_field": {Type: "string", Description: "Optional stable feature ID field for matching rows."}, - }, - Required: []string{"base", "compare"}, - }, - }, - { - Name: "execute_sql", - Description: "Execute a read-only PostGIS SQL query against the spatial database. Supports all PostGIS spatial functions (ST_Area, ST_Buffer, ST_Intersects, etc.). Results are returned as JSON rows.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "query": {Type: "string", Description: "The SQL query to execute. Must be a SELECT statement (read-only)."}, - }, - Required: []string{"query"}, - }, - }, - { - Name: "list_spatial_tables", - Description: "List all spatial tables in the PostGIS database with their schemas, geometry columns, SRIDs, and geometry types.", - InputSchema: InputSchema{Type: "object"}, - }, - { - Name: "get_duckdb_info", - Description: "Get DuckDB SQL engine status, capabilities, supported functions, and safety limits.", - InputSchema: InputSchema{Type: "object"}, - }, - { - Name: "list_duckdb_datasets", - Description: "List datasets available to the DuckDB SQL endpoint (/api/query/sql/datasets).", - InputSchema: InputSchema{Type: "object"}, - }, - { - Name: "geocode", - Description: "Forward geocode: convert an address or place name to geographic coordinates (latitude/longitude).", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "address": {Type: "string", Description: "The address or place name to geocode."}, - }, - Required: []string{"address"}, - }, - }, - { - Name: "reverse_geocode", - Description: "Reverse geocode: convert geographic coordinates to an address or place name.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "lat": {Type: "number", Description: "Latitude."}, - "lon": {Type: "number", Description: "Longitude."}, - }, - Required: []string{"lat", "lon"}, - }, - }, - { - Name: "compute_route", - Description: "Compute a driving/walking route between two or more points. Returns the route geometry, distance, and duration.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "origin": {Type: "object", Description: "Origin point as {\"lat\": number, \"lon\": number}."}, - "destination": {Type: "object", Description: "Destination point as {\"lat\": number, \"lon\": number}."}, - "waypoints": { - Type: "array", - Description: "Optional intermediate waypoints as [{\"lat\": number, \"lon\": number}, ...].", - Items: &PropertySchema{Type: "object"}, - }, - "profile": {Type: "string", Description: "Routing profile: 'driving', 'walking', 'cycling' (default: 'driving')."}, - }, - Required: []string{"origin", "destination"}, - }, - }, - { - Name: "compute_isochrone", - Description: "Compute travel-time isochrone polygons from an origin point.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "origin": {Type: "object", Description: "Origin point as {\"lat\": number, \"lon\": number}."}, - "minutes": { - Type: "array", - Description: "Travel-time thresholds in minutes (1-120).", - Items: &PropertySchema{Type: "number"}, - }, - "profile": {Type: "string", Description: "Routing profile: 'driving', 'walking', 'cycling' (default: 'driving')."}, - }, - Required: []string{"origin", "minutes"}, - }, - }, - { - Name: "compute_route_matrix", - Description: "Compute origin-destination travel-time and distance matrices.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "origins": { - Type: "array", - Description: "Origin points as [{\"lat\": number, \"lon\": number}, ...].", - Items: &PropertySchema{Type: "object"}, - }, - "destinations": { - Type: "array", - Description: "Destination points as [{\"lat\": number, \"lon\": number}, ...].", - Items: &PropertySchema{Type: "object"}, - }, - "profile": {Type: "string", Description: "Routing profile: 'driving', 'walking', 'cycling' (default: 'driving')."}, - }, - Required: []string{"origins", "destinations"}, - }, - }, - { - Name: "compute_service_area", - Description: "Compute distance-based service area polygons from an origin point.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "origin": {Type: "object", Description: "Origin point as {\"lat\": number, \"lon\": number}."}, - "meters": { - Type: "array", - Description: "Distance thresholds in meters (1-100000).", - Items: &PropertySchema{Type: "number"}, - }, - "profile": {Type: "string", Description: "Routing profile: 'driving', 'walking', 'cycling' (default: 'driving')."}, - }, - Required: []string{"origin", "meters"}, - }, - }, - { - Name: "list_operations", - Description: "List all available geoprocessing operations with their parameter schemas. Useful for discovering what operations are supported and what parameters they accept.", - InputSchema: InputSchema{Type: "object"}, - }, - { - Name: "list_analysis_operations", - Description: "List all available advanced analysis operations from /api/analysis/operations.", - InputSchema: InputSchema{Type: "object"}, - }, - { - Name: "browse_catalog", - Description: "Browse the built-in data catalog to discover available datasets for import. Supports text search and category filtering.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "search": {Type: "string", Description: "Text search across dataset names, descriptions, and providers."}, - "category": {Type: "string", Description: "Filter by category (e.g. 'boundaries', 'transportation', 'environment')."}, - "limit": {Type: "string", Description: "Maximum results to return (default 50, max 500)."}, - "offset": {Type: "string", Description: "Pagination offset."}, - }, - }, - }, - { - Name: "browse_catalog_enhanced", - Description: "Browse the enhanced catalog with advanced filters (formats, tags, live_only, bbox, sorting).", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "search": {Type: "string", Description: "Text search query."}, - "category": {Type: "string", Description: "Category filter."}, - "formats": {Type: "string", Description: "Comma-separated formats (e.g. 'geojson,parquet')."}, - "tags": {Type: "string", Description: "Comma-separated tags filter."}, - "live_only": {Type: "boolean", Description: "Only include live/updating datasets."}, - "sort": {Type: "string", Description: "Sort key: popularity, name, recent, updated."}, - "order": {Type: "string", Description: "Sort order: asc or desc."}, - "bbox": {Type: "string", Description: "Bounding box filter as 'minLon,minLat,maxLon,maxLat'."}, - "limit": {Type: "string", Description: "Maximum results (1-500)."}, - "offset": {Type: "string", Description: "Pagination offset."}, - }, - }, - }, - { - Name: "get_catalog_entry", - Description: "Get a single enhanced catalog entry by ID.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "id": {Type: "string", Description: "Enhanced catalog entry ID."}, - }, - Required: []string{"id"}, - }, - }, - { - Name: "list_catalog_categories", - Description: "List available catalog categories.", - InputSchema: InputSchema{Type: "object"}, - }, - { - Name: "list_catalog_tags", - Description: "List catalog tags ordered by frequency.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "limit": {Type: "string", Description: "Maximum tags to return (1-200)."}, - }, - }, - }, - { - Name: "import_from_catalog", - Description: "Import a dataset from the built-in data catalog by its catalog ID. Downloads and registers the dataset for querying and processing.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "catalog_id": {Type: "string", Description: "The catalog entry ID to import."}, - "project_id": {Type: "string", Description: "Optional project to attach the imported dataset to. Also configurable globally via --project-id."}, - }, - Required: []string{"catalog_id"}, - }, - }, - { - Name: "browse_stac_catalog", - Description: "Browse a remote STAC (SpatioTemporal Asset Catalog) catalog by URL. Returns the catalog metadata and links.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "url": {Type: "string", Description: "URL of the remote STAC catalog root (e.g. 'https://planetarycomputer.microsoft.com/api/stac/v1')."}, - }, - Required: []string{"url"}, - }, - }, - { - Name: "browse_stac_collections", - Description: "List collections available in a remote STAC catalog.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "url": {Type: "string", Description: "URL of the remote STAC catalog root."}, - }, - Required: []string{"url"}, - }, - }, - { - Name: "browse_stac_items", - Description: "List items (features) in a remote STAC collection. Supports bbox and datetime filtering.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "url": {Type: "string", Description: "URL of the remote STAC collection."}, - "bbox": {Type: "string", Description: "Bounding box filter as 'west,south,east,north'."}, - "datetime": {Type: "string", Description: "Temporal filter as ISO 8601 datetime or range 'start/end'."}, - }, - Required: []string{"url"}, - }, - }, - { - Name: "import_stac_asset", - Description: "Import an asset from a remote STAC catalog as a local dataset. Downloads the asset and registers it.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "asset_url": {Type: "string", Description: "Direct URL of the STAC asset to download (e.g. a GeoJSON or GeoParquet file URL)."}, - "name": {Type: "string", Description: "Name for the imported dataset."}, - "format": {Type: "string", Description: "Data format hint: 'geojson', 'parquet', 'gpkg', or 'csv'. Auto-detected if omitted."}, - "namespace": {Type: "string", Description: "Optional dataset namespace prefix."}, - "collection": {Type: "string", Description: "Optional STAC collection identifier."}, - "catalog_url": {Type: "string", Description: "Optional source catalog URL for provenance."}, - "project_id": {Type: "string", Description: "Optional project to attach the imported dataset to. Also configurable globally via --project-id."}, - }, - Required: []string{"asset_url", "name"}, - }, - }, - { - Name: "search_stac", - Description: "Search the local STAC catalog with spatial, temporal, and attribute filters. Supports bbox, datetime ranges, collection filtering, and CQL2 expressions.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "bbox": {Type: "string", Description: "Bounding box as 'west,south,east,north'."}, - "datetime": {Type: "string", Description: "Temporal filter as ISO 8601 datetime or range."}, - "collections": {Type: "string", Description: "Comma-separated collection IDs to search within."}, - "limit": {Type: "string", Description: "Maximum results (default 10)."}, - "filter": {Type: "string", Description: "CQL2-text filter expression."}, - }, - }, - }, - { - Name: "map_api", - Description: "Access map-focused operations (publishing, raster metadata, slope/aspect, geodesic area/length, raster classification, OGC feature edits) through a strict allowlist. Mutating operations require confirm=true.", - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "operation": { - Type: "string", - Description: "Map operation name.", - Enum: []string{ - "publish_map", "list_published_maps", "unpublish_map", "get_published_map_stats", - "update_map_embed_config", "get_public_map", - "get_raster_info", "get_raster_stats", "get_raster_histogram", "get_raster_dimensions", "get_raster_values", - "raster_zonal_stats", "export_raster_band", "raster_contour", "raster_viewshed", "raster_profile", "raster_kde", - "raster_slope", "raster_aspect", - "geodesic_area", "geodesic_length", - "classify_kmeans", "classify_isodata", "classify_ml", "classify_rf", - "create_feature", "update_feature", "delete_feature", - }, - }, - "token": {Type: "string", Description: "Published map token path parameter."}, - "name": {Type: "string", Description: "Raster name path parameter."}, - "collection_id": {Type: "string", Description: "Collection identifier for feature edit operations."}, - "feature_id": {Type: "string", Description: "Feature ID for update/delete operations."}, - "query": {Type: "object", Description: "Optional query string key/value map."}, - "body": {Type: "object", Description: "Optional JSON request body."}, - "confirm": {Type: "boolean", Description: "Required true for mutating operations."}, - }, - Required: []string{"operation"}, - }, - }, + "output": stringProp("Compatibility alias for output_name."), + "output_name": stringProp("Optional output dataset name."), + "register": boolProp("Whether to register the result as a dataset."), + }, "steps"), + tool("list_pipelines", "List persisted pipelines.", map[string]PropertySchema{}), + tool("get_pipeline", "Fetch a persisted pipeline by ID.", map[string]PropertySchema{ + "pipeline_id": stringProp("Pipeline identifier."), + }, "pipeline_id"), + tool("create_pipeline", "Create a persisted pipeline definition.", map[string]PropertySchema{ + "name": stringProp("Pipeline name."), + "description": stringProp("Optional pipeline description."), + "graph": objectProp("Pipeline graph payload."), + "canvas": objectProp("Pipeline canvas payload."), + }, "name"), + tool("update_pipeline", "Update a persisted pipeline definition.", map[string]PropertySchema{ + "pipeline_id": stringProp("Pipeline identifier."), + "name": stringProp("Pipeline name."), + "description": stringProp("Optional pipeline description."), + "graph": objectProp("Pipeline graph payload."), + "canvas": objectProp("Pipeline canvas payload."), + "version": numberProp("Current pipeline version."), + }, "pipeline_id", "name", "version"), + tool("delete_pipeline", "Delete a persisted pipeline by ID.", map[string]PropertySchema{ + "pipeline_id": stringProp("Pipeline identifier."), + }, "pipeline_id"), + tool("duplicate_pipeline", "Duplicate a persisted pipeline by ID.", map[string]PropertySchema{ + "pipeline_id": stringProp("Pipeline identifier."), + }, "pipeline_id"), + tool("execute_saved_pipeline", "Execute a persisted pipeline by ID.", map[string]PropertySchema{ + "pipeline_id": stringProp("Pipeline identifier."), + }, "pipeline_id"), + tool("list_pipeline_runs", "List execution runs for a persisted pipeline.", map[string]PropertySchema{ + "pipeline_id": stringProp("Pipeline identifier."), + }, "pipeline_id"), + tool("get_pipeline_run", "Fetch a pipeline run by ID.", map[string]PropertySchema{ + "run_id": stringProp("Pipeline run identifier."), + }, "run_id"), + tool("list_query_engines", "List available SQL query engines.", map[string]PropertySchema{}), + tool("get_query_engine_info", "Fetch SQL engine info for a specific engine.", map[string]PropertySchema{ + "engine": stringProp("Query engine identifier such as duckdb or postgis."), + }, "engine"), + tool("list_query_datasets", "List datasets visible to a specific SQL engine.", map[string]PropertySchema{ + "engine": stringProp("Query engine identifier such as duckdb or postgis."), + }, "engine"), + tool("execute_sql", "Execute a SQL query through Cairn's engine-aware query control plane.", map[string]PropertySchema{ + "engine": stringProp("Query engine identifier such as duckdb or postgis."), + "query": stringProp("Compatibility alias for sql."), + "sql": stringProp("SQL text to execute."), + "limit": numberProp("Optional result limit."), + "timeout_sec": numberProp("Optional execution timeout in seconds."), + "format": stringProp("Result format, typically json or arrow."), + "query_options": objectProp("Optional engine-specific options."), + }, "engine"), + tool("save_sql_result", "Execute SQL and save the result as a dataset.", map[string]PropertySchema{ + "engine": stringProp("Query engine identifier such as duckdb or postgis."), + "query": stringProp("Compatibility alias for sql."), + "sql": stringProp("SQL text to execute."), + "output_name": stringProp("Dataset name for the saved result."), + "geometry_column": stringProp("Optional geometry column override."), + "limit": numberProp("Optional result limit."), + "timeout_sec": numberProp("Optional execution timeout in seconds."), + "query_options": objectProp("Optional engine-specific options."), + "project_id": stringProp("Optional project override. Also configurable globally via --project-id."), + }, "engine", "output_name"), + tool("list_projects", "List accessible projects.", map[string]PropertySchema{}), + tool("get_project", "Fetch a project by ID.", map[string]PropertySchema{ + "project_id": stringProp("Project identifier."), + }, "project_id"), + tool("create_project", "Create a new project.", map[string]PropertySchema{ + "name": stringProp("Project name."), + "description": stringProp("Optional description."), + }, "name"), + tool("update_project", "Update an existing project.", map[string]PropertySchema{ + "project_id": stringProp("Project identifier."), + "name": stringProp("Project name."), + "description": stringProp("Optional description. Use an empty string to clear when supported by the server."), + }, "project_id"), + tool("delete_project", "Delete a project by ID.", map[string]PropertySchema{ + "project_id": stringProp("Project identifier."), + }, "project_id"), + tool("get_project_workspace", "Fetch workspace state for a project.", map[string]PropertySchema{ + "project_id": stringProp("Project identifier."), + }, "project_id"), + tool("set_project_workspace", "Replace workspace state for a project.", map[string]PropertySchema{ + "project_id": stringProp("Project identifier."), + "map_state": objectProp("Workspace map state."), + "layer_styles": objectProp("Optional layer style map."), + }, "project_id", "map_state"), + tool("publish_map", "Publish a map state and receive a public token.", map[string]PropertySchema{ + "title": stringProp("Optional published title."), + "description": stringProp("Optional published description."), + "map_state": objectProp("Serializable map state payload."), + "expires_hours": numberProp("Optional expiration window in hours."), + "embed_config": objectProp("Optional embed configuration."), + }, "map_state"), + tool("list_published_maps", "List published map links.", map[string]PropertySchema{}), + tool("delete_published_map", "Delete a published map by token.", map[string]PropertySchema{ + "token": stringProp("Published map token."), + }, "token"), + tool("get_published_map_stats", "Fetch statistics for a published map.", map[string]PropertySchema{ + "token": stringProp("Published map token."), + }, "token"), + tool("update_map_embed_config", "Update embed configuration for a published map.", map[string]PropertySchema{ + "token": stringProp("Published map token."), + "embed_config": objectProp("Embed configuration payload."), + }, "token", "embed_config"), } }