From 828224c88f3de41678c1ea7a1cfdd9fec45713c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 17:09:52 +0000 Subject: [PATCH] fix(console): the skill download teaches delete_record confirm: true and mirrors the served SKILL.md The Console's SKILL.md mirror had drifted from renderSkillMarkdown: it taught delete_record without confirm (now refused with ACTION_CONFIRMATION_REQUIRED) and lacked list_actions, run_action, resume_run, validate_expression and the OAuth track. The mirror now renders the canonical default-scope text, and its test imports the canonical renderer from source and compares byte for byte. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibwpdnaEYjR2FGdUuxD --- .../30865-console-skill-download-confirm.md | 10 ++ .../pages/developer/objectstack-skill.test.ts | 62 ++++++++-- .../src/pages/developer/objectstack-skill.ts | 115 +++++++++++++++--- 3 files changed, 158 insertions(+), 29 deletions(-) create mode 100644 .changeset/30865-console-skill-download-confirm.md diff --git a/.changeset/30865-console-skill-download-confirm.md b/.changeset/30865-console-skill-download-confirm.md new file mode 100644 index 00000000000..68144dec6bc --- /dev/null +++ b/.changeset/30865-console-skill-download-confirm.md @@ -0,0 +1,10 @@ +--- +'@object-ui/console': patch +--- + +fix(console): the "Download skill" SKILL.md teaches `delete_record` with `confirm: true`, and the rest of what the server serves + +Clause-②: no +Metadata-protocol: none + +The Integrations page's "Download skill" button wrote a SKILL.md that taught `delete_record({ objectName, recordId })` with no `confirm`. The MCP server refuses that call with `ACTION_CONFIRMATION_REQUIRED` and deletes nothing, so an agent that followed the downloaded skill failed every delete. The download now carries the same text the MCP server serves to an API key or a session: `delete_record({ objectName, recordId, confirm })` with the confirmation rule, the business-action tools (`list_actions`, `run_action`, `resume_run`), `validate_expression`, the OAuth connection track, and the `Authorization: Bearer` key form. A test compares the download with the server's renderer byte for byte, so the two cannot drift apart again unnoticed. diff --git a/ui/apps/console/src/pages/developer/objectstack-skill.test.ts b/ui/apps/console/src/pages/developer/objectstack-skill.test.ts index a5f660d291c..3aa94585a25 100644 --- a/ui/apps/console/src/pages/developer/objectstack-skill.test.ts +++ b/ui/apps/console/src/pages/developer/objectstack-skill.test.ts @@ -1,7 +1,18 @@ +// @vitest-environment node import { describe, it, expect } from 'vitest'; +// The canonical renderer, imported from source by relative path: the console +// does not depend on `@objectstack/mcp` (it is not browser-safe), and the +// comparison below is the whole point of this file. +import { + renderSkillMarkdown, + OBJECTSTACK_MCP_SERVER_NAME as CANONICAL_MCP_SERVER_NAME, + OBJECTSTACK_SKILL_DESCRIPTION as CANONICAL_SKILL_DESCRIPTION, + OBJECTSTACK_SKILL_NAME as CANONICAL_SKILL_NAME, +} from '../../../../../../packages/mcp/src/skill-md'; import { renderObjectStackSkill, + OBJECTSTACK_MCP_SERVER_NAME, OBJECTSTACK_SKILL_NAME, OBJECTSTACK_SKILL_DESCRIPTION, } from './objectstack-skill'; @@ -17,6 +28,12 @@ function frontmatter(md: string): Record { return out; } +/** The tool names the skill's `## Tools` section teaches, in order. */ +function toolNames(md: string): string[] { + const section = md.split('\n## Tools\n')[1]?.split('\n## ')[0] ?? ''; + return [...section.matchAll(/^- \*\*([a-z_]+)\(/gm)].map((m) => m[1]); +} + describe('renderObjectStackSkill', () => { it('emits valid SKILL.md frontmatter (name + description)', () => { const fm = frontmatter(renderObjectStackSkill()); @@ -34,29 +51,56 @@ describe('renderObjectStackSkill', () => { expect(renderObjectStackSkill()).toContain(''); }); - it('documents x-api-key auth, not Bearer', () => { - const md = renderObjectStackSkill(); - expect(md).toContain('x-api-key'); - expect(md).not.toMatch(/Authorization:\s*Bearer/); + it('documents the x-api-key header for headless clients', () => { + expect(renderObjectStackSkill()).toContain('x-api-key: '); }); - it('lists all object-CRUD tools and instructs live discovery', () => { + it('lists every data and action tool and instructs live discovery', () => { const md = renderObjectStackSkill(); - for (const t of [ + expect(toolNames(md)).toEqual([ 'list_objects', 'describe_object', + 'validate_expression', 'query_records', 'get_record', 'create_record', 'update_record', 'delete_record', - ]) { - expect(md).toContain(t); - } + 'list_actions', + 'run_action', + 'resume_run', + ]); expect(md).toContain('discovered live'); }); + it('teaches delete_record with confirm: true, which the server requires', () => { + const md = renderObjectStackSkill(); + expect(md).toContain('**delete_record({ objectName, recordId, confirm })**'); + expect(md).toContain('The server REFUSES a call without `confirm: true`'); + expect(md).not.toContain('delete_record({ objectName, recordId })'); + }); + it('includes the env name when provided', () => { expect(renderObjectStackSkill({ envName: 'Acme CRM' })).toContain('**Acme CRM**'); }); }); + +// Drift pin: the download must be the SKILL.md the server serves to a caller +// with no OAuth scopes (an API key or a session), byte for byte. +describe('renderObjectStackSkill mirrors renderSkillMarkdown', () => { + it('shares the canonical identity constants', () => { + expect(OBJECTSTACK_SKILL_NAME).toBe(CANONICAL_SKILL_NAME); + expect(OBJECTSTACK_SKILL_DESCRIPTION).toBe(CANONICAL_SKILL_DESCRIPTION); + expect(OBJECTSTACK_MCP_SERVER_NAME).toBe(CANONICAL_MCP_SERVER_NAME); + }); + + it.each([ + ['no options', {}], + ['an MCP URL', { mcpUrl: 'https://acme.objectos.app/api/v1/mcp' }], + ['an env name', { envName: 'Acme CRM' }], + ['both, padded with whitespace', { mcpUrl: ' https://acme.objectos.app/api/v1/mcp ', envName: ' Acme CRM ' }], + ['blank values', { mcpUrl: ' ', envName: '' }], + ])('renders the canonical text for %s', (_label, options) => { + expect(renderObjectStackSkill(options)).toBe(renderSkillMarkdown(options)); + }); +}); diff --git a/ui/apps/console/src/pages/developer/objectstack-skill.ts b/ui/apps/console/src/pages/developer/objectstack-skill.ts index 71fbccfcd92..8612fdae097 100644 --- a/ui/apps/console/src/pages/developer/objectstack-skill.ts +++ b/ui/apps/console/src/pages/developer/objectstack-skill.ts @@ -2,10 +2,14 @@ * objectstack-skill — client-side renderer for the portable ObjectStack Agent * Skill (`SKILL.md`), used by the Integrations page "Download skill" button. * - * CANONICAL SOURCE: `renderSkillMarkdown` in `@objectstack/mcp` (framework). - * That package pulls in the MCP SDK and is not browser-friendly to import here, - * so this is a faithful mirror for the download UX. Keep the two in sync — the - * unit test asserts the structural invariants (frontmatter, URL slot, tools). + * CANONICAL SOURCE: `renderSkillMarkdown` in `@objectstack/mcp` + * (`packages/mcp/src/skill-md.ts`). That package pulls in the MCP SDK and is + * not browser-friendly to import here, so this is a mirror of what the server + * serves to a caller with no OAuth scopes (an API key or a session): the data + * skill, with no authoring section and no federation tools. It must stay + * byte-identical to that render — `objectstack-skill.test.ts` imports the + * canonical renderer from source and compares the two, so an edit to either + * side without the other goes red there. * * Per ADR-0036 Amendment C this is ONE generic skill: it never enumerates a * tenant's schema (the agent discovers live via list_objects/describe_object); @@ -20,6 +24,8 @@ export interface RenderSkillOptions { } export const OBJECTSTACK_SKILL_NAME = 'objectstack'; +/** The server name the `claude mcp add` snippet registers (mirrors `OBJECTSTACK_MCP_SERVER_NAME`). */ +export const OBJECTSTACK_MCP_SERVER_NAME = 'objectstack'; export const OBJECTSTACK_SKILL_DESCRIPTION = 'Query and modify data in an ObjectStack app over MCP — discover objects, ' + 'read and filter records, and create/update/delete under your own ' + @@ -42,18 +48,23 @@ description: ${OBJECTSTACK_SKILL_DESCRIPTION} # ObjectStack -${intro} An ObjectStack environment exposes its data **objects** (tables) as -tools over the Model Context Protocol (MCP). Every operation runs **as you** — -under your account's permissions and row-level security — so you may see a -subset of rows, or get a permission error on a write. That is expected +${intro} An ObjectStack environment exposes its data **objects** (tables) and +its business **actions** (registered app logic: approvals, conversions, flow +triggers) as tools over the Model Context Protocol (MCP). Every operation runs +under the permissions and row-level security of the identity that connected you +— and, for an OAuth connection, **within the consent scopes you were granted** +(a read-only scope can never write, even where that user could). So you may see +a subset of rows, or get a permission error on a write. That is expected governance, not a failure. ## When to use Use these tools whenever the user wants to **inspect or change data** in their -ObjectStack app: look up records, filter/report, create or update entries, or -clean up data. Prefer these tools over guessing — the environment is the source -of truth. +ObjectStack app — look up records, filter/report, create or update entries, +clean up data — or **run a business action** the app defines (approve a +request, convert a lead, kick off a flow). Prefer these tools over guessing — +the environment is the source of truth, and an action is always better than +hand-editing the records it would have touched. ## Connect @@ -63,15 +74,32 @@ This skill drives the MCP server at: ${url} \`\`\` -Authenticate with an ObjectStack API key sent as a request header (the key is -shown to you once when created; treat it like a password): +Two authentication tracks are supported: + +**OAuth (recommended for interactive clients).** Add the URL as a remote MCP +server with no credentials; the deployment is its own OAuth 2.1 authorization +server, so an OAuth-capable client (claude.ai custom connectors, Claude +Desktop, Claude Code) discovers it automatically, registers itself, and opens +a browser login. You authorize the connection in the browser; the client then +acts as an agent **on your behalf** — every tool call is bounded by the +intersection of the consent scopes you grant and your own permissions/RLS (it +can never exceed either). Example (Claude Code): + +\`\`\` +claude mcp add --transport http ${OBJECTSTACK_MCP_SERVER_NAME} ${url} +\`\`\` + +**API key (headless: CI, scripts, agents without a browser).** Send an +ObjectStack API key as a request header (the key is shown to you once when +created; treat it like a password): \`\`\` x-api-key: \`\`\` -(The header \`Authorization: ApiKey \` is also accepted.) If your -MCP client supports custom headers on a remote server, set the header there. +(\`Authorization: ApiKey \` and \`Authorization: Bearer +\` are also accepted.) If your MCP client supports custom +headers on a remote server, set the header there. ## Discover before you act @@ -81,6 +109,8 @@ always current even as the app evolves: 1. \`list_objects\` — see what objects exist. 2. \`describe_object({ objectName })\` — get an object's fields (name, type, required) before querying or writing it. +3. \`list_actions\` — see what business actions you may run (each entry + includes its parameters and whether it needs a \`recordId\`). Always discover the relevant object's shape before constructing a filter or a create/update payload. @@ -89,14 +119,52 @@ create/update payload. - **list_objects()** — list available objects (system \`sys_*\` objects are hidden). - **describe_object({ objectName })** — an object's fields and features. +- **validate_expression({ objectName, expression, site?, severity?, triggerKind?, sweepObject? })** — check a CEL + expression against an object's schema *before* you author it into a formula, + validation, or flow condition. Returns build-time errors (bare field refs, + unknown fields/functions) and advisory warnings (text/boolean fields misused + in arithmetic; an error at a \`validation\` site, worded for the rule's + \`severity\`, and at a \`flow_condition\` of a record-triggered or + time-relative flow, where it fails the run; a warning on any other flow), plus the fields and + stdlib functions in scope. \`site\` is \`formula\` | \`validation\` | + \`flow_condition\` | \`template\` (default \`formula\`). Pass the rule's + \`severity\` or the flow's \`triggerKind\` (\`record_change\` by default) to + get the verdict the publish gate gives that rule or flow; for a + \`time_relative\` flow, pass the object its sweep scans (\`timeRelative.object\`) + as \`objectName\`: its conditions read rows of that object, and a start whose + \`objectName\` names another object is refused at publish + (\`flow-time-relative-object-mismatch\`). \`sweepObject\` names that same object. - **query_records({ objectName, where?, fields?, limit?, offset?, orderBy? })** — read records. \`where\` is a field→value match, e.g. \`{ "status": "open" }\`. Results are page-capped; use \`limit\`/\`offset\` to page. - **get_record({ objectName, recordId })** — fetch one record by id. - **create_record({ objectName, data })** — create a record. - **update_record({ objectName, recordId, data })** — change fields on a record. -- **delete_record({ objectName, recordId })** — delete a record (destructive — - confirm with the user first). +- **delete_record({ objectName, recordId, confirm })** — delete a record, + permanently. The server REFUSES a call without \`confirm: true\` + (\`ACTION_CONFIRMATION_REQUIRED\`) and nothing is deleted. Ask your human + first; the flag asserts an approval, it does not obtain one. +- **list_actions()** — list the business actions available to you (only those + the app author exposed to AI and you are permitted to run), with each + action's declared parameters, whether it operates on a record, and whether + it is flagged destructive. +- **run_action({ actionName, objectName?, recordId?, params?, confirm? })** — + invoke a business action by name. Invocation is permission-gated, but the + action body executes the app's registered logic as trusted code (it can + mutate data or trigger flows with the app's own authority). Pass + \`recordId\` for record-scoped actions and \`params\` for declared inputs; + \`objectName\` only disambiguates a name shared by multiple objects. Pass + \`confirm: true\` on an action \`list_actions\` reports as + \`requiresConfirmation\` — the server REFUSES such a call without it + (\`ACTION_CONFIRMATION_REQUIRED\`) and nothing runs. Ask your human first; + the flag asserts an approval, it does not obtain one. +- **resume_run({ runId, values?, confirm? })** — continue a flow run that + \`run_action\` answered with \`status: "paused"\` and a \`screen\` (a form the + flow needs filled in). Send the screen's field values in \`values\`, keyed by + the names in \`screen.fields\`. The run completes, or pauses again on its next + screen, which you fill with another \`resume_run\`. Only the user whose call + started the run can resume it, under the same gates as \`run_action\` + (including \`confirm: true\` for an action flagged \`requiresConfirmation\`). ## Conventions & gotchas @@ -108,13 +176,20 @@ create/update payload. - **Writes are real and immediate.** There is no implicit dry-run. Confirm destructive actions (\`delete_record\`, bulk updates) with the user. - **Page large reads.** Use \`limit\`/\`offset\` rather than asking for everything. +- **Prefer actions over hand-edits.** When \`list_actions\` offers an action for + the task (approve, convert, close, …), call it instead of updating the + records yourself — actions carry the app's validation and side effects. + Confirm first when an action is record-destructive or flagged for + confirmation. ## Recommended workflow -1. \`list_objects\` to orient. +1. \`list_objects\` (and \`list_actions\` when the task sounds like a business + operation) to orient. 2. \`describe_object\` on the target object. 3. \`query_records\` to read / verify current state. -4. \`create_record\` / \`update_record\` / \`delete_record\` to make changes, - confirming destructive steps with the user. +4. \`run_action\` when a matching business action exists; otherwise + \`create_record\` / \`update_record\` / \`delete_record\` — confirming + destructive steps with the user. `; }