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
10 changes: 10 additions & 0 deletions .changeset/30865-console-skill-download-confirm.md
Original file line number Diff line number Diff line change
@@ -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.
62 changes: 53 additions & 9 deletions ui/apps/console/src/pages/developer/objectstack-skill.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -17,6 +28,12 @@ function frontmatter(md: string): Record<string, string> {
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());
Expand All @@ -34,29 +51,56 @@ describe('renderObjectStackSkill', () => {
expect(renderObjectStackSkill()).toContain('<YOUR_ENV_MCP_URL>');
});

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: <YOUR_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));
});
});
115 changes: 95 additions & 20 deletions ui/apps/console/src/pages/developer/objectstack-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 ' +
Expand All @@ -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

Expand All @@ -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: <YOUR_API_KEY>
\`\`\`

(The header \`Authorization: ApiKey <YOUR_API_KEY>\` is also accepted.) If your
MCP client supports custom headers on a remote server, set the header there.
(\`Authorization: ApiKey <YOUR_API_KEY>\` and \`Authorization: Bearer
<YOUR_API_KEY>\` are also accepted.) If your MCP client supports custom
headers on a remote server, set the header there.

## Discover before you act

Expand All @@ -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.
Expand All @@ -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

Expand All @@ -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.
`;
}
Loading