Skip to main content
Glama
Kelley-Austin

SObjectActions

README.md
# sfka-invocableMCP

Generic, object-agnostic invocable Apex actions (27 MCP tools) covering discovery (describe, picklists,
list flows, access check, record summary), reads (read, find, count, aggregate, search, related), writes
(create, upsert, update, clone, delete, undelete, validate dry-run, assign owner, change record type),
intent shortcuts (log activity, close case, convert lead, post to Chatter, add note, attach file) and
run flow,
exposed as tools on a hosted Salesforce MCP server (`McpServerDefinition` = `SObjectActions`).
The same actions are callable from Flow, Agentforce agent actions, and REST
(`/services/data/vXX.X/actions/custom/apex/<ClassName>`).

Every action takes an `objectApiName` string plus records or Ids, so one deployment
covers any standard or custom object without further code.

---

## Quick start (repeatable)

```bash
scripts/setup.sh <org-alias> [--no-fixtures] [--no-eca] [--no-tests] [--no-smoke] [--assign user@example.com]
```
Deploys test fixtures, the 27 actions + echo flow + permission set, the MCP server definition and the External
Client App; assigns `SObjectActions_User`; runs all 45 unit tests and the 45-check REST smoke test; prints the
consumer key and the two remaining manual steps (activate the server in Setup, grant object CRUD/FLS).
Idempotent; verified on orgs with and without Enhanced Notes / record types.

Alternative: unlocked package **SObject Actions MCP** (Apex + flow + permission set; `scripts/package.sh install <org> <04t>`
also deploys `mcp/` and `eca/`, which Salesforce does not allow in packages). Also `scripts/scratch.sh` (fresh scratch org,
full install) and `.github/workflows/validate.yml` (check-only deploy + tests + doc drift on PRs; needs secret `SF_AUTH_URL`).

Repo layout: `force-app/` (packageable: classes, flows, permissionsets), `mcp/` (McpServerDefinition), `eca/` (External Client App),
`test-fixtures/` (optional).

## Documentation map

| Document | What it is for |
|---|---|
| `README.md` (this file) | Usage guide: deploy, enable, conventions, per-tool reference, security, limits, errors, testing |
| `docs/TOOLS.md` | Generated tool manifest: every tool with its inputs, outputs, annotations (`scripts/gen-tool-manifest.py`) |
| `docs/tools.json` | Same manifest as machine-readable JSON (for agent prompts, docs sites, CI diffing) |
| `docs/ARCHITECTURE.md` | How the classes fit together, shared helpers, design rules, how to add a tool |
| `docs/CLIENT_AUTH.md` | How external clients authenticate (ECA, PKCE, refresh tokens, Postman, headless fallback) |
| `docs/postman/SObjectActions.postman_collection.json` | Generated Postman collection: OAuth pre-set, MCP + REST request per tool |
| `docs/DEMO_SCRIPT.md` | 30-minute presentation script, slide by slide against the 23-slide deck |
| `docs/DEMO_SCRIPT_15MIN.md` | 15-minute cut of the same talk: 9 slides, one demo moment |
| `docs/DEMO_NOTES.md` | Reference behind the talk: the catalog explained, question bank, claims not to make |
| `CHANGELOG.md` | Release history |

## Contents

1. [Components](#components)
2. [Deploy](#deploy)
3. [Enable the MCP server](#enable-the-mcp-server)
4. [Common conventions](#common-conventions)
5. [Tool reference](#tool-reference)
   - [createRecords](#createrecords--sobjectcreateaction)
   - [readRecords](#readrecords--sobjectreadaction)
   - [updateRecords](#updaterecords--sobjectupdateaction)
   - [deleteRecords](#deleterecords--sobjectdeleteaction)
   - [upsertRecords](#upsertrecords--sobjectupsertaction)
   - [findRecords](#findrecords--sobjectfindaction)
   - [describeObject](#describeobject--sobjectdescribeaction)
   - [searchRecords](#searchrecords--sobjectsearchaction)
   - [relatedRecords](#relatedrecords--sobjectrelatedaction)
   - [countRecords](#countrecords--sobjectcountaction)
   - [undeleteRecords](#undeleterecords--sobjectundeleteaction)
   - [checkAccess](#checkaccess--sobjectaccessaction)
   - [cloneRecords](#clonerecords--sobjectcloneaction)
   - [validateRecords](#validaterecords--sobjectvalidateaction)
   - [aggregateRecords](#aggregaterecords--sobjectaggregateaction)
   - [runFlow](#runflow--sobjectrunflowaction)
   - [assignOwner](#assignowner--sobjectassignowneraction)
   - [changeRecordType](#changerecordtype--sobjectrecordtypeaction)
   - [picklistValues](#picklistvalues--sobjectpicklistaction)
   - [logActivity](#logactivity--sobjectlogactivityaction)
   - [closeCase](#closecase--sobjectclosecaseaction)
   - [convertLead](#convertlead--sobjectconvertleadaction)
   - [postChatter](#postchatter--sobjectpostchatteraction)
   - [addNote](#addnote--sobjectaddnoteaction)
   - [attachFile](#attachfile--sobjectattachfileaction)
   - [listFlows](#listflows--sobjectlistflowsaction)
   - [recordSummary](#recordsummary--sobjectsummaryaction)
6. [Calling from Flow](#calling-from-flow)
7. [Calling from REST](#calling-from-rest)
8. [Security model](#security-model)
9. [Limits and bulk behavior](#limits-and-bulk-behavior)
10. [Error catalog](#error-catalog)
11. [Testing](#testing)
12. [Extending](#extending)

---

## Tool families

| Family | Tools |
|---|---|
| Discover | `checkAccess`, `describeObject`, `picklistValues`, `listFlows`, `recordSummary` |
| Read | `readRecords`, `findRecords`, `countRecords`, `aggregateRecords`, `searchRecords`, `relatedRecords` |
| Write | `validateRecords` (dry run), `createRecords`, `upsertRecords`, `updateRecords`, `cloneRecords`, `assignOwner`, `changeRecordType`, `deleteRecords`, `undeleteRecords` |
| Intent shortcuts | `logActivity`, `closeCase`, `convertLead`, `postChatter`, `addNote`, `attachFile` |
| Automation | `runFlow` |

Full input/output reference for each: `docs/TOOLS.md`.

## Components

All source lives under `force-app/main/default/`.

| Path | Purpose |
|---|---|
| `classes/SObjectCreateAction.cls` | Invocable `Create Records (Generic)` -> MCP tool `createRecords` |
| `classes/SObjectReadAction.cls` | Invocable `Read Records (Generic)` -> MCP tool `readRecords` |
| `classes/SObjectUpdateAction.cls` | Invocable `Update Records (Generic)` -> MCP tool `updateRecords` |
| `classes/SObjectDeleteAction.cls` | Invocable `Delete Records (Generic)` -> MCP tool `deleteRecords` |
| `classes/SObjectUpsertAction.cls` | Invocable `Upsert Records (Generic)` -> MCP tool `upsertRecords` |
| `classes/SObjectFindAction.cls` | Invocable `Find Records (Generic)` -> MCP tool `findRecords` |
| `classes/SObjectDescribeAction.cls` | Invocable `Describe Object (Generic)` -> MCP tool `describeObject` |
| `classes/SObjectSearchAction.cls` | Invocable `Search Records (Generic)` -> MCP tool `searchRecords` (SOSL) |
| `classes/SObjectRelatedAction.cls` | Invocable `Get Related Records (Generic)` -> MCP tool `relatedRecords` |
| `classes/SObjectCountAction.cls` | Invocable `Count Records (Generic)` -> MCP tool `countRecords` |
| `classes/SObjectUndeleteAction.cls` | Invocable `Undelete Records (Generic)` -> MCP tool `undeleteRecords` |
| `classes/SObjectAccessAction.cls` | Invocable `Check Access (Generic)` -> MCP tool `checkAccess` |
| `classes/SObjectCloneAction.cls` | Invocable `Clone Records (Generic)` -> MCP tool `cloneRecords` |
| `classes/SObjectValidateAction.cls` | Invocable `Validate Records (Generic, dry run)` -> MCP tool `validateRecords` |
| `classes/SObjectAggregateAction.cls` | Invocable `Aggregate Records (Generic)` -> MCP tool `aggregateRecords` |
| `classes/SObjectRunFlowAction.cls` | Invocable `Run Flow (Generic)` -> MCP tool `runFlow` |
| `classes/SObjectAssignOwnerAction.cls` | `Assign Owner (Generic)` -> `assignOwner` |
| `classes/SObjectRecordTypeAction.cls` | `Change Record Type (Generic)` -> `changeRecordType` |
| `classes/SObjectPicklistAction.cls` | `Get Picklist Values (Generic)` -> `picklistValues` |
| `classes/SObjectLogActivityAction.cls` | `Log Activity` -> `logActivity` |
| `classes/SObjectCloseCaseAction.cls` | `Close Case` -> `closeCase` |
| `classes/SObjectConvertLeadAction.cls` | `Convert Lead` -> `convertLead` |
| `classes/SObjectPostChatterAction.cls` | `Post to Chatter` -> `postChatter` |
| `classes/SObjectAddNoteAction.cls` | `Add Note` -> `addNote` |
| `classes/SObjectAttachFileAction.cls` | `Attach File` -> `attachFile` |
| `classes/SObjectListFlowsAction.cls` | `List Flows` -> `listFlows` |
| `classes/SObjectSummaryAction.cls` | `Get Record Summary (Generic)` -> `recordSummary` |
| `flows/SObjectActions_EchoFlow.flow-meta.xml` | Tiny autolaunched flow used as the runFlow test fixture |
| `test-fixtures/` (separate package dir) | Optional: `SObjectActions_Fixture__c` with 2 record types + dependent picklist + permission set; only used by tests, referenced dynamically |
| `classes/SObjectActionUtil.cls` | Shared helpers: type resolution, JSON -> SObject, field validation, label lookup, duplicate-safe DML |
| `classes/SObjectActionsTest.cls` | 14 tests for create/read/update/delete |
| `classes/SObjectActionsExtTest.cls` | 6 tests for upsert/find/describe |
| `classes/SObjectActionsExt2Test.cls` | 7 tests for search/related/count/undelete/access |
| `classes/SObjectActionsExt3Test.cls` | 7 tests for clone/validate/aggregate/runFlow/urls |
| `classes/SObjectActionsExt4Test.cls` | 11 tests for the intent/utility tools (~97.5% total coverage) |
| `mcp/main/default/mcpServerDefinitions/SObjectActions.mcpServerDefinition-meta.xml` | MCP server definition wiring all 27 Apex actions to tools (own package dir: not packageable) |
| `scripts/smoke-test.sh` | REST end-to-end smoke test (45 checks) |
| `scripts/apex/smoke.apex` | Anonymous Apex smoke script |
| `scripts/gen-tool-manifest.py` | Regenerates `docs/TOOLS.md` and `docs/tools.json` from source |
| `scripts/gen-postman.py` | Regenerates the Postman collection from `docs/tools.json` |
| `scripts/setup.sh` | One-command install + verify into any org |
| `scripts/scratch.sh`, `scripts/package.sh` | Scratch org bootstrap; unlocked package create/version/install |
| `permissionsets/SObjectActions_User` | Execute access to all action classes (no object CRUD) |
| `eca/main/default/externalClientApps/SObjectActionsClient` (+ oauth settings, global oauth, policies) | The OAuth client for MCP: `MCP` + refresh scopes, PKCE, JWT tokens, common callbacks (own package dir: global OAuth settings are not packageable) |
| `.github/workflows/validate.yml` | CI: check-only deploy with tests, docs drift check |

API version: 67.0 (`sfdx-project.json`). Requires an org where hosted MCP servers and
`McpServerDefinition` metadata are available (Summer '26 or later; verify in current release notes).

---

## Deploy

```bash
# deploy everything and run the test class
sf project deploy start -o <alias> -d force-app/main/default \
  -l RunSpecifiedTests -t SObjectActionsTest -t SObjectActionsExtTest -t SObjectActionsExt2Test -t SObjectActionsExt3Test -t SObjectActionsExt4Test

# Test fixtures (record types + dependent picklist used by two tests; deploy first for full per-class coverage)
sf project deploy start -o <alias> -d test-fixtures && sf org assign permset -n SObjectActions_Fixture -o <alias>
# MCP server definition and External Client App (separate dirs)
sf project deploy start -o <alias> -d mcp -d eca

# Apex only (skip the MCP definition)
sf project deploy start -o <alias> -d force-app/main/default/classes \
  -l RunSpecifiedTests -t SObjectActionsTest -t SObjectActionsExtTest -t SObjectActionsExt2Test -t SObjectActionsExt3Test -t SObjectActionsExt4Test
```

Notes:
- The `McpServerDefinition` developer name must be alphanumeric, start with a letter, 2-40 chars
  (underscores are rejected). It is `SObjectActions`.
- `apiIdentifier` values are `aa:apex-<ClassName>` and resolve automatically from the API Catalog
  once the class is deployed. Deploy classes and the definition together or classes first.

---

## Enable the MCP server

1. Setup > **MCP Servers** > `SObject Actions` > link External Client App **SObject Actions MCP Client** (deployed by this repo) > **Activate**.
2. Client authentication details: `docs/CLIENT_AUTH.md`.
3. Assign users a permission set with access to the Apex classes plus CRUD/FLS on the objects
   they will operate on. The tools run **as the authenticated user**.
4. Point your MCP client (Claude, Agentforce, Cursor, etc.) at the server URL shown in Setup.
   `tools/list` returns `checkAccess`, `describeObject`, `searchRecords`, `findRecords`, `countRecords`,
   `aggregateRecords`, `readRecords`, `relatedRecords`, `recordSummary`, `picklistValues`, `validateRecords`,
   `createRecords`, `upsertRecords`, `updateRecords`, `cloneRecords`, `assignOwner`, `changeRecordType`,
   `deleteRecords`, `undeleteRecords`, `logActivity`, `closeCase`, `convertLead`, `postChatter`, `addNote`,
   `attachFile`, `listFlows`, `runFlow`.

Tool annotations set in the definition:

| Tool | readOnly | destructive | idempotent |
|---|---|---|---|
| createRecords | false | false | false |
| readRecords | true | false | true |
| updateRecords | false | false | true |
| deleteRecords | false | **true** | false |
| upsertRecords | false | false | true |
| findRecords | true | false | true |
| describeObject | true | false | true |
| searchRecords | true | false | true |
| relatedRecords | true | false | true |
| countRecords | true | false | true |
| undeleteRecords | false | false | true |
| checkAccess | true | false | true |
| cloneRecords | false | false | false |
| validateRecords | true | false | true |
| aggregateRecords | true | false | true |
| runFlow | false | false | false |
| assignOwner | false | false | true |
| changeRecordType | false | false | true |
| picklistValues | true | false | true |
| logActivity | false | false | false |
| closeCase | false | false | true |
| convertLead | false | false | false |
| postChatter | false | false | false |
| addNote | false | false | false |
| attachFile | false | false | false |
| listFlows | true | false | true |
| recordSummary | true | false | true |

Typical agent sequence: `describeObject` (get exact API names) -> `findRecords` / `readRecords`
-> `createRecords` / `upsertRecords` / `updateRecords` -> `deleteRecords`.

---

## Common conventions

### `objectApiName`
Required on every action. Case-insensitive API name: `Account`, `Case`, `Task`, `My_Object__c`, ...

**Activity (polymorphic Task/Event)**
`Activity` is not creatable/queryable directly, so it is treated as an alias for "Task or Event":

| Input style | How the concrete type is determined |
|---|---|
| `record` / `records` (SObject) | From the SObject's actual type; anything other than Task/Event is rejected |
| `recordsJson` | Each element **must** carry `{"attributes":{"type":"Task"}}` or `"Event"`; missing -> error |
| `recordId` / `recordIds` | From the Id prefix (`00T` = Task, `00U` = Event); other prefixes rejected |

A single request may therefore mix Tasks and Events when `objectApiName = "Activity"`.

### Record inputs (create / update)
Provide any combination; they are merged in this order:

| Field | Type | Audience | Notes |
|---|---|---|---|
| `record` | SObject | Flow | Type must match `objectApiName` |
| `records` | List\<SObject\> | Flow | Same |
| `recordsJson` | String | MCP / REST | JSON **object** or **array** of `{field: value}` maps |

`recordsJson` rules:
- Field names must exist on the object (direct field API names or relationship names such as
  `Account` for external-Id upserts). Unknown names cause the whole request to fail with
  `Unknown field(s) on <Object>: <names>` rather than being silently dropped.
- Values are coerced by the platform JSON deserializer: dates as `YYYY-MM-DD`,
  datetimes as ISO-8601 (`2030-01-01T10:00:00.000Z`), booleans as `true/false`, numbers unquoted.
- `attributes.type`, if present, must equal `objectApiName` (or Task/Event for Activity).

### Id inputs (read / delete)
- `recordId` (read only) and/or `recordIds` (read + delete). Blank entries are skipped.
- 15- or 18-character Ids accepted; invalid strings fail the request (`Invalid record Id: ...`).
- Each Id's type must match `objectApiName` (Task/Event when `Activity`).

### `allOrNone` (create / update / delete)
- Default `false`: partial success; each failed row is reported in `errors` as `row <n>: <message>`.
- `true`: the entire request rolls back on any failure; `failureCount` = number of rows,
  `errors` holds the DML exception message.

### Labels
`recordLabels` is aligned index-for-index with `recordIds`. Label source:

| Object | Label |
|---|---|
| Case | `CaseNumber - Subject` (Subject omitted if blank) |
| Everything else | The object's describe name field (`Name`, `Subject` for Task/Event, `CaseNumber`... ) |
| No name field / no read access | The record Id |

Create/update labels are re-queried after DML (so auto-numbers like `CaseNumber` are populated).
Delete captures labels **before** deleting.

### Common outputs

| Field | Type | Meaning |
|---|---|---|
| `isSuccess` | Boolean | true only if every row in the request succeeded (read: every Id found) |
| `successCount` / `failureCount` (CUD) | Integer | Row counts |
| `foundCount` / `notFoundCount` (read) | Integer | Row counts |
| `recordIds` | List\<String\> | Affected/found Ids, in input order |
| `recordLabels` | List\<String\> | Aligned with `recordIds` |
| `errors` | List\<String\> | `row <n>: <msg>` per failed row, or a single request-level error |
| `message` | String | One-line summary, e.g. `2 created, 1 failed.` |
| `recordUrls` | List\<String\> | Lightning record URLs aligned with `recordIds` (create/read/update/upsert/clone/find/search/related) |

Request-level validation errors (bad object name, malformed JSON, ...) set `isSuccess=false`,
`failureCount=1` and one entry in `errors`; no DML is performed for that request.
Other requests in the same call are unaffected.

---

## Tool reference

### createRecords / `SObjectCreateAction`

Invocable label: **Create Records (Generic)**, category `SObject Actions`.

Inputs: `objectApiName` (req), `record`, `records`, `recordsJson`, `allOrNone`.
Outputs: `isSuccess`, `successCount`, `failureCount`, `recordIds`, `recordLabels`, `errors`, `message`.

MCP call examples:

```json
{ "objectApiName": "Account",
  "recordsJson": "[{\"Name\":\"Acme\",\"Industry\":\"Energy\"},{\"Name\":\"Globex\"}]" }
```

```json
{ "objectApiName": "Case",
  "recordsJson": "{\"Subject\":\"Printer on fire\",\"Status\":\"New\",\"Origin\":\"Web\",\"AccountId\":\"001...\"}" }
```
-> `recordLabels: ["00001234 - Printer on fire"]`

```json
{ "objectApiName": "Activity",
  "recordsJson": "[{\"attributes\":{\"type\":\"Task\"},\"Subject\":\"Call\",\"WhatId\":\"001...\"},{\"attributes\":{\"type\":\"Event\"},\"Subject\":\"Meet\",\"DurationInMinutes\":30,\"ActivityDateTime\":\"2030-01-01T10:00:00.000Z\"}]" }
```

Sample result:
```json
{ "isSuccess": true, "successCount": 2, "failureCount": 0,
  "recordIds": ["00T...", "00U..."], "recordLabels": ["Call", "Meet"],
  "errors": [], "message": "2 created, 0 failed." }
```

Partial failure (`allOrNone=false`):
```json
{ "isSuccess": false, "successCount": 1, "failureCount": 1,
  "recordIds": ["003..."], "recordLabels": ["Good"],
  "errors": ["row 2: Required fields are missing: [LastName] [LastName]"],
  "message": "1 created, 1 failed." }
```

### readRecords / `SObjectReadAction`

Invocable label: **Read Records (Generic)**.

Inputs:

| Field | Type | Notes |
|---|---|---|
| `objectApiName` | String (req) | `Activity` allowed for mixed Task/Event Ids |
| `recordId` | String | Single Id |
| `recordIds` | List\<String\> | Bulk; duplicates collapsed |
| `fields` | List\<String\> | Optional. Field API names, relationship paths allowed (`Owner.Name`, `Account.Name`). Empty = **all fields accessible to the running user**. `Id` and the label fields are always included. |

Outputs:

| Field | Notes |
|---|---|
| `isSuccess` | true if every requested Id was found and visible |
| `foundCount`, `notFoundCount` | |
| `recordIds`, `recordLabels` | Found records, input order |
| `recordsJson` | JSON array of found records (serialized SObjects, includes `attributes.type`) - **MCP-facing** |
| `record`, `records` | First / all found records as SObjects - Flow-facing |
| `notFoundIds` | Ids that do not exist or are not visible under sharing |
| `errors`, `message` | |

Examples:
```json
{ "objectApiName": "Account", "recordId": "001..." }
```
```json
{ "objectApiName": "Case", "recordIds": ["500...","500..."], "fields": ["Status","Priority","Account.Name"] }
```
```json
{ "objectApiName": "Activity", "recordIds": ["00T...","00U..."], "fields": ["Subject","ActivityDate"] }
```

Sample result:
```json
{ "isSuccess": true, "foundCount": 1, "notFoundCount": 0,
  "recordIds": ["500..."], "recordLabels": ["00001234 - Printer on fire"],
  "recordsJson": "[{\"attributes\":{\"type\":\"Case\"},\"Id\":\"500...\",\"CaseNumber\":\"00001234\",\"Subject\":\"Printer on fire\",\"Status\":\"New\",\"Account\":{\"attributes\":{\"type\":\"Account\"},\"Name\":\"Acme\"}}]",
  "notFoundIds": [], "errors": [], "message": "1 found, 0 not found." }
```

Direct field names are validated before the query (`Unknown field on Account: Foo__c`).
Relationship paths are validated by SOQL itself; a bad path surfaces as a QueryException in `errors`.
Records the user cannot see are simply reported in `notFoundIds` (no error).

### updateRecords / `SObjectUpdateAction`

Invocable label: **Update Records (Generic)**.

Inputs: `objectApiName` (req), `record`, `records`, `recordsJson`, `allOrNone`.
Every row **must include `Id`**; a row without one fails the whole request
(`row <n>: Id is required for update.`).

Outputs: same shape as create (`successCount`, `recordIds`, `recordLabels`, ...).
Labels reflect the record's state **after all updates in the call** are applied.

Examples:
```json
{ "objectApiName": "Account",
  "recordsJson": "[{\"Id\":\"001...\",\"Name\":\"Acme Corp\",\"Phone\":\"555-0100\"}]" }
```
```json
{ "objectApiName": "Case", "recordsJson": "{\"Id\":\"500...\",\"Status\":\"Closed\"}" }
```
```json
{ "objectApiName": "Activity",
  "recordsJson": "[{\"attributes\":{\"type\":\"Task\"},\"Id\":\"00T...\",\"Status\":\"Completed\"}]" }
```

To clear a field pass `null`: `{"Id":"001...","Description":null}`.

### deleteRecords / `SObjectDeleteAction`

Invocable label: **Delete Records (Generic)**. Tool is flagged `destructive`.

Inputs: `objectApiName` (req), `recordIds`, `record`, `records`, `allOrNone`.
Records passed as SObjects must include `Id`.

Outputs: `isSuccess`, `successCount`, `failureCount`, `recordIds`, `recordLabels`
(captured before deletion), `errors`, `message`.

Examples:
```json
{ "objectApiName": "Account", "recordIds": ["001...", "001..."] }
```
```json
{ "objectApiName": "Activity", "recordIds": ["00T...", "00U..."] }
```

Deleted records go to the Recycle Bin (standard `Database.delete` semantics). Cascade
deletes follow the platform's master-detail / lookup rules.

### upsertRecords / `SObjectUpsertAction`

Invocable label: **Upsert Records (Generic)**.

Inputs: `objectApiName` (req), `externalIdField` (optional, default `Id`), `record`, `records`,
`recordsJson`, `allOrNone`. `externalIdField` must be an external Id / idLookup field on the object.
`Activity` is supported (Task/Event via `attributes.type`); the external Id field must exist on
the concrete type.

Outputs: `isSuccess`, `successCount`, `failureCount`, `createdCount`, `updatedCount`,
`recordIds`, `recordLabels`, `wasCreated[]` (aligned with `recordIds`), `errors`, `message`.

Examples:
```json
{ "objectApiName": "Account", "externalIdField": "External_Key__c",
  "recordsJson": "[{\"External_Key__c\":\"ERP-1001\",\"Name\":\"Acme\"},{\"External_Key__c\":\"ERP-1002\",\"Name\":\"Globex\"}]" }
```
```json
{ "objectApiName": "Contact",
  "recordsJson": "[{\"Id\":\"003...\",\"Email\":\"a@b.com\"},{\"LastName\":\"New Person\"}]" }
```
-> `wasCreated: [false, true]`, `message: "1 created, 1 updated, 0 failed."`

Rows are grouped per concrete object type; each group is one `Database.upsert`.
`allOrNone=true` uses a savepoint so a failure in any group rolls back the whole request.

### findRecords / `SObjectFindAction`

Invocable label: **Find Records (Generic)**. Structured filters only; **no raw SOQL/WHERE is accepted**.

Inputs:

| Field | Notes |
|---|---|
| `objectApiName` (req) | Must be queryable. Use `Task`/`Event`, not `Activity`. |
| `filtersJson` | JSON array (or single object) of `{"field","op","value"}`. Ops: `=`, `!=`, `<`, `<=`, `>`, `>=`, `LIKE`, `IN`, `NOT IN`. `value` may be `null` with `=`/`!=`. `IN`/`NOT IN` need an array. Filter fields must be direct, filterable fields (no relationship paths). |
| `filterLogic` | `AND` (default) or `OR`, across all filters. |
| `fields` | Extra fields to return; relationship paths allowed (`Owner.Name`). `Id` + label fields always included. |
| `orderBy` | `"Field"` or `"Field ASC|DESC"`. |
| `limitCount` | 1-200, default 50. |

Values are coerced to the field type before binding (dates `YYYY-MM-DD`, datetimes ISO-8601,
numbers, booleans, Ids). All values go through bind variables (`Database.queryWithBinds`, USER_MODE).

Outputs: `isSuccess`, `resultCount`, `recordIds`, `recordLabels`, `recordsJson`, `records` (Flow),
`soql` (the query that ran, values redacted as `:b0`, `:b1`...), `errors`, `message`
(`"... (limit reached)"` when `resultCount == limitCount`).

Examples:
```json
{ "objectApiName": "Case",
  "filtersJson": "[{\"field\":\"Status\",\"op\":\"IN\",\"value\":[\"New\",\"Working\"]},{\"field\":\"CreatedDate\",\"op\":\">=\",\"value\":\"2026-08-01T00:00:00Z\"}]",
  "fields": ["Status","Priority","Account.Name"], "orderBy": "CreatedDate DESC", "limitCount": 25 }
```
```json
{ "objectApiName": "Account",
  "filtersJson": "[{\"field\":\"Name\",\"op\":\"LIKE\",\"value\":\"Acme%\"},{\"field\":\"Industry\",\"op\":\"=\",\"value\":\"Energy\"}]",
  "filterLogic": "OR" }
```
```json
{ "objectApiName": "Task",
  "filtersJson": "[{\"field\":\"WhatId\",\"op\":\"=\",\"value\":\"001...\"},{\"field\":\"IsClosed\",\"op\":\"=\",\"value\":false}]" }
```

### describeObject / `SObjectDescribeAction`

Invocable label: **Describe Object (Generic)**. Two modes in one tool.

**Describe mode** (`objectApiName` set):

| Input | Notes |
|---|---|
| `objectApiName` | `Activity` describes `Task`. |
| `includeFields` | default true |
| `fieldNameContains` | case-insensitive filter on field API name or label |

Outputs: `objectApiName`, `objectLabel`, `keyPrefix`, `isCustom`, `isCreateable/Updateable/Deletable/Queryable`,
`labelFields` (e.g. `["CaseNumber","Subject"]` for Case), `requiredFields` (createable, non-nillable,
no default), `fieldsJson` (accessible fields only: `apiName, label, type, required, createable, updateable,
externalId, nameField, length, referenceTo[], relationshipName, picklistValues[]`), `recordTypesJson`
(active, available, non-master: `id, developerName, name, isDefault`), `childRelationshipsJson`
(`relationshipName, childObject, field`), `resultCount` (fields returned).

**List mode** (`objectApiName` blank):

| Input | Notes |
|---|---|
| `objectNameContains` | case-insensitive filter on API name or label |
| `customOnly` | default false |

Outputs: `objectsJson` (`apiName, label, keyPrefix, isCustom, createable, queryable` for accessible
objects; custom settings and prefix-less system objects excluded), `resultCount`.

Examples:
```json
{ "objectApiName": "Case", "fieldNameContains": "status" }
```
```json
{ "objectApiName": "My_Object__c" }
```
```json
{ "objectNameContains": "invoice", "customOnly": true }
```

### searchRecords / `SObjectSearchAction`

Invocable label: **Search Records (Generic)**. SOSL free-text search; the term is bound (`FIND :term`).

| Input | Notes |
|---|---|
| `searchTerm` (req) | min 2 chars; `*` and `?` wildcards allowed |
| `objectApiNames` | default `Account, Contact, Lead, Opportunity, Case`; each must be searchable; `Activity` not allowed (use Task/Event) |
| `searchIn` | `ALL` (default), `NAME`, `EMAIL`, `PHONE`, `SIDEBAR` |
| `fields` | extra fields, applied only where the field exists on that object |
| `limitCount` | per object, 1-200, default 20 |

Outputs: `resultCount`, `recordIds`, `recordLabels`, `recordObjectNames` (aligned), `recordsJson`, `records`.

```json
{ "searchTerm": "acme*", "objectApiNames": ["Account","Contact"], "searchIn": "NAME", "fields": ["Phone","Email"] }
```

### relatedRecords / `SObjectRelatedAction`

Invocable label: **Get Related Records (Generic)**.

| Input | Notes |
|---|---|
| `parentRecordId` (req) | parent object inferred from the Id |
| `relationshipName` (req) | child relationship name on the parent (`Contacts`, `Cases`, `Opportunities`, `Tasks`, `Events`, `My_Children__r`); case-insensitive; see `describeObject.childRelationshipsJson` |
| `fields`, `orderBy`, `limitCount` | as in findRecords (limit default 50, max 200) |

Outputs: `parentObjectApiName`, `childObjectApiName`, `resultCount`, `recordIds`, `recordLabels`, `recordsJson`, `records`.

```json
{ "parentRecordId": "001...", "relationshipName": "Cases", "fields": ["Status","Priority"], "orderBy": "CreatedDate DESC", "limitCount": 10 }
```

### countRecords / `SObjectCountAction`

Invocable label: **Count Records (Generic)**.

| Input | Notes |
|---|---|
| `objectApiName` (req) | Task/Event, not Activity |
| `filtersJson`, `filterLogic` | identical to findRecords |
| `groupByField` | optional groupable field; up to 200 groups, ordered by count desc; null group reported as `null` |

Outputs: `totalCount`, `groupValues[]`, `groupCounts[]` (aligned), `groupsJson` (`[{value,count}]`), `soql`.

```json
{ "objectApiName": "Case", "filtersJson": "[{\"field\":\"IsClosed\",\"op\":\"=\",\"value\":false}]", "groupByField": "Priority" }
```

### undeleteRecords / `SObjectUndeleteAction`

Invocable label: **Undelete Records (Generic)**. Restores from the Recycle Bin.

Inputs: `objectApiName` (req; `Activity` for Task/Event mix), `recordIds` (req), `allOrNone`.
Outputs: `successCount`, `failureCount`, `recordIds`, `recordLabels` (post-restore), `errors`, `message`.
Duplicate Ids in one request are collapsed. Records already restored or purged fail per row.

```json
{ "objectApiName": "Account", "recordIds": ["001..."] }
```

### checkAccess / `SObjectAccessAction`

Invocable label: **Check Access (Generic)**. All inputs optional; with none it is a "who am I".

| Input | Notes |
|---|---|
| `objectApiName` | CRUD check (`Activity` -> Task) |
| `fields` | requires `objectApiName`; each reported as `{apiName, exists, readable, editable, createable}` |
| `recordIds` | up to 200; uses `UserRecordAccess` -> `{recordId, hasRead, hasEdit, hasDelete, hasTransfer, maxAccessLevel}` (`None` when not visible / nonexistent) |

Outputs: `userId`, `userName`, `loginUsername`, `profileId`, `profileName`, `userType`, `organizationId`, `timeZone`,
`objectApiName`, `canCreate/canRead/canUpdate/canDelete`, `fieldAccessJson`, `recordAccessJson`, `message`.

```json
{ "objectApiName": "Opportunity", "fields": ["Amount","StageName"], "recordIds": ["006..."] }
```

### cloneRecords / `SObjectCloneAction`

Invocable label: **Clone Records (Generic)**.

| Input | Notes |
|---|---|
| `objectApiName` (req) | `Activity` for Task/Event mix |
| `recordIds` (req) | sources; not-found / invisible Ids fail per row |
| `overridesJson` | JSON object applied to every clone (field names validated) |
| `excludeFields` | fields not to copy (e.g. `OwnerId`, external Ids) |
| `allOrNone` | |

Copies every field that is createable, readable, not auto-number and not a formula. Child records
are not cloned. Outputs: `sourceRecordIds` (aligned), `recordIds`, `recordLabels`, `recordUrls`, counts, `errors`.

```json
{ "objectApiName": "Opportunity", "recordIds": ["006..."], "overridesJson": "{\"Name\":\"Renewal 2027\",\"StageName\":\"Prospecting\"}", "excludeFields": ["OwnerId"] }
```

### validateRecords / `SObjectValidateAction`

Invocable label: **Validate Records (Generic, dry run)**. Performs the insert/update inside a savepoint
and always rolls back; triggers, validation rules, required fields, FLS and sharing all run for real.

Inputs: `objectApiName` (req), `operation` (`CREATE` default | `UPDATE`), `record`, `records`, `recordsJson`.
Outputs: `isSuccess` (would all rows save), `successCount`, `failureCount`, `rowResultsJson`
(`[{row, valid, errors[]}]`), `errors`, `message` (`... Nothing was saved.`).

Note: the DML still counts toward governor limits and any auto-numbers consumed in the transaction are not reused.

```json
{ "objectApiName": "Contact", "recordsJson": "[{\"LastName\":\"Ok\"},{\"FirstName\":\"No last name\"}]" }
```

### aggregateRecords / `SObjectAggregateAction`

Invocable label: **Aggregate Records (Generic)**.

| Input | Notes |
|---|---|
| `objectApiName` (req) | Task/Event, not Activity |
| `aggregationsJson` (req) | `[{"function","field"}]`; functions `COUNT, COUNT_DISTINCT, SUM, AVG, MIN, MAX`; SUM/AVG need numeric fields; field must be aggregatable |
| `filtersJson`, `filterLogic` | as findRecords |
| `groupByFields` | 0-3 groupable fields |
| `limitCount` | groups only, 1-2000, default 200 (ungrouped queries cannot take LIMIT) |

Outputs: `resultCount`, `rowsJson` (each row: group field values + `FUNCTION_Field` keys, e.g. `SUM_Amount`), `soql`.

```json
{ "objectApiName": "Opportunity",
  "aggregationsJson": "[{\"function\":\"SUM\",\"field\":\"Amount\"},{\"function\":\"COUNT\",\"field\":\"Id\"}]",
  "filtersJson": "[{\"field\":\"IsClosed\",\"op\":\"=\",\"value\":false}]",
  "groupByFields": ["StageName"] }
```

### runFlow / `SObjectRunFlowAction`

Invocable label: **Run Flow (Generic)**.

| Input | Notes |
|---|---|
| `flowApiName` (req) | active autolaunched flow |
| `inputsJson` | JSON object of input variable -> value |
| `outputVariableNames` | variables marked "available for output" to return |

Outputs: `interviewId`, `outputsJson`, `errors` (flow faults surface as `Could not start flow ...` or the
fault message), `message`. The flow runs in the same transaction, in the flow's declared run mode.

```json
{ "flowApiName": "SObjectActions_EchoFlow", "inputsJson": "{\"inputText\":\"hi\",\"inputNumber\":1}", "outputVariableNames": ["outputText","outputNumber"] }
```

### assignOwner / `SObjectAssignOwnerAction`
Inputs: `objectApiName`, `recordIds`, `newOwnerId` (005/00G) **or** `newOwnerName` (exact user full name / username, or queue name / developer name; ambiguous names are rejected), `allOrNone`.
Outputs: resolved `ownerId`/`ownerName`, per-record counts, `recordIds`, `recordLabels`, `errors`.
```json
{ "objectApiName": "Case", "recordIds": ["500..."], "newOwnerName": "Tier 2 Support" }
```

### changeRecordType / `SObjectRecordTypeAction`
Inputs: `objectApiName`, `recordIds`, `recordType` (Id, DeveloperName or Name), `allOrNone`. Errors list the available developer names. Outputs: resolved `recordTypeId`/`recordTypeName`, per-record outcomes.

### picklistValues / `SObjectPicklistAction`
Inputs: `objectApiName`, `fieldApiName`, `includeInactive`. Outputs: `values[]`, `labels[]`, `valuesJson`
(`{value,label,active,default,validFor[]}`), `isRestricted`, `isDependent`, `controllingField`, `defaultValue`.
Values reflect the org-wide field definition (record-type value sets are not applied).

### logActivity / `SObjectLogActivityAction`
Logs a completed Task. Inputs: `subject` (req), `relatedRecordId` (WhatId), `personRecordId` (Contact/Lead WhoId), `description`, `activityDate` (default today), `activityType`, `status` (default first closed status), `priority`, `ownerId`, `extraFieldsJson`. Outputs: `recordId`, `recordUrl`, `status`.
```json
{ "subject": "Call with CFO", "relatedRecordId": "001...", "personRecordId": "003...", "description": "Discussed renewal", "activityType": "Call" }
```

### closeCase / `SObjectCloseCaseAction`
Inputs: `caseIds`, `status` (must be a closed status; default first closed status), `comment` (+ `commentIsPublic`), `extraFieldsJson`, `allOrNone`. Outputs: `status` used, per-case counts, `recordLabels` (`CaseNumber - Subject`).

### convertLead / `SObjectConvertLeadAction`
Inputs: `leadId`, `convertedStatus` (default first converted status), `accountId` / `contactId` (merge into existing), `createOpportunity` (default true), `opportunityName`, `ownerId`, `sendEmailToOwner`. Outputs: `accountId`, `contactId`, `opportunityId` + URLs.

### postChatter / `SObjectPostChatterAction`
Inputs: `recordId` (any feed-enabled record or User), `text`, `mentionUserIds`. Outputs: `feedItemId`. Fails with a clear message when the object has no feed tracking.

### addNote / `SObjectAddNoteAction`
Inputs: `title`, `body` (plain text or simple HTML), `recordIds`, `shareType` (V default / I / C). Creates a `ContentNote` and `ContentDocumentLink`s; if Enhanced Notes is disabled in the org, stores the note as an `.html` file instead (`storedAsFile=true`). Outputs: `noteId`, linked `recordIds`, `storedAsFile`.

### attachFile / `SObjectAttachFileAction`
Inputs: `fileName`, `textContent` **or** `base64Content`, `title`, `recordIds`, `shareType`. Outputs: `contentVersionId`, `contentDocumentId`, linked `recordIds`. Heap limit applies to large base64 payloads (~6 MB sync).

### listFlows / `SObjectListFlowsAction`
List mode: `nameContains`, `processType` (default `AutoLaunchedFlow`; `ALL`), `includeInactive`, `limitCount` -> `flowsJson`.
Detail mode: `flowApiName` -> `variablesJson` (`apiName, dataType, isInput, isOutput, isCollection, objectType, description`). Pair with `runFlow`.

### recordSummary / `SObjectSummaryAction`
Inputs: `recordId`, `fields` (default all accessible), `relationshipNames` (max 10; default common ones that exist), `recentActivityLimit` (0-20, default 5).
Outputs: `objectApiName`, `recordLabel`, `recordUrl`, `ownerName`, `recordJson`, `relatedCountsJson` (`{Contacts: 3, Cases: 1, ...}`), `recentActivityJson` (`[{id,type,subject,date,status,ownerName}]`).
Costs 1 SOQL for the record + 1 per relationship + 2 for activity.

---

## Calling from Flow

Add an **Action** element, search the `SObject Actions` category, pick the action.

- Set **Object API Name** to a literal or a text variable.
- For create/update, assign a record variable to **Record** or a record collection to **Records**.
  Flow's generic SObject inputs require you to pick the object type on the action element;
  it must match `objectApiName` (or be Task/Event with `Activity`).
- Read the outputs `Created Record Ids` / `Record` / `Records` / `Errors` into Flow variables.
- Leave **All Or None** empty for partial success, or set `{!$GlobalConstant.True}` to roll back.

Fault paths: request-level problems do **not** throw; check `Success` and `Errors`.
Only unexpected platform exceptions (limits, etc.) reach a Flow fault connector.

---

## Calling from REST

```http
POST /services/data/v67.0/actions/custom/apex/SObjectCreateAction
Authorization: Bearer <token>
Content-Type: application/json

{ "inputs": [
  { "objectApiName": "Account", "recordsJson": "[{\"Name\":\"Acme\"}]" },
  { "objectApiName": "Contact", "recordsJson": "[{\"LastName\":\"Smith\",\"AccountId\":\"001...\"}]" }
] }
```

Same shape for `SObjectReadAction`, `SObjectUpdateAction`, `SObjectDeleteAction`.
Each element of `inputs` maps to one element of the response `outputValues`.

---

## Security model

- All DML and SOQL run with `AccessLevel.USER_MODE`: object CRUD, field-level security and
  sharing of the **running user** are enforced by the platform.
  - Create/update of a non-writable field -> row error from the platform.
  - Read of an inaccessible object/field -> not returned (default field list only includes
    accessible fields; explicit inaccessible fields raise a query error).
  - Records outside the user's sharing scope -> `notFoundIds` on read, row error on update/delete.
- The classes are `global with sharing` (required for MCP discovery).
- No `Database.query` string is built from raw caller input: object names are validated through
  describe, field names through the field map, Ids through `Id.valueOf`; relationship paths in
  `readRecords.fields` are only ever placed in the SELECT list, never in a WHERE clause.
- Because the tools are generic, gate access with permission sets: who can call the Apex classes,
  and CRUD/FLS on the objects. Consider excluding `deleteRecords` from the server for agent use cases
  that should never delete.

---

## Limits and bulk behavior

- Invocable inputs are bulkified: all records across all requests in one call are combined into
  the fewest DML statements possible (one insert / update / delete per call in the normal case).
- **Duplicate Ids** across requests (same record updated or deleted twice in one call) are split into
  sequential DML batches instead of failing with `Duplicate id in list`.
- Label lookups: one SOQL per concrete object type per call (create/update/delete);
  read issues one SOQL per concrete type per request.
- Governor limits that apply per transaction: 100 SOQL, 150 DML statements, 10,000 rows DML,
  6 MB heap (sync). Very large `recordsJson` payloads or `readRecords` with no `fields` on wide
  objects and hundreds of Ids can approach heap/CPU limits; pass an explicit `fields` list for
  bulk reads.
- Mixed object types in one call are fine, but mixing setup and non-setup objects
  (e.g. User + Account) in a single call is subject to the platform's mixed-DML rule.
- Records per DML chunk of differing SObject types is limited to 10 distinct types per DML
  statement by the platform.

---

## Error catalog

| Message | Cause | Fix |
|---|---|---|
| `objectApiName is required.` | Missing input | Supply the API name |
| `Unknown object API name: X` | Typo / not visible | Check spelling and object access |
| `Activity is polymorphic. ...` | Used `Activity` without a way to resolve Task vs Event | Use `Task`/`Event`, or add `attributes.type` per JSON row |
| `Record of type X does not match objectApiName Y.` | SObject input of the wrong type | Align inputs |
| `attributes.type X does not match objectApiName Y.` | JSON row typed differently | Remove or fix `attributes.type` |
| `recordsJson is not valid JSON: ...` | Malformed string | Ensure it is a JSON object or array, properly escaped |
| `recordsJson must be a JSON object or array of objects.` | Scalar/other JSON | Wrap in `{}` or `[]` |
| `Unknown field(s) on X: a, b` | Field names not on the object | Fix API names (`__c` suffix, namespace) |
| `Could not build X from JSON: ...` | Value type coercion failed (bad date format, etc.) | Use ISO-8601 dates/datetimes, correct types |
| `No records supplied. ...` | Nothing to act on | Provide `record`, `records`, `recordsJson`, or Ids |
| `Invalid record Id: ...` | Not a valid 15/18-char Id | Fix Id |
| `Id X is a T and does not match objectApiName Y.` | Wrong prefix | Fix objectApiName or Id |
| `row n: Id is required for update.` | Update row without Id | Add `Id` |
| `Every record must include Id for delete.` | SObject delete input without Id | Add `Id` |
| `Unknown field on X: f` (read/find) | Bad entry in `fields` / `orderBy` / `externalIdField` | Fix API name |
| `Field X on Y is not an external Id / idLookup field ...` | Bad `externalIdField` | Use an external Id field or `Id` |
| `Unknown filter field on X: f (relationship paths are not allowed in filters).` | Bad/dotted filter field | Use a direct field |
| `Field X is not filterable.` | Long text / encrypted etc. | Filter on another field |
| `Unsupported op "X"` | Bad `op` | Use =, !=, <, <=, >, >=, LIKE, IN, NOT IN |
| `Value "x" is not valid for field F (TYPE)` | Coercion failed | Match the field type / ISO date format |
| `limitCount must be between 1 and 200.` | Out of range | Adjust |
| `row n: <platform message> [Field]` | DML failure (validation rule, required field, FLS, sharing) | Fix data or permissions |

---

## Testing

Three layers, cheapest first:

```bash
# 1. Unit tests (Apex, ~98% coverage)
sf apex run test -o <alias> -n SObjectActionsTest -n SObjectActionsExtTest -n SObjectActionsExt2Test -n SObjectActionsExt3Test -n SObjectActionsExt4Test -r human -w 10 -c

# 2. Anonymous Apex smoke: create -> read -> update -> delete, prints every result
sf apex run -o <alias> -f scripts/apex/smoke.apex

# 3. REST smoke through the Invocable Actions API (the exact path MCP tools use).
#    45 assertions across all 27 tools incl. Case labels, Activity Task/Event mix,
#    validation errors. Creates and removes its own records. Needs jq.
scripts/smoke-test.sh <alias> [apiVersion]
```

To exercise the MCP layer itself, activate the server (see above), connect an MCP client with
the External Client App, run `tools/list` and call e.g.
`readRecords {"objectApiName":"Account","recordId":"001..."}`.

Test coverage: create (SObject + JSON inputs, Case label, Activity resolution, validation matrix,
partial and all-or-none), read (all/explicit fields, relationship path, Activity mix, not-found,
validation), update (JSON/SObject/Activity, missing Id, ghost Id, all-or-none rollback,
duplicate Id across requests), delete (Ids/SObjects/Activity mix, labels, validation, partial,
all-or-none, duplicate Id), util fallbacks.

Tests assert by record Id rather than table counts so they pass in orgs with existing
triggers/automation on Account.

---

## Extending

Everything from the original design list is implemented. Natural next additions: record-type-aware
picklist values (UI API callout via Named Credential), sendEmail (single email / template),
approval submit/recall, and share/unshare (manual sharing) actions.
- **Custom label rules**: extend `SObjectActionUtil.labelFields()` (Case is the current
  special case).
- **New tool on the server**: add a `<tools>` block to
  `mcp/main/default/mcpServerDefinitions/SObjectActions.mcpServerDefinition-meta.xml` with
  `apiIdentifier = aa:apex-<ClassName>` and redeploy; re-activate the server in Setup if needed.