Script connections
Register OpenAPI, GraphQL, and MCP APIs once — with optional OAuth or config-backed credentials — and call them from any swarm script via typed ctx.api / ctx.mcp clients.
Script connections turn external APIs into first-class, typed clients inside the scripts runtime. A lead registers a connection once — an OpenAPI spec URL, a GraphQL endpoint, or an installed MCP server — and every script gets a generated client for it:
export default async function (args, ctx) {
const pets = await ctx.api.petstore.findPetsByStatus({ query: { status: "available" } });
const country = await ctx.api.countries.graphql(
"query($code: ID!) { country(code: $code) { name capital } }",
{ code: "ES" },
);
const metrics = await ctx.mcp.swarmmcp.getMetrics({});
return { pets: pets.length, country, metrics: metrics.structuredContent };
}Credentials attach to connections as bindings and are injected at network egress — script code never holds a raw secret (see Security model).
| Kind | Script surface | Registered from |
|---|---|---|
openapi | ctx.api.<slug>.<operationId>(args) | a spec URL or inline spec (JSON or YAML) |
graphql | ctx.api.<slug>.graphql(query, vars) | an endpoint URL |
mcp | ctx.mcp.<slug>.<toolName>(args) | an installed MCP server (tool discovery) |
Registering connections
Management is lead-only (RBAC verb script-connection.manage) through the
script-connections MCP tool: list, upsert-openapi, upsert-mcp,
upsert-graphql, refresh, disable. Connections are scoped global
(default), agent, or repo.
OpenAPI, by spec URL
// script-connections
{
"action": "upsert-openapi",
"slug": "notion",
"displayName": "Notion API",
"baseUrl": "https://api.notion.com",
"openapiSpecUrl": "https://api.apis.guru/v2/specs/notion.com/1.0.0/openapi.json",
"allowedHosts": ["api.notion.com"],
// optional: create a credential binding in the same call
"configKey": "NOTION_TOKEN",
"headerTemplate": "Authorization: Bearer [REDACTED:NOTION_TOKEN]"
}The spec is fetched, parsed (JSON or YAML — YAML is canonicalized to JSON at
ingest), and each operation becomes
a method — operationId when present, otherwise derived from the method+path.
The stored spec's ETag is remembered; {"action": "refresh", "id": "..."}
re-fetches and regenerates only when the spec actually changed. Inline specs go
in openapiSpecJson instead of openapiSpecUrl (mutually exclusive).
Call shape — arguments are grouped by parameter location:
await ctx.api.notion.retrieveAPage({
path: { page_id: "..." }, // path params
query: { filter_properties: "..." }, // query params
header: { "Notion-Version": "2022-06-28" }, // header params declared in the spec
body: { ... }, // request body (JSON)
});Base paths in baseUrl (e.g. https://host/api/v3) are preserved when spec
paths are resolved against it.
GraphQL
{
"action": "upsert-graphql",
"slug": "gh",
"displayName": "GitHub GraphQL",
"baseUrl": "https://api.github.com/graphql",
"allowedHosts": ["api.github.com"],
"configKey": "GH_GRAPHQL_TOKEN",
"headerTemplate": "Authorization: Bearer [REDACTED:GH_GRAPHQL_TOKEN]"
}Scripts call it with a positional signature — graphql(query, variables), not
an options object:
const data = await ctx.api.gh.graphql(
"query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { stargazerCount } }",
{ owner: "desplega-ai", name: "agent-swarm" },
);GraphQL errors (an errors array with no data) are thrown with the joined
error messages. allowedHosts is required even for credential-less endpoints.
MCP
MCP connections reference an installed MCP server (managed with the
mcp-server-* MCP tools) and discover its tools at
upsert time:
// 1. register the server (any transport the swarm supports)
// mcp-server-create
{ "name": "context7", "transport": "http", "url": "https://mcp.context7.com/mcp", "scope": "swarm" }
// 2. connect it for scripts — tools are discovered and camelCased into methods
// script-connections
{ "action": "upsert-mcp", "slug": "ctx7", "mcpServerId": "<id from step 1>" }const res = await ctx.mcp.ctx7.resolveLibraryId({ libraryName: "bun", query: "sqlite" });
const text = res.content?.[0]?.text; // raw MCP envelope — unwrap structuredContent/content yourselfTool calls are proxied server-side through
POST /api/script-connections/{id}/mcp-call (RBAC verb
script-connection.invoke) — the MCP server's auth headers and secrets stay in
the API server and never enter the script subprocess. Discovery honors the
connection's scope, so an agent-scoped server's secrets resolve as that agent.
Re-run discovery after a server adds tools with {"action": "refresh"}.
Credentials
Bindings are managed with the credential-bindings MCP tool (list, upsert,
oauth-app-upsert, oauth-authorize-url, import-legacy, disable) and are
stored relationally (script_credential_bindings). A binding is:
configKey— the placeholder identity, e.g.NOTION_TOKENauthKind—config(resolve from swarm config / env) oroauth(resolve from stored OAuth tokens)allowedHosts— exact hostnames where substitution may happenheaderTemplate/queryTemplate— where the credential goes
The template must contain the exact placeholder [REDACTED:<configKey>]:
Authorization: Bearer [REDACTED:NOTION_TOKEN]A template without that placeholder is silently ignored (the request goes out
unauthenticated) — this is the most common misconfiguration; {{KEY}}-style
templates do not work.
For authKind: "config", store the secret first:
// set-config
{ "key": "NOTION_TOKEN", "value": "ntn_...", "isSecret": true, "scope": "global" }Secrets are AES-256-GCM encrypted at rest when SECRETS_ENCRYPTION_KEY is set
— see Secrets encryption.
OAuth flow
For APIs where a user authorizes an OAuth app (GitHub, Google, Slack, ...):
// 1. register the OAuth app (client id/secret from the provider's dev console)
// credential-bindings
{
"action": "oauth-app-upsert",
"provider": "github",
"clientId": "...",
"clientSecret": "...",
"authorizeUrl": "https://github.com/login/oauth/authorize",
"tokenUrl": "https://github.com/login/oauth/access_token",
"scopes": ["repo", "read:org"]
}
// 2. get the authorization URL — the swarm generates state + PKCE (S256)
{ "action": "oauth-authorize-url", "provider": "github" }The user opens the returned URL, authorizes, and the provider redirects to
GET /api/oauth/{provider}/callback, where the swarm exchanges the code
(client secret + PKCE verifier), stores access + refresh tokens, and shows a
"you can close this tab" page.
By default client credentials are sent as form-encoded body parameters. For providers that require HTTP Basic auth and/or a JSON token request (e.g. Notion), set the token-endpoint knobs on the app:
{ "action": "oauth-app-upsert", "provider": "notion", ..., "tokenAuthStyle": "basic", "tokenBodyFormat": "json" }Then bind the token:
// 3. bind — the token resolves from oauth_tokens at run time
{
"action": "upsert",
"configKey": "GITHUB_OAUTH",
"authKind": "oauth",
"oauthProvider": "github",
"allowedHosts": ["api.github.com"],
"headerTemplate": "Authorization: Bearer [REDACTED:GITHUB_OAUTH]"
}Tokens expiring within 5 minutes are refreshed automatically (refresh is serialized per provider via a lock). A provider whose refresh fails is skipped for that run — the placeholder stays unsubstituted and other bindings are unaffected. Scopes are space-separated in the authorize URL per the OAuth spec.
Three identifiers are involved, deliberately independent:
script_connection slug: "notion" → what scripts call: ctx.api.notion
└ credential_binding configKey: NOTION_TOKEN, oauthProvider: "notion" → the link
└ oauth_app provider: "notion" → the callback URL segmentThe callback path segment is the OAuth app's provider slug, not the
connection slug. One authorization can back many connections — e.g. a single
google OAuth app + token bound into separate gcal, gmail, and gdrive
connections. The redirect URI you register in the provider's dev console stays
stable per app regardless of how many connections reference it.
Worked example: Notion (public integration)
Verified end-to-end against a real Notion workspace:
// credential-bindings — note owner=user (Notion-required authorize param)
// and the Basic+JSON token-endpoint style
{
"action": "oauth-app-upsert",
"provider": "notion",
"clientId": "<integration client id>",
"clientSecret": "<integration secret>",
"authorizeUrl": "https://api.notion.com/v1/oauth/authorize",
"tokenUrl": "https://api.notion.com/v1/oauth/token",
"scopes": [],
"extraParams": { "owner": "user" },
"tokenAuthStyle": "basic",
"tokenBodyFormat": "json"
}Register http://<your-host>/api/oauth/notion/callback as the redirect URI in
Notion's integration settings, open the oauth-authorize-url result in a
browser, authorize — then bind with authKind: "oauth", oauthProvider: "notion" and attach to a connection. Google works the same way with
extraParams: { "access_type": "offline", "prompt": "consent" } (required to
receive a refresh token) and the default body/form token style.
Security model
- Placeholder-only in scripts. Script code, args, and
ctxonly ever see[REDACTED:KEY]. The real value is substituted inside the runtime's fetch layer at egress, and only when the destination hostname is in the binding'sallowedHosts. A request carrying the placeholder to any other host goes out with the literal placeholder string. - MCP credentials never leave the server.
ctx.mcpcalls are proxied; the script subprocess sends tool name + arguments, the API server holds the MCP session and its auth. - Scrubbed logs. Resolved values are registered as volatile secrets, so
stdout/stderr and session logs render them as
[REDACTED:KEY]. - Encrypted at rest. Config-backed secrets use the swarm secrets cipher.
OAuth tokens are stored in
oauth_tokens(plaintext in v1 —TODO(secrets-cipher)).
Where connections resolve
ctx.api / ctx.mcp are populated everywhere scripts run: script-run (MCP
tool and POST /api/scripts/run), scheduled script runs,
workflow swarm-script nodes, and
external script endpoints (which resolve under the
endpoint's run-as agent).
Current limitations (v1)
- OpenAPI specs may be JSON or YAML (YAML is converted to JSON at ingest and stored canonically).
ctx.mcpreturns the raw MCP result envelope (content+structuredContent); unwrap it in the script.- GraphQL results are untyped (
any); OpenAPI response types are generated from the spec on a best-effort basis. - OAuth tokens are not yet encrypted at rest.
Trying it out
A self-contained HTML playground lives at
examples/script-connections-playground.html
in the repo — open it in a browser against a local server to run scripts
against your registered connections interactively, with presets for each
connection kind and a live leak-probe demonstrating the egress substitution
rules.
Scripts runtime
What the swarm-scripts runtime exposes to user code, what it does NOT expose, and how the typecheck stays aligned.
Scripts as external APIs
Expose a saved swarm script as a public, JSON-in/JSON-out HTTP endpoint — POST /api/x/script/<id> — with optional bearer auth, typed input validation, and per-endpoint usage tracking.