feat(salesforce): add Tooling API schema tools (custom field/object) + metadata query (#5209)

* feat(salesforce): add Tooling API schema tools (custom field/object) + metadata query

Add salesforce_create_custom_field, salesforce_update_custom_field,
salesforce_delete_custom_field, salesforce_create_custom_object, and
salesforce_tooling_query so the connector can make schema/metadata changes
(e.g. create a custom field on Account). Previously the integration only did
record CRUD via the REST Data API. Existing `api` OAuth scope covers the
Tooling API; metadata creation is profile-permission gated, so no scope change.

Also: fix Opportunity closeDate being wrongly required on update_opportunity,
make list_reports/list_dashboards descriptions honest (recently-viewed scope),
and document run_report's includeDetails default.

* improvement(salesforce): non-destructive custom field update + align metadata param types

- update_custom_field now does a read-modify-write (GET existing Metadata,
  overlay only provided changes, PATCH) so omitted properties are preserved
  instead of being reset by the Tooling API's full-metadata PATCH; no more
  fabricated label or injected create-time defaults on update
- fieldType is now optional on update (kept from the existing field unless changed)
- widen length/precision/scale/visibleLines param types to number | string to
  match the tool param configs (type: number)

* improvement(salesforce): preserve picklist values and clear stale metadata on field type change

- custom field update now unions provided picklist values with the field's
  existing values instead of replacing the whole valueSet (no data loss)
- when fieldType changes on update, drop the prior type's type-specific
  metadata (length/precision/scale/visibleLines/valueSet/defaultValue/unique/
  externalId) and backfill the new type's required defaults

* improvement(salesforce): scope custom field update to attributes, never the type

update_custom_field no longer changes a field's data type: Salesforce treats a
type change as a separate conversion operation, and a stale forwarded fieldType
could otherwise trigger an unintended destructive migration. The merge keeps the
field's existing type and overlays only the other provided properties, dropping
the type-change/stale-metadata-stripping logic entirely.
This commit is contained in:
Waleed
2026-06-25 10:37:05 -07:00
committed by GitHub
parent e748a64ff4
commit 34d32b97dd
15 changed files with 1585 additions and 17 deletions
@@ -27,7 +27,7 @@ The Salesforce tool is ideal for workflows where your agents need to streamline
## Usage Instructions
Integrate Salesforce into your workflow. Manage accounts, contacts, leads, opportunities, cases, and tasks with powerful automation capabilities.
Integrate Salesforce into your workflow. Manage accounts, contacts, leads, opportunities, cases, and tasks, run reports and SOQL queries, and manage org schema by creating custom fields and objects via the Tooling API.
@@ -717,7 +717,7 @@ Delete a task
### `salesforce_list_reports`
Get a list of reports accessible by the current user
Get a list of up to 200 recently viewed reports for the current user
#### Input
@@ -814,7 +814,7 @@ Get a list of available report types
### `salesforce_list_dashboards`
Get a list of dashboards accessible by the current user
Get a list of recently used dashboards for the current user
#### Input
@@ -1029,6 +1029,150 @@ Get a list of all available Salesforce objects
| ↳ `totalReturned` | number | Number of objects returned |
| ↳ `success` | boolean | Salesforce operation success |
### `salesforce_create_custom_field`
Create a custom field on a Salesforce object (e.g., Account) using the Tooling API
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `idToken` | string | No | No description |
| `instanceUrl` | string | No | No description |
| `objectName` | string | Yes | API name of the object to add the field to \(e.g., Account, Contact, Lead, MyObject__c\) |
| `fieldName` | string | Yes | API name of the new field; the __c suffix is added automatically \(e.g., Region\) |
| `label` | string | No | Display label shown in the UI \(defaults to the field name when omitted\) |
| `fieldType` | string | Yes | Field data type: Text, TextArea, LongTextArea, Html, Number, Currency, Percent, Checkbox, Date, DateTime, Time, Phone, Email, Url, Picklist, or MultiselectPicklist |
| `length` | number | No | Maximum length for Text \(1-255\), LongTextArea, Html, or MultiselectPicklist fields |
| `precision` | number | No | Total number of digits for Number, Currency, or Percent fields \(1-18\) |
| `scale` | number | No | Number of digits to the right of the decimal for numeric fields |
| `visibleLines` | number | No | Number of visible lines for LongTextArea, Html, or MultiselectPicklist fields |
| `required` | boolean | No | Whether the field is required on record create/edit |
| `unique` | boolean | No | Whether the field enforces unique values |
| `externalId` | boolean | No | Whether the field is an external ID \(for Text, Number, or Email fields\) |
| `defaultValue` | string | No | Default value; for Checkbox fields use true or false |
| `description` | string | No | Internal description of the field |
| `inlineHelpText` | string | No | Help text shown next to the field in the UI |
| `picklistValues` | string | No | Comma-separated values for Picklist or MultiselectPicklist fields |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Operation success status |
| `output` | object | Created custom field metadata |
| ↳ `id` | string | Tooling API Id of the newly created custom field |
| ↳ `fullName` | string | Full API name of the field, including object \(e.g., Account.Region__c\) |
| ↳ `success` | boolean | Whether the create operation was successful |
| ↳ `created` | boolean | Whether the field was created \(always true on success\) |
### `salesforce_update_custom_field`
Update an existing custom field on a Salesforce object using the Tooling API
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `idToken` | string | No | No description |
| `instanceUrl` | string | No | No description |
| `fieldId` | string | Yes | Tooling API Id of the custom field to update \(find it via the Tooling Query tool\) |
| `label` | string | No | Display label shown in the UI |
| `length` | number | No | Maximum length for Text, LongTextArea, Html, or MultiselectPicklist fields |
| `precision` | number | No | Total number of digits for Number, Currency, or Percent fields |
| `scale` | number | No | Number of digits to the right of the decimal for numeric fields |
| `visibleLines` | number | No | Number of visible lines for LongTextArea, Html, or MultiselectPicklist fields |
| `required` | boolean | No | Whether the field is required on record create/edit |
| `unique` | boolean | No | Whether the field enforces unique values |
| `externalId` | boolean | No | Whether the field is an external ID |
| `defaultValue` | string | No | Default value; for Checkbox fields use true or false |
| `description` | string | No | Internal description of the field |
| `inlineHelpText` | string | No | Help text shown next to the field in the UI |
| `picklistValues` | string | No | Comma-separated values to add to a Picklist or MultiselectPicklist field \(existing values are kept\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Operation success status |
| `output` | object | Updated custom field metadata |
| ↳ `id` | string | Tooling API Id of the updated custom field |
| ↳ `updated` | boolean | Whether the field was updated \(always true on success\) |
### `salesforce_delete_custom_field`
Delete a custom field from a Salesforce object using the Tooling API
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `idToken` | string | No | No description |
| `instanceUrl` | string | No | No description |
| `fieldId` | string | Yes | Tooling API Id of the custom field to delete \(find it via the Tooling Query tool\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Operation success status |
| `output` | object | Deleted custom field metadata |
| ↳ `id` | string | Tooling API Id of the deleted custom field |
| ↳ `deleted` | boolean | Whether the field was deleted \(always true on success\) |
### `salesforce_create_custom_object`
Create a custom object in Salesforce using the Tooling API
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `idToken` | string | No | No description |
| `instanceUrl` | string | No | No description |
| `objectName` | string | Yes | API name of the new object; the __c suffix is added automatically \(e.g., Project\) |
| `label` | string | Yes | Singular display label for the object \(e.g., Project\) |
| `pluralLabel` | string | Yes | Plural display label for the object \(e.g., Projects\) |
| `nameFieldLabel` | string | No | Label for the standard Name field \(defaults to "<label> Name"\) |
| `description` | string | No | Internal description of the object |
| `sharingModel` | string | No | Org-wide sharing model: ReadWrite, Read, Private, or ControlledByParent \(default ReadWrite\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Operation success status |
| `output` | object | Created custom object metadata |
| ↳ `id` | string | Tooling API Id of the newly created custom object |
| ↳ `fullName` | string | Full API name of the object \(e.g., Project__c\) |
| ↳ `success` | boolean | Whether the create operation was successful |
| ↳ `created` | boolean | Whether the object was created \(always true on success\) |
### `salesforce_tooling_query`
Execute a SOQL query against the Tooling API to inspect metadata objects
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `idToken` | string | No | No description |
| `instanceUrl` | string | No | No description |
| `query` | string | Yes | Tooling SOQL query \(e.g., SELECT Id, DeveloperName FROM CustomField WHERE TableEnumOrId = 'Account'\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Operation success status |
| `output` | object | Tooling query results |
| ↳ `records` | array | Array of Tooling API records matching the query |
| ↳ `query` | string | The executed Tooling SOQL query |
| ↳ `metadata` | object | Response metadata |
| ↳ `totalReturned` | number | Number of records returned in this response |
| ↳ `hasMore` | boolean | Whether more records exist \(inverse of done\) |
| ↳ `success` | boolean | Salesforce operation success |
## Triggers
+205 -6
View File
@@ -11,7 +11,7 @@ export const SalesforceBlock: BlockConfig<SalesforceResponse> = {
description: 'Interact with Salesforce CRM',
authMode: AuthMode.OAuth,
longDescription:
'Integrate Salesforce into your workflow. Manage accounts, contacts, leads, opportunities, cases, and tasks with powerful automation capabilities.',
'Integrate Salesforce into your workflow. Manage accounts, contacts, leads, opportunities, cases, and tasks, run reports and SOQL queries, and manage org schema by creating custom fields and objects via the Tooling API.',
docsLink: 'https://docs.sim.ai/integrations/salesforce',
category: 'tools',
integrationType: IntegrationType.Sales,
@@ -69,6 +69,11 @@ export const SalesforceBlock: BlockConfig<SalesforceResponse> = {
{ label: 'Get More Query Results', id: 'query_more' },
{ label: 'Describe Object', id: 'describe_object' },
{ label: 'List Objects', id: 'list_objects' },
{ label: 'Create Custom Field', id: 'create_custom_field' },
{ label: 'Update Custom Field', id: 'update_custom_field' },
{ label: 'Delete Custom Field', id: 'delete_custom_field' },
{ label: 'Create Custom Object', id: 'create_custom_object' },
{ label: 'Run Tooling Query', id: 'tooling_query' },
],
value: () => 'get_accounts',
},
@@ -445,7 +450,7 @@ export const SalesforceBlock: BlockConfig<SalesforceResponse> = {
type: 'short-input',
placeholder: 'YYYY-MM-DD (required for create)',
condition: { field: 'operation', value: ['create_opportunity', 'update_opportunity'] },
required: true,
required: { field: 'operation', value: ['create_opportunity'] },
wandConfig: {
enabled: true,
prompt: `Generate a date in YYYY-MM-DD format based on the user's description.
@@ -608,8 +613,8 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
title: 'SOQL Query',
type: 'long-input',
placeholder: 'SELECT Id, Name FROM Account LIMIT 10',
condition: { field: 'operation', value: ['query'] },
required: true,
condition: { field: 'operation', value: ['query', 'tooling_query'] },
required: { field: 'operation', value: ['query', 'tooling_query'] },
},
{
id: 'nextRecordsUrl',
@@ -624,8 +629,14 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
title: 'Object Name',
type: 'short-input',
placeholder: 'API name (e.g., Account, Lead, Custom_Object__c)',
condition: { field: 'operation', value: ['describe_object'] },
required: true,
condition: {
field: 'operation',
value: ['describe_object', 'create_custom_field', 'create_custom_object'],
},
required: {
field: 'operation',
value: ['describe_object', 'create_custom_field', 'create_custom_object'],
},
},
// Long-input fields at the bottom
{
@@ -649,9 +660,182 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
'update_case',
'create_task',
'update_task',
'create_custom_field',
'update_custom_field',
'create_custom_object',
],
},
},
// Schema / metadata fields (Tooling API)
{
id: 'fieldName',
title: 'Field Name',
type: 'short-input',
placeholder: 'API name without __c (e.g., Region)',
condition: { field: 'operation', value: ['create_custom_field'] },
required: { field: 'operation', value: ['create_custom_field'] },
},
{
id: 'fieldId',
title: 'Field ID',
type: 'short-input',
placeholder: 'Tooling API Id (find via Run Tooling Query)',
condition: { field: 'operation', value: ['update_custom_field', 'delete_custom_field'] },
required: { field: 'operation', value: ['update_custom_field', 'delete_custom_field'] },
},
{
id: 'fieldType',
title: 'Field Type',
type: 'dropdown',
options: [
{ label: 'Text', id: 'Text' },
{ label: 'Text Area', id: 'TextArea' },
{ label: 'Text Area (Long)', id: 'LongTextArea' },
{ label: 'Rich Text Area', id: 'Html' },
{ label: 'Number', id: 'Number' },
{ label: 'Currency', id: 'Currency' },
{ label: 'Percent', id: 'Percent' },
{ label: 'Checkbox', id: 'Checkbox' },
{ label: 'Date', id: 'Date' },
{ label: 'Date/Time', id: 'DateTime' },
{ label: 'Time', id: 'Time' },
{ label: 'Phone', id: 'Phone' },
{ label: 'Email', id: 'Email' },
{ label: 'URL', id: 'Url' },
{ label: 'Picklist', id: 'Picklist' },
{ label: 'Picklist (Multi-Select)', id: 'MultiselectPicklist' },
],
condition: { field: 'operation', value: ['create_custom_field'] },
required: { field: 'operation', value: ['create_custom_field'] },
},
{
id: 'label',
title: 'Label',
type: 'short-input',
placeholder: 'Display label',
condition: {
field: 'operation',
value: ['create_custom_field', 'update_custom_field', 'create_custom_object'],
},
required: { field: 'operation', value: ['create_custom_object'] },
},
{
id: 'pluralLabel',
title: 'Plural Label',
type: 'short-input',
placeholder: 'Plural display label (e.g., Projects)',
condition: { field: 'operation', value: ['create_custom_object'] },
required: { field: 'operation', value: ['create_custom_object'] },
},
{
id: 'picklistValues',
title: 'Picklist Values',
type: 'short-input',
placeholder: 'Comma-separated values (e.g., Low, Medium, High)',
condition: { field: 'operation', value: ['create_custom_field', 'update_custom_field'] },
},
{
id: 'length',
title: 'Length',
type: 'short-input',
placeholder: 'Max length for Text/LongTextArea/Html',
mode: 'advanced',
condition: { field: 'operation', value: ['create_custom_field', 'update_custom_field'] },
},
{
id: 'precision',
title: 'Precision',
type: 'short-input',
placeholder: 'Total digits for Number/Currency/Percent',
mode: 'advanced',
condition: { field: 'operation', value: ['create_custom_field', 'update_custom_field'] },
},
{
id: 'scale',
title: 'Scale',
type: 'short-input',
placeholder: 'Decimal places for numeric fields',
mode: 'advanced',
condition: { field: 'operation', value: ['create_custom_field', 'update_custom_field'] },
},
{
id: 'visibleLines',
title: 'Visible Lines',
type: 'short-input',
placeholder: 'Lines for LongTextArea/Html/MultiselectPicklist',
mode: 'advanced',
condition: { field: 'operation', value: ['create_custom_field', 'update_custom_field'] },
},
{
id: 'defaultValue',
title: 'Default Value',
type: 'short-input',
placeholder: 'Default value (true/false for Checkbox)',
mode: 'advanced',
condition: { field: 'operation', value: ['create_custom_field', 'update_custom_field'] },
},
{
id: 'inlineHelpText',
title: 'Help Text',
type: 'short-input',
placeholder: 'Help text shown next to the field',
mode: 'advanced',
condition: { field: 'operation', value: ['create_custom_field', 'update_custom_field'] },
},
{
id: 'required',
title: 'Required',
type: 'dropdown',
options: [
{ label: 'Yes', id: 'true' },
{ label: 'No', id: 'false' },
],
mode: 'advanced',
condition: { field: 'operation', value: ['create_custom_field', 'update_custom_field'] },
},
{
id: 'unique',
title: 'Unique',
type: 'dropdown',
options: [
{ label: 'Yes', id: 'true' },
{ label: 'No', id: 'false' },
],
mode: 'advanced',
condition: { field: 'operation', value: ['create_custom_field', 'update_custom_field'] },
},
{
id: 'externalId',
title: 'External ID',
type: 'dropdown',
options: [
{ label: 'Yes', id: 'true' },
{ label: 'No', id: 'false' },
],
mode: 'advanced',
condition: { field: 'operation', value: ['create_custom_field', 'update_custom_field'] },
},
{
id: 'nameFieldLabel',
title: 'Name Field Label',
type: 'short-input',
placeholder: 'Label for the Name field (defaults to "<label> Name")',
mode: 'advanced',
condition: { field: 'operation', value: ['create_custom_object'] },
},
{
id: 'sharingModel',
title: 'Sharing Model',
type: 'dropdown',
options: [
{ label: 'Read/Write', id: 'ReadWrite' },
{ label: 'Read Only', id: 'Read' },
{ label: 'Private', id: 'Private' },
{ label: 'Controlled By Parent', id: 'ControlledByParent' },
],
mode: 'advanced',
condition: { field: 'operation', value: ['create_custom_object'] },
},
...getTrigger('salesforce_record_created').subBlocks,
...getTrigger('salesforce_record_updated').subBlocks,
...getTrigger('salesforce_record_deleted').subBlocks,
@@ -696,6 +880,11 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
'salesforce_query_more',
'salesforce_describe_object',
'salesforce_list_objects',
'salesforce_create_custom_field',
'salesforce_update_custom_field',
'salesforce_delete_custom_field',
'salesforce_create_custom_object',
'salesforce_tooling_query',
],
config: {
tool: (params) => {
@@ -770,6 +959,16 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
return 'salesforce_describe_object'
case 'list_objects':
return 'salesforce_list_objects'
case 'create_custom_field':
return 'salesforce_create_custom_field'
case 'update_custom_field':
return 'salesforce_update_custom_field'
case 'delete_custom_field':
return 'salesforce_delete_custom_field'
case 'create_custom_object':
return 'salesforce_create_custom_object'
case 'tooling_query':
return 'salesforce_tooling_query'
default:
throw new Error(`Unknown operation: ${params.operation}`)
}
+24 -4
View File
@@ -13602,7 +13602,7 @@
"slug": "salesforce",
"name": "Salesforce",
"description": "Interact with Salesforce CRM",
"longDescription": "Integrate Salesforce into your workflow. Manage accounts, contacts, leads, opportunities, cases, and tasks with powerful automation capabilities.",
"longDescription": "Integrate Salesforce into your workflow. Manage accounts, contacts, leads, opportunities, cases, and tasks, run reports and SOQL queries, and manage org schema by creating custom fields and objects via the Tooling API.",
"bgColor": "#FFFFFF",
"iconName": "SalesforceIcon",
"docsUrl": "https://docs.sim.ai/integrations/salesforce",
@@ -13705,7 +13705,7 @@
},
{
"name": "List Reports",
"description": "Get a list of reports accessible by the current user"
"description": "Get a list of up to 200 recently viewed reports for the current user"
},
{
"name": "Get Report",
@@ -13721,7 +13721,7 @@
},
{
"name": "List Dashboards",
"description": "Get a list of dashboards accessible by the current user"
"description": "Get a list of recently used dashboards for the current user"
},
{
"name": "Get Dashboard",
@@ -13746,9 +13746,29 @@
{
"name": "List Objects",
"description": "Get a list of all available Salesforce objects"
},
{
"name": "Create Custom Field",
"description": "Create a custom field on a Salesforce object (e.g., Account) using the Tooling API"
},
{
"name": "Update Custom Field",
"description": "Update an existing custom field on a Salesforce object using the Tooling API"
},
{
"name": "Delete Custom Field",
"description": "Delete a custom field from a Salesforce object using the Tooling API"
},
{
"name": "Create Custom Object",
"description": "Create a custom object in Salesforce using the Tooling API"
},
{
"name": "Run Tooling Query",
"description": "Execute a SOQL query against the Tooling API to inspect metadata objects"
}
],
"operationCount": 35,
"operationCount": 40,
"triggers": [
{
"id": "salesforce_record_created",
+10
View File
@@ -2808,12 +2808,15 @@ import {
salesforceCreateAccountTool,
salesforceCreateCaseTool,
salesforceCreateContactTool,
salesforceCreateCustomFieldTool,
salesforceCreateCustomObjectTool,
salesforceCreateLeadTool,
salesforceCreateOpportunityTool,
salesforceCreateTaskTool,
salesforceDeleteAccountTool,
salesforceDeleteCaseTool,
salesforceDeleteContactTool,
salesforceDeleteCustomFieldTool,
salesforceDeleteLeadTool,
salesforceDeleteOpportunityTool,
salesforceDeleteTaskTool,
@@ -2834,9 +2837,11 @@ import {
salesforceQueryTool,
salesforceRefreshDashboardTool,
salesforceRunReportTool,
salesforceToolingQueryTool,
salesforceUpdateAccountTool,
salesforceUpdateCaseTool,
salesforceUpdateContactTool,
salesforceUpdateCustomFieldTool,
salesforceUpdateLeadTool,
salesforceUpdateOpportunityTool,
salesforceUpdateTaskTool,
@@ -7301,6 +7306,11 @@ export const tools: Record<string, ToolConfig> = {
salesforce_query_more: salesforceQueryMoreTool,
salesforce_describe_object: salesforceDescribeObjectTool,
salesforce_list_objects: salesforceListObjectsTool,
salesforce_create_custom_field: salesforceCreateCustomFieldTool,
salesforce_update_custom_field: salesforceUpdateCustomFieldTool,
salesforce_delete_custom_field: salesforceDeleteCustomFieldTool,
salesforce_create_custom_object: salesforceCreateCustomObjectTool,
salesforce_tooling_query: salesforceToolingQueryTool,
sap_concur_approve_expense_report: sapConcurApproveExpenseReportTool,
sap_concur_associate_attendees: sapConcurAssociateAttendeesTool,
sap_concur_create_cash_advance: sapConcurCreateCashAdvanceTool,
@@ -0,0 +1,198 @@
import { createLogger } from '@sim/logger'
import type {
SalesforceCreateCustomFieldParams,
SalesforceCreateCustomFieldResponse,
} from '@/tools/salesforce/types'
import { CUSTOM_FIELD_CREATE_OUTPUT_PROPERTIES } from '@/tools/salesforce/types'
import {
buildCustomFieldMetadata,
extractErrorMessage,
getInstanceUrl,
requireId,
toCustomApiName,
} from '@/tools/salesforce/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('SalesforceCreateCustomField')
/**
* Create a custom field on a Salesforce object (standard or custom) via the
* Tooling API. This is a schema/metadata change — distinct from record CRUD —
* and requires the user to have the "Customize Application" permission.
* @see https://developer.salesforce.com/docs/atlas.en-us.api_tooling.meta/api_tooling/tooling_api_objects_customfield.htm
*/
export const salesforceCreateCustomFieldTool: ToolConfig<
SalesforceCreateCustomFieldParams,
SalesforceCreateCustomFieldResponse
> = {
id: 'salesforce_create_custom_field',
name: 'Create Custom Field in Salesforce',
description: 'Create a custom field on a Salesforce object (e.g., Account) using the Tooling API',
version: '1.0.0',
oauth: {
required: true,
provider: 'salesforce',
},
params: {
accessToken: { type: 'string', required: true, visibility: 'hidden' },
idToken: { type: 'string', required: false, visibility: 'hidden' },
instanceUrl: { type: 'string', required: false, visibility: 'hidden' },
objectName: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'API name of the object to add the field to (e.g., Account, Contact, Lead, MyObject__c)',
},
fieldName: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'API name of the new field; the __c suffix is added automatically (e.g., Region)',
},
label: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Display label shown in the UI (defaults to the field name when omitted)',
},
fieldType: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'Field data type: Text, TextArea, LongTextArea, Html, Number, Currency, Percent, Checkbox, Date, DateTime, Time, Phone, Email, Url, Picklist, or MultiselectPicklist',
},
length: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description:
'Maximum length for Text (1-255), LongTextArea, Html, or MultiselectPicklist fields',
},
precision: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Total number of digits for Number, Currency, or Percent fields (1-18)',
},
scale: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of digits to the right of the decimal for numeric fields',
},
visibleLines: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of visible lines for LongTextArea, Html, or MultiselectPicklist fields',
},
required: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether the field is required on record create/edit',
},
unique: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether the field enforces unique values',
},
externalId: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether the field is an external ID (for Text, Number, or Email fields)',
},
defaultValue: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Default value; for Checkbox fields use true or false',
},
description: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Internal description of the field',
},
inlineHelpText: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Help text shown next to the field in the UI',
},
picklistValues: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Comma-separated values for Picklist or MultiselectPicklist fields',
},
},
request: {
url: (params) => {
const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl)
return `${instanceUrl}/services/data/v59.0/tooling/sobjects/CustomField`
},
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
body: (params) => {
const objectName = requireId(params.objectName, 'Object Name')
const fieldApiName = toCustomApiName(params.fieldName, 'Field Name')
const fallbackLabel = fieldApiName.replace(/__c$/, '').replace(/_/g, ' ')
return {
FullName: `${objectName}.${fieldApiName}`,
Metadata: buildCustomFieldMetadata(params, fallbackLabel),
}
},
},
transformResponse: async (response: Response, params) => {
const data = await response.json()
if (!response.ok || data?.success === false) {
const errorMessage = extractErrorMessage(
data,
response.status,
'Failed to create custom field in Salesforce'
)
logger.error('Failed to create custom field', { data, status: response.status })
throw new Error(errorMessage)
}
const objectName = params?.objectName?.trim() ?? ''
const fieldApiName = params?.fieldName ? toCustomApiName(params.fieldName, 'Field Name') : ''
return {
success: true,
output: {
id: data.id,
fullName: objectName && fieldApiName ? `${objectName}.${fieldApiName}` : '',
success: data.success === true,
created: data.success === true,
},
}
},
outputs: {
success: { type: 'boolean', description: 'Operation success status' },
output: {
type: 'object',
description: 'Created custom field metadata',
properties: CUSTOM_FIELD_CREATE_OUTPUT_PROPERTIES,
},
},
}
@@ -0,0 +1,148 @@
import { createLogger } from '@sim/logger'
import type {
SalesforceCreateCustomObjectParams,
SalesforceCreateCustomObjectResponse,
} from '@/tools/salesforce/types'
import { CUSTOM_OBJECT_CREATE_OUTPUT_PROPERTIES } from '@/tools/salesforce/types'
import { extractErrorMessage, getInstanceUrl, toCustomApiName } from '@/tools/salesforce/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('SalesforceCreateCustomObject')
/**
* Create a custom object via the Tooling API. The object is created with a
* Text Name field and deployed immediately. Custom fields can then be added
* with the Create Custom Field tool.
* @see https://developer.salesforce.com/docs/atlas.en-us.api_tooling.meta/api_tooling/tooling_api_objects_customobject.htm
*/
export const salesforceCreateCustomObjectTool: ToolConfig<
SalesforceCreateCustomObjectParams,
SalesforceCreateCustomObjectResponse
> = {
id: 'salesforce_create_custom_object',
name: 'Create Custom Object in Salesforce',
description: 'Create a custom object in Salesforce using the Tooling API',
version: '1.0.0',
oauth: {
required: true,
provider: 'salesforce',
},
params: {
accessToken: { type: 'string', required: true, visibility: 'hidden' },
idToken: { type: 'string', required: false, visibility: 'hidden' },
instanceUrl: { type: 'string', required: false, visibility: 'hidden' },
objectName: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'API name of the new object; the __c suffix is added automatically (e.g., Project)',
},
label: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Singular display label for the object (e.g., Project)',
},
pluralLabel: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Plural display label for the object (e.g., Projects)',
},
nameFieldLabel: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Label for the standard Name field (defaults to "<label> Name")',
},
description: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Internal description of the object',
},
sharingModel: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Org-wide sharing model: ReadWrite, Read, Private, or ControlledByParent (default ReadWrite)',
},
},
request: {
url: (params) => {
const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl)
return `${instanceUrl}/services/data/v59.0/tooling/sobjects/CustomObject`
},
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
body: (params) => {
const objectApiName = toCustomApiName(params.objectName, 'Object Name')
const label = params.label?.trim()
const pluralLabel = params.pluralLabel?.trim()
if (!label) throw new Error('Label is required to create a custom object.')
if (!pluralLabel) throw new Error('Plural Label is required to create a custom object.')
const metadata: Record<string, any> = {
label,
pluralLabel,
nameField: {
type: 'Text',
label: params.nameFieldLabel?.trim() || `${label} Name`,
},
deploymentStatus: 'Deployed',
sharingModel: params.sharingModel?.trim() || 'ReadWrite',
}
if (params.description?.trim()) metadata.description = params.description.trim()
return {
FullName: objectApiName,
Metadata: metadata,
}
},
},
transformResponse: async (response: Response, params) => {
const data = await response.json()
if (!response.ok || data?.success === false) {
const errorMessage = extractErrorMessage(
data,
response.status,
'Failed to create custom object in Salesforce'
)
logger.error('Failed to create custom object', { data, status: response.status })
throw new Error(errorMessage)
}
return {
success: true,
output: {
id: data.id,
fullName: params?.objectName ? toCustomApiName(params.objectName, 'Object Name') : '',
success: data.success === true,
created: data.success === true,
},
}
},
outputs: {
success: { type: 'boolean', description: 'Operation success status' },
output: {
type: 'object',
description: 'Created custom object metadata',
properties: CUSTOM_OBJECT_CREATE_OUTPUT_PROPERTIES,
},
},
}
@@ -0,0 +1,87 @@
import { createLogger } from '@sim/logger'
import type {
SalesforceDeleteCustomFieldParams,
SalesforceDeleteCustomFieldResponse,
} from '@/tools/salesforce/types'
import { CUSTOM_FIELD_DELETE_OUTPUT_PROPERTIES } from '@/tools/salesforce/types'
import { extractErrorMessage, getInstanceUrl, requireId } from '@/tools/salesforce/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('SalesforceDeleteCustomField')
/**
* Delete a custom field via the Tooling API. Deleting a field removes its data;
* the field is moved to the org's recycle bin. Retrieve the field Id with the
* Tooling Query tool.
* @see https://developer.salesforce.com/docs/atlas.en-us.api_tooling.meta/api_tooling/tooling_api_objects_customfield.htm
*/
export const salesforceDeleteCustomFieldTool: ToolConfig<
SalesforceDeleteCustomFieldParams,
SalesforceDeleteCustomFieldResponse
> = {
id: 'salesforce_delete_custom_field',
name: 'Delete Custom Field in Salesforce',
description: 'Delete a custom field from a Salesforce object using the Tooling API',
version: '1.0.0',
oauth: {
required: true,
provider: 'salesforce',
},
params: {
accessToken: { type: 'string', required: true, visibility: 'hidden' },
idToken: { type: 'string', required: false, visibility: 'hidden' },
instanceUrl: { type: 'string', required: false, visibility: 'hidden' },
fieldId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'Tooling API Id of the custom field to delete (find it via the Tooling Query tool)',
},
},
request: {
url: (params) => {
const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl)
const fieldId = requireId(params.fieldId, 'Field ID')
return `${instanceUrl}/services/data/v59.0/tooling/sobjects/CustomField/${fieldId}`
},
method: 'DELETE',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response, params) => {
if (!response.ok) {
const data = await response.json().catch(() => ({}))
const errorMessage = extractErrorMessage(
data,
response.status,
'Failed to delete custom field in Salesforce'
)
logger.error('Failed to delete custom field', { status: response.status })
throw new Error(errorMessage)
}
return {
success: true,
output: {
id: params?.fieldId?.trim() ?? '',
deleted: true,
},
}
},
outputs: {
success: { type: 'boolean', description: 'Operation success status' },
output: {
type: 'object',
description: 'Deleted custom field metadata',
properties: CUSTOM_FIELD_DELETE_OUTPUT_PROPERTIES,
},
},
}
+5
View File
@@ -1,12 +1,15 @@
export { salesforceCreateAccountTool } from './create_account'
export { salesforceCreateCaseTool } from './create_case'
export { salesforceCreateContactTool } from './create_contact'
export { salesforceCreateCustomFieldTool } from './create_custom_field'
export { salesforceCreateCustomObjectTool } from './create_custom_object'
export { salesforceCreateLeadTool } from './create_lead'
export { salesforceCreateOpportunityTool } from './create_opportunity'
export { salesforceCreateTaskTool } from './create_task'
export { salesforceDeleteAccountTool } from './delete_account'
export { salesforceDeleteCaseTool } from './delete_case'
export { salesforceDeleteContactTool } from './delete_contact'
export { salesforceDeleteCustomFieldTool } from './delete_custom_field'
export { salesforceDeleteLeadTool } from './delete_lead'
export { salesforceDeleteOpportunityTool } from './delete_opportunity'
export { salesforceDeleteTaskTool } from './delete_task'
@@ -27,9 +30,11 @@ export { salesforceQueryTool } from './query'
export { salesforceQueryMoreTool } from './query_more'
export { salesforceRefreshDashboardTool } from './refresh_dashboard'
export { salesforceRunReportTool } from './run_report'
export { salesforceToolingQueryTool } from './tooling_query'
export { salesforceUpdateAccountTool } from './update_account'
export { salesforceUpdateCaseTool } from './update_case'
export { salesforceUpdateContactTool } from './update_contact'
export { salesforceUpdateCustomFieldTool } from './update_custom_field'
export { salesforceUpdateLeadTool } from './update_lead'
export { salesforceUpdateOpportunityTool } from './update_opportunity'
export { salesforceUpdateTaskTool } from './update_task'
+4 -2
View File
@@ -10,7 +10,9 @@ import type { ToolConfig } from '@/tools/types'
const logger = createLogger('SalesforceDashboards')
/**
* List all dashboards accessible by the current user
* List the current user's recently used dashboards.
* The Dashboard List resource returns recently used dashboards, not the org's
* full dashboard catalog.
* @see https://developer.salesforce.com/docs/atlas.en-us.api_analytics.meta/api_analytics/sforce_analytics_rest_api_getbasic_dashboardlist.htm
*/
export const salesforceListDashboardsTool: ToolConfig<
@@ -19,7 +21,7 @@ export const salesforceListDashboardsTool: ToolConfig<
> = {
id: 'salesforce_list_dashboards',
name: 'List Dashboards from Salesforce',
description: 'Get a list of dashboards accessible by the current user',
description: 'Get a list of recently used dashboards for the current user',
version: '1.0.0',
oauth: {
+4 -2
View File
@@ -10,7 +10,9 @@ import type { ToolConfig } from '@/tools/types'
const logger = createLogger('SalesforceReports')
/**
* List all reports accessible by the current user
* List up to 200 of the current user's most recently viewed reports.
* The Report List resource returns recently viewed reports, not the org's full
* report catalog — use a SOQL query against the Report object for that.
* @see https://developer.salesforce.com/docs/atlas.en-us.api_analytics.meta/api_analytics/sforce_analytics_rest_api_get_reportlist.htm
*/
export const salesforceListReportsTool: ToolConfig<
@@ -19,7 +21,7 @@ export const salesforceListReportsTool: ToolConfig<
> = {
id: 'salesforce_list_reports',
name: 'List Reports from Salesforce',
description: 'Get a list of reports accessible by the current user',
description: 'Get a list of up to 200 recently viewed reports for the current user',
version: '1.0.0',
oauth: {
+2
View File
@@ -58,6 +58,8 @@ export const salesforceRunReportTool: ToolConfig<
throw new Error('Report ID is required. Please provide a valid Salesforce Report ID.')
}
const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl)
// Default to including detail rows (Salesforce's own API default is false);
// report runs in a workflow almost always want the underlying rows.
const includeDetails = params.includeDetails !== 'false'
return `${instanceUrl}/services/data/v59.0/analytics/reports/${params.reportId}?includeDetails=${includeDetails}`
},
+104
View File
@@ -0,0 +1,104 @@
import { createLogger } from '@sim/logger'
import type {
SalesforceToolingQueryParams,
SalesforceToolingQueryResponse,
} from '@/tools/salesforce/types'
import { TOOLING_QUERY_OUTPUT_PROPERTIES } from '@/tools/salesforce/types'
import { extractErrorMessage, getInstanceUrl } from '@/tools/salesforce/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('SalesforceToolingQuery')
/**
* Execute a SOQL query against the Tooling API. Use this to inspect metadata
* objects such as CustomField and CustomObject — for example to find a field's
* Id before updating or deleting it:
* `SELECT Id, DeveloperName FROM CustomField WHERE TableEnumOrId = 'Account'`.
* @see https://developer.salesforce.com/docs/atlas.en-us.api_tooling.meta/api_tooling/intro_rest_resources.htm
*/
export const salesforceToolingQueryTool: ToolConfig<
SalesforceToolingQueryParams,
SalesforceToolingQueryResponse
> = {
id: 'salesforce_tooling_query',
name: 'Run Tooling SOQL Query in Salesforce',
description: 'Execute a SOQL query against the Tooling API to inspect metadata objects',
version: '1.0.0',
oauth: {
required: true,
provider: 'salesforce',
},
params: {
accessToken: { type: 'string', required: true, visibility: 'hidden' },
idToken: { type: 'string', required: false, visibility: 'hidden' },
instanceUrl: { type: 'string', required: false, visibility: 'hidden' },
query: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
"Tooling SOQL query (e.g., SELECT Id, DeveloperName FROM CustomField WHERE TableEnumOrId = 'Account')",
},
},
request: {
url: (params) => {
if (!params.query || params.query.trim() === '') {
throw new Error(
"Tooling SOQL Query is required (e.g., SELECT Id, DeveloperName FROM CustomField WHERE TableEnumOrId = 'Account')."
)
}
const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl)
const encodedQuery = encodeURIComponent(params.query)
return `${instanceUrl}/services/data/v59.0/tooling/query?q=${encodedQuery}`
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response, params) => {
const data = await response.json()
if (!response.ok) {
const errorMessage = extractErrorMessage(
data,
response.status,
'Failed to execute Tooling SOQL query'
)
logger.error('Failed to execute Tooling SOQL query', { data, status: response.status })
throw new Error(errorMessage)
}
const records = data.records || []
const done = data.done !== false
return {
success: true,
output: {
records,
totalSize: data.totalSize ?? records.length,
done,
nextRecordsUrl: data.nextRecordsUrl ?? null,
query: params?.query || '',
metadata: {
totalReturned: records.length,
hasMore: !done,
},
success: true,
},
}
},
outputs: {
success: { type: 'boolean', description: 'Operation success status' },
output: {
type: 'object',
description: 'Tooling query results',
properties: TOOLING_QUERY_OUTPUT_PROPERTIES,
},
},
}
+184
View File
@@ -1200,6 +1200,83 @@ export const REFRESH_DASHBOARD_OUTPUT_PROPERTIES = {
success: { type: 'boolean', description: 'Salesforce operation success' },
} as const satisfies Record<string, OutputProperty>
/**
* Output definition for a Tooling API custom field create response.
* Creating a CustomField via POST to /tooling/sobjects/CustomField returns the
* standard Tooling sObject create envelope ({ id, success, errors }).
* @see https://developer.salesforce.com/docs/atlas.en-us.api_tooling.meta/api_tooling/tooling_api_objects_customfield.htm
*/
export const CUSTOM_FIELD_CREATE_OUTPUT_PROPERTIES = {
id: { type: 'string', description: 'Tooling API Id of the newly created custom field' },
fullName: {
type: 'string',
description: 'Full API name of the field, including object (e.g., Account.Region__c)',
},
success: { type: 'boolean', description: 'Whether the create operation was successful' },
created: {
type: 'boolean',
description: 'Whether the field was created (always true on success)',
},
} as const satisfies Record<string, OutputProperty>
/**
* Output definition for a Tooling API custom field update response.
* A successful PATCH to /tooling/sobjects/CustomField/{id} returns HTTP 204.
*/
export const CUSTOM_FIELD_UPDATE_OUTPUT_PROPERTIES = {
id: { type: 'string', description: 'Tooling API Id of the updated custom field' },
updated: {
type: 'boolean',
description: 'Whether the field was updated (always true on success)',
},
} as const satisfies Record<string, OutputProperty>
/**
* Output definition for a Tooling API custom field delete response.
* A successful DELETE to /tooling/sobjects/CustomField/{id} returns HTTP 204.
*/
export const CUSTOM_FIELD_DELETE_OUTPUT_PROPERTIES = {
id: { type: 'string', description: 'Tooling API Id of the deleted custom field' },
deleted: {
type: 'boolean',
description: 'Whether the field was deleted (always true on success)',
},
} as const satisfies Record<string, OutputProperty>
/**
* Output definition for a Tooling API custom object create response.
* Creating a CustomObject via POST to /tooling/sobjects/CustomObject returns the
* standard Tooling sObject create envelope ({ id, success, errors }).
* @see https://developer.salesforce.com/docs/atlas.en-us.api_tooling.meta/api_tooling/tooling_api_objects_customobject.htm
*/
export const CUSTOM_OBJECT_CREATE_OUTPUT_PROPERTIES = {
id: { type: 'string', description: 'Tooling API Id of the newly created custom object' },
fullName: { type: 'string', description: 'Full API name of the object (e.g., Project__c)' },
success: { type: 'boolean', description: 'Whether the create operation was successful' },
created: {
type: 'boolean',
description: 'Whether the object was created (always true on success)',
},
} as const satisfies Record<string, OutputProperty>
/**
* Output definition for a Tooling API SOQL query response.
* The Tooling query endpoint mirrors the data query endpoint shape.
* @see https://developer.salesforce.com/docs/atlas.en-us.api_tooling.meta/api_tooling/intro_rest_resources.htm
*/
export const TOOLING_QUERY_OUTPUT_PROPERTIES = {
records: {
type: 'array',
description: 'Array of Tooling API records matching the query',
},
totalSize: QUERY_PAGING_OUTPUT_PROPERTIES.totalSize,
done: QUERY_PAGING_OUTPUT_PROPERTIES.done,
nextRecordsUrl: QUERY_PAGING_OUTPUT_PROPERTIES.nextRecordsUrl,
query: { type: 'string', description: 'The executed Tooling SOQL query' },
metadata: RESPONSE_METADATA_OUTPUT,
success: { type: 'boolean', description: 'Salesforce operation success' },
} as const satisfies Record<string, OutputProperty>
/**
* Base parameters shared by all Salesforce operations
*/
@@ -1873,6 +1950,108 @@ export interface SalesforceListObjectsResponse {
}
}
/**
* Shared metadata parameters for creating or updating a custom field via the
* Tooling API. Which properties apply depends on `fieldType`:
* - Text / LongTextArea / Html / EncryptedText: `length`
* - LongTextArea / Html / MultiselectPicklist: `visibleLines`
* - Number / Currency / Percent: `precision`, `scale`
* - Checkbox: `defaultValue` (true/false, required by Salesforce)
* - Picklist / MultiselectPicklist: `picklistValues`
*/
interface SalesforceCustomFieldMetadataParams {
fieldType?: string
label?: string
length?: number | string
precision?: number | string
scale?: number | string
visibleLines?: number | string
required?: boolean | string
unique?: boolean | string
externalId?: boolean | string
defaultValue?: string
description?: string
inlineHelpText?: string
picklistValues?: string
}
export interface SalesforceCreateCustomFieldParams
extends BaseSalesforceParams,
SalesforceCustomFieldMetadataParams {
objectName: string
fieldName: string
}
export interface SalesforceCreateCustomFieldResponse extends ToolResponse {
output: {
id: string
fullName: string
success: boolean
created: boolean
}
}
export interface SalesforceUpdateCustomFieldParams
extends BaseSalesforceParams,
SalesforceCustomFieldMetadataParams {
fieldId: string
}
export interface SalesforceUpdateCustomFieldResponse extends ToolResponse {
output: {
id: string
updated: boolean
}
}
export interface SalesforceDeleteCustomFieldParams extends BaseSalesforceParams {
fieldId: string
}
export interface SalesforceDeleteCustomFieldResponse extends ToolResponse {
output: {
id: string
deleted: boolean
}
}
export interface SalesforceCreateCustomObjectParams extends BaseSalesforceParams {
objectName: string
label: string
pluralLabel: string
nameFieldLabel?: string
description?: string
sharingModel?: string
}
export interface SalesforceCreateCustomObjectResponse extends ToolResponse {
output: {
id: string
fullName: string
success: boolean
created: boolean
}
}
export interface SalesforceToolingQueryParams extends BaseSalesforceParams {
query: string
}
export interface SalesforceToolingQueryResponse extends ToolResponse {
output: {
records: any[]
totalSize: number
done: boolean
nextRecordsUrl?: string | null
query: string
metadata: {
totalReturned: number
hasMore: boolean
}
success: boolean
}
}
export type SalesforceResponse =
| SalesforceGetAccountsResponse
| SalesforceCreateAccountResponse
@@ -1909,3 +2088,8 @@ export type SalesforceResponse =
| SalesforceQueryMoreResponse
| SalesforceDescribeObjectResponse
| SalesforceListObjectsResponse
| SalesforceCreateCustomFieldResponse
| SalesforceUpdateCustomFieldResponse
| SalesforceDeleteCustomFieldResponse
| SalesforceCreateCustomObjectResponse
| SalesforceToolingQueryResponse
@@ -0,0 +1,236 @@
import { createLogger } from '@sim/logger'
import type {
SalesforceUpdateCustomFieldParams,
SalesforceUpdateCustomFieldResponse,
} from '@/tools/salesforce/types'
import { CUSTOM_FIELD_UPDATE_OUTPUT_PROPERTIES } from '@/tools/salesforce/types'
import {
extractErrorMessage,
getInstanceUrl,
mergeCustomFieldMetadata,
requireId,
} from '@/tools/salesforce/utils'
import type { ToolConfig, ToolResponse } from '@/tools/types'
const logger = createLogger('SalesforceUpdateCustomField')
/**
* Update an existing custom field via the Tooling API.
*
* Updates a field's attributes (label, length, help text, required, picklist
* values, etc.) while keeping its existing data type — changing a field's type
* is a separate, conversion-driven operation in Salesforce and is intentionally
* out of scope here.
*
* The Tooling API PATCH replaces the field's entire `Metadata` compound, so a
* naive partial PATCH would wipe any property the caller omits. To avoid that,
* this tool performs a read-modify-write in `directExecution`: it GETs the
* field's current metadata, overlays only the provided changes, then PATCHes the
* merged result. Unspecified properties (type, length, etc.) are preserved.
* @see https://developer.salesforce.com/docs/atlas.en-us.api_tooling.meta/api_tooling/tooling_api_objects_customfield.htm
*/
export const salesforceUpdateCustomFieldTool: ToolConfig<
SalesforceUpdateCustomFieldParams,
SalesforceUpdateCustomFieldResponse
> = {
id: 'salesforce_update_custom_field',
name: 'Update Custom Field in Salesforce',
description: 'Update an existing custom field on a Salesforce object using the Tooling API',
version: '1.0.0',
oauth: {
required: true,
provider: 'salesforce',
},
params: {
accessToken: { type: 'string', required: true, visibility: 'hidden' },
idToken: { type: 'string', required: false, visibility: 'hidden' },
instanceUrl: { type: 'string', required: false, visibility: 'hidden' },
fieldId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'Tooling API Id of the custom field to update (find it via the Tooling Query tool)',
},
label: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Display label shown in the UI',
},
length: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum length for Text, LongTextArea, Html, or MultiselectPicklist fields',
},
precision: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Total number of digits for Number, Currency, or Percent fields',
},
scale: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of digits to the right of the decimal for numeric fields',
},
visibleLines: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of visible lines for LongTextArea, Html, or MultiselectPicklist fields',
},
required: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether the field is required on record create/edit',
},
unique: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether the field enforces unique values',
},
externalId: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether the field is an external ID',
},
defaultValue: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Default value; for Checkbox fields use true or false',
},
description: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Internal description of the field',
},
inlineHelpText: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Help text shown next to the field in the UI',
},
picklistValues: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Comma-separated values to add to a Picklist or MultiselectPicklist field (existing values are kept)',
},
},
/**
* Read-modify-write so omitted properties are preserved rather than reset by
* the Tooling API's full-metadata PATCH semantics.
*/
directExecution: async (params): Promise<ToolResponse> => {
const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl)
const fieldId = requireId(params.fieldId, 'Field ID')
const url = `${instanceUrl}/services/data/v59.0/tooling/sobjects/CustomField/${fieldId}`
const headers = {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
const readResponse = await fetch(url, { headers })
const existing = await readResponse.json().catch(() => ({}))
if (!readResponse.ok) {
const errorMessage = extractErrorMessage(
existing,
readResponse.status,
'Failed to load custom field for update'
)
logger.error('Failed to read custom field metadata', { status: readResponse.status })
throw new Error(errorMessage)
}
const metadata = mergeCustomFieldMetadata(existing?.Metadata, params)
const patchResponse = await fetch(url, {
method: 'PATCH',
headers,
body: JSON.stringify({ Metadata: metadata }),
})
if (!patchResponse.ok) {
const errorData = await patchResponse.json().catch(() => ({}))
const errorMessage = extractErrorMessage(
errorData,
patchResponse.status,
'Failed to update custom field in Salesforce'
)
logger.error('Failed to update custom field', { status: patchResponse.status })
throw new Error(errorMessage)
}
return {
success: true,
output: {
id: fieldId,
updated: true,
},
}
},
/**
* Declarative fallback. `directExecution` is the authoritative path and handles
* the read-modify-write; this is only used if direct execution is bypassed.
*/
request: {
url: (params) => {
const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl)
const fieldId = requireId(params.fieldId, 'Field ID')
return `${instanceUrl}/services/data/v59.0/tooling/sobjects/CustomField/${fieldId}`
},
method: 'PATCH',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
body: (params) => ({ Metadata: mergeCustomFieldMetadata(undefined, params) }),
},
transformResponse: async (response: Response, params) => {
if (!response.ok) {
const data = await response.json().catch(() => ({}))
const errorMessage = extractErrorMessage(
data,
response.status,
'Failed to update custom field in Salesforce'
)
logger.error('Failed to update custom field', { status: response.status })
throw new Error(errorMessage)
}
return {
success: true,
output: {
id: params?.fieldId?.trim() ?? '',
updated: true,
},
}
},
outputs: {
success: { type: 'boolean', description: 'Operation success status' },
output: {
type: 'object',
description: 'Updated custom field metadata',
properties: CUSTOM_FIELD_UPDATE_OUTPUT_PROPERTIES,
},
},
}
+227
View File
@@ -53,6 +53,228 @@ export function requireId(value: string | undefined, label: string): string {
return trimmed
}
/**
* Ensures a custom field/object API name carries the required `__c` suffix.
* Salesforce metadata components created via the Tooling API must end in `__c`;
* users commonly omit it, so we append it when missing.
* @param value - The raw API name from params (e.g. "Region" or "Region__c")
* @param label - Human-readable field name used in the error message
* @returns The trimmed API name guaranteed to end with `__c`
* @throws Error if the name is absent or whitespace-only
*/
export function toCustomApiName(value: string | undefined, label: string): string {
const trimmed = value?.trim()
if (!trimmed) {
throw new Error(`${label} is required. Please provide a valid Salesforce API name.`)
}
return trimmed.endsWith('__c') ? trimmed : `${trimmed}__c`
}
/**
* Normalizes a boolean-ish param value into a real boolean.
* Tool params arrive as actual booleans from the LLM or as strings from block
* inputs; this collapses both forms and treats empty values as "unset".
* @param value - The raw param value
* @returns The boolean value, or undefined when the param was not provided
*/
export function normalizeBoolean(value: unknown): boolean | undefined {
if (value === undefined || value === null || value === '') return undefined
if (typeof value === 'boolean') return value
if (typeof value === 'string') return value.trim().toLowerCase() === 'true'
return Boolean(value)
}
/**
* Parses a comma-separated list into trimmed, non-empty entries.
* Used for picklist value sets supplied as a single delimited string.
* @param value - The raw comma-separated string
* @returns An array of trimmed values (empty when nothing parseable is present)
*/
export function parseDelimitedList(value: string | undefined): string[] {
if (!value) return []
return value
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
}
/**
* Shape of the custom field metadata inputs accepted from tool params.
* Numeric dimensions arrive as real numbers from the LLM (param `type: 'number'`)
* or as strings from block inputs, so both forms are accepted.
*/
export interface CustomFieldMetadataInput {
fieldType?: string
label?: string
length?: number | string
precision?: number | string
scale?: number | string
visibleLines?: number | string
required?: boolean | string
unique?: boolean | string
externalId?: boolean | string
defaultValue?: string
description?: string
inlineHelpText?: string
picklistValues?: string
}
/**
* Coerces a numeric-ish metadata value (number or string) into a number.
* @returns The parsed number, or undefined when unset or unparseable
*/
function toFieldNumber(value?: number | string): number | undefined {
if (value === undefined || value === null || String(value).trim() === '') return undefined
const parsed = Number(value)
return Number.isNaN(parsed) ? undefined : parsed
}
/**
* Overlays only the explicitly-provided custom field properties onto `target`,
* leaving any property the caller did not supply untouched. Shared by create
* (onto a fresh object) and update (onto the field's existing metadata), so an
* update never fabricates values for omitted properties.
* @param target - The metadata object to mutate in place
* @param params - The provided custom field metadata inputs
*/
function applyProvidedFieldMetadata(
target: Record<string, any>,
params: CustomFieldMetadataInput
): void {
if (params.fieldType?.trim()) target.type = params.fieldType.trim()
if (params.label?.trim()) target.label = params.label.trim()
const length = toFieldNumber(params.length)
if (length !== undefined) target.length = length
const precision = toFieldNumber(params.precision)
if (precision !== undefined) target.precision = precision
const scale = toFieldNumber(params.scale)
if (scale !== undefined) target.scale = scale
const visibleLines = toFieldNumber(params.visibleLines)
if (visibleLines !== undefined) target.visibleLines = visibleLines
const required = normalizeBoolean(params.required)
if (required !== undefined) target.required = required
const unique = normalizeBoolean(params.unique)
if (unique !== undefined) target.unique = unique
const externalId = normalizeBoolean(params.externalId)
if (externalId !== undefined) target.externalId = externalId
if (params.description?.trim()) target.description = params.description.trim()
if (params.inlineHelpText?.trim()) target.inlineHelpText = params.inlineHelpText.trim()
if (params.defaultValue !== undefined && String(params.defaultValue).trim() !== '') {
target.defaultValue =
target.type === 'Checkbox'
? (normalizeBoolean(params.defaultValue) ?? false)
: params.defaultValue
}
const picklistValues = parseDelimitedList(params.picklistValues)
if (picklistValues.length > 0) {
// Union with any existing values so an update adds new options without
// dropping the field's current values (or their default flags).
const existingValues: Array<Record<string, any>> = Array.isArray(
target.valueSet?.valueSetDefinition?.value
)
? target.valueSet.valueSetDefinition.value
: []
const existingFullNames = new Set(existingValues.map((entry) => entry.fullName))
const additions = picklistValues
.filter((value) => !existingFullNames.has(value))
.map((value) => ({ fullName: value, default: false, label: value }))
target.valueSet = {
...(target.valueSet ?? {}),
valueSetDefinition: {
...(target.valueSet?.valueSetDefinition ?? {}),
sorted: target.valueSet?.valueSetDefinition?.sorted ?? false,
value: [...existingValues, ...additions],
},
}
}
}
/**
* Applies type-specific defaults required by Salesforce when the caller did not
* supply them, so common field types work out of the box on create.
* @param metadata - The metadata object to mutate in place (must have a `type`)
*/
function applyFieldTypeDefaults(metadata: Record<string, any>): void {
const fieldType = metadata.type
if (fieldType === 'Text' && metadata.length === undefined) {
metadata.length = 255
}
if (fieldType === 'LongTextArea' || fieldType === 'Html') {
if (metadata.length === undefined) metadata.length = 32768
if (metadata.visibleLines === undefined) metadata.visibleLines = 3
}
if (fieldType === 'MultiselectPicklist') {
if (metadata.visibleLines === undefined) metadata.visibleLines = 4
// Salesforce requires `length` (total characters across selected values) for
// multi-select picklists in addition to visibleLines.
if (metadata.length === undefined) metadata.length = 255
}
if (fieldType === 'Number' || fieldType === 'Currency' || fieldType === 'Percent') {
if (metadata.precision === undefined) metadata.precision = 18
if (metadata.scale === undefined) metadata.scale = 0
}
// Checkbox fields require a default value; Salesforce rejects them without one.
if (fieldType === 'Checkbox' && metadata.defaultValue === undefined) {
metadata.defaultValue = false
}
}
/**
* Builds the `Metadata` object for a Tooling API CustomField create body.
* Applies type-specific defaults so common field types work without the caller
* supplying every property (e.g. Text defaults to length 255).
* @param params - The custom field metadata params
* @param fallbackLabel - Label to use when none is provided
* @returns The Salesforce CustomField Metadata object
* @throws Error if the field type is missing
* @see https://developer.salesforce.com/docs/atlas.en-us.api_tooling.meta/api_tooling/tooling_api_objects_customfield.htm
*/
export function buildCustomFieldMetadata(
params: CustomFieldMetadataInput,
fallbackLabel: string
): Record<string, any> {
const fieldType = params.fieldType?.trim()
if (!fieldType) {
throw new Error('Field Type is required (e.g., Text, Number, Checkbox, Date, Picklist).')
}
const metadata: Record<string, any> = {
type: fieldType,
label: params.label?.trim() || fallbackLabel,
}
applyProvidedFieldMetadata(metadata, params)
applyFieldTypeDefaults(metadata)
return metadata
}
/**
* Merges caller-provided custom field changes onto a field's existing metadata
* for a Tooling API update. The Tooling API PATCH replaces the whole `Metadata`
* compound, so we start from the field's current metadata (read first) and
* overlay only what changed — never fabricating defaults or labels that would
* silently clobber unspecified properties.
* @param existing - The field's current `Metadata` object (from a GET)
* @param params - The provided custom field changes
* @returns The merged Salesforce CustomField Metadata object
* @see https://developer.salesforce.com/docs/atlas.en-us.api_tooling.meta/api_tooling/tooling_api_objects_customfield.htm
*/
export function mergeCustomFieldMetadata(
existing: Record<string, any> | undefined,
params: CustomFieldMetadataInput
): Record<string, any> {
const metadata: Record<string, any> = { ...(existing ?? {}) }
// An attribute update never changes the field's data type — Salesforce treats
// a type change as a separate, conversion-driven operation. Keep the field's
// existing type and overlay only the other provided properties.
applyProvidedFieldMetadata(metadata, { ...params, fieldType: undefined })
return metadata
}
/**
* Extracts a descriptive error message from Salesforce API responses
* @param data - The response data from Salesforce API
@@ -64,6 +286,11 @@ export function extractErrorMessage(data: any, status: number, defaultMessage: s
if (Array.isArray(data) && data[0]?.message) {
return `Salesforce API Error (${status}): ${data[0].message}${data[0].errorCode ? ` [${data[0].errorCode}]` : ''}`
}
// Tooling API metadata writes return { success: false, errors: [{ message, statusCode }] }
if (Array.isArray(data?.errors) && data.errors[0]?.message) {
const first = data.errors[0]
return `Salesforce API Error (${status}): ${first.message}${first.statusCode ? ` [${first.statusCode}]` : ''}`
}
if (data?.message) {
return `Salesforce API Error (${status}): ${data.message}`
}