diff --git a/skills/manage-apis/SKILL.md b/skills/manage-apis/SKILL.md index 8152607..7ca5cdf 100644 --- a/skills/manage-apis/SKILL.md +++ b/skills/manage-apis/SKILL.md @@ -1,159 +1,205 @@ --- name: manage-apis -description: Create, update, or inspect Neptune DXP API artifacts via the MCP tools `list_apis`, `get_api`, `save_api`, `delete_api`. Use when the user wants to add an API, modify paths/definitions, change runtime flags (proxy, tracing, access), assign roles, or import/clone an API. Trigger phrases include "add an API", "create an endpoint", "update the API", "delete this API", "add a path", "add a definition", "enable proxy on", "restrict access to". +description: Create, update, inspect, or delete Neptune DXP API artifacts via the MCP tools `list_apis`, `get_api`, `save_api`, `delete_api`. Use when the user wants to add an API (script-backed, HTTP proxy, or table), wire a server script to an endpoint, define request/response types or definitions, add or fix paths/operations, change runtime flags (proxy, tracing, access, public), inspect who uses an API, or clean up APIs. Trigger phrases include "add an API", "create an endpoint", "expose this script as an API", "fix the request/response types", "add a path", "enable proxy", "restrict access", "delete this API". --- # Managing Neptune DXP API artifacts via MCP -An API artifact in Neptune DXP is a config record that the runtime uses to: proxy HTTP calls to an upstream service (`/proxy//`), route to server scripts (`/api/serverscript/:name/:operation`), enforce role-based access, and optionally trace requests. The MCP tools `save_api` / `delete_api` write the same `api` table the cockpit's API Designer reads from — changes are immediately live. +An API artifact is a config record in the `api` table. Depending on its `apiType`, the runtime uses it to expose a server script over HTTP, gate and enrich proxy calls to an upstream service, or document CRUD access to a table. `save_api` / `delete_api` write the same records the Cockpit's API Designer edits — **changes are live immediately, there is no draft/activate step**. -## Tools - -| Tool | Purpose | -|---|---| -| `list_apis` | All APIs with id/name/endpoint/description/type/status — no nested paths/definitions in the response. | -| `get_api({ id })` | Returns an **envelope** `{ api, apps, scripts, webapps }` — the artifact itself is under `.api` (paths, definitions, auth, endpoints, roles all nested there), with consumer/dependent info alongside. ~30KB for a typical OpenAPI 3 import. (Note: `list_apis` returns flat records, not this envelope.) | -| `save_api({ api })` | Create (no `id`) or update (with `id`). Pass the full object — partial updates replace whole arrays (see Partial updates below). | -| `delete_api({ id })` | Permanent. No undo. | - -The `save_api` inputSchema is the full `APISaveSchema` minus base-artifact noise. Every field, enum, and nested shape is documented in the schema the LLM already sees on `tools/list` — this skill is for the things the schema can't convey: gotchas, conventions, and discovery patterns. - -## Required for a new API +## The three API types -Just two: +| `apiType` | What it is | `endpoint` value (the identity, not a free choice) | Served at | +|---|---|---|---| +| `"script"` | Server-script backed API | `/api/serverscript/` — slug = name lowercased, spaces and `/` removed. With `isPublic: true`: `/public/serverscript/` | `/` | +| `""` or `null` | External HTTP API (proxy config) | The upstream base URL, absolute with protocol (`https://api.example.com/v1`) | `/proxy//` | +| `"table"` | Table CRUD API | Always exactly `/api/entity` | `/api/entity/` | -- `name` (string, max 64) — the designer name -- `endpoint` (string, max 128) — base URL the runtime treats as canonical. For proxy APIs: the upstream base URL (`https://api.example.com/v1`). For server-script APIs: a local path like `/api/serverscript/my-api`. +**`apiType` and `apiFormat` are chosen at creation and NEVER changed afterwards.** The Cockpit provides no way to change them once created. `save_api` will *not* stop you — a flip persists silently (verified) and produces an unsupported, inconsistent artifact (paths bound to scripts on a "proxy" API, wrong doc generation, broken designer UI). If an API has the wrong type: create a new API with the right type, migrate the paths/definitions, then delete the old one. Never recommend or attempt an in-place type or format change. -Everything else has sane defaults. Note `apiType` is **not settable over MCP** (see below). +**`apiFormat`**: `0` = Swagger 2, `1` = OpenAPI 3. It selects the spec-generation branch and designer editors. **Always set `apiFormat: 1` for new APIs** (the Cockpit does; the Zod default is 0 for legacy reasons, and the Swagger 2 branch depends on metadata MCP cannot write — see Definitions below). -## `apiType` is read-only over MCP — a real limitation +## Tools -`apiType` decides what an API *is*, and the cockpit/runtime branch on it: +| Tool | Behavior (verified) | +|---|---| +| `list_apis` | Flat summaries: id, name, description, endpoint, apiType, version, apiGroup, package, createdBy/changedBy, createdAt/updatedAt, enableProxy, enableTrace. No paths/definitions (payload-size guard). | +| `get_api({ id })` | Envelope `{ api, apps: {children}, scripts: {children}, webapps: {children} }`. The artifact is under `.api` (paths, definitions, auth, endpoints, documentation, and `roles`). The three siblings are **where-used**: apps, server scripts, and web apps consuming this API. Id only — lookup by name is not supported. | +| `save_api({ api })` | Create (no `id`) or update (with `id`). Returns the **full saved artifact** — use it to verify instead of an extra `get_api` (it lacks `roles`/where-used, which only `get_api` returns). | +| `delete_api({ id })` | Permanent, no undo; also deletes the API's trace records. **Returns "deleted" even for nonexistent ids** (verified) — success is not proof the id existed. | -| `apiType` | What the API is | Server scripts attachable? | -|---|---|---| -| `""` / null | External HTTP-proxy API (forwarded to `endpoint` via `/proxy/...`) | No | -| `"table"` | Backed by a Neptune table / dictionary | No | -| `"script"` | Backed by server scripts (`/api/serverscript//`) | Yes | +## The `save_api` contract -**You cannot set `apiType` through `save_api`.** It is an excluded key in `APISaveSchema` (`src/validation/schemas/artifacts/api.schemas.ts`), so it is silently stripped — every MCP-created API is `apiType: null` (verified: saving `apiType: "script"` reads back `null`). Consequence: an API created over MCP is untyped, so the cockpit Script Editor won't let you attach a server script to its paths (it filters to `apiType === "script"`). To get a script-backed endpoint, create/convert the API in the cockpit API Designer (which sets `apiType: "script"`), then use `save_api` for its paths/definitions. +**Update semantics (verified live) — this is the most important rule set:** -## Runtime flags — the security-relevant ones +| You send | What happens | +|---|---| +| Key **absent** | Column untouched — existing value preserved. Partial updates are safe and preferred: `{ "id": "...", "enableTrace": true }` changes exactly one flag. | +| Array key = **`null`** | Coerced to `[]` → **wipes the array** (verified: `paths: null` erased all paths). | +| Array key **present** | **Replaced wholesale** — never merged. To change one path, send *all* paths with one modified (see Workflows). | +| Unknown/read-only key | **Silently stripped, no error** (verified). See the strip list below — this is the #1 source of "my save didn't work". | -These determine what the proxy route does at runtime. Get them wrong and you've either broken the API or opened a hole. +- **Upsert trap**: an `id` that doesn't exist **creates** a new record carrying that id (verified). A stale or guessed id never fails "not found" — it makes a ghost. Confirm with `get_api` when you intend update-only. +- **No duplicate-name guard** (verified: two APIs with the same name saved fine). The Cockpit checks separately; MCP does not. Since script-API runtime lookup can fall back to matching by *name*, duplicates cause ambiguous routing. Always check `list_apis` for the name before creating. +- **No endpoint validation**: MCP accepts a proxy endpoint without `http(s)://` (verified). The Cockpit validates this client-side; a protocol-less external endpoint later breaks doc generation ("Protocol for api endpoint is missing"). +- **Server-managed fields**: `id` (on create), `ver` (datestamp like `26.07.17.1546`, regenerated every save), `createdBy`/`changedBy`, `createdAt`/`updatedAt`. Don't send them; sending is harmless (stripped or overwritten). +- **Nested ids are required and client-generated**: every `paths[]` and `definitions[]` item needs `"id": ""` — generate one per item. Missing id → clear validation error (`api.paths.0.id Required`), nothing is saved. Path ids are uppercased server-side, definition ids keep their case — compare case-insensitively when matching after read. +- Limits: `name` ≤ 64, `endpoint` ≤ 128, `description` ≤ 1024. `method` ∈ `GET POST PUT PATCH TRACE OPTIONS HEAD DELETE CONNECT`. -- `enableProxy: true` — required for `/proxy//` to forward to the upstream. Off by default. -- `restrictAccess: true` — blocks **every** proxy call, regardless of roles. The hard-off switch. -- `role: [{ id: }, ...]` — users must hold at least one of these roles to proxy through this API. **On `save_api` the field is `role` (singular); the save schema strips `roles` (plural). But `get_api` returns the assignment back under `roles`.** Read = `roles`, write = `role` (see Gotchas). -- `paths[].operAccess: ["", ...]` — per-operation role narrowing. Applied *after* the API-level roles pass. -- `enableTrace: true` — writes an `api_trace` row per proxy call (api id, operation id, runtime, status). Useful for debugging; noisy at high volume. -- `tlsAllowUntrusted: true` — accepts self-signed/invalid upstream certs. Don't set unless you understand why. -- `forwardProxy: ""` — tunnel upstream calls through a corporate HTTP proxy. +### Fields that are silently stripped (never sent successfully) -## Paths +Sending any of these does nothing — no error, no effect (all verified live or in `APISaveSchema`): -The `paths` array describes each operation the API exposes. Shape per item is in the schema, but two things to know up front: +- `roles` (plural) anywhere, and **`role` (singular) is accepted by the schema but ignored by the ORM** — role assignment over MCP is a **silent no-op** (verified: save succeeded, `roles` stayed `[]`). Assign roles in the Cockpit only. +- On properties (in `definitions[].properties` and `content[].items`): **`reference`**, `parent`, `isParent`, `default`, `maxLength`, `minLength` — see Definitions below for what this breaks and how to work around it. +- On `parameters[]` / headers: `type`. +- On the API root: `enableScriptEditor`, `documentation`, `NAME`, `title`, `application`, `accessLevel`, `ver`, `createdBy`, `changedBy`. -### Path parameter syntax: parens, not curly braces +After any save, **verify by reading the returned artifact** — if a field you sent isn't in the response, it was stripped; don't retry the same payload. -Neptune DXP's importer converts OpenAPI's `{petId}` to `(petId)`. When you write paths by hand, use parens too: +## Script-backed APIs (`apiType: "script"`) — the full recipe +1. **Check the name is unused** (`list_apis`), then create: +```json +save_api({ "api": { + "name": "order-status", + "endpoint": "/api/serverscript/order-status", + "apiType": "script", + "apiFormat": 1, + "description": "Order status lookup" +}}) ``` -/pets/(petId) ← correct -/pets/{petId} ← will NOT match dynamic routes +The endpoint MUST follow the formula (name lowercased, spaces/`/` stripped, prefixed `/api/serverscript/`). The runtime resolves `` in the URL against the `endpoint` column first, then **falls back to matching the artifact `name`** — so a wrong endpoint often still "works", hiding the misconfiguration until a rename or a duplicate name breaks it. `isPublic: true` + `/public/serverscript/` endpoint makes it callable without login at that public path. + +2. **Add operations, each bound to a server script by id** (`paths[].serverScript` = the script's id — find it via `list_script_projects` / `list_ungrouped_scripts`, see the manage-server-scripts skill): +```json +{ "id": "", "method": "POST", "path": "/status", + "serverScript": "", + "summary": "Get order status", + "content": [{ "name": "StatusRequest", "objectType": "reference" }], + "responses": [{ "status": "200", "description": "OK", + "content": [{ "name": "StatusResponse", "objectType": "reference" }] }] } ``` -`findOperation` in the proxy code matches `(name)` with the regex `[^/]+`, so `/pets/(petId)` matches both `/pets/123` and `/pets/foo`. +3. **Runtime contract** (what actually happens on `POST /api/serverscript/order-status/status`): +- Operation matching: `path` must start with `/`; matched case-insensitively and exactly against the first URL segment after the API name; a path of `"/"` matches a bare `/api/serverscript/` call; `HEAD` requests match `GET` operations. Dynamic segments use **parentheses**: `/orders/(orderId)` matches `/orders/123` and fills `req.params.orderId`. Curly braces `{orderId}` never match. +- The script receives `req` (with `req.body`, `req.query`, `req.params`, `req.user`, and `req.p9.api` / `req.p9.operation`) and writes to `result`: `result.data` (payload), `result.statusCode`, `result.contentType`, `result.filename`, `result.headers`. No `res` object. +- **Request/response definitions are documentation + typing only — nothing validates them at runtime.** The wire response is whatever the script puts in `result`. Keep definitions and script behavior in sync manually. +- A matched path with no `serverScript` returns `{"status": "Server Script not set in Operation"}` — that exact message means the binding is missing. +- `useOwnProcess: true` runs the script in a forked process (isolation for heavy/risky scripts). -### HTTP method enum +4. **Cockpit visibility gotcha**: the Script Editor's API tree only shows APIs with `enableScriptEditor: true`, which the Cockpit sets on save but **MCP cannot set** (stripped). An MCP-created script API works at runtime and its `serverScript` bindings function, but it won't appear in the Script Editor tree until someone opens and saves it once in API Designer. Mention this when handing over to Cockpit users. Assigning an `apiGroup` also keeps it organized in Cockpit trees. -`method` is one of: `GET, POST, PUT, PATCH, TRACE, OPTIONS, HEAD, DELETE, CONNECT`. The SDK rejects anything else with a clear ZodError. +## Definitions and request/response types — what actually works over MCP -## Definitions +Definition/property metadata is stored as a **flat list with `parent` pointers**; spec generation and client codegen rebuild nesting *only* from `parent` — and MCP strips `parent`, `isParent`, and `reference` from every property (verified). Consequences: -Reusable schemas referenced from paths' request/response bodies. +**DO (these shapes survive and export correctly with `apiFormat: 1`):** +- **Flat definitions** — one level of primitive properties, every property with an explicit `objectType`: +```json +{ "id": "", "name": "StatusRequest", "type": "object", + "properties": [ + { "id": "", "name": "orderId", "objectType": "string", "required": true }, + { "id": "", "name": "verbose", "objectType": "boolean" } ] } +``` +- **Content-level references** from a path's request body (`paths[].content`) or response (`responses[].content`) to a definition **by name**: `{ "name": "StatusRequest", "objectType": "reference" }`, plus `"isArray": true` for arrays of that definition. +- **Inline nested shapes directly in `content[].items`** (only there — content items are exported via `children` recursion, not parent pointers): +```json +"content": [{ "objectType": "object", "items": [ + { "id": "", "name": "order", "objectType": "object", "children": [ + { "id": "", "name": "total", "objectType": "number" } ] } ] }] +``` -- `type`: `object | array | boolean | string | number` -- `properties[].objectType`: `uuid | boolean | object | number | string | integer | array | reference` -- `properties[].children` recurses — for nested objects/arrays. +**DON'T:** +- **Property-level references** (`objectType: "reference"` with a `reference` field) — the `reference` value is silently stripped (verified), leaving a dangling reference that exports as `$ref: .../undefined`. There is no MCP-writable substitute; restructure to content-level references or inline items. +- **Nested `children` inside `definitions[].properties`** — the children are *stored* but spec generation/codegen rebuild from `parent` and will render the object **empty**. Keep definitions flat; put nesting inline in content. +- **Omitting `objectType` on a property** — it defaults to `"reference"` (a broken one). Always set it: `uuid | boolean | object | number | string | integer | array | reference | vector`. +- **Round-tripping imported APIs**: an API imported from OpenAPI in the Cockpit carries `parent`/`reference`/`type` metadata that MCP save strips. A get → save of its `definitions` — even unmodified — **permanently destroys nesting and references**. When updating such an API, *omit* `definitions` (and any array you don't intend to change) from the payload; absent keys are untouched. For deep definition surgery on imported APIs, use the Cockpit. -When importing from OpenAPI 3, each `components/schemas` entry lands here. +`parameters[]` (query/path params) are safe over MCP except `type` (stripped; docs render them as string). `inPath: true` marks a path parameter — its `name` must match a `(name)` segment in the path. -## Gotchas (schema can't tell you these) +## External proxy APIs (`apiType: ""`) -- **Read/write field-name asymmetry on roles.** `get_api` returns assigned roles under **`roles`** (the entity relation), but `save_api` accepts only **`role`** (singular) and **strips `roles`** (it's in `APISaveSchema`'s excluded keys). So if you `get_api`, edit, and `save_api` back verbatim, the role assignment is dropped — you must set `role: [{ id }]` before saving. (Derived from `src/validation/schemas/artifacts/api.schemas.ts`; not live-tested here because the dev instance had no roles defined.) -- **`apiFormat` (0 vs 1) is import provenance, not behavior.** Swagger 2 and OpenAPI 3 describe the same shapes; the runtime treats them interchangeably. Default `0` is fine for hand-crafted APIs. -- **Auto-set fields are read-only in practice.** `id` (set on create), `ver` (set per save), `createdAt`/`updatedAt`/`createdBy`/`changedBy` (auto). Don't pass them on update. -- **Saving with an unknown `id` creates it (upsert), it does NOT no-op.** Verified on 24.15: `save_api` with a random `id` inserts a new record carrying that id. Don't treat a stale `id` as a safe no-op — confirm with `get_api` if you intend update-only. -- **`delete_api` does not validate existence.** Deleting a non-existent id returns `API '' deleted` with no error (unlike `delete_app`, which errors). A success message is not proof the id existed. -- **`enableProxy: false` + paths defined** is valid but useless — the runtime route is closed. Easy to set up an API and wonder why `/proxy/...` returns nothing. -- **`api_trace.statusCode` column is always NULL** — Planet 9 doesn't populate it. The trace row's `status` field (`'success' | 'error'`) is what tells you the outcome, not HTTP status. +```json +save_api({ "api": { + "name": "weather", "endpoint": "https://api.weather.gov", + "apiType": "", "apiFormat": 1, "enableProxy": true, + "paths": [{ "id": "", "method": "GET", "path": "/points/(lat),(lon)", + "responses": [{ "status": "200", "description": "OK" }] }] }}) +``` +- Call shape: `/proxy//`, e.g. `/proxy/https%3A%2F%2Fapi.weather.gov%2Fpoints%2F40.7%2C-74.0/`. The platform attaches the API's auth, role checks, tracing, forward proxy, and TLS settings to the call. +- **`enableProxy` is a consumer-side switch, not a server gate**: it tells App Designer data sources and generated API clients to route through `/proxy/...` instead of calling the upstream directly from the browser (CORS). The proxy route itself doesn't check it. +- **Paths are advisory unless the instance sets `proxy.restrictedEnabled`**: with restriction on, calls that don't match a defined path+method are blocked; without it, any URL passes once API-level access checks pass. Define paths accurately either way — they also drive docs and clients. +- `forwardProxy` (API- or path-level): route upstream calls through a corporate HTTP proxy. `tlsAllowUntrusted: true`: accept invalid upstream certs — only with explicit user intent. -## Partial updates +### Multi-environment endpoints and target-system auth -`save_api` with an `id` does a full-record save. Nested arrays (`paths`, `definitions`, `roles`, `auth`, `endpoints`) are **replaced wholesale** — they aren't merged. +- `endpoints: [{ "id": "", "role": "DEV|TST|QUA|PRD|...", "endpoint": "" }]` — alternate upstream URLs per system role (must be valid absolute URLs). +- `auth: [{ "id": "", "apiAuth": "", "authName": "