SObjectActions
Enables object-agnostic Salesforce operations through 27 invocable Apex MCP tools, covering discovery, reading, writing, intent shortcuts, and flow execution. It allows any standard or custom object to be managed from MCP clients or called from Flow, Agentforce, and REST.
README
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)
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
- Components
- Deploy
- Enable the MCP server
- Common conventions
- Tool reference
- createRecords
- readRecords
- updateRecords
- deleteRecords
- upsertRecords
- findRecords
- describeObject
- searchRecords
- relatedRecords
- countRecords
- undeleteRecords
- checkAccess
- cloneRecords
- validateRecords
- aggregateRecords
- runFlow
- assignOwner
- changeRecordType
- picklistValues
- logActivity
- closeCase
- convertLead
- postChatter
- addNote
- attachFile
- listFlows
- recordSummary
- Calling from Flow
- Calling from REST
- Security model
- Limits and bulk behavior
- Error catalog
- Testing
- 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
# 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
McpServerDefinitiondeveloper name must be alphanumeric, start with a letter, 2-40 chars (underscores are rejected). It isSObjectActions. apiIdentifiervalues areaa: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
- Setup > MCP Servers >
SObject Actions> link External Client App SObject Actions MCP Client (deployed by this repo) > Activate. - Client authentication details:
docs/CLIENT_AUTH.md. - 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.
- Point your MCP client (Claude, Agentforce, Cursor, etc.) at the server URL shown in Setup.
tools/listreturnscheckAccess,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
Accountfor external-Id upserts). Unknown names cause the whole request to fail withUnknown 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 astrue/false, numbers unquoted. attributes.type, if present, must equalobjectApiName(or Task/Event for Activity).
Id inputs (read / delete)
recordId(read only) and/orrecordIds(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 whenActivity).
allOrNone (create / update / delete)
- Default
false: partial success; each failed row is reported inerrorsasrow <n>: <message>. true: the entire request rolls back on any failure;failureCount= number of rows,errorsholds 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:
{ "objectApiName": "Account",
"recordsJson": "[{\"Name\":\"Acme\",\"Industry\":\"Energy\"},{\"Name\":\"Globex\"}]" }
{ "objectApiName": "Case",
"recordsJson": "{\"Subject\":\"Printer on fire\",\"Status\":\"New\",\"Origin\":\"Web\",\"AccountId\":\"001...\"}" }
-> recordLabels: ["00001234 - Printer on fire"]
{ "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:
{ "isSuccess": true, "successCount": 2, "failureCount": 0,
"recordIds": ["00T...", "00U..."], "recordLabels": ["Call", "Meet"],
"errors": [], "message": "2 created, 0 failed." }
Partial failure (allOrNone=false):
{ "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:
{ "objectApiName": "Account", "recordId": "001..." }
{ "objectApiName": "Case", "recordIds": ["500...","500..."], "fields": ["Status","Priority","Account.Name"] }
{ "objectApiName": "Activity", "recordIds": ["00T...","00U..."], "fields": ["Subject","ActivityDate"] }
Sample result:
{ "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:
{ "objectApiName": "Account",
"recordsJson": "[{\"Id\":\"001...\",\"Name\":\"Acme Corp\",\"Phone\":\"555-0100\"}]" }
{ "objectApiName": "Case", "recordsJson": "{\"Id\":\"500...\",\"Status\":\"Closed\"}" }
{ "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:
{ "objectApiName": "Account", "recordIds": ["001...", "001..."] }
{ "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:
{ "objectApiName": "Account", "externalIdField": "External_Key__c",
"recordsJson": "[{\"External_Key__c\":\"ERP-1001\",\"Name\":\"Acme\"},{\"External_Key__c\":\"ERP-1002\",\"Name\":\"Globex\"}]" }
{ "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 |
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:
{ "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 }
{ "objectApiName": "Account",
"filtersJson": "[{\"field\":\"Name\",\"op\":\"LIKE\",\"value\":\"Acme%\"},{\"field\":\"Industry\",\"op\":\"=\",\"value\":\"Energy\"}]",
"filterLogic": "OR" }
{ "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:
{ "objectApiName": "Case", "fieldNameContains": "status" }
{ "objectApiName": "My_Object__c" }
{ "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.
{ "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.
{ "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.
{ "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.
{ "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.
{ "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.
{ "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.
{ "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.
{ "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.
{ "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.
{ "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.
{ "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 ContentDocumentLinks; 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 withActivity). - Read the outputs
Created Record Ids/Record/Records/Errorsinto 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
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 ->
notFoundIdson read, row error on update/delete.
- The classes are
global with sharing(required for MCP discovery). - No
Database.querystring is built from raw caller input: object names are validated through describe, field names through the field map, Ids throughId.valueOf; relationship paths inreadRecords.fieldsare 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
deleteRecordsfrom 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
recordsJsonpayloads orreadRecordswith nofieldson wide objects and hundreds of Ids can approach heap/CPU limits; pass an explicitfieldslist 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:
# 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 tomcp/main/default/mcpServerDefinitions/SObjectActions.mcpServerDefinition-meta.xmlwithapiIdentifier = aa:apex-<ClassName>and redeploy; re-activate the server in Setup if needed.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。