diff --git a/content/docs/ai/agents.mdx b/content/docs/ai/agents.mdx index c332864c6b..dabbca6a9d 100644 --- a/content/docs/ai/agents.mdx +++ b/content/docs/ai/agents.mdx @@ -43,6 +43,13 @@ instead — see the callout in the [AI Overview](/docs/ai).) ## Connect your AI (BYO-AI over MCP) + +Step-by-step client setup (Claude Code, Claude Desktop, `.mcp.json`, API keys) +with verification and troubleshooting lives in +[Connect an MCP Client](/docs/ai/connect-mcp). The summary below covers the +architecture. + + Every deployment serves MCP at `/api/v1/mcp` by default (a core platform capability — set `OS_MCP_SERVER_ENABLED=false` to opt out), with two authentication tracks: The in-product entry point is the diff --git a/content/docs/ai/connect-mcp.mdx b/content/docs/ai/connect-mcp.mdx new file mode 100644 index 0000000000..47e0cfebce --- /dev/null +++ b/content/docs/ai/connect-mcp.mdx @@ -0,0 +1,159 @@ +--- +title: Connect an MCP Client +description: Point Claude Code, Claude Desktop, or any MCP client at your running app — OAuth or API key — and verify the agent can see and operate it. +--- + +# Connect an MCP Client + +Every ObjectStack deployment is already an MCP server. The runtime serves the +[Model Context Protocol](https://modelcontextprotocol.io) at **`/api/v1/mcp`** +— on by default, no plugin to install, no configuration step. Your objects and +exposed actions become typed tools the moment you define them; this page is +about the only thing left to do: **connecting a client and proving it works**. + + +To turn the surface off, set `OS_MCP_SERVER_ENABLED=false` — the endpoint then +returns 404 and the **Setup → Connect an Agent** page disappears with it. See +[environment variables](/docs/deployment/environment-variables#mcp-server). + + +## Claude Code (one command) + +Interactive clients use OAuth — each deployment is its own OAuth 2.1 +authorization server, so there are no admin-minted credentials to pass around. +The first tool call opens a browser login and you connect **as yourself**: + +```bash +# local dev server +claude mcp add --transport http my-app http://localhost:3000/api/v1/mcp + +# a deployed instance +claude mcp add --transport http my-app https://your-deployment.example.com/api/v1/mcp +``` + +For headless use (CI, containers) skip OAuth and attach an +[API key](#headless-api-keys) instead: + +```bash +claude mcp add --transport http my-app https://your-deployment.example.com/api/v1/mcp \ + --header "x-api-key: osk_..." +``` + +## Claude Desktop and claude.ai + +**Settings → Connectors → Add custom connector**, then paste the MCP URL +(`https://your-deployment.example.com/api/v1/mcp`). The first use walks through +the same browser login. + +## Any MCP client (`.mcp.json`) + +Clients that read an `mcpServers` map connect the same way. With an API key: + +```json +{ + "mcpServers": { + "objectstack": { + "type": "http", + "url": "https://your-deployment.example.com/api/v1/mcp", + "headers": { "x-api-key": "osk_..." } + } + } +} +``` + +## Headless: API keys + +Mint a key from **Setup → Connect an Agent** in the Console (which also shows +copy-paste-ready connect snippets per client), or over REST: + +```bash +curl -b cookies.txt -X POST https://your-deployment.example.com/api/v1/keys +# → { "key": "osk_..." } — shown once; store it in your secret manager +``` + +Send it on every request in any of three equivalent forms: + +| Header | Example | +|:---|:---| +| `x-api-key` | `x-api-key: osk_...` | +| `Authorization: ApiKey` | `Authorization: ApiKey osk_...` | +| `Authorization: Bearer` | `Authorization: Bearer osk_...` (recognized by the `osk_` prefix) | + + +OAuth requires TLS — plain-HTTP deployments (except `localhost`) fall back to +**API-key-only**: the browser-login track is disabled rather than allowed to +run insecurely. + + +## What the agent gets + +Ten data and action tools, generated from your metadata: + +| Tool | What it does | +|:---|:---| +| `list_objects` / `describe_object` | Discover which objects exist and their fields | +| `query_records` / `get_record` | Read data (list queries are capped at 50 rows per page by default) | +| `aggregate_records` | Grouped aggregation (registered when the active driver supports it) | +| `create_record` / `update_record` / `delete_record` | Write data | +| `list_actions` / `run_action` | Discover and invoke your business actions by name | + +Two exposure rules to know: + +- **Objects are exposed automatically** — except `sys_*` system objects, which + are blocked fail-closed. +- **Actions require the author's opt-in**: `ai: { exposed: true }` plus an + `ai.description` of at least 40 characters, and the action must be callable + without a UI (`script` with a body or registered handler, or `flow`). + See [Actions as Tools](/docs/ai/actions-as-tools) and + [Actions](/docs/ui/actions). + +## The security model + +- **Every call runs as the caller.** The MCP bridge resolves the same + `ExecutionContext` as a REST request, so RBAC, row-level security, and + field-level security apply to the agent exactly as they do to a person in + the Console. Sparse results or denied writes usually mean governance is + *working*, not that the connection is broken. +- **OAuth scopes narrow the toolset.** Tokens carry `data:read`, + `data:write`, and `actions:execute` scopes — tools outside the granted + scopes are not even registered for that session. API-key and session + callers get the full set, still permission-checked per call. +- **Action bodies run as trusted app code** once invoked (the `ai.exposed` + gate and `requiredPermissions` are checked at *invoke* time). Treat writing + an action as a code-review-worthy act — that's the real security boundary. +- An action can also declare `ai.requiresConfirmation`; destructive-looking + actions (`confirmText`, danger variants) default to requiring it. + +## Verify the connection + +Ask the agent something only the live schema can answer: + +> "What objects does this app have, and what fields does the main one carry?" + +You should see `list_objects` and `describe_object` fire. The natural working +pattern for an agent is `list_objects` → `describe_object` → `query_records` → +`run_action` — if all four work, the connection is fully operational. + +Agents work noticeably better with the app's **skill file**: download it from +`GET /api/v1/mcp/skill`, or install the +[official Claude plugin](https://github.com/objectstack-ai/claude-plugin) +(`claude plugin marketplace add objectstack-ai/claude-plugin`) which bundles the +skill and a guided `/objectstack:connect` command. + +## Troubleshooting + +| Symptom | Cause → fix | +|:---|:---| +| `404` on `/api/v1/mcp` | The surface is disabled — unset `OS_MCP_SERVER_ENABLED` (default is on) | +| `501 Not Implemented` | The MCP plugin isn't part of this build — check your stack's plugins | +| `401` on every call | Anonymous or invalid credentials. Interactive clients: complete the browser login (the `WWW-Authenticate` header advertises the OAuth metadata). Headless: check the `osk_` key and header spelling | +| `403 insufficient_scope` | The OAuth token lacks the scope for that tool family (e.g. writes without `data:write`) — reconnect and grant the scope | +| An action is missing from `list_actions` | `ai.exposed` is not `true`, `ai.description` is shorter than 40 characters, the type isn't headless-callable (`url` / `modal` / `form` never appear), it targets a `sys_*` object, or the caller fails its `requiredPermissions` | +| Reads return few rows / writes denied | Working as designed — the caller's permissions and RLS apply. Verify with the same user in the Console | + +## Related + +- [Actions as Tools](/docs/ai/actions-as-tools) — the `run_action` bridge and its governance +- [Actions](/docs/ui/actions) — defining the actions you expose +- [Your app as an MCP server](/docs/api#your-app-as-an-mcp-server) — the API-level view +- [AI Agents](/docs/ai/agents) — building agents *inside* your app (the other direction) diff --git a/content/docs/ai/meta.json b/content/docs/ai/meta.json index 3140ed6a8f..6fdbbcf2ef 100644 --- a/content/docs/ai/meta.json +++ b/content/docs/ai/meta.json @@ -3,6 +3,7 @@ "icon": "Bot", "pages": [ "index", + "connect-mcp", "agents", "actions-as-tools", "knowledge-rag", diff --git a/content/docs/ui/actions.mdx b/content/docs/ui/actions.mdx new file mode 100644 index 0000000000..c373e03419 --- /dev/null +++ b/content/docs/ui/actions.mdx @@ -0,0 +1,243 @@ +--- +title: Actions +description: Declarative buttons with server-side behavior — define once, bind to lists, records, and navigation, permission-check on both surfaces, and optionally expose to AI. +--- + +# Actions + +An action is a **button declared as metadata**: where it appears +(`locations`), when it's visible (`visible`), who may run it +(`requiredPermissions`), and what it executes — an inline sandboxed script, a +registered server handler, a flow, or a URL. The same declaration renders in +the Console, executes over REST, and (with an explicit opt-in) becomes an AI +tool over MCP. + +The types you'll actually use: + +| `type` | What it does | Server behavior | +|:---|:---|:---| +| `script` *(default)* | Run server-side logic | Inline `body` **or** a handler registered via `target` | +| `flow` | Launch a flow (e.g. a screen-flow wizard) | `target` names the flow | +| `url` | Navigate / open a link | `target` is the URL (`${ctx.record.id}` interpolation supported) | +| `modal` | Open a modal page to collect input, then submit to a handler | `target` names the modal page | + + +The schema also accepts `api` and `form` types, but they have **no runtime +executor / renderer today** — stick to the four above. Likewise prefer +`confirmText`/`params` over schema properties that are not yet wired +(`shortcut`, `bulkEnabled`, `timeout`). + + +## Define your first action + +From the bundled Todo example — a "Mark Complete" button on the task list and +record header: + +```typescript title="src/actions/task.actions.ts" +import { defineAction } from '@objectstack/spec/ui'; + +export const CompleteTaskAction = defineAction({ + name: 'complete_task', + label: 'Mark Complete', + objectName: 'todo_task', // which object this action belongs to + icon: 'check-circle', + type: 'script', + target: 'completeTask', // resolved to the handler registered below + locations: ['record_header', 'list_item'], + successMessage: 'Task marked as complete!', + refreshAfter: true, + ai: { + exposed: true, + description: 'Mark a todo task as complete. Use when the user says a task is done or finished.', + }, +}); +``` + +Register it in your stack — top-level actions carrying an `objectName` are +merged into that object automatically (and ordered by `order`): + +```typescript title="objectstack.config.ts" +export default defineStack({ + // ... + actions: Object.values(actions), +}); +``` + +## Give it server behavior — two paths + +### Path A: inline `body` (sandboxed) + +Self-contained logic ships inside the metadata and runs in the server sandbox +— signature `(input, ctx)`, with `ctx.api.object(name)` for data access, a +5-second default timeout, and declared `capabilities`: + +```typescript +export const MarkDoneAction = defineAction({ + name: 'showcase_mark_done', + label: 'Mark Done', + objectName: 'showcase_task', + type: 'script', + body: { + language: 'js', + source: + "var id = ctx.recordId || (ctx.record && ctx.record.id);" + + "if (!id) throw new Error('No record to mark done');" + + "await ctx.api.object('showcase_task').update({ id: id, done: true, progress: 100 });" + + "return { ok: true, id: id };", + capabilities: ['api.write'], + }, + successMessage: 'Task marked done.', + visible: '!record.done', + locations: ['list_item', 'record_header', 'record_section'], + refreshAfter: true, +}); +``` + +Body-carrying actions are **registered automatically** at boot. + +### Path B: a registered handler (full TypeScript) + +For logic that belongs in real source files, point `target` at a handler name +and register it in your config's `onEnable` lifecycle hook: + +```typescript title="src/actions/task.handlers.ts" +export async function completeTask(ctx: ActionContext): Promise { + const { record, engine } = ctx; // ctx = { record, user, engine, params } + await engine.update('todo_task', record.id as string, { + status: 'completed', + completed_date: new Date().toISOString(), + }); +} +``` + +```typescript title="objectstack.config.ts" +export const onEnable = async (ctx: { ql: { registerAction: (...args: unknown[]) => void } }) => { + ctx.ql.registerAction('todo_task', 'completeTask', completeTask); +}; +``` + + +**The "dead button" trap:** only actions with an inline `body` register +themselves. A `script` action whose `target` names a handler that was never +`registerAction`-ed compiles fine but throws `Action 'x' on object 'y' not +found` at click time. (An action with *neither* `body` nor `target` is +rejected at authoring time.) Also note the handler's `engine` facade is +**trusted** — it bypasses row- and field-level security, so enforce any +caller-specific rules yourself. + + +## Bind it to the UI + +`locations` is the primary binding — the action appears wherever it declares: + +| Location | Where the button renders | +|:---|:---| +| `list_toolbar` | List view toolbar (no record context) | +| `list_item` | Per-row menu in list views | +| `record_header` | Record page header | +| `record_more` | Record page overflow ("…") menu | +| `record_related` | Related-list sections | +| `record_section` | Named action bars on record pages | +| `global_nav` | App-level navigation | + +Surfaces can also reference actions **by name**: + +```typescript +// List views — row and bulk menus +defineView({ + // ... + rowActions: ['complete_task'], + bulkActions: ['showcase_bulk_reassign'], +}); + +// Record pages — a quick-actions bar +{ type: 'record:quick_actions', + properties: { location: 'record_section', actionNames: ['showcase_mark_done'] } } + +// App navigation — an action as a nav item +{ type: 'action', actionDef: { actionName: 'crm_convert_lead' } } +``` + + +Naming an action in a widget does **not** bypass location filtering — the +engine still requires the action to declare the matching location (that's why +`MarkDoneAction` above includes `record_section`). + + +## Collect input and shape the UX + +- **`params`** — prompt the user for input before execution. Prefer + field-backed params (`{ field: 'due_date' }`) which inherit the object + field's label, type, and validation; inline params + (`{ name, label, type, required, options }`) cover the rest. + `defaultFromRow` prefills from the current record. +- **`confirmText`** — confirmation dialog before running. +- **`successMessage` / `errorMessage` / `refreshAfter`** — post-run feedback + and an automatic data refresh. +- **`resultDialog`** — a one-time reveal dialog for output the user must copy + (generated tokens, export links). +- **`variant` / `icon` / `order`** — presentation and sort position. + +## Permissions and visibility + +- **`requiredPermissions: ['can_close_tickets']`** is a **dual-surface gate** + (one declaration, two enforcement points): the server rejects unauthorized + calls with 403, and the UI hides or disables the button for the same users. + Unset means no gate beyond object CRUD permissions. Referenced capabilities + must exist — `os lint` checks that. +- **`visible`** is a CEL predicate evaluated **fail-closed**: an expression + that throws hides the action silently. Two rules save real debugging time: + always prefix record fields (`record.status != "closed"`, never a bare + `status`), and keep it to a single operand — see the + [formulas guide](/docs/data-modeling/formulas) for CEL syntax. +- **`requiresFeature`** ties visibility to a feature flag (compiled into a + `visible` predicate). + +## Call it over REST + +Every action is also an endpoint — the Console button and the API call run +the same gate and handler: + +```bash +curl -b cookies.txt -X POST \ + https://your-app.example.com/api/v1/actions/todo_task/complete_task \ + -H "Content-Type: application/json" \ + -d '{ "recordId": "rec_123", "params": {} }' +# → { "success": true, "data": ... } (errors: { "success": false, "error": ... }) +``` + +Global (object-less) actions post to `/api/v1/actions/global/:action`. For +credentials, see [API Authentication](/docs/api#authentication). + +## Expose it to AI (MCP) + +Actions are **not** AI-visible by default. Opting in takes two fields — and +makes the action a governed MCP tool alongside the data tools: + +```typescript +ai: { + exposed: true, + description: 'At least 40 characters explaining when an agent should use this action.', +} +``` + +Only headless-callable types appear (`script` with body or handler, `flow`); +`url`/`modal` never do. The caller's identity and `requiredPermissions` are +enforced per invocation. Details: [Actions as Tools](/docs/ai/actions-as-tools) +and [Connect an MCP Client](/docs/ai/connect-mcp). + +## Troubleshooting + +| Symptom | Cause → fix | +|:---|:---| +| Button doesn't appear | `locations` doesn't include the surface; or the `visible` CEL throws (bare field name, multiple operands) and fail-closed hides it; or the user fails `requiredPermissions` | +| Click → `Action 'x' … not found` | `target`-style script with no registered handler — add the `registerAction` call in `onEnable` (Path B above) | +| Appears in UI, missing from `list_actions` (MCP) | `ai.exposed` not `true`, `ai.description` under 40 characters, or the type isn't headless-callable | +| Runs but the list looks stale | Add `refreshAfter: true` | + +## Related + +- [Actions as Tools](/docs/ai/actions-as-tools) — the AI exposure model in depth +- [Views](/docs/ui/views) — `rowActions` / `bulkActions` binding +- [Action protocol](/docs/protocol/objectui/actions) — the full spec narrative +- [Action schema reference](/docs/references/ui/action) — every property, generated from the spec diff --git a/content/docs/ui/meta.json b/content/docs/ui/meta.json index 0a56e5bca3..2385275725 100644 --- a/content/docs/ui/meta.json +++ b/content/docs/ui/meta.json @@ -6,6 +6,7 @@ "apps", "pages", "views", + "actions", "dashboards", "forms", "doc-pages", diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index 2f2ae43eb2..16fa0fc795 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -64,8 +64,8 @@ const taskListView = { | `pagination` | `object` | optional | Pagination settings | | `selection` | `object` | optional | Row selection mode | | `navigation` | `object` | optional | Row click navigation | -| `rowActions` | `array` | optional | Per-row action buttons | -| `bulkActions` | `array` | optional | Bulk selection actions | +| `rowActions` | `array` | optional | Per-row action buttons, by action name — see [Actions](/docs/ui/actions) | +| `bulkActions` | `array` | optional | Bulk selection actions, by action name — see [Actions](/docs/ui/actions) | | `inlineEdit` | `boolean` | optional | Enable inline editing | | `exportOptions` | `string[]` | optional | Enabled export formats (`csv`, `xlsx`, `pdf`, `json`) |