Skip to content

design api tools

sinrimin edited this page Oct 26, 2026 · 2 revisions

Design: API tools — declarative HTTP APIs as assistant tools + MCP exposure (#80)

Issue #80. Converged over three discussion rounds 2026-10-21/22; rides the #79 class system. Status: shipped (four slices on main, 2026-10-20 push; the meta-class assistant tools for config create/modify landed as #81 — see "Assistant-side management tools" below). Naming: API tools (api-tools) — the concept; http is the binding type in config and code, not the product name.

Concept

A first-class resource: a JSON-defined SERVICE bundle (base URL + shared auth/headers + endpoint tools) that the platform assistant calls as tools and client agents receive through the aggregated /mcp endpoint (a virtual, in-process MCP server in the McpRegistry — no child process). Config authoring is LLM-first: JSON upload/download + display UI + a PROBE panel; deliberately no forms. The probe closes the LLM config loop: schema validation → "rendered request + what the model would see" (success AND a failure preview) → behavior validation, not just shape validation.

Config format

Compact own format, zod in packages/shared, version field built in. OpenAPI is an import SOURCE (v2), never the storage format — LLMs write small schemas with precise zod errors far better than big OpenAPI documents.

Per service: name, baseUrl, shared headers, credential refs. Per tool: method, path ({param} slots), params (in path/query/header/body, typed, required), body content type (json | form; multipart rejected — no file support in v1), class (derived: GET → read, others → write, per #79), timeoutMs, maxResultChars (default 10000), response, errors.

Response model (jsonata/jslt deliberately rejected — LLM blind-write accuracy)

"response": { "type": "json" | "text",
  "envelope": { "codeField": "code", "success": [0, "0", 200],
                "payloadField": "body", "errorFields": ["message", "desc"] } }
  • Body params are KEYS of the request body object ({name: value}), always wrapped — a lone body param is never sent as a bare JSON value (#87: the shortcut 400'd on object-expecting upstreams like DeepSeek fetch).
  • Field addressing: DOT PATHS only (data.result), no DSL. success compares loosely (String()-coerced — 0/"0"/"success" are one family). payloadField absent = whole body.
  • Matrix: 2xx + success → serialized payload, char-capped; 2xx + code-fail → TOOL ERROR (first non-empty errorField); non-2xx → tool error (status + capped body, default 500 chars); non-2xx + treatBodyOnNon2xx → same envelope parse. Declared-json-but-received-HTML → error + first 200 chars (the WAF/login-page diagnosis path).
  • No spill-to-file / pipeline tools (cut deliberately — uncertain blast radius outside a trusted-internal audience): maxResultChars truncates with …[truncated, N/M chars]; a server-side raw-body READ CAP (~2 MB) protects memory before truncation applies. Binary responses are rejected at validation (json/text only).
  • Errors are ALWAYS tool errors, never success-shaped. Timeout/IO/DNS: fixed semantics (error + duration), no retry in v1. No SSE/WebSocket/long-polling — request/response only, transport behind an interface for a future "SSE-harvest" mode.

Network & secrets

  • Outbound POLICY (#85, admin-managed, lives in system settings): a DEFAULT action plus TWO rule lists split by network side. allow (the default) — everything dials except the lists, which are BLACKLISTS; deny — nothing dials except the lists, which are WHITELISTS. external applies to targets resolving to public addresses, internal to private/loopback ones (all-IPs-internal ⇒ internal side; anti-rebinding by construction: a public-listed name never blesses a private resolution). Entries: hostname, *.domain wildcard, IP literal, or CIDR range (BigInt mask compare, v4+v6 — Node's BlockList was dropped: its IPv6 prefix checks are wrong). Enforced at dial always; at SAVE under deny-mode a host matching no NAME rule is refused (409 API_TOOL_HOST_NOT_ALLOWED). Cloud-metadata/link-local ranges are hard-denied in EVERY mode: that guard is not a policy. Outbound HTTP surface #3, same discipline as marketplace fetch (#7.2).
  • Credentials ride the existing credential system by NAME; injection is server-side at request time. SECRET SUBSTITUTION AND PARAMETER SUBSTITUTION ARE STRUCTURALLY SEPARATE: secrets fill only config template slots, tool arguments fill only declared param slots — a prompt injection can never angle a secret into an attacker-visible position. Display/logs go through the standard redaction.
  • Assistant-visible creation/modification of API tool configs is meta class (#79) — it opens a new outbound surface and never goes silent.

Save-time guards + review fixes (#83/#84)

The review round after the live DeepSeek E2E hardened the authoring path so failures surface at SAVE time, not first probe:

  • Host guard at save (create + update + api_tools_save), while the #85 default action is deny: the baseUrl host must match a NAME rule in either list — 409 API_TOOL_HOST_NOT_ALLOWED naming the host and the remedy. Under allow (blacklists) any http/https host saves; the runtime check stays either way (defense in depth).
  • Tool description is REQUIRED (#85, min(1)): it rides the api__<service>__<tool> definition into the model's context — an absent description left the LLM guessing what a tool does. Service-level description stays optional (page subtitle, not model-facing); param descriptions stay recommended-not-required.
  • Credentials resolve by ROW scope (was: the saver's visibility): a GLOBAL service references GLOBAL credentials only (409 API_TOOL_CREDENTIAL_SCOPE — the runtime resolves global scope only, so a personal credential bound at save was a service dead for everyone); a personal service resolves global first, then its owner's. Same #21 rule the MCP registry applies to global rows.
  • Manage gate: PUT/DELETE assert admin on global rows (403) — reading a global row never conferred rewrite rights.
  • api_tools_save description now states the deny-mode whitelist requirement (tell the user, don't retry) and the one-service-per-host grouping (ONE service = ONE API host, its endpoints are the tools — the E2E produced two single-tool services for search/fetch because nothing steered the grouping).

Sibling fixes in the same round (#83, other docs): MCP operator actions on global rows are admin-gated in the UI (the service always 404-hid them); assistant:session.open resolves an existing session's OWN assistant (the ?s= hand-off used to die as SESSION_NOT_FOUND) and the ack carries assistantId. Page idioms moved to the system devices (#84): plain font-medium name (no signal accent on a config name), baseUrl/tool names as Well chips, ScopeBadge, probe form on Field + Input.

Assistant-side management tools (#81)

The authoring loop closes INSIDE a conversation: the assistant reads API docs and wires the service up itself. Four static tools in the api-tools group (the runner's dynamic api__* execution tools are unchanged):

  • api_tools_list / api_tools_get (read): visible services, then the FULL bundle by id or unique visible name — the editing base.
  • api_tools_save (meta): create or full-replace through the #53 service — scope rules and credential-visibility validation unchanged. Zod issues are pre-validated into compact field-named errors (the LLM feedback loop gets verbatim, fixable messages). A save from a turn AUTO-ATTACHES the service to the calling assistant config, so its tools join the NEXT turn; per-call class gating (GET read / others write) still applies to every execution.
  • api_tools_delete (meta): soft-delete by id or unique visible name; a name matching both a global and a personal row must disambiguate by id.

meta means every save/delete prompts — in EVERY permission mode, including autoWrite (#79's no-silent-tier rule for boundary-changing operations).

Live E2E findings (2026-10-20, local stack, tui-verify)

The assistant (deepseek-4.1-flash, server-side websearch) read the DeepSeek search/fetch docs itself and created both services through the meta tools; the loop — card → approve → verbatim zod error → self-correction → save → auto-attach → api__* injection next turn — held end to end. Found and fixed on the way (#82, #80 residue):

  • The tool description IS the model's schema spec. api_tools_save's prose said auth:{type:...} where the schema discriminates on style — the model followed the description and bounced with a 422. Keep the description in lockstep with the zod schema, field names verbatim.
  • The assistant dialog's group list (a web-side copy) lacked api-tools — the group could not be enabled from the UI at all.
  • (2026-10-26 trial) Attaching a service in the dialog's picker did NOT enable the api-tools group — the runner's per-turn gate silently ignored the attached services and the assistant honestly reported the tool as invisible. Checking a service (or an MCP server → the mcp group) now auto-enables its group in the same draft update, and the picker shows a warn line while services are attached but the group is off (legacy rows).
  • (#82) Rebinding the agent on a lazy (#54, un-persisted) session crashed on the assistant_messages FK and left the pending row under the old assistant; it now re-stamps the pending row.
  • Observation, unfixed: the CREATE dialog silently no-ops when the provider is switched after the model was picked (the model value goes stale); REST works. Also the 60 s approval timeout on the stack config is tight for unattended approvals.
  • Follow-up review pass on the page itself: the list now uses the standard row dropdown (View bundle / Probe / Download / Replace / Delete), a NEW detail dialog renders the bundle human-readable (auth by credential name, per-tool method+path, a params TABLE — in is OpenAPI-style parameter vocabulary — envelope summary), and the probe dialog generates a typed form from the selected tool's params instead of a blind JSON textarea. Authoring stays JSON-only by design; reading is human.

Phasing

  1. Foundation: shared schema + core port + storage (sqlite + memory) + service (#36 scoping) + REST (JSON upload/download + probe) + sdk-ts.
  2. Runtime: outbound policy guard + IP validation + fetch + envelope/errors + caps; assistant tool injection (group api-tools).
  3. MCP exposure: virtual in-process server in the registry, /mcp visibility per profile.
  4. Web: list page + upload/download + detail + probe panel + i18n (en/zh, API 工具).

Clone this wiki locally