Repository navigation
design api tools
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.
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.
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": { "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.successcompares loosely (String()-coerced — 0/"0"/"success" are one family).payloadFieldabsent = 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):
maxResultCharstruncates 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.
- 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.externalapplies to targets resolving to public addresses,internalto private/loopback ones (all-IPs-internal ⇒ internal side; anti-rebinding by construction: a public-listed name never blesses a private resolution). Entries: hostname,*.domainwildcard, 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
metaclass (#79) — it opens a new outbound surface and never goes silent.
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 isdeny: the baseUrl host must match a NAME rule in either list —409 API_TOOL_HOST_NOT_ALLOWEDnaming the host and the remedy. Underallow(blacklists) any http/https host saves; the runtime check stays either way (defense in depth). -
Tool
descriptionis REQUIRED (#85,min(1)): it rides theapi__<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_savedescription 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 thetools— 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.
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).
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 saidauth:{type:...}where the schema discriminates onstyle— 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-toolsgroup — 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 → themcpgroup) 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_messagesFK 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 —
inis 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.
- Foundation: shared schema + core port + storage (sqlite + memory) + service (#36 scoping) + REST (JSON upload/download + probe) + sdk-ts.
- Runtime: outbound policy guard + IP validation + fetch + envelope/errors + caps;
assistant tool injection (group
api-tools). - MCP exposure: virtual in-process server in the registry,
/mcpvisibility per profile. - Web: list page + upload/download + detail + probe panel + i18n (en/zh, API 工具).