Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/28582-skills-mcp-tools-snapshot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/skills': patch
---

docs(skills): the automation skill's `mcp` recipe teaches the `providerConfig.toolsSnapshot` pin

Clause-②: no
Metadata-protocol: none

- **Before.** `providerConfig.toolsSnapshot` (a sha256 pin per MCP tool, checked on every materialization) shipped in `@objectstack/connector-mcp` and on the connectors page, but no published skill named it. An agent authoring an MCP connector app from the skills declared `include` alone, with no protection against a server silently rewriting a tool's description or `inputSchema`.
- **Now.** `skills/objectstack-automation/references/integration-recipes.md`, recipe 2 (remote MCP), gains one paragraph: what `toolsSnapshot` pins and that a pinned name `include` leaves out is refused at boot; where the digests come from (`computeMcpToolsSnapshot(tools)`, or the served digest the degraded reason prints against a placeholder pin); what an author sees when a pin no longer matches (the instance degraded with no actions, `CONNECTOR_UPSTREAM_CHANGED`, the reason on `GET /api/v1/automation/connectors` naming each tool and the digest it now serves), and the link to the connectors page's pin section.
- The file stays under its token ceiling: the paragraph is paid for by deleting restatements in the same file that keep a home elsewhere (the `connector_action` row of `SKILL.md`, its `api`-trigger *Signature* bullet, the objectstack-platform skill's `plugins:`/`requires` rule, and the connectors page). No ceiling moves, no frontmatter, schema, key or behaviour changes.
2 changes: 1 addition & 1 deletion content/docs/automation/connectors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -866,7 +866,7 @@ retry):
| Instance `name` collides with a plugin-registered connector | **Hard boot error** — there is no silent precedence between the two worlds |
| stdio transport without host opt-in | **Hard boot error** with the `declarativeStdio` fix inline — a security rejection is never retried |
| MCP server unreachable / `tools/list` fails; remote spec URL unreachable or transiently failing (408/429/5xx) | **Not fatal**: the instance registers **degraded** (visible on `GET /api/v1/automation/connectors` with the reason), dispatches fail with that reason, and the platform retries on a 5s→5min backoff — recovery is automatic |
| An `mcp` instance's `toolsSnapshot` does not match what the server serves (a pinned tool's description or `inputSchema` changed, or a pinned tool is no longer listed) | **Not fatal**: the instance registers **degraded** with no actions, its reason naming each tool and the digest it now serves, and the logs say "serves a tool definition that differs from its toolsSnapshot" (`CONNECTOR_UPSTREAM_CHANGED`); it is re-checked on the same backoff and recovers once the server serves the pinned definitions again — or set the pin to the reviewed digest |
| An `mcp` instance's `toolsSnapshot` does not match what the server serves (a pinned tool's description or `inputSchema` changed; or a pinned tool is no longer listed, on an instance whose credential did not reach the server) | **Not fatal**: the instance registers **degraded** with no actions, its reason naming each tool and the digest it now serves, and the logs say "serves a tool definition that differs from its toolsSnapshot" (`CONNECTOR_UPSTREAM_CHANGED`); it is re-checked on the same backoff and recovers once the server serves the pinned definitions again — or set the pin to the reviewed digest. A pinned tool no longer listed on an instance whose credential **did** reach the server (its `auth` over http) takes the credential row above instead: a server may hide tools from a credential, so the reason names both causes (fix the credential and set it again, or update the pin), a stored credential is marked `rejected`, and no retry runs against the same value |
| A retry of a degraded instance reaches the upstream and meets a configuration fault (an `include` naming a tool or operation the upstream does not offer; on an `mcp` instance that sends a static credential, the credential row above applies instead) | **Not fatal**: the instance stays **degraded**, now with the configuration reason, and is **not retried** — dispatches fail saying to edit its declaration; an edit of the entry (or a restart) re-attempts it |
| Wrong spec URL (other 4xx) or unparseable document | Configuration fault — **hard boot error** |
| An `openapi` operation's parameter declares a `style` OpenAPI 3.x does not define for its location (`deepObject` on a path parameter, `form` on a header) | Configuration fault — **hard boot error** naming the operation and the parameter; correct the document or leave the operation out of `include` |
Expand Down
78 changes: 40 additions & 38 deletions skills/objectstack-automation/references/integration-recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,27 @@ one thing the **customer's administrator** supplies: the value behind
value set in the running app at `PUT /api/v1/automation/connectors/credential-refs/:ref`;
an unset ref leaves its instance degraded until set. A spec is not a
secret and ships in the package; an `api` flow's hook secret goes to the
deployment and to the sender. Scaffolded apps list the three executors in
`plugins:`; keep `automation` in `requires` whenever a connector is declared. Full guide: https://objectstack.ai/docs/automation/connectors.
deployment and to the sender. Keep `automation` in `requires` whenever a
connector is declared. Full guide: https://objectstack.ai/docs/automation/connectors.

## Holds on every recipe

- Actions come from the upstream (an OpenAPI `operationId`, an MCP tool name,
the `rest` provider's single `request`) — authored on the entry, they are refused.
- `connectorConfig.input` is a value-slot map like `subflow` `input`: a top-level
CEL envelope is evaluated, any other value kept as written. `registerFlow` and
`os validate` refuse a `{…}` token at `connectorConfig.input.<key>`: a value
from the record is a CEL envelope. Before dispatch the result is judged
against the action's `inputSchema`: `required`, `type` (with OpenAPI
`nullable`), `properties`, `items`; `null` on an optional parameter is
absent. A breach fails the step `VALIDATION_ERROR` (`fault`-routable) naming
each path, nothing sent. Text is never converted: cast a string-held id,
`path: { dialect: 'cel', source: '{"petId": int(record.vendor_id)}' }`.
`os validate` does not judge the `inputSchema` fit yet.
- `connectorConfig.input` is a value-slot map (SKILL.md's `connector_action`
row): a top-level CEL envelope is evaluated, any other value kept as written,
so a value from the record is a CEL envelope. The pre-dispatch `inputSchema`
judgement honours OpenAPI `nullable`, reads `null` on an optional parameter
as absent and names each breached path. Text is never converted: cast a
string-held id, `path: { dialect: 'cel', source: '{"petId": int(record.vendor_id)}' }`.
- The answer lands as `<nodeId>.<key>` (`rest` / `openapi`: `{ status, ok, body }`;
`mcp`: `{ ok, content, structuredContent? }`), read from a later node's value slot.
- `auth`, each variant with a `credentialRef`: `bearer`; `api-key` (`headerName`,
default `X-API-Key`, or `paramName` for a query-string key); `basic` (`username`
or `paramName` for a query-string key); `basic` (`username`
stays in metadata); `signed` on `openapi` / `rest` (`scheme`
`aliyun-acs3-hmac-sha256`, `tencentcloud-tc3-hmac-sha256` or `alipay-v3`,
`keyId`) signs each request; `oauth2` / `app-credential`: recipe 6. A secret in a url path has no variant — use a
`keyId`) signs each request — WeChat Pay has no scheme yet: blocked;
`oauth2` / `app-credential`: recipe 6. A secret in a url path has no variant — use a
token-authenticated connector. `authentication: { token }` is refused at `os validate`.
- `defineConnector`: `@objectstack/spec/integration`; `defineFlow`: `@objectstack/spec`; both register in `defineStack`.

Expand All @@ -47,14 +44,14 @@ export const StripeConnector = defineConnector({
// crm_account gains `stripe_available` (currency) and `stripe_synced_at` (datetime).
export const StripeBalanceSweep = defineFlow({
name: 'stripe_balance_sweep', label: 'Stripe balance → house account',
type: 'schedule', runAs: 'system', // a scheduled run has no trigger user
type: 'schedule', runAs: 'system',
nodes: [
{ id: 'start', type: 'start', label: 'Nightly 03:00',
config: { schedule: { type: 'cron', expression: '0 3 * * *' } } },
{ id: 'bal', type: 'connector_action', label: 'GET /v1/balance',
connectorConfig: { connectorId: 'stripe', actionId: 'GetBalance', input: {} } },
{ id: 'write', type: 'update_record', label: 'Write balance', config: {
objectName: 'crm_account', filter: { is_house_account: true }, // multi-tenant: pin the organization too
objectName: 'crm_account', filter: { is_house_account: true },
fields: { stripe_available: { dialect: 'cel', source: 'bal.body.available[0].amount / 100.0' },
stripe_synced_at: { dialect: 'cel', source: 'isoDatetime(now())' } } } },
{ id: 'end', type: 'end', label: 'End' },
Expand All @@ -66,8 +63,8 @@ export const StripeBalanceSweep = defineFlow({

`requires: ['automation', 'triggers', 'job']`. Convert Swagger 2.0 or YAML and
dereference every `$ref` offline, in-document ones included: the loader skips a
`$ref` parameter, request body or response, and the derived input/output schema
silently loses it. `providerConfig.include` allowlists operation keys as the
`$ref` parameter, request body or response and the derived schema silently
loses it. `providerConfig.include` allowlists operation keys as the
`mcp` provider's does (absent = every operation; a non-array or unknown entry is
fatal).

Expand All @@ -77,23 +74,35 @@ fatal).
export const GitHubMcp = defineConnector({
name: 'github_mcp', label: 'GitHub (remote MCP)', type: 'api', provider: 'mcp',
providerConfig: {
transport: { kind: 'http', url: 'https://api.githubcopilot.com/mcp/', // `stdio` spawns a local process:
headers: { 'X-MCP-Readonly': 'true' } }, // denied unless the host allowlists the command
transport: { kind: 'http', url: 'https://api.githubcopilot.com/mcp/', // `stdio` needs the host's allowlist
headers: { 'X-MCP-Readonly': 'true' } },
include: ['search_issues'],
},
auth: { type: 'bearer', credentialRef: 'GITHUB_MCP_TOKEN' }, // folded into the request headers
auth: { type: 'bearer', credentialRef: 'GITHUB_MCP_TOKEN' },
});
// Weekly flow: connector_action { connectorId: 'github_mcp', actionId: 'search_issues',
// input: { query: 'repo:acme/app is:open label:p0' } } → update_record on `eng_kpi`
// fields: { open_p0: { dialect: 'cel', source: 'p0.content' } } (a `json` field).
```

`toolsSnapshot` pins each tool the app depends on: tool name → sha256 of its
`tools/list` definition; a pinned name `include` leaves out is refused at boot.
Digests: `computeMcpToolsSnapshot(tools)` (`@objectstack/connector-mcp`), or a
64-hex placeholder pin, then the served digest from the degraded reason. The
instance degrades with no actions; the `GET /api/v1/automation/connectors` reason
names each tool: a pinned tool served differently is `CONNECTOR_UPSTREAM_CHANGED`
with the digest it now serves (review, re-pin); one no longer listed is
the credential path when the instance's credential reached the server, as this
`bearer` does — the stored credential marked `rejected`: fix the credential and
set it again, or update the pin — and `CONNECTOR_UPSTREAM_CHANGED` otherwise. Rest:
https://objectstack.ai/docs/automation/connectors#pinning-tools-toolssnapshot.

## 3 · Plain REST API — `provider: 'rest'`, the one `request` action

```ts
export const WarehouseApi = defineConnector({
name: 'warehouse_api', label: 'Warehouse API', type: 'api', provider: 'rest',
providerConfig: { baseUrl: 'https://wms.example.com', defaultHeaders: { Accept: 'application/json' } },
providerConfig: { baseUrl: 'https://wms.example.com' },
auth: { type: 'api-key', credentialRef: 'WMS_API_KEY', headerName: 'X-Api-Key' },
});
// connector_action { connectorId: 'warehouse_api', actionId: 'request',
Expand All @@ -107,7 +116,7 @@ export const WarehouseApi = defineConnector({
```ts
export const CarrierCallback = defineFlow({
name: 'carrier_callback', label: 'Carrier status callback', type: 'api',
runAs: 'system', // no trigger user
runAs: 'system',
nodes: [
{ id: 'start', type: 'start', label: 'POST …/hooks/carrier_callback/carrier',
config: { hookId: 'carrier', secretRef: 'CARRIER_HOOK_SECRET' } },
Expand All @@ -121,10 +130,9 @@ export const CarrierCallback = defineFlow({
```

`requires: ['automation', 'triggers', 'queue']`. The sender signs each post
`x-objectstack-signature: t=<unix>,v1=<hex>` — HMAC-SHA256 of `<t>.<raw body>`
under `CARRIER_HOOK_SECRET`. The door has no unsigned form and verifies no other
scheme, so a vendor whose webhook signs its own header cannot post here today:
poll its status (recipe 1's shape) until vendor schemes verify.
under `CARRIER_HOOK_SECRET` (SKILL.md *Signature* bullet). The door verifies no
other scheme: a vendor whose webhook signs its own header cannot post here
today — poll its status (recipe 1's shape) until vendor schemes verify.

## 5 · Vendor with no machine-readable spec — write the document by hand

Expand Down Expand Up @@ -162,15 +170,9 @@ auth: { type: 'app-credential', vendor: 'feishu', appId: 'cli_a1b2', credentialR

`client_credentials` and `app-credential` dispatch once the ref is set.
`authorization_code` is degraded "not connected" until an administrator holding
`manage_connector_connections` (organization admins by default) connects it,
once per organization, from a signed-in session (an agent or API key is refused):
`POST /api/v1/automation/connectors/:name/connection` answers `{ authorizationUrl }`
for the admin to open; the vendor redirects to `GET …/connectors/oauth/callback`.
`GET` reads the status; `DELETE` revokes. Endpoints are `https://`; an
`authorization_code` instance on `mcp` may omit them and `clientId`, discovered at connect.
`manage_connector_connections` connects it, once per organization, from a
signed-in session (an agent or API key is refused):
`POST /api/v1/automation/connectors/:name/connection` answers `{ authorizationUrl }`;
the vendor redirects to `GET …/connectors/oauth/callback`. An `authorization_code`
instance on `mcp` may omit the `https://` endpoints and `clientId`, discovered at connect.
Still never park a short-lived token behind a `bearer` ref.

## Not yet — mark the integration blocked, write no plugin

- **WeChat Pay**: no `signed` scheme yet (Alipay's is `alipay-v3`, which also
verifies responses).
Loading