diff --git a/apps/docs/content/docs/en/integrations/cloudflare.mdx b/apps/docs/content/docs/en/integrations/cloudflare.mdx index e8c8e34c48d..a7d1257ee7e 100644 --- a/apps/docs/content/docs/en/integrations/cloudflare.mdx +++ b/apps/docs/content/docs/en/integrations/cloudflare.mdx @@ -61,7 +61,7 @@ Lists all zones (domains) in the Cloudflare account. | ↳ `name` | string | Domain name | | ↳ `status` | string | Zone status \(initializing, pending, active, moved\) | | ↳ `paused` | boolean | Whether the zone is paused | -| ↳ `type` | string | Zone type \(full, partial, or secondary\) | +| ↳ `type` | string | Zone type \(full, partial, secondary, or internal\) | | ↳ `name_servers` | array | Assigned Cloudflare name servers | | ↳ `original_name_servers` | array | Original name servers before moving to Cloudflare | | ↳ `created_on` | string | ISO 8601 date when the zone was created | @@ -114,7 +114,7 @@ Gets details for a specific zone (domain) by its ID. | `name` | string | Domain name | | `status` | string | Zone status \(initializing, pending, active, moved\) | | `paused` | boolean | Whether the zone is paused | -| `type` | string | Zone type \(full, partial, or secondary\) | +| `type` | string | Zone type \(full, partial, secondary, or internal\) | | `name_servers` | array | Assigned Cloudflare name servers | | `original_name_servers` | array | Original name servers before moving to Cloudflare | | `created_on` | string | ISO 8601 date when the zone was created | @@ -157,7 +157,7 @@ Adds a new zone (domain) to the Cloudflare account. | --------- | ---- | -------- | ----------- | | `name` | string | Yes | The domain name to add \(e.g., "example.com"\) | | `accountId` | string | Yes | The Cloudflare account ID | -| `type` | string | No | Zone type: "full" \(Cloudflare manages DNS\), "partial" \(CNAME setup\), or "secondary" \(secondary DNS\) | +| `type` | string | No | Zone type: "full" \(Cloudflare manages DNS\), "partial" \(CNAME setup\), or "secondary" \(secondary DNS\). Cloudflare also defines "internal", which is not creatable through this tool | | `apiKey` | string | Yes | Cloudflare API Token | #### Output @@ -168,7 +168,7 @@ Adds a new zone (domain) to the Cloudflare account. | `name` | string | Domain name | | `status` | string | Zone status \(initializing, pending, active, moved\) | | `paused` | boolean | Whether the zone is paused | -| `type` | string | Zone type \(full, partial, or secondary\) | +| `type` | string | Zone type \(full, partial, secondary, or internal\) | | `name_servers` | array | Assigned Cloudflare name servers | | `original_name_servers` | array | Original name servers before moving to Cloudflare | | `created_on` | string | ISO 8601 date when the zone was created | @@ -257,7 +257,7 @@ Lists DNS records for a specific zone. | ↳ `proxied` | boolean | Whether Cloudflare proxy is enabled | | ↳ `ttl` | number | TTL in seconds \(1 = automatic\) | | ↳ `locked` | boolean | Whether the record is locked | -| ↳ `priority` | number | MX/SRV record priority | +| ↳ `priority` | number | Record priority, returned for MX and URI records | | ↳ `comment` | string | Comment associated with the record | | ↳ `tags` | array | Tags associated with the record | | ↳ `comment_modified_on` | string | ISO 8601 timestamp when the comment was last modified | @@ -326,7 +326,7 @@ Updates an existing DNS record for a zone. | `content` | string | No | DNS record content \(e.g., IP address\) | | `ttl` | number | No | Time to live in seconds \(1 = automatic\) | | `proxied` | boolean | No | Whether to enable Cloudflare proxy | -| `priority` | number | No | Priority for MX and SRV records | +| `priority` | number | No | Record priority. Cloudflare accepts this top-level field for MX and URI records only; an SRV record carries its priority, weight, port, and target inside the record content instead | | `comment` | string | No | Comment for the DNS record | | `tags` | string | No | Comma-separated tags for the DNS record | | `apiKey` | string | Yes | Cloudflare API Token | @@ -345,7 +345,7 @@ Updates an existing DNS record for a zone. | `proxied` | boolean | Whether Cloudflare proxy is enabled | | `ttl` | number | Time to live in seconds \(1 = automatic\) | | `locked` | boolean | Whether the record is locked | -| `priority` | number | Priority for MX and SRV records | +| `priority` | number | Record priority, returned for MX and URI records | | `comment` | string | Comment associated with the record | | `tags` | array | Tags associated with the record | | `comment_modified_on` | string | ISO 8601 timestamp when the comment was last modified | @@ -440,25 +440,29 @@ Lists SSL/TLS certificate packs for a zone. ### Cloudflare Get Zone Settings -Gets all settings for a zone including SSL mode, caching level, and security settings. +Reads zone settings such as SSL mode, minimum TLS version, security level, and caching level. Cloudflare retired the endpoint that read every setting in one request, so each setting is read individually — name the ones you need to keep the read small. Defaults to $\{DEFAULT_ZONE_SETTING_IDS.join(', ')\}. #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `zoneId` | string | Yes | The zone ID to get settings for | +| `settingIds` | string | No | Comma-separated setting IDs to read, e.g. "ssl,min_tls_version,security_level". Leave blank to read the default set \($\{DEFAULT_ZONE_SETTING_IDS.join\(', '\)\}\). At most $\{MAX_ZONE_SETTING_IDS\} settings per call. | | `apiKey` | string | Yes | Cloudflare API Token | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `settings` | array | List of zone settings | +| `settings` | array | The zone settings that were readable | | ↳ `id` | string | Setting identifier \(e.g., ssl, cache_level, security_level, always_use_https\) | | ↳ `value` | string | Setting value as a string. Simple values returned as-is \(e.g., "full", "on"\). Complex values are JSON-stringified \(e.g., \{"css":"on","html":"on","js":"on"\}\). | | ↳ `editable` | boolean | Whether the setting can be modified for the current zone plan | | ↳ `modified_on` | string | ISO 8601 timestamp when the setting was last modified | -| ↳ `time_remaining` | number | Seconds remaining until the setting can be modified again \(only present for rate-limited settings\) | +| ↳ `time_remaining` | number | Development mode countdown, in seconds. Cloudflare documents this only on the zones_development_mode setting, where it is the interval from when development mode expires \(positive\) or last expired \(negative\) | +| `unreadable` | array | Requested settings Cloudflare refused, typically because the zone plan does not expose them or the setting ID does not exist | +| ↳ `id` | string | The requested setting identifier | +| ↳ `error` | string | Why Cloudflare would not return the setting | ### Cloudflare Update Zone Setting @@ -481,7 +485,7 @@ Updates a specific zone setting such as SSL mode, security level, cache level, o | `value` | string | Updated setting value as a string. Simple values returned as-is \(e.g., "full", "on"\). Complex values are JSON-stringified. | | `editable` | boolean | Whether the setting can be modified for the current zone plan | | `modified_on` | string | ISO 8601 timestamp when the setting was last modified | -| `time_remaining` | number | Seconds remaining until the setting can be modified again \(only present for rate-limited settings\) | +| `time_remaining` | number | Development mode countdown, in seconds. Cloudflare documents this only on the zones_development_mode setting, where it is the interval from when development mode expires \(positive\) or last expired \(negative\) | ### Cloudflare DNS Analytics @@ -494,7 +498,7 @@ Gets DNS analytics report for a zone including query counts and trends. | `zoneId` | string | Yes | The zone ID to get DNS analytics for | | `since` | string | No | Start date for analytics \(ISO 8601, e.g., "2024-01-01T00:00:00Z"\) or relative \(e.g., "-6h"\) | | `until` | string | No | End date for analytics \(ISO 8601, e.g., "2024-01-31T23:59:59Z"\) or relative \(e.g., "now"\) | -| `metrics` | string | No | Comma-separated metrics to retrieve \(e.g., "queryCount,uncachedCount,staleCount,responseTimeAvg,responseTimeMedian,responseTime90th,responseTime99th"\). Optional — Cloudflare returns its default metric set when it is omitted | +| `metrics` | string | No | Comma-separated metrics to retrieve \(e.g., "queryCount,uncachedCount,staleCount,responseTimeAvg,responseTimeMedian,responseTime90th,responseTime99th"\). Optional in the API | | `dimensions` | string | No | Comma-separated dimensions to group by \(e.g., "queryName,queryType,responseCode,responseCached,coloName,origin,dayOfWeek,tcp,ipVersion,querySizeBucket,responseSizeBucket"\) | | `filters` | string | No | Filters to apply to the data \(e.g., "queryType==A"\) | | `sort` | string | No | Sort order for the result set. Fields must be included in metrics or dimensions \(e.g., "+queryCount" or "-responseTimeAvg"\) | @@ -505,30 +509,16 @@ Gets DNS analytics report for a zone including query counts and trends. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `totals` | object | Aggregate DNS analytics totals for the entire queried period | -| ↳ `queryCount` | number | Total number of DNS queries | -| ↳ `uncachedCount` | number | Number of uncached DNS queries | -| ↳ `staleCount` | number | Number of stale DNS queries | +| `totals` | object | Aggregate DNS analytics totals for the entire queried period. Only the metrics that were requested are present. | +| ↳ `queryCount` | number | Total number of DNS queries. Absent when queryCount was not requested | +| ↳ `uncachedCount` | number | Number of uncached DNS queries. Absent when uncachedCount was not requested | +| ↳ `staleCount` | number | Number of stale DNS queries. Absent when staleCount was not requested | | ↳ `responseTimeAvg` | number | Average response time in milliseconds | | ↳ `responseTimeMedian` | number | Median response time in milliseconds | | ↳ `responseTime90th` | number | 90th percentile response time in milliseconds | | ↳ `responseTime99th` | number | 99th percentile response time in milliseconds | -| `min` | object | Minimum values across the analytics period | -| ↳ `queryCount` | number | Minimum number of DNS queries | -| ↳ `uncachedCount` | number | Minimum number of uncached DNS queries | -| ↳ `staleCount` | number | Minimum number of stale DNS queries | -| ↳ `responseTimeAvg` | number | Minimum average response time in milliseconds | -| ↳ `responseTimeMedian` | number | Minimum median response time in milliseconds | -| ↳ `responseTime90th` | number | Minimum 90th percentile response time in milliseconds | -| ↳ `responseTime99th` | number | Minimum 99th percentile response time in milliseconds | -| `max` | object | Maximum values across the analytics period | -| ↳ `queryCount` | number | Maximum number of DNS queries | -| ↳ `uncachedCount` | number | Maximum number of uncached DNS queries | -| ↳ `staleCount` | number | Maximum number of stale DNS queries | -| ↳ `responseTimeAvg` | number | Maximum average response time in milliseconds | -| ↳ `responseTimeMedian` | number | Maximum median response time in milliseconds | -| ↳ `responseTime90th` | number | Maximum 90th percentile response time in milliseconds | -| ↳ `responseTime99th` | number | Maximum 99th percentile response time in milliseconds | +| `min` | json | Per-metric minimums. Cloudflare documents this field as currently always an empty object, so treat a populated value as unexpected rather than relied upon. | +| `max` | json | Per-metric maximums. Cloudflare documents this field as currently always an empty object, so treat a populated value as unexpected rather than relied upon. | | `data` | array | Raw analytics data rows returned by the Cloudflare DNS analytics report | | ↳ `dimensions` | array | Dimension values for this data row, parallel to the requested dimensions list | | ↳ `metrics` | array | Metric values for this data row, parallel to the requested metrics list | @@ -908,7 +898,7 @@ Creates a rate limiting rule in the http_ratelimit phase entry point ruleset of | `zoneId` | string | Yes | The zone ID to add the rate limiting rule to | | `rulesetId` | string | Yes | The http_ratelimit entry point ruleset ID, as returned by "List Rate Limiting Rules" | | `expression` | string | Yes | Cloudflare filter expression selecting the requests the rule applies to, e.g. \(http.request.uri.path matches "^/api/"\) | -| `characteristics` | string | Yes | Comma-separated counting characteristics. cf.colo.id is mandatory, plus exactly one of ip.src or cf.unique_visitor_id. Example: cf.colo.id,ip.src | +| `characteristics` | string | Yes | Comma-separated counting characteristics. cf.colo.id is mandatory. ip.src and cf.unique_visitor_id are mutually exclusive — include at most one. Example: cf.colo.id,ip.src | | `period` | number | Yes | Counting window in seconds. Cloudflare accepts only 10, 60, 120, 300, 600, or 3600 | | `requestsPerPeriod` | number | Yes | Number of requests allowed within the counting period before the action fires | | `action` | string | No | Action applied once the limit is exceeded, e.g. block, managed_challenge, js_challenge, challenge, or log. Defaults to block | @@ -956,7 +946,7 @@ Updates a rate limiting rule in the http_ratelimit phase entry point ruleset of | `rulesetId` | string | Yes | The http_ratelimit entry point ruleset ID, as returned by "List Rate Limiting Rules" | | `ruleId` | string | Yes | The rate limiting rule ID to update | | `expression` | string | Yes | Cloudflare filter expression selecting the requests the rule applies to | -| `characteristics` | string | Yes | Comma-separated counting characteristics. cf.colo.id is mandatory, plus exactly one of ip.src or cf.unique_visitor_id | +| `characteristics` | string | Yes | Comma-separated counting characteristics. cf.colo.id is mandatory. ip.src and cf.unique_visitor_id are mutually exclusive — include at most one. | | `period` | number | Yes | Counting window in seconds. Cloudflare accepts only 10, 60, 120, 300, 600, or 3600 | | `requestsPerPeriod` | number | Yes | Number of requests allowed within the counting period before the action fires | | `action` | string | Yes | Action applied once the limit is exceeded: block, managed_challenge, js_challenge, challenge, or log. Required because this endpoint replaces the rule rather than merging into it — a defaulted action would silently convert an existing log or challenge rule into a hard block | @@ -965,6 +955,9 @@ Updates a rate limiting rule in the http_ratelimit phase entry point ruleset of | `requestsToOrigin` | boolean | No | When true, only requests that reach the origin are counted | | `description` | string | No | Human-readable description of the rule | | `enabled` | boolean | No | Whether the rule is enabled | +| `ref` | string | No | Reference tag that stays stable across rule updates. Because the update replaces the rule, omitting it resets the tag to the rule ID and breaks anything matching on the old value | +| `actionParameters` | string | No | JSON object of action-specific parameters for the mitigation action, e.g. \{"response":\{"status_code":429,"content":"\{\\"error\\":\\"rate limited\\"\}","content_type":"application/json"\}\} for a custom block response. Because the update replaces the rule, omitting it resets action_parameters to \{\} and the rule falls back to Cloudflare\'s default block page | +| `logging` | string | No | JSON logging configuration to preserve, e.g. \{"enabled":true\}. Omitting it on a rule that had logging configured resets it to the default | | `apiKey` | string | Yes | Cloudflare API Token | #### Output @@ -1114,7 +1107,7 @@ Creates a Cloudflare Access (Zero Trust) application that puts an identity check ### Cloudflare Update Access Application -Updates a Cloudflare Access (Zero Trust) application. This replaces the application definition rather than merging it, so send every field the application should keep — anything you omit reverts to its default, which can widen or break access. Read the current configuration with "Get Access Application" first. Requires an API token with Account Access: Apps and Policies Edit. +Updates a Cloudflare Access (Zero Trust) application. Cloudflare does not document merge behavior for this PUT, so treat it as a replace: send every field the application should keep, because an omitted field may revert to its default and widen or break access. Read the current configuration with "Get Access Application" first. Requires an API token with Account Access: Apps and Policies Edit. #### Input @@ -1256,7 +1249,7 @@ Creates a Cloudflare Access (Zero Trust) policy on an application, deciding who ### Cloudflare Update Access Policy -Updates a Cloudflare Access (Zero Trust) policy on an application. This replaces the policy definition rather than merging it, so send every rule the policy should keep — omitted exclude or require rules are dropped, which can widen who gets in. The change applies to live traffic immediately. Read the current policy with "List Access Policies" first. Requires an API token with Account Access: Apps and Policies Edit. +Updates a Cloudflare Access (Zero Trust) policy on an application. Cloudflare does not document merge behavior for this PUT, so treat it as a replace: send every rule the policy should keep, because an omitted exclude or require rule may be dropped and widen who gets in. The change applies to live traffic immediately. Read the current policy with "List Access Policies" first. Requires an API token with Account Access: Apps and Policies Edit. #### Input diff --git a/apps/docs/content/docs/en/integrations/crowdstrike.mdx b/apps/docs/content/docs/en/integrations/crowdstrike.mdx index 58a69fea6a1..87637a0f06e 100644 --- a/apps/docs/content/docs/en/integrations/crowdstrike.mdx +++ b/apps/docs/content/docs/en/integrations/crowdstrike.mdx @@ -49,7 +49,7 @@ Create custom CrowdStrike Falcon indicators of compromise (POST /iocs/entities/i | `clientId` | string | Yes | CrowdStrike Falcon API client ID | | `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | | `cloud` | string | Yes | CrowdStrike Falcon cloud region | -| `indicators` | json | Yes | JSON array of indicators to create. Each entry requires type, value, and applied_globally \(boolean\). type is one of sha256, md5, domain, ipv4, ipv6; action is one of no_action, allow, prevent_no_ui, prevent, detect; severity is one of informational, low, medium, high, critical; platforms entries are windows, mac, or linux. Other documented fields: host_groups \(array\), description, source, tags \(array\), expiration \(ISO 8601\), mobile_action, metadata \(\{ filename \}\). Either applied_globally must be true or host_groups must be supplied. Tenants can extend these value sets, so treat them as the documented defaults rather than a closed list. | +| `indicators` | json | Yes | JSON array of indicators to create. Each entry requires type, value, and applied_globally \(boolean\). type is one of sha256, md5, domain, ipv4, ipv6; action is one of no_action, allow, prevent, detect \(prevent_no_ui is widely reported and appears in the Falcon console, but CrowdStrike does not enumerate it in the IOC API docs - call GET /iocs/queries/actions/v1 to read the actions your tenant actually accepts\); severity is one of informational, low, medium, high, critical; platforms entries are windows, mac, or linux. Other documented fields: host_groups \(array\), description, source, tags \(array\), expiration \(ISO 8601\), mobile_action, metadata \(\{ filename \}\). Either applied_globally must be true or host_groups must be supplied. Tenants can extend these value sets, so treat them as the documented defaults rather than a closed list. | | `comment` | string | No | Audit comment explaining why these indicators were created | | `retrodetects` | boolean | No | Whether to generate retroactive detections for the new indicators | | `ignoreWarnings` | boolean | No | Whether to create the indicators even when CrowdStrike returns warnings | @@ -774,7 +774,7 @@ Search custom CrowdStrike Falcon indicators of compromise (IOCs) with a Falcon Q | `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | | `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `filter` | string | No | Falcon Query Language filter over IOC fields | -| `limit` | number | No | Maximum number of IOC IDs to return \(1-500, default 100\) | +| `limit` | number | No | Maximum number of IOC IDs to return \(default 100\). CrowdStrike publishes no maximum for this endpoint; Sim caps it at 500 to keep a single request bounded | | `offset` | number | No | Pagination offset. Mutually exclusive with the after cursor; use after beyond 10,000 IOCs. | | `after` | string | No | Pagination cursor from a previous response. Mutually exclusive with offset. | | `sort` | string | No | Sort expression. Supported fields include action, applied_globally, created_by, created_on, expiration, expired, modified_by, modified_on, severity_number, source, type, and value. | diff --git a/apps/docs/content/docs/en/integrations/datadog.mdx b/apps/docs/content/docs/en/integrations/datadog.mdx index af30f7efe9a..f8dabe5b7ea 100644 --- a/apps/docs/content/docs/en/integrations/datadog.mdx +++ b/apps/docs/content/docs/en/integrations/datadog.mdx @@ -95,7 +95,7 @@ Post an event to the Datadog event stream. Use for deployment notifications, ale | `tags` | string | No | Comma-separated list of tags \(e.g., "env:production,service:api", "team:backend,priority:high"\) | | `aggregationKey` | string | No | Key to aggregate events together | | `sourceTypeName` | string | No | Source type name for the event | -| `dateHappened` | number | No | Unix timestamp in seconds when the event occurred \(e.g., 1705320000, defaults to now\) | +| `dateHappened` | number | No | Unix timestamp in seconds when the event occurred \(e.g., 1705320000, defaults to now\). Datadog limits this to events no older than 18 hours. | | `apiKey` | string | Yes | Datadog API key | | `site` | string | No | Datadog site/region \(default: datadoghq.com\) | @@ -197,8 +197,8 @@ List all monitors in Datadog with optional filtering by name, tags, or state. | `tags` | string | No | Comma-separated list of tags to filter by \(e.g., "env:prod,team:backend"\) | | `monitorTags` | string | No | Comma-separated list of monitor tags to filter by \(e.g., "service:api,priority:high"\) | | `withDowntimes` | boolean | No | Include downtime data with monitors | -| `page` | number | No | Page number for pagination \(0-indexed, e.g., 0, 1, 2\) | -| `pageSize` | number | No | Number of monitors per page \(e.g., 50, max: 1000\) | +| `page` | number | No | Page to start paginating from \(0-indexed, e.g., 0, 1, 2\). Datadog returns every monitor in the org without pagination when this is not specified, so set it to bound the response. Setting Page Size alone implies page 0. | +| `pageSize` | number | No | Number of monitors per page \(e.g., 50, max: 1000\). Datadog only applies this when a page is specified — otherwise it returns all monitors with no page size limit — so setting this alone sends page 0. With a page but no page size, Datadog defaults to 100. | | `apiKey` | string | Yes | Datadog API key | | `applicationKey` | string | Yes | Datadog Application key | | `site` | string | No | Datadog site/region \(default: datadoghq.com\) | @@ -312,7 +312,7 @@ Send log entries to Datadog for centralized logging and analysis. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `logs` | string | Yes | JSON array of log entries. Each entry should have message and optionally ddsource, ddtags, hostname, service. | +| `logs` | string | Yes | JSON array of log entries. Each entry should have message and optionally ddsource, ddtags, hostname, service. Sim fills in ddsource="custom" when an entry omits it — that is a Sim default, not a Datadog one; set ddsource yourself to have Datadog apply the matching integration log pipeline. | | `apiKey` | string | Yes | Datadog API key | | `site` | string | No | Datadog site/region \(default: datadoghq.com\) | diff --git a/apps/docs/content/docs/en/integrations/microsoft_ad.mdx b/apps/docs/content/docs/en/integrations/microsoft_ad.mdx index 7cbefb0800e..3fff9ba8934 100644 --- a/apps/docs/content/docs/en/integrations/microsoft_ad.mdx +++ b/apps/docs/content/docs/en/integrations/microsoft_ad.mdx @@ -55,6 +55,17 @@ List users in Azure AD (Microsoft Entra ID) | Parameter | Type | Description | | --------- | ---- | ----------- | | `users` | array | List of users | +| ↳ `id` | string | User ID | +| ↳ `displayName` | string | Display name | +| ↳ `givenName` | string | First name | +| ↳ `surname` | string | Last name | +| ↳ `userPrincipalName` | string | User principal name \(email\) | +| ↳ `mail` | string | Email address | +| ↳ `jobTitle` | string | Job title | +| ↳ `department` | string | Department | +| ↳ `officeLocation` | string | Office location | +| ↳ `mobilePhone` | string | Mobile phone number | +| ↳ `accountEnabled` | boolean | Whether the account is enabled | | `userCount` | number | Number of users returned | | `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | @@ -182,6 +193,16 @@ List groups in Azure AD (Microsoft Entra ID) | Parameter | Type | Description | | --------- | ---- | ----------- | | `groups` | array | List of groups | +| ↳ `id` | string | Group ID | +| ↳ `displayName` | string | Display name | +| ↳ `description` | string | Group description | +| ↳ `mail` | string | Email address | +| ↳ `mailEnabled` | boolean | Whether mail is enabled | +| ↳ `mailNickname` | string | Mail nickname | +| ↳ `securityEnabled` | boolean | Whether security is enabled | +| ↳ `groupTypes` | array | Group types | +| ↳ `visibility` | string | Group visibility | +| ↳ `createdDateTime` | string | Creation date | | `groupCount` | number | Number of groups returned | | `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | @@ -298,6 +319,10 @@ List members of a group in Azure AD (Microsoft Entra ID) | Parameter | Type | Description | | --------- | ---- | ----------- | | `members` | array | List of group members | +| ↳ `id` | string | Member ID | +| ↳ `displayName` | string | Display name | +| ↳ `mail` | string | Email address | +| ↳ `odataType` | string | Directory object type | | `memberCount` | number | Number of members returned | | `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | @@ -360,6 +385,8 @@ Add or remove subscription licenses (SKUs) on a user in Microsoft Entra ID. Remo | `displayName` | string | Display name of the user | | `userPrincipalName` | string | User principal name of the user | | `assignedLicenses` | array | Licenses assigned to the user after the change | +| ↳ `skuId` | string | SKU ID of the assigned license | +| ↳ `disabledPlans` | array | Service plan IDs disabled on this license | ### List Microsoft Entra ID User Licenses @@ -376,6 +403,14 @@ List the subscription licenses assigned to a user in Microsoft Entra ID | Parameter | Type | Description | | --------- | ---- | ----------- | | `licenses` | array | Licenses assigned to the user | +| ↳ `id` | string | License detail ID | +| ↳ `skuId` | string | SKU ID of the license | +| ↳ `skuPartNumber` | string | SKU part number \(e.g., "ENTERPRISEPACK"\) | +| ↳ `servicePlans` | array | Service plans included in the license | +| ↳ `servicePlanId` | string | Service plan ID | +| ↳ `servicePlanName` | string | Service plan name | +| ↳ `provisioningStatus` | string | Provisioning status of the service plan | +| ↳ `appliesTo` | string | Whether the plan applies to "User" or "Company" | | `licenseCount` | number | Number of licenses returned | ### List Microsoft Entra ID Subscribed SKUs @@ -392,6 +427,22 @@ List the subscription SKUs the tenant owns, including how many license units are | Parameter | Type | Description | | --------- | ---- | ----------- | | `skus` | array | Subscription SKUs owned by the tenant | +| ↳ `id` | string | Subscribed SKU object ID | +| ↳ `skuId` | string | SKU ID, used when assigning or removing licenses | +| ↳ `skuPartNumber` | string | SKU part number \(e.g., "ENTERPRISEPACK"\) | +| ↳ `appliesTo` | string | Whether the SKU applies to "User" or "Company" | +| ↳ `capabilityStatus` | string | Capability status of the subscription | +| ↳ `consumedUnits` | number | Number of licenses currently assigned | +| ↳ `prepaidUnits` | object | Prepaid license unit counts by status | +| ↳ `enabled` | number | Number of units that are enabled | +| ↳ `suspended` | number | Number of units that are suspended | +| ↳ `warning` | number | Number of units that are in warning status | +| ↳ `lockedOut` | number | Number of units that are locked out | +| ↳ `servicePlans` | array | Service plans included in the SKU | +| ↳ `servicePlanId` | string | Service plan ID | +| ↳ `servicePlanName` | string | Service plan name | +| ↳ `provisioningStatus` | string | Provisioning status of the service plan | +| ↳ `appliesTo` | string | Whether the plan applies to "User" or "Company" | | `skuCount` | number | Number of SKUs returned | ### Revoke Microsoft Entra ID Sign-In Sessions @@ -467,6 +518,9 @@ List the authentication methods a user has registered, such as passwords, phone | Parameter | Type | Description | | --------- | ---- | ----------- | | `methods` | array | Authentication methods registered by the user | +| ↳ `id` | string | Authentication method ID | +| ↳ `odataType` | string | Authentication method type \(e.g., "#microsoft.graph.phoneAuthenticationMethod"\). Method-specific details vary by type. | +| ↳ `createdDateTime` | string | When the authentication method was registered | | `methodCount` | number | Number of authentication methods returned | ### List Microsoft Entra ID Sign-Ins @@ -486,6 +540,34 @@ List sign-in events from the Microsoft Entra ID sign-in logs, newest first. Requ | Parameter | Type | Description | | --------- | ---- | ----------- | | `signIns` | array | Sign-in events | +| ↳ `id` | string | Sign-in event ID | +| ↳ `createdDateTime` | string | When the sign-in was initiated | +| ↳ `userId` | string | ID of the user who signed in | +| ↳ `userDisplayName` | string | Display name of the user | +| ↳ `userPrincipalName` | string | User principal name of the user | +| ↳ `appId` | string | ID of the application used to sign in | +| ↳ `appDisplayName` | string | Display name of the application | +| ↳ `resourceId` | string | ID of the resource that was accessed | +| ↳ `resourceDisplayName` | string | Display name of the resource | +| ↳ `ipAddress` | string | IP address the sign-in came from | +| ↳ `clientAppUsed` | string | Legacy client app used to sign in | +| ↳ `correlationId` | string | Correlation ID for the sign-in request | +| ↳ `conditionalAccessStatus` | string | Conditional access result: success, failure, notApplied, or unknownFutureValue | +| ↳ `isInteractive` | boolean | Whether the sign-in was interactive | +| ↳ `riskDetail` | string | Reason behind a specific risk state | +| ↳ `riskLevelAggregated` | string | Aggregated risk level for the sign-in | +| ↳ `riskState` | string | Risk state of the user or sign-in | +| ↳ `errorCode` | number | Sign-in status error code. 0 indicates a successful sign-in. | +| ↳ `failureReason` | string | Failure reason from the sign-in status | +| ↳ `deviceDisplayName` | string | Display name of the device used | +| ↳ `deviceId` | string | ID of the device used | +| ↳ `deviceOperatingSystem` | string | Operating system of the device used | +| ↳ `deviceBrowser` | string | Browser used to sign in | +| ↳ `deviceIsCompliant` | boolean | Whether the device is compliant | +| ↳ `deviceIsManaged` | boolean | Whether the device is managed | +| ↳ `locationCity` | string | City the sign-in came from | +| ↳ `locationState` | string | State the sign-in came from | +| ↳ `locationCountryOrRegion` | string | Two-letter country or region code the sign-in came from | | `signInCount` | number | Number of sign-ins returned | | `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | @@ -506,6 +588,25 @@ List directory audit records showing who changed what in Microsoft Entra ID, suc | Parameter | Type | Description | | --------- | ---- | ----------- | | `audits` | array | Directory audit records | +| ↳ `id` | string | Audit record ID | +| ↳ `activityDateTime` | string | When the activity took place | +| ↳ `activityDisplayName` | string | Name of the activity | +| ↳ `category` | string | Category of the activity | +| ↳ `correlationId` | string | Correlation ID for the activity | +| ↳ `loggedByService` | string | Service that logged the activity | +| ↳ `operationType` | string | Operation type \(e.g., Add, Update, Delete\) | +| ↳ `result` | string | Result of the activity: success, failure, timeout, or unknownFutureValue | +| ↳ `resultReason` | string | Reason for the result | +| ↳ `initiatedByUserId` | string | ID of the user who initiated the activity | +| ↳ `initiatedByUserPrincipalName` | string | User principal name of the initiating user | +| ↳ `initiatedByUserDisplayName` | string | Display name of the initiating user | +| ↳ `initiatedByAppId` | string | App ID that initiated the activity | +| ↳ `initiatedByAppDisplayName` | string | Display name of the app that initiated the activity | +| ↳ `targetResources` | array | Resources the activity acted on | +| ↳ `id` | string | ID of the target resource | +| ↳ `displayName` | string | Display name of the target resource | +| ↳ `type` | string | Type of the target resource \(e.g., User, Group\) | +| ↳ `userPrincipalName` | string | User principal name of the target, null for non-user resources | | `auditCount` | number | Number of audit records returned | | `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | @@ -527,6 +628,14 @@ List the application role assignments granted to a user, including assignments t | Parameter | Type | Description | | --------- | ---- | ----------- | | `assignments` | array | App role assignments granted to the user | +| ↳ `id` | string | App role assignment ID, used when removing the assignment | +| ↳ `appRoleId` | string | ID of the app role. All-zero GUID means the assignment grants access without a specific role. | +| ↳ `createdDateTime` | string | When the assignment was created | +| ↳ `principalId` | string | ID of the assigned principal | +| ↳ `principalDisplayName` | string | Display name of the assigned principal | +| ↳ `principalType` | string | Principal type: User, Group, or ServicePrincipal | +| ↳ `resourceId` | string | ID of the resource service principal that defines the app role | +| ↳ `resourceDisplayName` | string | Display name of the resource | | `assignmentCount` | number | Number of assignments returned | | `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | @@ -538,7 +647,7 @@ Grant a user an application role on a service principal, giving them access to t | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `userId` | string | Yes | User ID or user principal name to grant the app role to | +| `userId` | string | Yes | Object ID or user principal name of the user to grant the app role to. A user principal name is resolved to its object ID before the grant. | | `resourceId` | string | Yes | Object ID of the resource service principal that defines the app role. Use List Service Principals to find it. | | `appRoleId` | string | Yes | ID of the app role to grant. Use the all-zero GUID 00000000-0000-0000-0000-000000000000 to assign access without a specific role. | @@ -593,6 +702,21 @@ List the enterprise applications and service principals in the tenant, including | Parameter | Type | Description | | --------- | ---- | ----------- | | `servicePrincipals` | array | Service principals in the tenant | +| ↳ `id` | string | Service principal object ID, used as the resource ID of an app role assignment | +| ↳ `appId` | string | Application ID associated with the service principal | +| ↳ `displayName` | string | Display name of the service principal | +| ↳ `servicePrincipalType` | string | Type of service principal \(e.g., Application, ManagedIdentity, Legacy\) | +| ↳ `accountEnabled` | boolean | Whether users can sign in to the associated application | +| ↳ `appOwnerOrganizationId` | string | Tenant ID where the application is registered | +| ↳ `signInAudience` | string | Which Microsoft accounts are supported by the associated application | +| ↳ `tags` | array | Custom strings used to categorize the service principal | +| ↳ `appRoles` | array | App roles exposed by the associated application | +| ↳ `id` | string | App role ID, used when granting an app role assignment | +| ↳ `displayName` | string | Display name of the app role | +| ↳ `description` | string | Description of the app role | +| ↳ `value` | string | Value included in the roles claim for this app role | +| ↳ `isEnabled` | boolean | Whether the app role can be assigned | +| ↳ `allowedMemberTypes` | array | Principal types the app role can be assigned to \(User and/or Application\) | | `servicePrincipalCount` | number | Number of service principals returned | | `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | @@ -613,6 +737,14 @@ List every user, group, and service principal assigned to an application, by rea | Parameter | Type | Description | | --------- | ---- | ----------- | | `assignments` | array | Principals assigned to the application | +| ↳ `id` | string | App role assignment ID, used when removing the assignment | +| ↳ `appRoleId` | string | ID of the app role. All-zero GUID means the assignment grants access without a specific role. | +| ↳ `createdDateTime` | string | When the assignment was created | +| ↳ `principalId` | string | ID of the assigned principal | +| ↳ `principalDisplayName` | string | Display name of the assigned principal | +| ↳ `principalType` | string | Principal type: User, Group, or ServicePrincipal | +| ↳ `resourceId` | string | ID of the resource service principal that defines the app role | +| ↳ `resourceDisplayName` | string | Display name of the resource | | `assignmentCount` | number | Number of assignments returned | | `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | @@ -630,6 +762,10 @@ List the administrator roles that are activated in the tenant, such as Global Ad | Parameter | Type | Description | | --------- | ---- | ----------- | | `roles` | array | Activated directory roles | +| ↳ `id` | string | Directory role object ID | +| ↳ `displayName` | string | Display name of the directory role | +| ↳ `description` | string | Description of the directory role | +| ↳ `roleTemplateId` | string | ID of the directory role template | | `roleCount` | number | Number of directory roles returned | ### List Microsoft Entra ID Directory Role Members @@ -647,6 +783,10 @@ List the principals holding an administrator role. Returns up to 1000 members; t | Parameter | Type | Description | | --------- | ---- | ----------- | | `members` | array | Principals holding the directory role | +| ↳ `id` | string | Member ID | +| ↳ `displayName` | string | Display name | +| ↳ `mail` | string | Email address | +| ↳ `odataType` | string | Directory object type | | `memberCount` | number | Number of members returned | ### Add Microsoft Entra ID Directory Role Member @@ -705,6 +845,20 @@ List the devices registered in Microsoft Entra ID | Parameter | Type | Description | | --------- | ---- | ----------- | | `devices` | array | Registered devices | +| ↳ `id` | string | Device object ID, used to get, update, or delete the device | +| ↳ `deviceId` | string | Unique device identifier set during registration | +| ↳ `displayName` | string | Display name of the device | +| ↳ `operatingSystem` | string | Operating system of the device | +| ↳ `operatingSystemVersion` | string | Operating system version of the device | +| ↳ `accountEnabled` | boolean | Whether the device is enabled | +| ↳ `isCompliant` | boolean | Whether the device complies with MDM policies | +| ↳ `isManaged` | boolean | Whether the device is managed by an MDM app | +| ↳ `trustType` | string | Device registration type: Workplace, AzureAd, or ServerAd | +| ↳ `profileType` | string | Device profile type: RegisteredDevice, SecureVM, Printer, Shared, or IoT | +| ↳ `manufacturer` | string | Manufacturer of the device | +| ↳ `model` | string | Model of the device | +| ↳ `approximateLastSignInDateTime` | string | Approximate time the device last signed in | +| ↳ `registrationDateTime` | string | When the device was registered | | `deviceCount` | number | Number of devices returned | | `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | @@ -756,6 +910,20 @@ List the devices a user has registered or owns. Devices the caller cannot read a | Parameter | Type | Description | | --------- | ---- | ----------- | | `devices` | array | Devices linked to the user | +| ↳ `id` | string | Device object ID, used to get, update, or delete the device | +| ↳ `deviceId` | string | Unique device identifier set during registration | +| ↳ `displayName` | string | Display name of the device | +| ↳ `operatingSystem` | string | Operating system of the device | +| ↳ `operatingSystemVersion` | string | Operating system version of the device | +| ↳ `accountEnabled` | boolean | Whether the device is enabled | +| ↳ `isCompliant` | boolean | Whether the device complies with MDM policies | +| ↳ `isManaged` | boolean | Whether the device is managed by an MDM app | +| ↳ `trustType` | string | Device registration type: Workplace, AzureAd, or ServerAd | +| ↳ `profileType` | string | Device profile type: RegisteredDevice, SecureVM, Printer, Shared, or IoT | +| ↳ `manufacturer` | string | Manufacturer of the device | +| ↳ `model` | string | Model of the device | +| ↳ `approximateLastSignInDateTime` | string | Approximate time the device last signed in | +| ↳ `registrationDateTime` | string | When the device was registered | | `deviceCount` | number | Number of devices returned | | `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | @@ -776,6 +944,15 @@ List the conditional access policies configured in the tenant, including their s | Parameter | Type | Description | | --------- | ---- | ----------- | | `policies` | array | Conditional access policies | +| ↳ `id` | string | Conditional access policy ID | +| ↳ `displayName` | string | Display name of the policy | +| ↳ `state` | string | Policy state: enabled, disabled, or enabledForReportingButNotEnforced | +| ↳ `templateId` | string | ID of the template the policy was created from | +| ↳ `createdDateTime` | string | When the policy was created | +| ↳ `modifiedDateTime` | string | When the policy was last modified | +| ↳ `conditions` | json | Conditions that trigger the policy \(users, applications, platforms, locations, risk levels\) | +| ↳ `grantControls` | json | Controls enforced when the policy applies, or null when none are configured | +| ↳ `sessionControls` | json | Session controls enforced when the policy applies, or null when none are set | | `policyCount` | number | Number of policies returned | | `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | diff --git a/apps/docs/content/docs/en/integrations/mssql.mdx b/apps/docs/content/docs/en/integrations/mssql.mdx index a00619fe009..8bbe43fed5a 100644 --- a/apps/docs/content/docs/en/integrations/mssql.mdx +++ b/apps/docs/content/docs/en/integrations/mssql.mdx @@ -31,7 +31,7 @@ Execute a SELECT query on a Microsoft SQL Server database | `database` | string | Yes | Database name to connect to | | `username` | string | Yes | Database username | | `password` | string | Yes | Database password | -| `encrypt` | string | No | Encrypt the connection with TLS \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext | +| `encrypt` | string | No | Request TLS encryption for the connection \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext. Enabling requests encryption over TDS 7.4, which the server negotiates during prelogin - a server that answers NOT_SUP yields an unencrypted session rather than an error, so this is a request, not a guarantee | | `trustServerCertificate` | string | No | Trust a self-signed server certificate \(enabled, disabled\). Defaults to disabled. Enabling skips certificate validation, so the connection is open to a machine-in-the-middle | | `connectionTimeout` | number | No | Connection and request timeout in milliseconds \(default: 15000\) | | `query` | string | Yes | T-SQL SELECT query to execute, optionally led by a WITH clause. Statements that modify data or schema are rejected — use the Execute Raw SQL operation for those. | @@ -43,6 +43,8 @@ Execute a SELECT query on a Microsoft SQL Server database | `message` | string | Operation status message | | `rows` | array | Array of rows returned from the query | | `rowCount` | number | Number of rows returned | +| `truncated` | boolean | Present and true only when rows were dropped to stay inside the response ceilings. Absent means the recordset is complete | +| `truncationReason` | string | Which ceiling was hit and how to read the remaining rows | ### Microsoft SQL Server Insert @@ -57,7 +59,7 @@ Insert data into a Microsoft SQL Server table | `database` | string | Yes | Database name to connect to | | `username` | string | Yes | Database username | | `password` | string | Yes | Database password | -| `encrypt` | string | No | Encrypt the connection with TLS \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext | +| `encrypt` | string | No | Request TLS encryption for the connection \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext. Enabling requests encryption over TDS 7.4, which the server negotiates during prelogin - a server that answers NOT_SUP yields an unencrypted session rather than an error, so this is a request, not a guarantee | | `trustServerCertificate` | string | No | Trust a self-signed server certificate \(enabled, disabled\). Defaults to disabled. Enabling skips certificate validation, so the connection is open to a machine-in-the-middle | | `connectionTimeout` | number | No | Connection and request timeout in milliseconds \(default: 15000\) | | `table` | string | Yes | Table name to insert data into | @@ -70,6 +72,8 @@ Insert data into a Microsoft SQL Server table | `message` | string | Operation status message | | `rows` | array | Rows returned by the statement \(empty for a plain INSERT\) | | `rowCount` | number | Number of rows inserted | +| `truncated` | boolean | Present and true only when rows were dropped to stay inside the response ceilings. Absent means the recordset is complete | +| `truncationReason` | string | Which ceiling was hit and how to read the remaining rows | ### Microsoft SQL Server Update @@ -84,7 +88,7 @@ Update rows in a Microsoft SQL Server table | `database` | string | Yes | Database name to connect to | | `username` | string | Yes | Database username | | `password` | string | Yes | Database password | -| `encrypt` | string | No | Encrypt the connection with TLS \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext | +| `encrypt` | string | No | Request TLS encryption for the connection \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext. Enabling requests encryption over TDS 7.4, which the server negotiates during prelogin - a server that answers NOT_SUP yields an unencrypted session rather than an error, so this is a request, not a guarantee | | `trustServerCertificate` | string | No | Trust a self-signed server certificate \(enabled, disabled\). Defaults to disabled. Enabling skips certificate validation, so the connection is open to a machine-in-the-middle | | `connectionTimeout` | number | No | Connection and request timeout in milliseconds \(default: 15000\) | | `table` | string | Yes | Table name to update | @@ -98,6 +102,8 @@ Update rows in a Microsoft SQL Server table | `message` | string | Operation status message | | `rows` | array | Rows returned by the statement \(empty for a plain UPDATE\) | | `rowCount` | number | Number of rows updated | +| `truncated` | boolean | Present and true only when rows were dropped to stay inside the response ceilings. Absent means the recordset is complete | +| `truncationReason` | string | Which ceiling was hit and how to read the remaining rows | ### Microsoft SQL Server Delete @@ -112,7 +118,7 @@ Delete rows from a Microsoft SQL Server table | `database` | string | Yes | Database name to connect to | | `username` | string | Yes | Database username | | `password` | string | Yes | Database password | -| `encrypt` | string | No | Encrypt the connection with TLS \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext | +| `encrypt` | string | No | Request TLS encryption for the connection \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext. Enabling requests encryption over TDS 7.4, which the server negotiates during prelogin - a server that answers NOT_SUP yields an unencrypted session rather than an error, so this is a request, not a guarantee | | `trustServerCertificate` | string | No | Trust a self-signed server certificate \(enabled, disabled\). Defaults to disabled. Enabling skips certificate validation, so the connection is open to a machine-in-the-middle | | `connectionTimeout` | number | No | Connection and request timeout in milliseconds \(default: 15000\) | | `table` | string | Yes | Table name to delete rows from | @@ -125,6 +131,8 @@ Delete rows from a Microsoft SQL Server table | `message` | string | Operation status message | | `rows` | array | Rows returned by the statement \(empty for a plain DELETE\) | | `rowCount` | number | Number of rows deleted | +| `truncated` | boolean | Present and true only when rows were dropped to stay inside the response ceilings. Absent means the recordset is complete | +| `truncationReason` | string | Which ceiling was hit and how to read the remaining rows | ### Microsoft SQL Server Execute @@ -139,7 +147,7 @@ Execute a raw T-SQL statement on a Microsoft SQL Server database | `database` | string | Yes | Database name to connect to | | `username` | string | Yes | Database username | | `password` | string | Yes | Database password | -| `encrypt` | string | No | Encrypt the connection with TLS \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext | +| `encrypt` | string | No | Request TLS encryption for the connection \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext. Enabling requests encryption over TDS 7.4, which the server negotiates during prelogin - a server that answers NOT_SUP yields an unencrypted session rather than an error, so this is a request, not a guarantee | | `trustServerCertificate` | string | No | Trust a self-signed server certificate \(enabled, disabled\). Defaults to disabled. Enabling skips certificate validation, so the connection is open to a machine-in-the-middle | | `connectionTimeout` | number | No | Connection and request timeout in milliseconds \(default: 15000\) | | `query` | string | Yes | T-SQL statement to execute | @@ -151,6 +159,8 @@ Execute a raw T-SQL statement on a Microsoft SQL Server database | `message` | string | Operation status message | | `rows` | array | Rows returned by the statement, when it returns a result set | | `rowCount` | number | Number of rows returned or affected | +| `truncated` | boolean | Present and true only when rows were dropped to stay inside the response ceilings. Absent means the recordset is complete | +| `truncationReason` | string | Which ceiling was hit and how to read the remaining rows | ### Microsoft SQL Server Introspect @@ -165,7 +175,7 @@ Introspect a Microsoft SQL Server schema to retrieve table structures, columns, | `database` | string | Yes | Database name to connect to | | `username` | string | Yes | Database username | | `password` | string | Yes | Database password | -| `encrypt` | string | No | Encrypt the connection with TLS \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext | +| `encrypt` | string | No | Request TLS encryption for the connection \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext. Enabling requests encryption over TDS 7.4, which the server negotiates during prelogin - a server that answers NOT_SUP yields an unencrypted session rather than an error, so this is a request, not a guarantee | | `trustServerCertificate` | string | No | Trust a self-signed server certificate \(enabled, disabled\). Defaults to disabled. Enabling skips certificate validation, so the connection is open to a machine-in-the-middle | | `connectionTimeout` | number | No | Connection and request timeout in milliseconds \(default: 15000\) | | `schema` | string | No | Schema to introspect \(default: dbo\) | @@ -176,6 +186,29 @@ Introspect a Microsoft SQL Server schema to retrieve table structures, columns, | --------- | ---- | ----------- | | `message` | string | Operation status message | | `tables` | array | Array of table schemas with columns, keys, and indexes | +| ↳ `name` | string | Table name | +| ↳ `schema` | string | Schema name \(e.g., dbo\) | +| ↳ `columns` | array | Table columns in ordinal position order | +| ↳ `name` | string | Column name | +| ↳ `type` | string | Data type \(e.g., int, nvarchar, datetime2\) | +| ↳ `nullable` | boolean | Whether the column allows NULL values | +| ↳ `default` | string | Default value expression | +| ↳ `isPrimaryKey` | boolean | Whether the column is part of the primary key | +| ↳ `isForeignKey` | boolean | Whether the column is a foreign key | +| ↳ `references` | object | Foreign key reference information | +| ↳ `schema` | string | Referenced schema name | +| ↳ `table` | string | Referenced table name | +| ↳ `column` | string | Referenced column name | +| ↳ `primaryKey` | array | Primary key column names, in key order | +| ↳ `foreignKeys` | array | Foreign key constraints declared on this table | +| ↳ `column` | string | Local column name | +| ↳ `referencesSchema` | string | Referenced schema name | +| ↳ `referencesTable` | string | Referenced table name | +| ↳ `referencesColumn` | string | Referenced column name | +| ↳ `indexes` | array | Non-primary-key rowstore indexes on this table | +| ↳ `name` | string | Index name | +| ↳ `columns` | array | Key columns included in the index, in key order | +| ↳ `unique` | boolean | Whether the index enforces uniqueness | | `schemas` | array | List of available schemas in the database | diff --git a/apps/docs/content/docs/en/integrations/okta.mdx b/apps/docs/content/docs/en/integrations/okta.mdx index 94fc083b92f..5cf8c8e7be7 100644 --- a/apps/docs/content/docs/en/integrations/okta.mdx +++ b/apps/docs/content/docs/en/integrations/okta.mdx @@ -280,7 +280,7 @@ Generate a one-time token to reset a user password. Can email the reset link to | `apiKey` | string | Yes | Okta API token for authentication | | `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) | | `userId` | string | Yes | User ID or login to reset password for | -| `sendEmail` | boolean | No | Send password reset email to the user \(default: true\) | +| `sendEmail` | boolean | No | Send password reset email to the user. Okta requires this parameter and declares no default of its own; leaving it blank sends the email | #### Output @@ -1219,7 +1219,7 @@ Query the Okta System Log for sign-ins, admin changes, and security events. Supp | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Okta API token for authentication | | `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) | -| `since` | string | No | Start of the query time window as an ISO 8601 timestamp \(default: 7 days before "until"\) | +| `since` | string | No | Start of the query time window as an ISO 8601 timestamp \(default: 7 days before "until"\). Ignored when a cursor is supplied in "after", which already encodes the resume position | | `until` | string | No | End of the query time window as an ISO 8601 timestamp \(default: now\) | | `filter` | string | No | SCIM filter expression \(e.g., eventType eq "user.session.start" or outcome.result eq "FAILURE"\) | | `q` | string | No | Keyword search across the event payload \(max 40 characters per keyword, max 10 keywords\) | @@ -1267,8 +1267,8 @@ Query the Okta System Log for sign-ins, admin changes, and security events. Supp | ↳ `displayName` | string | Target display name | | ↳ `debugData` | json | Extra context whose keys depend on the event type. Okta states these keys and values can change between releases, so treat them as a debugging aid rather than a contract | | `count` | number | Number of events returned | -| `nextCursor` | string | Cursor for the next page, or null on the last page | -| `hasMore` | boolean | Whether more events are available | +| `nextCursor` | string | Cursor to resume from, or null when Okta advertised no next link. On a polling query it stays set on an empty page so the next scheduled run resumes from here rather than replaying from the start | +| `hasMore` | boolean | Whether more events are available. A query with no "until" is a polling query, which Okta always answers with a next link even when there are no new events, so this reports false once a page comes back empty | | `success` | boolean | Operation success status | diff --git a/apps/docs/content/docs/en/integrations/servicenow.mdx b/apps/docs/content/docs/en/integrations/servicenow.mdx index acce8017734..2359530996b 100644 --- a/apps/docs/content/docs/en/integrations/servicenow.mdx +++ b/apps/docs/content/docs/en/integrations/servicenow.mdx @@ -354,7 +354,7 @@ Search ServiceNow incidents by state, priority, assignment, caller, or text. All | `callerId` | string | No | sys_id of the caller. | | `active` | string | No | Restrict to active \("true"\) or inactive \("false"\) incidents. | | `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). | -| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). | +| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). Omitting it sends no limit at all, and the Table API then applies its own default of 10,000 records, so always set it to what you will actually read. | | `offset` | number | No | Number of records to skip for pagination \(sysparm_offset\). | | `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. | | `displayValue` | string | No | How reference and choice fields are returned: "all" \(default — both the sys_id and the label, as \{value, display_value\}\), "true" \(labels only\), or "false" \(raw sys_ids and coded values only\). | @@ -739,7 +739,7 @@ Search ServiceNow change requests by state, type, risk, assignment, or text. All | `assignedTo` | string | No | sys_id of the assigned user. | | `active` | string | No | Restrict to active \("true"\) or inactive \("false"\) change requests. | | `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). | -| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). | +| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). Omitting it sends no limit at all, and the Table API then applies its own default of 10,000 records, so always set it to what you will actually read. | | `offset` | number | No | Number of records to skip for pagination \(sysparm_offset\). | | `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. | | `displayValue` | string | No | How reference and choice fields are returned: "all" \(default — both the sys_id and the label, as \{value, display_value\}\), "true" \(labels only\), or "false" \(raw sys_ids and coded values only\). | @@ -1032,7 +1032,7 @@ List requested items (RITMs) from the ServiceNow Requested Item [sc_req_item] ta | `catalogItemSysId` | string | No | sys_id of the catalog item \(cat_item\) to filter by. | | `active` | string | No | Restrict to active \("true"\) or inactive \("false"\) requested items. | | `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). | -| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). | +| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). Omitting it sends no limit at all, and the Table API then applies its own default of 10,000 records, so always set it to what you will actually read. | | `offset` | number | No | Number of records to skip for pagination \(sysparm_offset\). | | `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. | | `displayValue` | string | No | How reference and choice fields are returned: "all" \(default — both the sys_id and the label, as \{value, display_value\}\), "true" \(labels only\), or "false" \(raw sys_ids and coded values only\). | @@ -1132,7 +1132,7 @@ List approval records from the ServiceNow Approval [sysapproval_approver] table. | `state` | string | No | Approval state: "requested" \(pending, the default\), "approved", or "rejected". Pass an empty string with a custom query to list every state. | | `approvalFor` | string | No | sys_id of the record being approved, matched against the sysapproval reference field. | | `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). | -| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). | +| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). Omitting it sends no limit at all, and the Table API then applies its own default of 10,000 records, so always set it to what you will actually read. | | `offset` | number | No | Number of records to skip for pagination \(sysparm_offset\). | | `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. | | `displayValue` | string | No | How reference and choice fields are returned: "all" \(default — both the sys_id and the label, as \{value, display_value\}\), "true" \(labels only\), or "false" \(raw sys_ids and coded values only\). | @@ -1234,7 +1234,7 @@ Search the ServiceNow CMDB for configuration items. Defaults to the base cmdb_ci | `name` | string | No | Text to match against the CI name using the ServiceNow LIKE operator, which matches anywhere in the field. | | `operationalStatus` | string | No | Operational status coded value \(operational_status\). The choice list is configured per instance. | | `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). | -| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). | +| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). Omitting it sends no limit at all, and the Table API then applies its own default of 10,000 records, so always set it to what you will actually read. | | `offset` | number | No | Number of records to skip for pagination \(sysparm_offset\). | | `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. | | `displayValue` | string | No | How reference and choice fields are returned: "all" \(default — both the sys_id and the label, as \{value, display_value\}\), "true" \(labels only\), or "false" \(raw sys_ids and coded values only\). | @@ -1316,7 +1316,7 @@ List rows from the CI Relationship [cmdb_rel_ci] table for a configuration item. | `ciSysId` | string | Yes | sys_id of the configuration item whose relationships should be listed. | | `direction` | string | No | Which side of the relationship the CI sits on: "parent", "child", or "both" \(default\). "both" matches rows where the CI is either the parent or the child. | | `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). | -| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). | +| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). Omitting it sends no limit at all, and the Table API then applies its own default of 10,000 records, so always set it to what you will actually read. | | `offset` | number | No | Number of records to skip for pagination \(sysparm_offset\). | | `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. | | `displayValue` | string | No | How reference and choice fields are returned: "all" \(default — both the sys_id and the label, as \{value, display_value\}\), "true" \(labels only\), or "false" \(raw sys_ids and coded values only\). | @@ -1436,7 +1436,7 @@ Look up ServiceNow users by email, user name, or display name. Use this to resol | `name` | string | No | Text to match against the display name using the ServiceNow LIKE operator, which matches anywhere in the field. | | `active` | string | No | Restrict to active \("true"\) or inactive \("false"\) users. | | `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). | -| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). | +| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). Omitting it sends no limit at all, and the Table API then applies its own default of 10,000 records, so always set it to what you will actually read. | | `offset` | number | No | Number of records to skip for pagination \(sysparm_offset\). | | `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. | | `displayValue` | string | No | How reference and choice fields are returned: "all" \(default — both the sys_id and the label, as \{value, display_value\}\), "true" \(labels only\), or "false" \(raw sys_ids and coded values only\). | @@ -1487,7 +1487,7 @@ List the members of a ServiceNow group from the Group Member [sys_user_grmember] | `groupSysId` | string | No | sys_id of the sys_user_group whose members should be listed. | | `groupName` | string | No | Exact group name, resolved against the referenced group record. Provide this or the group sys_id. | | `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). | -| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). | +| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). Omitting it sends no limit at all, and the Table API then applies its own default of 10,000 records, so always set it to what you will actually read. | | `offset` | number | No | Number of records to skip for pagination \(sysparm_offset\). | | `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. | | `displayValue` | string | No | How reference and choice fields are returned: "all" \(default — both the sys_id and the label, as \{value, display_value\}\), "true" \(labels only\), or "false" \(raw sys_ids and coded values only\). | diff --git a/apps/docs/content/docs/en/integrations/splunk.mdx b/apps/docs/content/docs/en/integrations/splunk.mdx index 8c5a98c001f..03c872e8d61 100644 --- a/apps/docs/content/docs/en/integrations/splunk.mdx +++ b/apps/docs/content/docs/en/integrations/splunk.mdx @@ -20,7 +20,7 @@ Integrate Splunk Enterprise or Splunk Cloud into workflows. Run SPL searches syn ### Splunk Run Search -Run an SPL search synchronously and return its results in a single call (oneshot mode). Use for short searches; use Create Search Job for long-running ones. +Run an SPL search synchronously and return its results in a single call (oneshot mode). A oneshot search buffers the whole result set in one response with no paging, so use it for short searches; for anything large use Create Search Job with Get Search Results, which defaults to 100 rows and pages with offset. #### Input @@ -37,62 +37,19 @@ Run an SPL search synchronously and return its results in a single call (oneshot | `latestTime` | string | No | Latest \(exclusive\) time bound — relative \(e.g. now\) or absolute time | | `adhocSearchLevel` | string | No | Search mode: verbose, fast, or smart. Defaults to fast. | | `autoCancel` | number | No | Cancel the search after this many seconds of inactivity \(e.g. 60\). 0 never auto-cancels. | -| `maxCount` | number | No | Maximum number of results the search stores and returns. Defaults to 10000. Lower it to bound large oneshot responses. | +| `maxCount` | number | No | Number of events accessible in any given status bucket, and in transforming mode the maximum number of results to store. Defaults to 10000. | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `results` | json | Search result rows, each holding the fields the search produced | -| `resultCount` | number | Number of result rows returned | -| `preview` | boolean | Whether the results are previews | -| `initOffset` | number | Offset of the first returned row | -| `messages` | json | Messages returned with the response \(\[\{type, text\}\]\) | -| `sid` | string | Search ID of the job | -| `label` | string | Custom name of the search job | -| `dispatchState` | string | Current state of the search job | -| `doneProgress` | number | Approximate job progress between 0 and 1 | -| `isDone` | boolean | Whether the search has completed | -| `isFailed` | boolean | Whether the search failed | -| `isFinalized` | boolean | Whether the search was finalized | -| `isPaused` | boolean | Whether the search is paused | -| `isZombie` | boolean | Whether the search process died | -| `isSaved` | boolean | Whether the job artifacts are saved | -| `isSavedSearch` | boolean | Whether the job came from a saved search | -| `isRealTimeSearch` | boolean | Whether this is a real-time search | -| `eventCount` | number | Number of events returned | -| `eventAvailableCount` | number | Number of events available for export | -| `eventFieldCount` | number | Number of fields found in the results | -| `resultPreviewCount` | number | Number of rows in the latest preview | -| `scanCount` | number | Number of events scanned off disk | -| `runDuration` | number | Seconds the search took to complete | -| `priority` | number | Search priority between 0 and 10 | -| `earliestTime` | string | Earliest time bound of the job | -| `latestTime` | string | Latest time bound of the job | -| `searchEarliestTime` | string | Earliest time as specified in the search command | -| `searchLatestTime` | string | Latest time as specified in the search command | -| `savedSearches` | json | Saved searches \(\[\{name, id, author, updated, search, description, disabled, isScheduled, cronSchedule, alertType\}\]\) | -| `name` | string | Saved search name | -| `id` | string | Fully qualified REST URI of the resource | -| `author` | string | Owner of the saved search | -| `updated` | string | Last update timestamp | -| `search` | string | SPL the saved search runs | -| `qualifiedSearch` | string | Exact search string the scheduler runs | -| `description` | string | Saved search description | -| `disabled` | boolean | Whether the saved search is disabled | -| `isScheduled` | boolean | Whether the search runs on a schedule | -| `isVisible` | boolean | Whether the search is listed as visible | -| `cronSchedule` | string | Cron schedule for the search | -| `nextScheduledTime` | string | Next scheduled run time | -| `alertType` | string | Alert condition type | -| `dispatchEarliestTime` | string | Earliest time used when dispatching | -| `dispatchLatestTime` | string | Latest time used when dispatching | -| `alerts` | json | Saved searches with currently triggered alerts \(\[\{name, id, updated, triggeredAlertCount\}\]\) | -| `firedAlerts` | json | Triggered instances of an alert \(\[\{name, savedSearchName, alertType, severity, sid, triggerTime\}\]\) | -| `indexes` | json | Indexes configured on the instance \(\[\{name, datatype, disabled, totalEventCount, currentDBSizeMB, maxTotalDataSizeMB, minTime, maxTime\}\]\) | -| `apps` | json | Apps installed on the instance \(name, label, version, author, disabled\) | -| `total` | number | Total number of entries matching a list request, from the response paging envelope. Compare with offset to decide whether another page remains. | -| `offset` | number | Offset of the first entry in the returned page, from the paging envelope | +| `results` | array | Result rows. Each row holds the fields produced by the search. | +| `resultCount` | number | Number of result rows returned in this response | +| `preview` | boolean | Whether these are preview results from a still-running job | +| `initOffset` | number | Offset of the first returned row within the full result set | +| `messages` | array | Search messages returned alongside the results | +| ↳ `type` | string | Message severity | +| ↳ `text` | string | Message text | ### Splunk Create Search Job @@ -169,8 +126,8 @@ Get the status and progress of a Splunk search job by search ID, including dispa | `priority` | number | Search priority between 0 and 10 | | `earliestTime` | string | Earliest \(inclusive\) time bound for the search | | `latestTime` | string | Latest \(exclusive\) time bound for the search | -| `searchEarliestTime` | string | Earliest time as specified in the search command itself | -| `searchLatestTime` | string | Latest time as specified in the search command itself | +| `searchEarliestTime` | number | Earliest time as specified in the search command itself, as an epoch timestamp. Unlike earliestTime, which the job entry renders as an ISO string, this pair is documented as bare numbers \(e.g. 1308589800.000000000\). | +| `searchLatestTime` | number | Latest time as specified in the search command itself, as an epoch timestamp. Unlike latestTime, which the job entry renders as an ISO string, this pair is documented as bare numbers. | | `messages` | json | Errors and debug messages recorded for the job | ### Splunk Get Search Results @@ -197,56 +154,13 @@ Fetch the transformed results of a completed Splunk search job by search ID, wit | Parameter | Type | Description | | --------- | ---- | ----------- | -| `results` | json | Search result rows, each holding the fields the search produced | -| `resultCount` | number | Number of result rows returned | -| `preview` | boolean | Whether the results are previews | -| `initOffset` | number | Offset of the first returned row | -| `messages` | json | Messages returned with the response \(\[\{type, text\}\]\) | -| `sid` | string | Search ID of the job | -| `label` | string | Custom name of the search job | -| `dispatchState` | string | Current state of the search job | -| `doneProgress` | number | Approximate job progress between 0 and 1 | -| `isDone` | boolean | Whether the search has completed | -| `isFailed` | boolean | Whether the search failed | -| `isFinalized` | boolean | Whether the search was finalized | -| `isPaused` | boolean | Whether the search is paused | -| `isZombie` | boolean | Whether the search process died | -| `isSaved` | boolean | Whether the job artifacts are saved | -| `isSavedSearch` | boolean | Whether the job came from a saved search | -| `isRealTimeSearch` | boolean | Whether this is a real-time search | -| `eventCount` | number | Number of events returned | -| `eventAvailableCount` | number | Number of events available for export | -| `eventFieldCount` | number | Number of fields found in the results | -| `resultPreviewCount` | number | Number of rows in the latest preview | -| `scanCount` | number | Number of events scanned off disk | -| `runDuration` | number | Seconds the search took to complete | -| `priority` | number | Search priority between 0 and 10 | -| `earliestTime` | string | Earliest time bound of the job | -| `latestTime` | string | Latest time bound of the job | -| `searchEarliestTime` | string | Earliest time as specified in the search command | -| `searchLatestTime` | string | Latest time as specified in the search command | -| `savedSearches` | json | Saved searches \(\[\{name, id, author, updated, search, description, disabled, isScheduled, cronSchedule, alertType\}\]\) | -| `name` | string | Saved search name | -| `id` | string | Fully qualified REST URI of the resource | -| `author` | string | Owner of the saved search | -| `updated` | string | Last update timestamp | -| `search` | string | SPL the saved search runs | -| `qualifiedSearch` | string | Exact search string the scheduler runs | -| `description` | string | Saved search description | -| `disabled` | boolean | Whether the saved search is disabled | -| `isScheduled` | boolean | Whether the search runs on a schedule | -| `isVisible` | boolean | Whether the search is listed as visible | -| `cronSchedule` | string | Cron schedule for the search | -| `nextScheduledTime` | string | Next scheduled run time | -| `alertType` | string | Alert condition type | -| `dispatchEarliestTime` | string | Earliest time used when dispatching | -| `dispatchLatestTime` | string | Latest time used when dispatching | -| `alerts` | json | Saved searches with currently triggered alerts \(\[\{name, id, updated, triggeredAlertCount\}\]\) | -| `firedAlerts` | json | Triggered instances of an alert \(\[\{name, savedSearchName, alertType, severity, sid, triggerTime\}\]\) | -| `indexes` | json | Indexes configured on the instance \(\[\{name, datatype, disabled, totalEventCount, currentDBSizeMB, maxTotalDataSizeMB, minTime, maxTime\}\]\) | -| `apps` | json | Apps installed on the instance \(name, label, version, author, disabled\) | -| `total` | number | Total number of entries matching a list request, from the response paging envelope. Compare with offset to decide whether another page remains. | -| `offset` | number | Offset of the first entry in the returned page, from the paging envelope | +| `results` | array | Result rows. Each row holds the fields produced by the search. | +| `resultCount` | number | Number of result rows returned in this response | +| `preview` | boolean | Whether these are preview results from a still-running job | +| `initOffset` | number | Offset of the first returned row within the full result set | +| `messages` | array | Search messages returned alongside the results | +| ↳ `type` | string | Message severity | +| ↳ `text` | string | Message text | ### Splunk Cancel Search Job @@ -269,6 +183,9 @@ Cancel a running Splunk search job and delete its result cache. | Parameter | Type | Description | | --------- | ---- | ----------- | | `sid` | string | Search ID of the cancelled job | +| `messages` | array | Informational, warning, and error messages returned with the response | +| ↳ `type` | string | Message severity \(INFO, WARN, ERROR, DEBUG\) | +| ↳ `text` | string | Message text | ### Splunk List Saved Searches @@ -308,6 +225,8 @@ List saved searches and reports configured in Splunk, including their SPL, sched | ↳ `alertType` | string | Alert condition type \(e.g. always, custom, number of events\) | | ↳ `dispatchEarliestTime` | string | Earliest time bound used when the search is dispatched | | ↳ `dispatchLatestTime` | string | Latest time bound used when the search is dispatched | +| `total` | number | Total number of entries matching the request, from the response paging envelope. Compare with offset to decide whether another page remains. | +| `offset` | number | Offset of the first entry in this page, echoed from the response paging envelope | ### Splunk Get Saved Search @@ -400,6 +319,8 @@ List the saved searches with currently triggered (unexpired) Splunk alerts and h | ↳ `id` | string | Fully qualified REST URI of the entry | | ↳ `updated` | string | Last update timestamp | | ↳ `triggeredAlertCount` | number | Trigger count for this alert | +| `total` | number | Total number of entries matching the request, from the response paging envelope. Compare with offset to decide whether another page remains. | +| `offset` | number | Offset of the first entry in this page, echoed from the response paging envelope | ### Splunk Get Fired Alerts @@ -415,7 +336,7 @@ List the unexpired triggered instances of a Splunk alert by saved search name, i | `password` | string | No | Splunk password, used for basic authentication when no token is supplied | | `owner` | string | No | Namespace owner for /servicesNS requests \(e.g. admin, or nobody for app-shared objects\). Leave both this and the app empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `app` | string | No | Namespace app context for /servicesNS requests \(e.g. search\). Leave both this and the owner empty to use the authenticated user context; set only one and the other becomes the - wildcard. | -| `name` | string | Yes | Name of the alerting saved search \(e.g. Errors in the last 24 hours\). Use - to return the fired alerts of every saved search. | +| `name` | string | Yes | Name of the alerting saved search \(e.g. Errors in the last 24 hours\). Use - to return the fired alerts of every saved search — this endpoint documents "Request parameters: None", so there is no count or offset to bound that with. Name one saved search unless you really want all of them. | #### Output @@ -473,6 +394,8 @@ List the indexes configured on the Splunk instance with their size, event count, | ↳ `homePath` | string | Path to the hot and warm buckets | | ↳ `coldPath` | string | Path to the cold buckets | | ↳ `thawedPath` | string | Path to the thawed buckets | +| `total` | number | Total number of entries matching the request, from the response paging envelope. Compare with offset to decide whether another page remains. | +| `offset` | number | Offset of the first entry in this page, echoed from the response paging envelope | ### Splunk List Apps @@ -509,5 +432,7 @@ List the apps installed on the Splunk instance with their label, version, author | ↳ `configured` | boolean | Whether the custom app setup has been completed | | ↳ `checkForUpdates` | boolean | Whether Splunkbase is checked for app updates | | ↳ `stateChangeRequiresRestart` | boolean | Whether changing the app state requires a restart | +| `total` | number | Total number of entries matching the request, from the response paging envelope. Compare with offset to decide whether another page remains. | +| `offset` | number | Offset of the first entry in this page, echoed from the response paging envelope | diff --git a/apps/sim/app/api/tools/crowdstrike/query/falcon.ts b/apps/sim/app/api/tools/crowdstrike/query/falcon.ts index d5a5efaa167..fa21e3181b7 100644 --- a/apps/sim/app/api/tools/crowdstrike/query/falcon.ts +++ b/apps/sim/app/api/tools/crowdstrike/query/falcon.ts @@ -156,6 +156,21 @@ export function getFalconErrorMessage(data: unknown, fallback: string): string { ) } +/** + * Raised when the Falcon OAuth2 token exchange fails. Carries the Falcon status + * so the route can answer with the real cause (401 for bad credentials) instead + * of letting a credential problem fall through to a generic 500. + */ +export class CrowdStrikeAuthError extends Error { + readonly status: number + + constructor(message: string, status: number) { + super(message) + this.name = 'CrowdStrikeAuthError' + this.status = status >= 400 && status <= 599 ? status : 502 + } +} + export async function getAccessToken(params: CrowdStrikeBaseParams): Promise { const baseUrl = getCloudBaseUrl(params.cloud) const response = await fetch(`${baseUrl}/oauth2/token`, { @@ -174,11 +189,14 @@ export async function getAccessToken(params: CrowdStrikeBaseParams): Promise null) if (!response.ok) { - throw new Error(getFalconErrorMessage(data, 'Failed to authenticate with CrowdStrike')) + throw new CrowdStrikeAuthError( + getFalconErrorMessage(data, 'Failed to authenticate with CrowdStrike'), + response.status + ) } if (!isRecordLike(data) || typeof data.access_token !== 'string') { - throw new Error('CrowdStrike authentication did not return an access token') + throw new CrowdStrikeAuthError('CrowdStrike authentication did not return an access token', 502) } return data.access_token diff --git a/apps/sim/app/api/tools/crowdstrike/query/route.test.ts b/apps/sim/app/api/tools/crowdstrike/query/route.test.ts index 634ff43f649..d4657e8690d 100644 --- a/apps/sim/app/api/tools/crowdstrike/query/route.test.ts +++ b/apps/sim/app/api/tools/crowdstrike/query/route.test.ts @@ -73,6 +73,45 @@ describe('CrowdStrike query route', () => { }) }) + it('surfaces a credential failure with the Falcon status, not a generic 500', async () => { + // getAccessToken runs before the operation dispatch, so a Falcon 401 used to + // fall through to the catch-all and reach the caller as a 500. + fetchMock.mockResolvedValueOnce( + jsonResponse({ errors: [{ code: 401, message: 'access denied, invalid bearer token' }] }, 401) + ) + + const response = await POST( + createMockRequest('POST', { + clientId: 'client-id', + clientSecret: 'wrong-secret', + cloud: 'us-1', + limit: 1, + operation: 'crowdstrike_query_sensors', + }) + ) + const data = await response.json() + + expect(response.status).toBe(401) + expect(data).toEqual({ success: false, error: 'access denied, invalid bearer token' }) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('reports an unusable token response as a bad gateway rather than a 500', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ nothing: true })) + + const response = await POST( + createMockRequest('POST', { + clientId: 'client-id', + clientSecret: 'client-secret', + cloud: 'us-1', + limit: 1, + operation: 'crowdstrike_query_sensors', + }) + ) + + expect(response.status).toBe(502) + }) + it('hydrates sensor details after querying sensor ids', async () => { fetchMock .mockResolvedValueOnce(jsonResponse({ access_token: 'token-123' })) diff --git a/apps/sim/app/api/tools/crowdstrike/query/route.ts b/apps/sim/app/api/tools/crowdstrike/query/route.ts index ed502f38521..e943aa77904 100644 --- a/apps/sim/app/api/tools/crowdstrike/query/route.ts +++ b/apps/sim/app/api/tools/crowdstrike/query/route.ts @@ -6,6 +6,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { + CrowdStrikeAuthError, type CrowdStrikeCallResult, callCrowdStrike, getAccessToken, @@ -314,6 +315,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, output: result.output }) } catch (error) { const message = toError(error).message + + /** + * The token exchange runs before the operation dispatch, so without this a + * bad client ID or secret (Falcon 401) reaches the caller as a 500. + */ + if (error instanceof CrowdStrikeAuthError) { + logger.warn('CrowdStrike authentication failed', { error: message, status: error.status }) + return NextResponse.json({ success: false, error: message }, { status: error.status }) + } + logger.error('CrowdStrike request failed', { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } diff --git a/apps/sim/app/api/tools/mssql/delete/route.ts b/apps/sim/app/api/tools/mssql/delete/route.ts index 7864b9e421a..44aab52a61b 100644 --- a/apps/sim/app/api/tools/mssql/delete/route.ts +++ b/apps/sim/app/api/tools/mssql/delete/route.ts @@ -6,7 +6,12 @@ import { mssqlDeleteContract } from '@/lib/api/contracts/tools/databases/mssql' import { parseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { buildDeleteQuery, createMSSQLConnection, executeQuery } from '@/app/api/tools/mssql/utils' +import { + buildDeleteQuery, + createMSSQLConnection, + executeQuery, + toRowsResponseBody, +} from '@/app/api/tools/mssql/utils' const logger = createLogger('MSSQLDeleteAPI') @@ -28,19 +33,33 @@ export const POST = withRouteHandler(async (request: NextRequest) => { `[${requestId}] Deleting data from ${params.table} on ${params.host}:${params.port}/${params.database}` ) + /** + * Built before connecting so a rejected WHERE clause or a bad identifier + * costs no TLS+login round trip and answers 400 like the query and execute + * routes, rather than falling through to the catch-all as a 500. + */ + let built: { query: string; values: unknown[] } + try { + built = buildDeleteQuery(params.table, params.where) + } catch (error) { + const message = getErrorMessage(error, 'Invalid statement') + logger.warn(`[${requestId}] Delete statement rejected: ${message}`) + return NextResponse.json( + { error: `Microsoft SQL Server delete failed: ${message}` }, + { status: 400 } + ) + } + const pool = await createMSSQLConnection(params) try { - const { query, values } = buildDeleteQuery(params.table, params.where) - const result = await executeQuery(pool, query, values) + const result = await executeQuery(pool, built.query, built.values) logger.info(`[${requestId}] Delete executed successfully, ${result.rowCount} row(s) deleted`) - return NextResponse.json({ - message: `Data deleted successfully. ${result.rowCount} row(s) affected.`, - rows: result.rows, - rowCount: result.rowCount, - }) + return NextResponse.json( + toRowsResponseBody(result, `Data deleted successfully. ${result.rowCount} row(s) affected.`) + ) } finally { await pool.close() } diff --git a/apps/sim/app/api/tools/mssql/execute/route.ts b/apps/sim/app/api/tools/mssql/execute/route.ts index 145a1dd8358..43bf87198b6 100644 --- a/apps/sim/app/api/tools/mssql/execute/route.ts +++ b/apps/sim/app/api/tools/mssql/execute/route.ts @@ -6,7 +6,12 @@ import { mssqlExecuteContract } from '@/lib/api/contracts/tools/databases/mssql' import { parseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createMSSQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mssql/utils' +import { + createMSSQLConnection, + executeQuery, + toRowsResponseBody, + validateQuery, +} from '@/app/api/tools/mssql/utils' const logger = createLogger('MSSQLExecuteAPI') @@ -44,11 +49,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] T-SQL executed successfully, ${result.rowCount} row(s) affected`) - return NextResponse.json({ - message: `SQL executed successfully. ${result.rowCount} row(s) affected.`, - rows: result.rows, - rowCount: result.rowCount, - }) + return NextResponse.json( + toRowsResponseBody(result, `SQL executed successfully. ${result.rowCount} row(s) affected.`) + ) } finally { await pool.close() } diff --git a/apps/sim/app/api/tools/mssql/insert/route.ts b/apps/sim/app/api/tools/mssql/insert/route.ts index 7ac25679902..6280ce22e77 100644 --- a/apps/sim/app/api/tools/mssql/insert/route.ts +++ b/apps/sim/app/api/tools/mssql/insert/route.ts @@ -6,7 +6,12 @@ import { mssqlInsertContract } from '@/lib/api/contracts/tools/databases/mssql' import { parseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { buildInsertQuery, createMSSQLConnection, executeQuery } from '@/app/api/tools/mssql/utils' +import { + buildInsertQuery, + createMSSQLConnection, + executeQuery, + toRowsResponseBody, +} from '@/app/api/tools/mssql/utils' const logger = createLogger('MSSQLInsertAPI') @@ -28,19 +33,36 @@ export const POST = withRouteHandler(async (request: NextRequest) => { `[${requestId}] Inserting data into ${params.table} on ${params.host}:${params.port}/${params.database}` ) + /** + * Built before connecting so a bad identifier costs no TLS+login round trip + * and answers 400 like the update, delete, query, and execute routes, rather + * than falling through to the catch-all as a 500. + */ + let built: { query: string; values: unknown[] } + try { + built = buildInsertQuery(params.table, params.data) + } catch (error) { + const message = getErrorMessage(error, 'Invalid statement') + logger.warn(`[${requestId}] Insert statement rejected: ${message}`) + return NextResponse.json( + { error: `Microsoft SQL Server insert failed: ${message}` }, + { status: 400 } + ) + } + const pool = await createMSSQLConnection(params) try { - const { query, values } = buildInsertQuery(params.table, params.data) - const result = await executeQuery(pool, query, values) + const result = await executeQuery(pool, built.query, built.values) logger.info(`[${requestId}] Insert executed successfully, ${result.rowCount} row(s) inserted`) - return NextResponse.json({ - message: `Data inserted successfully. ${result.rowCount} row(s) affected.`, - rows: result.rows, - rowCount: result.rowCount, - }) + return NextResponse.json( + toRowsResponseBody( + result, + `Data inserted successfully. ${result.rowCount} row(s) affected.` + ) + ) } finally { await pool.close() } diff --git a/apps/sim/app/api/tools/mssql/query/route.ts b/apps/sim/app/api/tools/mssql/query/route.ts index ab26602f155..ba0a1a1848b 100644 --- a/apps/sim/app/api/tools/mssql/query/route.ts +++ b/apps/sim/app/api/tools/mssql/query/route.ts @@ -9,6 +9,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createMSSQLConnection, executeQuery, + toRowsResponseBody, validateReadOnlyQuery, } from '@/app/api/tools/mssql/utils' @@ -48,11 +49,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] Query executed successfully, returned ${result.rowCount} rows`) - return NextResponse.json({ - message: `Query executed successfully. ${result.rowCount} row(s) returned.`, - rows: result.rows, - rowCount: result.rowCount, - }) + return NextResponse.json( + toRowsResponseBody( + result, + `Query executed successfully. ${result.rowCount} row(s) returned.` + ) + ) } finally { await pool.close() } diff --git a/apps/sim/app/api/tools/mssql/route-guards.test.ts b/apps/sim/app/api/tools/mssql/route-guards.test.ts new file mode 100644 index 00000000000..5d57ef4149b --- /dev/null +++ b/apps/sim/app/api/tools/mssql/route-guards.test.ts @@ -0,0 +1,94 @@ +/** + * @vitest-environment node + * + * The insert, update, and delete routes build their statement before opening a + * connection, so a rejected WHERE clause or a bad identifier costs no TLS+login + * round trip and answers 400 like the query and execute routes do. + */ +import { createMockRequest, hybridAuthMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockResolveHostAddresses, mockConnectionPool, mockQuery } = vi.hoisted(() => { + const query = vi.fn().mockResolvedValue({ recordset: [], rowsAffected: [1] }) + const pool = vi.fn(function ConnectionPool(this: Record) { + this.connect = vi.fn().mockResolvedValue(undefined) + this.close = vi.fn().mockResolvedValue(undefined) + this.request = () => ({ input: vi.fn(), query }) + }) + return { mockResolveHostAddresses: vi.fn(), mockConnectionPool: pool, mockQuery: query } +}) + +vi.mock('mssql', () => ({ + default: { ConnectionPool: mockConnectionPool }, + ConnectionPool: mockConnectionPool, +})) + +vi.mock('@sim/security/dns', () => ({ + resolveHostAddresses: mockResolveHostAddresses, + preferIpv4: (addresses: string[]) => addresses[0], +})) + +import { POST as DELETE_POST } from '@/app/api/tools/mssql/delete/route' +import { POST as INSERT_POST } from '@/app/api/tools/mssql/insert/route' +import { POST as UPDATE_POST } from '@/app/api/tools/mssql/update/route' + +const connection = { + host: 'db.example.com', + port: 1433, + database: 'app', + username: 'app', + password: 'secret', + encrypt: 'enabled', + trustServerCertificate: 'disabled', + connectionTimeout: 15000, +} + +describe('MSSQL insert, update, and delete guards run before connecting', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-123', + authType: 'internal_jwt', + }) + mockResolveHostAddresses.mockResolvedValue({ addresses: ['93.184.216.34'], isPrivate: false }) + mockQuery.mockResolvedValue({ recordset: [], rowsAffected: [1] }) + }) + + it.each([ + ['update', UPDATE_POST, { table: 'users', data: { a: 1 }, where: 'id = 1 OR 1=1' }], + ['delete', DELETE_POST, { table: 'users', where: 'id = 1 OR 1=1' }], + ])( + 'answers 400 for a rejected WHERE clause on %s without connecting', + async (_op, handler, body) => { + const response = await handler(createMockRequest('POST', { ...connection, ...body })) + + expect(response.status).toBe(400) + expect(mockConnectionPool).not.toHaveBeenCalled() + } + ) + + it.each([ + ['insert', INSERT_POST, { table: 'users-table', data: { a: 1 } }], + ['insert column', INSERT_POST, { table: 'users', data: { 'bad-col': 1 } }], + ['update', UPDATE_POST, { table: 'users-table', data: { a: 1 }, where: 'id = 1' }], + ['delete', DELETE_POST, { table: 'users-table', where: 'id = 1' }], + ])('answers 400 for a bad identifier on %s without connecting', async (_op, handler, body) => { + const response = await handler(createMockRequest('POST', { ...connection, ...body })) + + expect(response.status).toBe(400) + expect(mockConnectionPool).not.toHaveBeenCalled() + }) + + it.each([ + ['insert', INSERT_POST, { table: 'users', data: { a: 1 } }], + ['update', UPDATE_POST, { table: 'users', data: { a: 1 }, where: 'id = 1' }], + ['delete', DELETE_POST, { table: 'users', where: 'id = 1' }], + ])('still runs an accepted %s statement', async (_op, handler, body) => { + const response = await handler(createMockRequest('POST', { ...connection, ...body })) + + expect(response.status).toBe(200) + expect(mockConnectionPool).toHaveBeenCalledTimes(1) + expect(mockQuery).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/app/api/tools/mssql/update/route.ts b/apps/sim/app/api/tools/mssql/update/route.ts index 7bd78844b2e..392aa3d5d1e 100644 --- a/apps/sim/app/api/tools/mssql/update/route.ts +++ b/apps/sim/app/api/tools/mssql/update/route.ts @@ -6,7 +6,12 @@ import { mssqlUpdateContract } from '@/lib/api/contracts/tools/databases/mssql' import { parseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { buildUpdateQuery, createMSSQLConnection, executeQuery } from '@/app/api/tools/mssql/utils' +import { + buildUpdateQuery, + createMSSQLConnection, + executeQuery, + toRowsResponseBody, +} from '@/app/api/tools/mssql/utils' const logger = createLogger('MSSQLUpdateAPI') @@ -28,19 +33,33 @@ export const POST = withRouteHandler(async (request: NextRequest) => { `[${requestId}] Updating data in ${params.table} on ${params.host}:${params.port}/${params.database}` ) + /** + * Built before connecting so a rejected WHERE clause or a bad identifier + * costs no TLS+login round trip and answers 400 like the query and execute + * routes, rather than falling through to the catch-all as a 500. + */ + let built: { query: string; values: unknown[] } + try { + built = buildUpdateQuery(params.table, params.data, params.where) + } catch (error) { + const message = getErrorMessage(error, 'Invalid statement') + logger.warn(`[${requestId}] Update statement rejected: ${message}`) + return NextResponse.json( + { error: `Microsoft SQL Server update failed: ${message}` }, + { status: 400 } + ) + } + const pool = await createMSSQLConnection(params) try { - const { query, values } = buildUpdateQuery(params.table, params.data, params.where) - const result = await executeQuery(pool, query, values) + const result = await executeQuery(pool, built.query, built.values) logger.info(`[${requestId}] Update executed successfully, ${result.rowCount} row(s) updated`) - return NextResponse.json({ - message: `Data updated successfully. ${result.rowCount} row(s) affected.`, - rows: result.rows, - rowCount: result.rowCount, - }) + return NextResponse.json( + toRowsResponseBody(result, `Data updated successfully. ${result.rowCount} row(s) affected.`) + ) } finally { await pool.close() } diff --git a/apps/sim/app/api/tools/mssql/utils.test.ts b/apps/sim/app/api/tools/mssql/utils.test.ts index c29ff99f63f..f2e4b8df2f0 100644 --- a/apps/sim/app/api/tools/mssql/utils.test.ts +++ b/apps/sim/app/api/tools/mssql/utils.test.ts @@ -38,8 +38,10 @@ import { buildInsertQuery, buildUpdateQuery, createMSSQLConnection, + executeIntrospect, executeQuery, type MSSQLConnectionConfig, + toRowsResponseBody, validateQuery, validateReadOnlyQuery, } from '@/app/api/tools/mssql/utils' @@ -388,3 +390,283 @@ describe('createMSSQLConnection DNS pinning', () => { expect(config.options.trustServerCertificate).toBe(true) }) }) + +describe('read-only screens cover the rest of the session and transaction family', () => { + /** + * Each is a valid semicolon-less second statement, and the file's stated rule + * is that a second statement is rejected structurally rather than by what it + * happens to do. + */ + it.each([ + ['SAVE TRANSACTION', 'SELECT 1 SAVE TRANSACTION sp1'], + ['SAVE TRAN', 'SELECT 1 SAVE TRAN sp1'], + ['OPEN SYMMETRIC KEY', 'SELECT 1 OPEN SYMMETRIC KEY k DECRYPTION BY CERTIFICATE c'], + ['OPEN MASTER KEY', "SELECT 1 OPEN MASTER KEY DECRYPTION BY PASSWORD = 'p'"], + ['CLOSE ALL SYMMETRIC KEYS', 'SELECT 1 CLOSE ALL SYMMETRIC KEYS'], + ['CLOSE MASTER KEY', 'SELECT 1 CLOSE MASTER KEY'], + ['DEALLOCATE', 'SELECT 1 DEALLOCATE cur'], + ['ADD SIGNATURE', 'SELECT 1 ADD SIGNATURE TO dbo.p BY CERTIFICATE c'], + ['RAISERROR WITH LOG', "SELECT 1 RAISERROR ('boom', 16, 1) WITH LOG"], + ])('rejects %s in the Query operation', (_label, query) => { + expect(validateReadOnlyQuery(query).isValid).toBe(false) + }) + + it.each([ + ['SAVE TRANSACTION', 'id = 1 SAVE TRANSACTION sp1'], + ['OPEN SYMMETRIC KEY', 'id = 1 OPEN SYMMETRIC KEY k DECRYPTION BY CERTIFICATE c'], + ['CLOSE ALL SYMMETRIC KEYS', 'id = 1 CLOSE ALL SYMMETRIC KEYS'], + ['DEALLOCATE', 'id = 1 DEALLOCATE cur'], + ['ADD SIGNATURE', 'id = 1 ADD SIGNATURE TO dbo.p BY CERTIFICATE c'], + ['RAISERROR WITH LOG', "id = 1 RAISERROR ('boom', 16, 1) WITH LOG"], + ])('rejects %s in an update or delete WHERE clause', (_label, where) => { + expect(() => buildUpdateQuery('t', { a: 1 }, where)).toThrow() + expect(() => buildDeleteQuery('t', where)).toThrow() + }) + + /** + * The over-screening guard. `open`, `close`, `save`, and `add` are ordinary + * column names (a price table has all four), so a bare-word screen would make + * the plain SELECTs this operation exists to run un-runnable. + */ + it('still accepts ordinary identifiers that start with a screened phrase word', () => { + const allowed = [ + 'SELECT open, close, high, low FROM dbo.prices', + 'SELECT close FROM dbo.prices WHERE open > 10', + 'SELECT save_id, add_on, open_date, close_date FROM dbo.orders', + 'SELECT o.open, o.close FROM dbo.ohlc o ORDER BY o.open DESC', + ] + + for (const query of allowed) { + expect(validateReadOnlyQuery(query)).toEqual({ isValid: true }) + } + + expect(() => buildUpdateQuery('prices', { close: 2 }, 'open > 10')).not.toThrow() + expect(() => buildDeleteQuery('prices', 'close < 1 AND open_date > 0')).not.toThrow() + }) +}) + +describe('read-only screens cover RENAME and the Service Broker statement family', () => { + /** + * `RENAME` is documented T-SQL DDL for Azure Synapse dedicated SQL pools and + * Analytics Platform System, both reachable over TDS with the connection + * fields this block exposes — so a schema change was passing an operation + * advertised as read-only. + */ + it.each([ + ['RENAME OBJECT', 'SELECT 1 RENAME OBJECT dbo.Customer TO Customer1'], + ['RENAME OBJECT COLUMN', 'SELECT 1 RENAME OBJECT dbo.t COLUMN c1 TO c2'], + ['RENAME DATABASE', 'SELECT 1 RENAME DATABASE db1 TO db2'], + ['RECEIVE', 'SELECT 1 RECEIVE TOP(1) * FROM dbo.MyQueue'], + ['END CONVERSATION', "SELECT 1 END CONVERSATION '00000000-0000-0000-0000-000000000000'"], + [ + 'MOVE CONVERSATION', + "SELECT 1 MOVE CONVERSATION '00000000-0000-0000-0000-000000000000' TO '00000000-0000-0000-0000-000000000001'", + ], + ['GET CONVERSATION GROUP', 'SELECT 1 GET CONVERSATION GROUP @g FROM dbo.MyQueue'], + [ + 'SEND ON CONVERSATION', + "SELECT 1 SEND ON CONVERSATION '00000000-0000-0000-0000-000000000000' MESSAGE TYPE [t] ('x')", + ], + ])('rejects %s in the Query operation', (_label, query) => { + expect(validateReadOnlyQuery(query).isValid).toBe(false) + }) + + it.each([ + ['RENAME OBJECT', 'id = 1 RENAME OBJECT dbo.t TO t2'], + ['RECEIVE', 'id = 1 RECEIVE TOP(1) * FROM dbo.MyQueue'], + ['END CONVERSATION', "id = 1 END CONVERSATION '00000000-0000-0000-0000-000000000000'"], + ['GET CONVERSATION GROUP', 'id = 1 GET CONVERSATION GROUP @g FROM dbo.MyQueue'], + ])('rejects %s in an update or delete WHERE clause', (_label, where) => { + expect(() => buildUpdateQuery('t', { a: 1 }, where)).toThrow() + expect(() => buildDeleteQuery('t', where)).toThrow() + }) + + /** + * The over-screening guard. `END` closes every `CASE`, and `rename`/`receive` + * are the stems of ordinary column names, so neither addition may cost the + * plain SELECTs this operation exists to run. + */ + it('still accepts CASE … END and ordinary identifiers built on the new words', () => { + const allowed = [ + "SELECT CASE WHEN status = 1 THEN 'on' ELSE 'off' END FROM dbo.jobs", + "SELECT CASE WHEN a = 1 THEN 'x' END AS conversation_state FROM dbo.t", + 'SELECT renamed_at, rename_log, received_at, receive_queue FROM dbo.audit', + 'SELECT conversation_id, get_flag, move_order, send_at, end_date FROM dbo.t', + ] + + for (const query of allowed) { + expect(validateReadOnlyQuery(query)).toEqual({ isValid: true }) + } + + expect(() => buildUpdateQuery('audit', { a: 1 }, 'renamed_at > 0')).not.toThrow() + expect(() => buildDeleteQuery('audit', 'received_at > 0 AND conversation_id = 3')).not.toThrow() + }) +}) + +describe('executeQuery result caps', () => { + function makeCapPool(recordset: unknown[]) { + return { + request: () => ({ + input: vi.fn(), + query: vi.fn().mockResolvedValue({ recordset, rowsAffected: [0] }), + }), + } as never + } + + it('caps the recordset at the row ceiling and says so', async () => { + const result = await executeQuery( + makeCapPool(Array.from({ length: 10_001 }, (_, i) => ({ i }))), + 'SELECT 1' + ) + + expect(result.rows).toHaveLength(10_000) + expect(result.rowCount).toBe(10_000) + expect(result.truncated).toBe(true) + expect(result.truncationReason).toMatch(/OFFSET/) + }) + + it('caps on bytes even when the row count is small', async () => { + // 20 rows of ~1MB each: well under the row ceiling, well over the byte one. + const fat = Array.from({ length: 20 }, () => ({ blob: 'x'.repeat(1024 * 1024) })) + const result = await executeQuery(makeCapPool(fat), 'SELECT 1') + + expect(result.rows.length).toBeLessThan(20) + expect(result.truncated).toBe(true) + }) + + it('leaves an ordinary result untouched', async () => { + const rows = [{ id: 1 }, { id: 2 }] + const result = await executeQuery(makeCapPool(rows), 'SELECT 1') + + expect(result.rows).toEqual(rows) + expect(result.truncated).toBeUndefined() + expect(result.truncationReason).toBeUndefined() + }) + + it('never serializes past the byte ceiling', async () => { + const fat = Array.from({ length: 20 }, () => ({ blob: 'x'.repeat(1024 * 1024) })) + const result = await executeQuery(makeCapPool(fat), 'SELECT 1') + + expect(JSON.stringify(result.rows).length).toBeLessThanOrEqual(10 * 1024 * 1024) + }) + + it('drops a lone row that is larger than the byte ceiling rather than admitting it', async () => { + const oversized = [{ blob: 'x'.repeat(11 * 1024 * 1024) }] + const result = await executeQuery(makeCapPool(oversized), 'SELECT 1') + + expect(result.rows).toEqual([]) + expect(result.truncated).toBe(true) + expect(result.truncationReason).toMatch(/exceeds the 10 MB response ceiling/) + }) +}) + +describe('toRowsResponseBody truncation disclosure', () => { + it('discloses a truncated result in both the message and machine-readable fields', () => { + const body = toRowsResponseBody( + { + rows: [{ id: 1 }], + rowCount: 1, + truncated: true, + truncationReason: 'Result truncated to 1 row(s): page with OFFSET ... FETCH NEXT.', + }, + 'Query executed successfully. 1 row(s) returned.' + ) + + expect(body.truncated).toBe(true) + expect(body.truncationReason).toMatch(/OFFSET/) + expect(body.message).toBe( + 'Query executed successfully. 1 row(s) returned. Result truncated to 1 row(s): page with OFFSET ... FETCH NEXT.' + ) + }) + + it('leaves a complete result free of truncation fields', () => { + const body = toRowsResponseBody( + { rows: [{ id: 1 }], rowCount: 1 }, + 'Query executed successfully. 1 row(s) returned.' + ) + + expect(body.message).toBe('Query executed successfully. 1 row(s) returned.') + expect(body).not.toHaveProperty('truncated') + expect(body).not.toHaveProperty('truncationReason') + }) +}) + +describe('executeIntrospect issues a fixed number of queries', () => { + const schemas = [{ SCHEMA_NAME: 'dbo' }] + const introspectTables = Array.from({ length: 50 }, (_, i) => ({ + TABLE_NAME: `t${i}`, + TABLE_SCHEMA: 'dbo', + })) + const introspectColumns = introspectTables.flatMap((t) => [ + { + TABLE_NAME: t.TABLE_NAME, + COLUMN_NAME: 'id', + DATA_TYPE: 'int', + IS_NULLABLE: 'NO', + COLUMN_DEFAULT: null, + }, + { + TABLE_NAME: t.TABLE_NAME, + COLUMN_NAME: 'owner_id', + DATA_TYPE: 'int', + IS_NULLABLE: 'YES', + COLUMN_DEFAULT: null, + }, + ]) + const introspectPks = introspectTables.map((t) => ({ + TABLE_NAME: t.TABLE_NAME, + COLUMN_NAME: 'id', + })) + const introspectFks = introspectTables.map((t) => ({ + TABLE_NAME: t.TABLE_NAME, + COLUMN_NAME: 'owner_id', + REFERENCED_TABLE_SCHEMA: 'dbo', + REFERENCED_TABLE_NAME: 'owners', + REFERENCED_COLUMN_NAME: 'id', + })) + const introspectIndexes = introspectTables.map((t) => ({ + TABLE_NAME: t.TABLE_NAME, + INDEX_NAME: `ix_${t.TABLE_NAME}_owner`, + COLUMN_NAME: 'owner_id', + IS_UNIQUE: 0, + })) + + function makeIntrospectPool() { + const query = vi.fn(async (text: string) => { + if (text.includes('FROM sys.schemas s')) return { recordset: schemas } + if (text.includes('INFORMATION_SCHEMA.TABLES')) return { recordset: introspectTables } + if (text.includes('INFORMATION_SCHEMA.COLUMNS')) return { recordset: introspectColumns } + if (text.includes('PRIMARY KEY')) return { recordset: introspectPks } + if (text.includes('sys.foreign_keys')) return { recordset: introspectFks } + if (text.includes('sys.index_columns')) return { recordset: introspectIndexes } + throw new Error(`unexpected query: ${text}`) + }) + return { pool: { request: () => ({ input: vi.fn().mockReturnThis(), query }) } as never, query } + } + + it('does not scale its round trips with the table count', async () => { + // Previously 4 queries per table plus 2: 50 tables meant 202 sequential + // round trips, each under its own request timeout. + const { pool, query } = makeIntrospectPool() + + const result = await executeIntrospect(pool, 'dbo') + + expect(result.tables).toHaveLength(50) + expect(query.mock.calls.length).toBeLessThanOrEqual(6) + }) + + it('still attributes columns, keys, and indexes to the right table', async () => { + const { pool } = makeIntrospectPool() + + const result = await executeIntrospect(pool, 'dbo') + const table = result.tables.find((t) => t.name === 't7')! + + expect(table.schema).toBe('dbo') + expect(table.columns.map((c) => c.name)).toEqual(['id', 'owner_id']) + expect(table.primaryKey).toEqual(['id']) + expect(table.columns[0].isPrimaryKey).toBe(true) + expect(table.columns[1].isForeignKey).toBe(true) + expect(table.columns[1].references).toEqual({ schema: 'dbo', table: 'owners', column: 'id' }) + expect(table.indexes).toEqual([{ name: 'ix_t7_owner', columns: ['owner_id'], unique: false }]) + }) +}) diff --git a/apps/sim/app/api/tools/mssql/utils.ts b/apps/sim/app/api/tools/mssql/utils.ts index e8cd95d0961..4a4d2336da4 100644 --- a/apps/sim/app/api/tools/mssql/utils.ts +++ b/apps/sim/app/api/tools/mssql/utils.ts @@ -124,6 +124,10 @@ export async function createMSSQLConnection( export interface MSSQLQueryResult { rows: unknown[] rowCount: number + /** Set when the recordset hit a row or byte ceiling and rows were dropped. */ + truncated?: boolean + /** Human-readable explanation of the ceiling that was hit. */ + truncationReason?: string } /** @@ -145,6 +149,49 @@ function toBindableValue(value: unknown): unknown { return JSON.stringify(value) } +/** + * Ceilings on what a single statement may materialize into the response. + * + * The driver buffers the whole recordset before `request.query` resolves, and + * the route then serializes it into a JSON body, so an unbounded `SELECT` over a + * large table is held in memory twice. A caller who wants more pages it with + * `OFFSET ... FETCH NEXT`. The byte ceiling exists because row count alone does + * not bound size — 1,000 rows of `nvarchar(max)` is not a small result. + */ +const MSSQL_MAX_RESULT_ROWS = 10_000 +const MSSQL_MAX_RESULT_BYTES = 10 * 1024 * 1024 + +/** + * Truncates a recordset to the row and byte ceilings. + * + * Measures with `JSON.stringify` on each row because that is what the route will + * do anyway, so the number bounds the response the caller actually receives + * rather than an in-memory estimate that does not correspond to it. + * + * A row is admitted only when it still fits, so a single row larger than the + * byte ceiling is dropped rather than admitted as a lone exception — otherwise + * `SELECT` of one `nvarchar(max)` value would serialize an unbounded body and + * the ceiling would bound everything except the case it exists for. The drop is + * disclosed through {@link MSSQLQueryResult.truncationReason}, so an empty + * recordset is never mistaken for an empty table. + */ +function capRecordset(rows: unknown[]): { rows: unknown[]; truncated: boolean } { + if (rows.length === 0) return { rows, truncated: false } + + const capped: unknown[] = [] + let bytes = 0 + + for (const row of rows) { + if (capped.length >= MSSQL_MAX_RESULT_ROWS) break + const rowBytes = JSON.stringify(row)?.length ?? 0 + if (bytes + rowBytes > MSSQL_MAX_RESULT_BYTES) break + bytes += rowBytes + capped.push(row) + } + + return { rows: capped, truncated: capped.length < rows.length } +} + /** * Runs a statement with positional values bound as `@param1`, `@param2`, … . * @@ -164,7 +211,7 @@ export async function executeQuery( }) const result = await request.query(query) - const rows: unknown[] = result.recordset ?? [] + const { rows, truncated } = capRecordset(result.recordset ?? []) const affected = (result.rowsAffected ?? []).reduce( (total: number, count: number) => total + count, 0 @@ -173,6 +220,34 @@ export async function executeQuery( return { rows, rowCount: rows.length > 0 ? rows.length : affected, + ...(truncated && { + truncated: true, + truncationReason: + rows.length === 0 + ? `No rows returned: the first row alone exceeds the ${MSSQL_MAX_RESULT_BYTES / (1024 * 1024)} MB response ceiling. Select fewer columns, or slice large values with SUBSTRING.` + : `Result truncated to ${rows.length} row(s): a single statement returns at most ${MSSQL_MAX_RESULT_ROWS} rows or ${MSSQL_MAX_RESULT_BYTES / (1024 * 1024)} MB. Page with OFFSET ... FETCH NEXT to read the rest.`, + }), + } +} + +/** + * Builds the success body every statement route returns. + * + * A truncated recordset is disclosed twice on purpose: folded into `message`, so + * an agent that reads only the status line still learns rows were dropped, and + * as `truncated`/`truncationReason`, so a caller can branch on it without + * parsing prose. Without this the route reported a capped result as a complete + * one and paging looked unnecessary. + */ +export function toRowsResponseBody(result: MSSQLQueryResult, message: string) { + return { + message: result.truncationReason ? `${message} ${result.truncationReason}` : message, + rows: result.rows, + rowCount: result.rowCount, + ...(result.truncated && { + truncated: true, + truncationReason: result.truncationReason, + }), } } @@ -187,7 +262,11 @@ export async function executeQuery( * `DISABLE`/`ENABLE` are here because `SELECT 1 DISABLE TRIGGER dbo.audit ON * dbo.users` is a valid semicolon-less batch that turns auditing off, and * `SET`/`BEGIN`/`COMMIT`/`ROLLBACK` because session and transaction state are - * changed the same way (`SET IDENTITY_INSERT`, `SET ANSI_NULLS`). + * changed the same way (`SET IDENTITY_INSERT`, `SET ANSI_NULLS`). The rest of + * that family — `SAVE TRANSACTION`, the symmetric/master key statements, + * `ADD SIGNATURE`, and `RAISERROR ... WITH LOG` — opens with a word that is also + * an ordinary identifier, so it is screened as a two-token phrase in + * {@link MSSQL_STATEMENT_PHRASES} instead. * * The text statements `UPDATETEXT`, `WRITETEXT`, and `READTEXT` are listed in * their own right rather than left to `update`: there is no word boundary after @@ -197,6 +276,20 @@ export async function executeQuery( * but it introduces a second statement in exactly the same semicolon-less way, * which is what this list exists to reject. * + * `RENAME` is documented T-SQL DDL — it applies to Azure Synapse Analytics + * dedicated SQL pools and Analytics Platform System, both of which speak TDS on + * port 1433 and are reachable with exactly the connection fields this block + * exposes. `SELECT 1 RENAME OBJECT dbo.Customer TO Customer1` is a valid + * semicolon-less batch that changes schema through an operation advertised as + * read-only, and `RENAME DATABASE` and `RENAME OBJECT … COLUMN … TO …` reach it + * the same way. + * + * `RECEIVE` is the Service Broker read that *removes* the messages it returns, + * so it is a write in everything but name. Its siblings — `END`/`MOVE`/`GET` + * `CONVERSATION` and `SEND ON CONVERSATION` — open with words that are ordinary + * identifiers (`END` closes every `CASE`), so they are screened as phrases in + * {@link MSSQL_STATEMENT_PHRASES} instead. + * * `FETCH` is deliberately **absent**: `OFFSET … FETCH NEXT` is the standard * T-SQL paging clause, so screening it would reject the ordinary paged SELECT * this operation exists to run. Word boundaries keep the additions off ordinary @@ -204,7 +297,50 @@ export async function executeQuery( * @see https://learn.microsoft.com/en-us/sql/t-sql/statements/statements */ const MSSQL_STATEMENT_KEYWORDS = - /\b(?:insert|update|updatetext|writetext|readtext|delete|merge|drop|create|alter|truncate|disable|enable|set|begin|commit|rollback|grant|revoke|deny|exec|execute|backup|restore|shutdown|reconfigure|dbcc|kill|checkpoint|use|bulk|revert|setuser|openrowset|opendatasource|openquery|openxml|waitfor|into)\b/i + /\b(?:insert|update|updatetext|writetext|readtext|delete|merge|drop|create|alter|truncate|rename|receive|disable|enable|set|begin|commit|rollback|grant|revoke|deny|exec|execute|backup|restore|shutdown|reconfigure|dbcc|kill|checkpoint|use|bulk|revert|setuser|openrowset|opendatasource|openquery|openxml|waitfor|into|deallocate)\b/i + +/** + * The remaining session, transaction, cursor, and key-management statements, + * every one of which is a valid semicolon-less second statement the single-word + * list above cannot carry. + * + * Each is matched as a **two-token** phrase rather than a bare word, because the + * leading words are ordinary identifiers: `open` and `close` are columns in any + * price table, `save` and `add` are common verbs, and `END` closes every `CASE`. + * Screening those bare would reject the plain SELECTs this operation exists to + * run. `DEALLOCATE` is the one exception and lives in the word list above — it + * has no ordinary-identifier reading. + * + * Most of these write neither table data nor schema, which is why they were + * missed; they are screened because the file's stated rule is that a second + * statement is rejected structurally, not by what it happens to do. + * `RAISERROR ... WITH LOG` writes to the error log and the Windows application + * log, so it is not inert. The Service Broker conversation statements are not + * inert either: `END CONVERSATION ... WITH CLEANUP` drops every message in a + * conversation, `MOVE CONVERSATION` reassigns it, and `SEND ON CONVERSATION` + * enqueues a message — and the handles they need are enumerable through this + * same path, because the catalog screen applies only to WHERE clauses. + * @see https://learn.microsoft.com/en-us/sql/t-sql/statements/end-conversation-transact-sql + * @see https://learn.microsoft.com/en-us/sql/t-sql/statements/statements + */ +const MSSQL_STATEMENT_PHRASES: readonly RegExp[] = [ + /\bsave\s+tran(?:saction)?\b/i, + /\bopen\s+(?:symmetric|master)\s+key\b/i, + /\bclose\s+(?:all\s+symmetric\s+keys|master\s+key|symmetric\s+key)\b/i, + /\badd\s+signature\b/i, + /\braiserror[\s\S]*?\bwith\s+log\b/i, + /\b(?:end|move|get)\s+conversation\b/i, + /\bsend\s+on\s+conversation\b/i, +] + +/** Matches the first screened statement phrase, or `null`. */ +function matchStatementPhrase(masked: string): string | null { + for (const pattern of MSSQL_STATEMENT_PHRASES) { + const match = pattern.exec(masked) + if (match) return match[0] + } + return null +} /** Extended, OLE-automation, and system stored procedures, called with or without `EXEC`. */ const MSSQL_PROCEDURE_PATTERN = /\b(?:xp_|sp_)\w+/i @@ -355,11 +491,14 @@ export function validateReadOnlyQuery(query: string): { isValid: boolean; error? } } - const disallowed = MSSQL_STATEMENT_KEYWORDS.exec(masked) ?? MSSQL_PROCEDURE_PATTERN.exec(masked) + const disallowed = + MSSQL_STATEMENT_KEYWORDS.exec(masked)?.[0] ?? + MSSQL_PROCEDURE_PATTERN.exec(masked)?.[0] ?? + matchStatementPhrase(masked) if (disallowed) { return { isValid: false, - error: `The Query operation cannot run ${disallowed[0].toUpperCase()}. Use the Execute Raw SQL operation for statements that modify data, schema, or server state.`, + error: `The Query operation cannot run ${disallowed.toUpperCase()}. Use the Execute Raw SQL operation for statements that modify data, schema, or server state.`, } } @@ -519,6 +658,7 @@ function validateWhereClause(where: string): void { const masked = maskSqlStringLiterals(where) if ( MSSQL_STATEMENT_KEYWORDS.test(masked) || + matchStatementPhrase(masked) !== null || MSSQL_PROCEDURE_PATTERN.test(masked) || MSSQL_WHERE_SELECT.test(masked) || MSSQL_WHERE_CONSTANT_TAUTOLOGY.some((pattern) => pattern.test(masked)) || @@ -592,6 +732,7 @@ interface TableRow { } interface ColumnRow { + TABLE_NAME: string COLUMN_NAME: string DATA_TYPE: string IS_NULLABLE: string @@ -599,10 +740,12 @@ interface ColumnRow { } interface KeyColumnRow { + TABLE_NAME: string COLUMN_NAME: string } interface ForeignKeyRow { + TABLE_NAME: string COLUMN_NAME: string REFERENCED_TABLE_SCHEMA: string REFERENCED_TABLE_NAME: string @@ -610,6 +753,7 @@ interface ForeignKeyRow { } interface IndexRow { + TABLE_NAME: string INDEX_NAME: string COLUMN_NAME: string IS_UNIQUE: boolean | number @@ -654,75 +798,142 @@ export async function executeIntrospect( ORDER BY TABLE_NAME` ) + const tableRows = tablesResult.recordset as TableRow[] + if (tableRows.length === 0) return { tables: [], schemas } + + /** + * The column, primary key, foreign key, and index reads below are filtered by + * schema and grouped in memory, rather than run once per table. Per-table they + * were four round trips each — a 500-table schema meant ~2,000 sequential + * queries, every one under its own connection timeout. + */ + const columnsResult = await pool + .request() + .input('schema', schemaName) + .query( + `SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = @schema + ORDER BY TABLE_NAME, ORDINAL_POSITION` + ) + + const pkResult = await pool + .request() + .input('schema', schemaName) + .query( + `SELECT tc.TABLE_NAME, kcu.COLUMN_NAME + FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc + JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu + ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME + AND tc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA + WHERE tc.CONSTRAINT_TYPE = 'PRIMARY KEY' + AND tc.TABLE_SCHEMA = @schema + ORDER BY tc.TABLE_NAME, kcu.ORDINAL_POSITION` + ) + + const fkResult = await pool + .request() + .input('schema', schemaName) + .query( + /** + * Resolved through the catalog views rather than + * `INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS`, which reaches the + * referenced side by joining `TABLE_CONSTRAINTS` — a view that returns + * "one row for each table constraint" and so has no row at all when a + * foreign key references a unique *index*, silently dropping the key. + * The catalog views resolve the referenced table and column by ID. + * @see https://learn.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-foreign-key-columns-transact-sql + * @see https://learn.microsoft.com/en-us/sql/relational-databases/system-information-schema-views/table-constraints-transact-sql + */ + `SELECT + pt.name AS TABLE_NAME, + pc.name AS COLUMN_NAME, + rs.name AS REFERENCED_TABLE_SCHEMA, + rt.name AS REFERENCED_TABLE_NAME, + rc.name AS REFERENCED_COLUMN_NAME + FROM sys.foreign_keys fk + JOIN sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id + JOIN sys.tables pt ON pt.object_id = fk.parent_object_id + JOIN sys.schemas ps ON ps.schema_id = pt.schema_id + JOIN sys.columns pc + ON pc.object_id = fkc.parent_object_id AND pc.column_id = fkc.parent_column_id + JOIN sys.tables rt ON rt.object_id = fkc.referenced_object_id + JOIN sys.schemas rs ON rs.schema_id = rt.schema_id + JOIN sys.columns rc + ON rc.object_id = fkc.referenced_object_id AND rc.column_id = fkc.referenced_column_id + WHERE ps.name = @schema + ORDER BY pt.name, fk.name, fkc.constraint_column_id` + ) + + const indexResult = await pool + .request() + .input('schema', schemaName) + .query( + /** + * `key_ordinal > 0` restricts the result to key columns: it is the + * "ordinal (1-based) within set of key-columns", and `0` marks INCLUDEd + * non-key columns, partitioning columns, **and every column of an XML, + * spatial, columnstore, or JSON index**. The partitioning columns are + * why `is_included_column` alone is not enough — those report `0` for it + * too. The index families are the cost of the filter: they contribute no + * key column, so they are absent from the result rather than listed with + * an empty column set. Rowstore keys, which is what a query planner + * reader is after, are reported in full. + * + * `is_hypothetical = 0` drops the statistics-only indexes the Database + * Engine Tuning Advisor leaves behind ("can't be used directly as a data + * access path"), and `is_disabled = 0` drops indexes that exist but are + * not maintained. Reporting either as a live index misleads. + * + * `is_primary_key = 0` keeps the primary key out, since `primaryKey` + * carries it already. A UNIQUE *constraint* is deliberately left in: it + * is a unique index and nothing else in the result reports it. + * @see https://learn.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-index-columns-transact-sql + * @see https://learn.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-indexes-transact-sql + */ + `SELECT t.name AS TABLE_NAME, i.name AS INDEX_NAME, c.name AS COLUMN_NAME, + i.is_unique AS IS_UNIQUE + FROM sys.indexes i + JOIN sys.index_columns ic + ON i.object_id = ic.object_id AND i.index_id = ic.index_id + JOIN sys.columns c + ON ic.object_id = c.object_id AND ic.column_id = c.column_id + JOIN sys.tables t ON i.object_id = t.object_id + JOIN sys.schemas s ON t.schema_id = s.schema_id + WHERE s.name = @schema + AND i.is_primary_key = 0 + AND i.is_hypothetical = 0 + AND i.is_disabled = 0 + AND i.name IS NOT NULL + AND ic.key_ordinal > 0 + ORDER BY t.name, i.name, ic.key_ordinal` + ) + + /** Groups rows by their `TABLE_NAME`, preserving each group's server order. */ + function groupByTable(rows: TRow[]): Map { + const grouped = new Map() + for (const row of rows) { + const existing = grouped.get(row.TABLE_NAME) + if (existing) existing.push(row) + else grouped.set(row.TABLE_NAME, [row]) + } + return grouped + } + + const columnsByTable = groupByTable(columnsResult.recordset as ColumnRow[]) + const pkByTable = groupByTable(pkResult.recordset as KeyColumnRow[]) + const fkByTable = groupByTable(fkResult.recordset as ForeignKeyRow[]) + const indexRowsByTable = groupByTable(indexResult.recordset as IndexRow[]) + const tables: MSSQLIntrospectionResult['tables'] = [] - for (const tableRow of tablesResult.recordset as TableRow[]) { + for (const tableRow of tableRows) { const tableName = tableRow.TABLE_NAME const tableSchema = tableRow.TABLE_SCHEMA - const columnsResult = await pool - .request() - .input('schema', tableSchema) - .input('table', tableName) - .query( - `SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT - FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table - ORDER BY ORDINAL_POSITION` - ) - - const pkResult = await pool - .request() - .input('schema', tableSchema) - .input('table', tableName) - .query( - `SELECT kcu.COLUMN_NAME - FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc - JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu - ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME - AND tc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA - WHERE tc.CONSTRAINT_TYPE = 'PRIMARY KEY' - AND tc.TABLE_SCHEMA = @schema - AND tc.TABLE_NAME = @table - ORDER BY kcu.ORDINAL_POSITION` - ) - const primaryKeyColumns = pkResult.recordset.map((row: KeyColumnRow) => row.COLUMN_NAME) - - const fkResult = await pool - .request() - .input('schema', tableSchema) - .input('table', tableName) - .query( - /** - * Resolved through the catalog views rather than - * `INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS`, which reaches the - * referenced side by joining `TABLE_CONSTRAINTS` — a view that returns - * "one row for each table constraint" and so has no row at all when a - * foreign key references a unique *index*, silently dropping the key. - * The catalog views resolve the referenced table and column by ID. - * @see https://learn.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-foreign-key-columns-transact-sql - * @see https://learn.microsoft.com/en-us/sql/relational-databases/system-information-schema-views/table-constraints-transact-sql - */ - `SELECT - pc.name AS COLUMN_NAME, - rs.name AS REFERENCED_TABLE_SCHEMA, - rt.name AS REFERENCED_TABLE_NAME, - rc.name AS REFERENCED_COLUMN_NAME - FROM sys.foreign_keys fk - JOIN sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id - JOIN sys.tables pt ON pt.object_id = fk.parent_object_id - JOIN sys.schemas ps ON ps.schema_id = pt.schema_id - JOIN sys.columns pc - ON pc.object_id = fkc.parent_object_id AND pc.column_id = fkc.parent_column_id - JOIN sys.tables rt ON rt.object_id = fkc.referenced_object_id - JOIN sys.schemas rs ON rs.schema_id = rt.schema_id - JOIN sys.columns rc - ON rc.object_id = fkc.referenced_object_id AND rc.column_id = fkc.referenced_column_id - WHERE ps.name = @schema AND pt.name = @table - ORDER BY fk.name, fkc.constraint_column_id` - ) - - const foreignKeys = fkResult.recordset.map((row: ForeignKeyRow) => ({ + const primaryKeyColumns = (pkByTable.get(tableName) ?? []).map((row) => row.COLUMN_NAME) + + const foreignKeys = (fkByTable.get(tableName) ?? []).map((row) => ({ column: row.COLUMN_NAME, referencesSchema: row.REFERENCED_TABLE_SCHEMA, referencesTable: row.REFERENCED_TABLE_NAME, @@ -734,53 +945,8 @@ export async function executeIntrospect( if (!fkByColumn.has(fk.column)) fkByColumn.set(fk.column, fk) } - const indexResult = await pool - .request() - .input('schema', tableSchema) - .input('table', tableName) - .query( - /** - * `key_ordinal > 0` restricts the result to key columns: it is the - * "ordinal (1-based) within set of key-columns", and `0` marks INCLUDEd - * non-key columns, partitioning columns, **and every column of an XML, - * spatial, columnstore, or JSON index**. The partitioning columns are - * why `is_included_column` alone is not enough — those report `0` for it - * too. The index families are the cost of the filter: they contribute no - * key column, so they are absent from the result rather than listed with - * an empty column set. Rowstore keys, which is what a query planner - * reader is after, are reported in full. - * - * `is_hypothetical = 0` drops the statistics-only indexes the Database - * Engine Tuning Advisor leaves behind ("can't be used directly as a data - * access path"), and `is_disabled = 0` drops indexes that exist but are - * not maintained. Reporting either as a live index misleads. - * - * `is_primary_key = 0` keeps the primary key out, since `primaryKey` - * carries it already. A UNIQUE *constraint* is deliberately left in: it - * is a unique index and nothing else in the result reports it. - * @see https://learn.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-index-columns-transact-sql - * @see https://learn.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-indexes-transact-sql - */ - `SELECT i.name AS INDEX_NAME, c.name AS COLUMN_NAME, i.is_unique AS IS_UNIQUE - FROM sys.indexes i - JOIN sys.index_columns ic - ON i.object_id = ic.object_id AND i.index_id = ic.index_id - JOIN sys.columns c - ON ic.object_id = c.object_id AND ic.column_id = c.column_id - JOIN sys.tables t ON i.object_id = t.object_id - JOIN sys.schemas s ON t.schema_id = s.schema_id - WHERE s.name = @schema - AND t.name = @table - AND i.is_primary_key = 0 - AND i.is_hypothetical = 0 - AND i.is_disabled = 0 - AND i.name IS NOT NULL - AND ic.key_ordinal > 0 - ORDER BY i.name, ic.key_ordinal` - ) - const indexMap = new Map() - for (const row of indexResult.recordset as IndexRow[]) { + for (const row of indexRowsByTable.get(tableName) ?? []) { const indexName = row.INDEX_NAME if (!indexMap.has(indexName)) { indexMap.set(indexName, { name: indexName, columns: [], unique: Boolean(row.IS_UNIQUE) }) @@ -791,7 +957,7 @@ export async function executeIntrospect( const primaryKeySet = new Set(primaryKeyColumns) - const columns = columnsResult.recordset.map((col: ColumnRow) => { + const columns = (columnsByTable.get(tableName) ?? []).map((col) => { const columnName = col.COLUMN_NAME const fk = fkByColumn.get(columnName) diff --git a/apps/sim/blocks/blocks/cloudflare.ts b/apps/sim/blocks/blocks/cloudflare.ts index 2f3322b0ae5..14a5f60e5ce 100644 --- a/apps/sim/blocks/blocks/cloudflare.ts +++ b/apps/sim/blocks/blocks/cloudflare.ts @@ -43,7 +43,11 @@ const SUBBLOCK_ALIASES: Record> = { create_ruleset: { name: 'rulesetName' }, update_ruleset_rule: { enabled: 'updateRuleEnabled' }, create_rate_limit_rule: { action: 'rateLimitAction' }, - update_rate_limit_rule: { action: 'updateRateLimitAction', enabled: 'updateRuleEnabled' }, + update_rate_limit_rule: { + action: 'updateRateLimitAction', + enabled: 'updateRuleEnabled', + actionParameters: 'rateLimitActionParameters', + }, create_access_application: { type: 'appType', tags: 'accessAppTags' }, update_access_application: { type: 'updateAppType', tags: 'accessAppTags' }, update_access_policy: { decision: 'updatePolicyDecision' }, @@ -121,7 +125,10 @@ export const CloudflareBlock: BlockConfig = { { text: 'List certificate packs for zone', field: 'zoneId', core: true }, { text: ', with status', field: 'certificateStatus' }, ], - get_zone_settings: [{ text: 'Read all settings of zone', field: 'zoneId', core: true }], + get_zone_settings: [ + { text: 'Read settings of zone', field: 'zoneId', core: true }, + { text: ', limited to', field: 'settingIds' }, + ], update_zone_setting: [ { text: 'Set', field: 'settingId', core: true }, { text: 'to', field: 'value' }, @@ -672,7 +679,7 @@ export const CloudflareBlock: BlockConfig = { id: 'priority', title: 'Priority', type: 'short-input', - placeholder: 'MX/SRV priority (e.g., 10)', + placeholder: 'MX/URI priority (e.g., 10)', condition: { field: 'operation', value: 'create_dns_record' }, mode: 'advanced', }, @@ -774,7 +781,7 @@ export const CloudflareBlock: BlockConfig = { id: 'priority', title: 'Priority', type: 'short-input', - placeholder: 'MX/SRV priority (e.g., 10)', + placeholder: 'MX/URI priority (e.g., 10)', condition: { field: 'operation', value: 'update_dns_record' }, mode: 'advanced', }, @@ -873,6 +880,19 @@ export const CloudflareBlock: BlockConfig = { placeholder: 'Enter zone ID', condition: { field: 'operation', value: 'get_zone_settings' }, }, + { + /** + * Cloudflare retired the endpoint that read every setting in one request, + * so this operation reads one setting per request. Naming the settings + * keeps the fan-out to what the workflow actually reads. + */ + id: 'settingIds', + title: 'Settings', + type: 'short-input', + placeholder: 'Comma-separated setting IDs (blank reads the default set)', + condition: { field: 'operation', value: 'get_zone_settings' }, + mode: 'advanced', + }, // Update Zone Setting inputs { @@ -1001,7 +1021,6 @@ Return ONLY the timestamp or relative expression - no explanations, no quotes, n title: 'Metrics', type: 'short-input', placeholder: 'Comma-separated (e.g., queryCount,uncachedCount,responseTimeAvg)', - required: { field: 'operation', value: 'dns_analytics' }, condition: { field: 'operation', value: 'dns_analytics' }, wandConfig: { enabled: true, @@ -1126,7 +1145,7 @@ Return ONLY the filter expression - no explanations, no quotes, no extra text.`, { label: 'Yes - Purge All', id: 'true' }, { label: 'No - Purge Specific', id: 'false' }, ], - value: () => 'true', + value: () => 'false', condition: { field: 'operation', value: 'purge_cache' }, }, { @@ -1135,7 +1154,11 @@ Return ONLY the filter expression - no explanations, no quotes, no extra text.`, type: 'long-input', placeholder: 'Comma-separated URLs (e.g., https://example.com/style.css, https://example.com/app.js)', - condition: { field: 'operation', value: 'purge_cache' }, + condition: { + field: 'operation', + value: 'purge_cache', + and: { field: 'purge_everything', value: 'true', not: true }, + }, wandConfig: { enabled: true, prompt: `Generate a comma-separated list of URLs to purge from Cloudflare's cache based on the user's description. @@ -1155,7 +1178,11 @@ Return ONLY the comma-separated URLs - no explanations, no extra text.`, title: 'Cache Tags', type: 'short-input', placeholder: 'Comma-separated cache tags (Enterprise only)', - condition: { field: 'operation', value: 'purge_cache' }, + condition: { + field: 'operation', + value: 'purge_cache', + and: { field: 'purge_everything', value: 'true', not: true }, + }, mode: 'advanced', }, { @@ -1163,7 +1190,11 @@ Return ONLY the comma-separated URLs - no explanations, no extra text.`, title: 'Hostnames', type: 'short-input', placeholder: 'Comma-separated hostnames (Enterprise only)', - condition: { field: 'operation', value: 'purge_cache' }, + condition: { + field: 'operation', + value: 'purge_cache', + and: { field: 'purge_everything', value: 'true', not: true }, + }, mode: 'advanced', }, { @@ -1171,7 +1202,11 @@ Return ONLY the comma-separated URLs - no explanations, no extra text.`, title: 'URL Prefixes', type: 'short-input', placeholder: 'Comma-separated URL prefixes (Enterprise only)', - condition: { field: 'operation', value: 'purge_cache' }, + condition: { + field: 'operation', + value: 'purge_cache', + and: { field: 'purge_everything', value: 'true', not: true }, + }, mode: 'advanced', }, @@ -1503,7 +1538,7 @@ Return ONLY the expression - no explanations, no quotes around the whole express placeholder: 'Stable reference that survives rule updates', condition: { field: 'operation', - value: ['create_ruleset_rule', 'update_ruleset_rule'], + value: ['create_ruleset_rule', 'update_ruleset_rule', 'update_rate_limit_rule'], }, mode: 'advanced', }, @@ -1521,7 +1556,10 @@ Return ONLY the expression - no explanations, no quotes around the whole express title: 'Logging Configuration', type: 'long-input', placeholder: '{"enabled":true}', - condition: { field: 'operation', value: 'update_ruleset_rule' }, + condition: { + field: 'operation', + value: ['update_ruleset_rule', 'update_rate_limit_rule'], + }, mode: 'advanced', }, { @@ -1591,7 +1629,7 @@ Return ONLY the JSON object - no explanations, no markdown fences.`, enabled: true, prompt: `Generate a comma-separated list of Cloudflare rate limiting counting characteristics from the user's description. -cf.colo.id is mandatory in every list. Include exactly one of ip.src or cf.unique_visitor_id. +cf.colo.id is mandatory in every list. ip.src and cf.unique_visitor_id are mutually exclusive - include at most one, and neither is required. Do not add an IP or visitor characteristic the user did not ask for; a rule keyed on host, path, country, header, cookie or JA3/JA4 alone is valid. Available characteristics: - cf.colo.id (mandatory) @@ -1610,7 +1648,7 @@ Available characteristics: Examples: - "per IP address" -> cf.colo.id,ip.src - "per visitor" -> cf.colo.id,cf.unique_visitor_id -- "per API key header" -> cf.colo.id,ip.src,http.request.headers["x-api-key"] +- "per API key header" -> cf.colo.id,http.request.headers["x-api-key"] - "per country" -> cf.colo.id,ip.src.country Return ONLY the comma-separated list - no explanations, no extra text.`, @@ -1703,6 +1741,39 @@ Return ONLY the comma-separated list - no explanations, no extra text.`, }, mode: 'advanced', }, + { + /** + * A rate limiting rule carries its custom mitigation response here, and + * the update endpoint replaces the rule — so leaving this blank resets + * action_parameters to {} and the rule falls back to Cloudflare's default + * block page. It gets its own id because the WAF control of the same name + * holds a managed-ruleset payload, which is not what this rule takes. + */ + id: 'rateLimitActionParameters', + title: 'Action Parameters', + type: 'long-input', + placeholder: + '{"response":{"status_code":429,"content":"{\\"error\\":\\"rate limited\\"}","content_type":"application/json"}}', + condition: { field: 'operation', value: 'update_rate_limit_rule' }, + wandConfig: { + enabled: true, + prompt: `Generate the JSON action_parameters object for a Cloudflare rate limiting rule from the user's description. + +Only a "block" action takes action_parameters, and only to define a custom response: +{"response":{"status_code":429,"content":"You have been rate limited.","content_type":"text/plain"}} + +status_code must be in the 400-499 range. content_type is one of "text/plain", "text/html", or "application/json". For a JSON body, "content" is the JSON payload as a string: +{"response":{"status_code":429,"content":"{\\"error\\":\\"rate limited\\"}","content_type":"application/json"}} + +Challenge and log actions take no action_parameters - return {} for those. + +Return ONLY the JSON object - no explanations, no markdown fences.`, + placeholder: + 'Describe the mitigation response (e.g., "return a 429 with a JSON error body")...', + generationType: 'json-object', + }, + mode: 'advanced', + }, // Access application inputs { @@ -2556,12 +2627,6 @@ Return ONLY the JSON array - no explanations, no markdown fences.`, result[aliasId] = undefined } - if (result.ttl) result.ttl = Number(result.ttl) - if (result.priority) result.priority = Number(result.priority) - if (result.limit) result.limit = Number(result.limit) - if (result.page) result.page = Number(result.page) - if (result.per_page) result.per_page = Number(result.per_page) - if (result.proxied === 'true') result.proxied = true else if (result.proxied === 'false') result.proxied = false else if (result.proxied === '') result.proxied = undefined @@ -2569,6 +2634,23 @@ Return ONLY the JSON array - no explanations, no markdown fences.`, if (result.purge_everything === 'true') result.purge_everything = true else if (result.purge_everything === 'false') result.purge_everything = false + /** + * `tags`, `hosts`, and `prefixes` are advanced controls, and an advanced + * control serializes on stored value alone — the serializer returns + * `isNonEmptyValue(...)` before it ever evaluates the + * `and: { field: 'purge_everything', not: true }` guard + * (`serializer/index.ts`). So a target typed while purging specific + * content survives the switch to "Purge Everything", and the tool then + * refuses the whole purge over a field the editor no longer renders. + * This mapper is the only layer that can override a stale raw input. + */ + if (operation === 'purge_cache' && result.purge_everything === true) { + result.files = undefined + result.tags = undefined + result.hosts = undefined + result.prefixes = undefined + } + if (result.type === '') result.type = undefined if (result.status === '') result.status = undefined if (result.order === '') result.order = undefined @@ -2583,14 +2665,25 @@ Return ONLY the JSON array - no explanations, no markdown fences.`, if (result.comment === '') result.comment = undefined } + /** + * A blank optional number must reach the tool as `undefined`, not as + * `Number('')` — which is `0`, a value the tools then forward because + * they test presence rather than truthiness. `0` is out of range for + * `ttl` and silently rewrites an MX record's `priority`. + */ const numericFields = [ + 'ttl', + 'priority', + 'limit', + 'page', + 'per_page', 'period', 'requestsPerPeriod', 'mitigationTimeout', 'precedence', ] as const for (const field of numericFields) { - if (result[field] === '' || result[field] === undefined) { + if (result[field] === '' || result[field] == null) { result[field] = undefined } else { result[field] = Number(result[field]) @@ -2634,7 +2727,11 @@ Return ONLY the JSON array - no explanations, no markdown fences.`, apiKey: { type: 'string', description: 'Cloudflare API token' }, zoneId: { type: 'string', description: 'Zone ID' }, accountId: { type: 'string', description: 'Cloudflare account ID' }, - zoneType: { type: 'string', description: 'Zone type (full, partial, or secondary)' }, + zoneType: { + type: 'string', + description: + 'Zone type to create (full, partial, or secondary). Cloudflare also defines an internal type, which is not creatable here but can appear on zones returned by reads', + }, order: { type: 'string', description: 'Sort field when listing zones' }, direction: { type: 'string', description: 'Sort direction (asc, desc)' }, match: { type: 'string', description: 'Match logic for filters (any, all)' }, @@ -2705,13 +2802,21 @@ Return ONLY the JSON array - no explanations, no markdown fences.`, content: { type: 'string', description: 'DNS record content' }, ttl: { type: 'number', description: 'Time to live in seconds' }, proxied: { type: 'boolean', description: 'Whether Cloudflare proxy is enabled' }, - priority: { type: 'number', description: 'Record priority (MX/SRV)' }, + priority: { + type: 'number', + description: + 'Record priority. Cloudflare accepts this top-level field for MX and URI records only; an SRV record carries its priority inside the record content instead', + }, comment: { type: 'string', description: 'Record comment' }, search: { type: 'string', description: 'Free-text search across record properties' }, tag: { type: 'string', description: 'Filter by an exact tag name' }, tag_match: { type: 'string', description: 'Tag filter match logic (any, all)' }, commentFilter: { type: 'string', description: 'Filter records by comment content' }, settingId: { type: 'string', description: 'Zone setting ID' }, + settingIds: { + type: 'string', + description: 'Comma-separated zone setting IDs to read, or blank for the default set', + }, value: { type: 'string', description: 'Setting value' }, since: { type: 'string', description: 'Start date for analytics' }, until: { type: 'string', description: 'End date for analytics' }, @@ -2747,6 +2852,10 @@ Return ONLY the JSON array - no explanations, no markdown fences.`, enabled: { type: 'boolean', description: 'Whether the rule is enabled' }, ref: { type: 'string', description: 'Rule reference tag' }, actionParameters: { type: 'string', description: 'JSON action parameters for a rule' }, + rateLimitActionParameters: { + type: 'string', + description: 'JSON action parameters a replaced rate limiting rule ends up with', + }, ratelimit: { type: 'string', description: 'JSON rate limiting configuration to preserve when replacing a rule', @@ -2855,9 +2964,21 @@ Return ONLY the JSON array - no explanations, no markdown fences.`, records: { type: 'json', description: 'List of DNS records' }, certificates: { type: 'json', description: 'List of SSL/TLS certificate packs' }, settings: { type: 'json', description: 'List of zone settings' }, + unreadable: { + type: 'json', + description: 'Requested zone settings Cloudflare refused, with the reason for each', + }, totals: { type: 'json', description: 'Aggregate DNS analytics totals' }, - min: { type: 'json', description: 'Minimum values across the DNS analytics period' }, - max: { type: 'json', description: 'Maximum values across the DNS analytics period' }, + min: { + type: 'json', + description: + 'Per-metric DNS analytics minimums. Cloudflare documents this as currently always an empty object', + }, + max: { + type: 'json', + description: + 'Per-metric DNS analytics maximums. Cloudflare documents this as currently always an empty object', + }, query: { type: 'json', description: 'Echo of the DNS analytics query parameters sent' }, validation_errors: { type: 'json', description: 'Validation issues for certificate packs' }, data: { type: 'json', description: 'Raw analytics data rows from the DNS analytics report' }, @@ -2898,7 +3019,7 @@ Return ONLY the JSON array - no explanations, no markdown fences.`, proxied: { type: 'boolean', description: 'Whether Cloudflare proxy is enabled' }, ttl: { type: 'number', description: 'TTL in seconds (1 = automatic)' }, locked: { type: 'boolean', description: 'Whether the record is locked' }, - priority: { type: 'number', description: 'Priority for MX and SRV records' }, + priority: { type: 'number', description: 'Record priority, returned for MX and URI records' }, comment: { type: 'string', description: 'Record comment' }, tags: { type: 'json', description: 'Tags associated with the record or cache tags to purge' }, comment_modified_on: { @@ -2913,7 +3034,11 @@ Return ONLY the JSON array - no explanations, no markdown fences.`, modified_on: { type: 'string', description: 'Last modified date (ISO 8601)' }, value: { type: 'string', description: 'Setting value (complex values are JSON-stringified)' }, editable: { type: 'boolean', description: 'Whether the setting can be modified' }, - time_remaining: { type: 'number', description: 'Seconds until setting can be modified again' }, + time_remaining: { + type: 'number', + description: + 'Development mode countdown in seconds — documented only on the zones_development_mode setting, positive until it expires and negative afterwards', + }, total_count: { type: 'number', description: 'Total count of results' }, rulesets: { type: 'json', description: 'Rulesets defined on the zone' }, rules: { type: 'json', description: 'Rules contained in a ruleset, in evaluation order' }, @@ -3179,7 +3304,7 @@ export const CloudflareBlockMeta = { description: 'Protect an API path from abuse with a Cloudflare rate limiting rule using the current Rulesets-based rate limiting API.', content: - '# Rate Limit an API Endpoint\n\nRate limiting rules are rules in the `http_ratelimit` phase entry point ruleset. The legacy `rate_limits` endpoint is no longer the way to do this.\n\n## Steps\n1. Resolve the zone ID for the domain serving the API.\n2. List the existing rate limiting rules to get the `http_ratelimit` entry point ruleset ID and see what is already in place.\n3. Decide the counting characteristics. `cf.colo.id` is mandatory, plus exactly one of `ip.src` (per IP) or `cf.unique_visitor_id` (per visitor); add `http.request.headers[""]` to count per API key.\n4. Pick a counting period (10, 60, 120, 300, 600, or 3600 seconds) and the request allowance for that period.\n5. Create the rule with the matching expression (e.g. `(http.request.uri.path matches "^/api/")`), the counting configuration, and the mitigation action.\n6. Read the rules back and confirm the new rule and its limit.\n\n## Output\nThe ruleset ID, the new rule ID, the expression, and the effective limit (requests per period, characteristics, and mitigation timeout).\n\n## Cautions\nThe rule applies to live traffic as soon as it is created. Size the allowance against real traffic before choosing `block` over `log` or `managed_challenge`.', + '# Rate Limit an API Endpoint\n\nRate limiting rules are rules in the `http_ratelimit` phase entry point ruleset. The legacy `rate_limits` endpoint is no longer the way to do this.\n\n## Steps\n1. Resolve the zone ID for the domain serving the API.\n2. List the existing rate limiting rules to get the `http_ratelimit` entry point ruleset ID and see what is already in place.\n3. Decide the counting characteristics. `cf.colo.id` is mandatory. `ip.src` (per IP) and `cf.unique_visitor_id` (per visitor) are mutually exclusive - include at most one, and neither is required; a rule keyed on host, path, country, header or cookie alone is valid. Add `http.request.headers[""]` to count per API key.\n4. Pick a counting period (10, 60, 120, 300, 600, or 3600 seconds) and the request allowance for that period.\n5. Create the rule with the matching expression (e.g. `(http.request.uri.path matches "^/api/")`), the counting configuration, and the mitigation action.\n6. Read the rules back and confirm the new rule and its limit.\n\n## Output\nThe ruleset ID, the new rule ID, the expression, and the effective limit (requests per period, characteristics, and mitigation timeout).\n\n## Cautions\nThe rule applies to live traffic as soon as it is created. Size the allowance against real traffic before choosing `block` over `log` or `managed_challenge`.', }, { name: 'review-zero-trust-access', diff --git a/apps/sim/blocks/blocks/crowdstrike.test.ts b/apps/sim/blocks/blocks/crowdstrike.test.ts index e75ba076a29..784cf33d70b 100644 --- a/apps/sim/blocks/blocks/crowdstrike.test.ts +++ b/apps/sim/blocks/blocks/crowdstrike.test.ts @@ -196,6 +196,49 @@ describe('CrowdStrike block params', () => { expect(ids).toContain('detection_unsuppress') }) + /** + * CrowdStrike declares `include_hidden` with `"default": true` on every alert + * endpoint this switch feeds, so omitting the parameter still returns hidden + * alerts. A switch that renders off while the wire behaves as on tells the + * analyst the opposite of what Falcon does. + */ + it('seeds the hidden-alert switch on, matching the CrowdStrike default', () => { + const includeHidden = CrowdStrikeBlock.subBlocks.find( + (subBlock) => subBlock.id === 'includeHidden' + ) + + expect(includeHidden?.value?.({})).toBe('true') + }) + + it.each([ + ['crowdstrike_query_alerts', {}], + ['crowdstrike_get_alert_details', { compositeIds: '["cid:aid:alert"]' }], + ['crowdstrike_update_alerts', { compositeIds: '["cid:aid:alert"]', updateStatus: 'closed' }], + ])('sends the seeded hidden-alert switch as an explicit true for %s', (operation, extra) => { + const includeHidden = CrowdStrikeBlock.subBlocks.find( + (subBlock) => subBlock.id === 'includeHidden' + ) + + const merged = merge({ + ...credentials, + ...extra, + operation, + includeHidden: includeHidden?.value?.({}), + }) + + expect(merged.includeHidden).toBe(true) + }) + + it('still sends false when the analyst turns the hidden-alert switch off', () => { + const merged = merge({ + ...credentials, + operation: 'crowdstrike_query_alerts', + includeHidden: false, + }) + + expect(merged.includeHidden).toBe(false) + }) + it('keeps every tool description inside the docs generator id-search window', () => { const toolsDir = path.join(__dirname, '../../tools/crowdstrike') const offenders: string[] = [] diff --git a/apps/sim/blocks/blocks/crowdstrike.ts b/apps/sim/blocks/blocks/crowdstrike.ts index 6ac65d5cb14..1f908d29025 100644 --- a/apps/sim/blocks/blocks/crowdstrike.ts +++ b/apps/sim/blocks/blocks/crowdstrike.ts @@ -8,7 +8,12 @@ import { } from '@/blocks/utils' import type { CrowdStrikeResponse } from '@/tools/crowdstrike/types' -/** Documented maximum `limit` for each CrowdStrike query collection. */ +/** + * Maximum `limit` for each CrowdStrike query collection. Every entry except IOC + * Management is the `maximum` CrowdStrike publishes in its swagger. The IOC + * indicators endpoint publishes no `maximum` at all, so 500 is a Sim cap chosen + * to keep a single request bounded — do not describe it as CrowdStrike's. + */ const QUERY_LIMITS: Record = { crowdstrike_query_sensors: { min: 1, max: 200 }, crowdstrike_query_alerts: { min: 1, max: 10000 }, @@ -356,10 +361,13 @@ export const CrowdStrikeBlock: BlockConfig = { mode: 'advanced', }, /** - * Falcon has two sort spellings. Alerts, IOC Management, Spotlight, and Cases - * document `field|direction`; Host Groups and Identity Protection sensors - * document `field.direction`. One placeholder cannot show both, so the field - * is declared twice under the same id with mutually exclusive conditions. + * Falcon has two sort spellings. Alerts, Spotlight, and Cases document + * `field|direction`; Host Groups, Identity Protection sensors, and IOC + * Management document `field.direction`. IOC Management also has its own + * field names — its sort enum has no `created_timestamp`, only `created_on` + * and `modified_on` — so it gets a placeholder of its own. One placeholder + * cannot show all three, so the field is declared three times under the same + * id with mutually exclusive conditions. */ { id: 'sort', @@ -370,13 +378,20 @@ export const CrowdStrikeBlock: BlockConfig = { field: 'operation', value: [ 'crowdstrike_query_alerts', - 'crowdstrike_query_indicators', 'crowdstrike_query_vulnerabilities', 'crowdstrike_query_cases', ], }, mode: 'advanced', }, + { + id: 'sort', + title: 'Sort', + type: 'short-input', + placeholder: 'created_on.desc', + condition: { field: 'operation', value: 'crowdstrike_query_indicators' }, + mode: 'advanced', + }, { id: 'sort', title: 'Sort', @@ -389,9 +404,18 @@ export const CrowdStrikeBlock: BlockConfig = { mode: 'advanced', }, { + /** + * CrowdStrike declares `include_hidden` with a default of `true` on all + * three alert endpoints this switch feeds (`GET /alerts/queries/alerts/v2`, + * `POST /alerts/entities/alerts/v2`, `PATCH /alerts/entities/alerts/v3`). + * An untouched switch omits the parameter, so Falcon returns hidden alerts + * either way — seeding `true` makes the rendered state match the wire + * instead of showing off while hidden alerts come back. + */ id: 'includeHidden', title: 'Include Hidden Alerts', type: 'switch', + value: () => 'true', condition: { field: 'operation', value: [ diff --git a/apps/sim/blocks/blocks/datadog.test.ts b/apps/sim/blocks/blocks/datadog.test.ts index 0eef4a11f3a..dc3724edb58 100644 --- a/apps/sim/blocks/blocks/datadog.test.ts +++ b/apps/sim/blocks/blocks/datadog.test.ts @@ -103,6 +103,63 @@ describe('datadog list_monitors params', () => { expect(subBlock?.condition).toEqual({ field: 'operation', value: 'datadog_list_monitors' }) } }) + + /** + * Both controls sit under Advanced with no help text, so Page Size reads as a + * bound while Datadog ignores it unless a page is also sent. + */ + it('tells the user that a page size only applies with a page', () => { + const pageSize = DatadogBlock.subBlocks.find((c) => c.id === 'listMonitorPageSize') + const page = DatadogBlock.subBlocks.find((c) => c.id === 'listMonitorPage') + + expect(pageSize?.tooltip).toMatch(/page number/i) + expect(page?.tooltip).toMatch(/every monitor/i) + }) + + it('states the same rule on the inputs the model reads', () => { + expect(String(DatadogBlock.inputs.listMonitorPageSize.description)).toMatch(/page number/i) + expect(String(DatadogBlock.inputs.listMonitorPage.description)).toMatch(/every monitor/i) + }) +}) + +/** + * A bare `Number()` on a free-text field turns a typo or an unresolved reference + * into `NaN`, which `JSON.stringify` writes as `null` and a query string carries + * as the literal "NaN" — Datadog then rejects the call naming nothing the user + * typed. Every numeric mapping goes through the shared coercion, not just the + * two List Monitors fields. + */ +describe('datadog numeric coercion', () => { + it.each([ + ['datadog_mute_monitor', { muteMonitorId: '123', end: 'tomorrow' }, 'end'], + ['datadog_query_logs', { logLimit: 'lots' }, 'limit'], + ['datadog_query_timeseries', { from: 'yesterday', to: 'now' }, 'from'], + ['datadog_list_incidents', { incidentPageSize: '{{unresolved}}' }, 'pageSize'], + ['datadog_list_slos', { sloLimit: 'many' }, 'limit'], + ['datadog_list_dashboards', { dashboardCount: 'n/a' }, 'count'], + ['datadog_search_spans', { spanLimit: 'lots' }, 'limit'], + ['datadog_list_services', { servicePageSize: 'big' }, 'pageSize'], + ['datadog_list_security_rules', { rulePageNumber: 'first' }, 'pageNumber'], + ['datadog_list_synthetics_tests', { syntheticsPageSize: 'all' }, 'pageSize'], + ])('drops a non-numeric %s input rather than sending NaN', (operation, inputs, key) => { + const params = mergedParams({ ...baseInputs, operation, ...inputs }) + + expect(params[key]).toBeUndefined() + }) + + /** + * An explicit 0 is a real offset/page/threshold. A `` reference + * resolves to the number `0`, which the old truthiness guard dropped outright. + */ + it.each([ + ['datadog_list_downtimes', { downtimeOffset: 0 }, 'offset'], + ['datadog_list_slos', { sloOffset: 0 }, 'offset'], + ['datadog_list_dashboards', { dashboardStart: 0 }, 'start'], + ])('keeps an explicit zero on %s', (operation, inputs, key) => { + const params = mergedParams({ ...baseInputs, operation, ...inputs }) + + expect(params[key]).toBe(0) + }) }) describe('datadog create_monitor params', () => { diff --git a/apps/sim/blocks/blocks/datadog.ts b/apps/sim/blocks/blocks/datadog.ts index 0dcfe862503..dee922cad4e 100644 --- a/apps/sim/blocks/blocks/datadog.ts +++ b/apps/sim/blocks/blocks/datadog.ts @@ -16,14 +16,17 @@ function toSwitchBoolean(value: unknown): boolean | undefined { } /** - * Coerce a List Monitors pagination input, dropping anything that is not a finite - * number. These are advanced free-text fields, so they can carry a typo or an - * unresolved reference, and a bare `Number()` would put the literal `NaN` in the - * query string instead of omitting the parameter. An untouched subBlock resolves - * to `null` and an empty one to `''`; both are omissions rather than zeros, while - * an explicit `0` is Datadog's own first page and is kept. + * Coerce a numeric block input, dropping anything that is not a finite number. + * + * These are free-text fields, so they can carry a typo or an unresolved + * reference, and a bare `Number()` would put the literal `NaN` into the request + * — `JSON.stringify` writes it as `null` and it reaches a query string as the + * string "NaN", either of which Datadog rejects with a message naming nothing + * the user typed. An untouched subBlock resolves to `null` and an empty one to + * `''`; both are omissions rather than zeros, while an explicit `0` is + * meaningful (page 0, offset 0, a zero threshold) and is kept. */ -function datadogPageNumber(value: unknown): number | undefined { +function datadogNumber(value: unknown): number | undefined { if (value == null || value === '') return undefined const parsed = Number(value) return Number.isFinite(parsed) ? parsed : undefined @@ -533,6 +536,8 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`, title: 'Page Size', type: 'short-input', placeholder: '50', + tooltip: + 'Monitors per page (max 1000). Datadog only applies a page size when a page number is sent, so setting this alone uses page 0.', condition: { field: 'operation', value: 'datadog_list_monitors' }, mode: 'advanced', }, @@ -541,6 +546,8 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`, title: 'Page Number', type: 'short-input', placeholder: '0', + tooltip: + 'Page to start from (0-indexed). Leaving both this and Page Size blank returns every monitor in the org without pagination.', condition: { field: 'operation', value: 'datadog_list_monitors' }, mode: 'advanced', }, @@ -1100,9 +1107,20 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`, { label: 'Monitor', id: 'monitor' }, ], value: () => 'metric', - condition: { field: 'operation', value: ['datadog_create_slo', 'datadog_update_slo'] }, + condition: { field: 'operation', value: ['datadog_create_slo'] }, required: { field: 'operation', value: ['datadog_create_slo'] }, }, + { + id: 'sloUpdateType', + title: 'SLO Type', + type: 'dropdown', + options: [ + { label: 'Keep current', id: '' }, + { label: 'Metric', id: 'metric' }, + { label: 'Monitor', id: 'monitor' }, + ], + condition: { field: 'operation', value: ['datadog_update_slo'] }, + }, { id: 'sloThresholds', title: 'Thresholds (JSON)', @@ -1985,8 +2003,8 @@ Return ONLY the search query string - no explanations.`, return { ...baseParams, query: params.query, - from: params.from ? Number(params.from) : undefined, - to: params.to ? Number(params.to) : undefined, + from: datadogNumber(params.from), + to: datadogNumber(params.to), } case 'datadog_create_event': @@ -2007,7 +2025,7 @@ Return ONLY the search query string - no explanations.`, query: params.monitorQuery, message: params.message, tags: params.monitorTags, - priority: params.monitorPriority ? Number(params.monitorPriority) : undefined, + priority: datadogNumber(params.monitorPriority), options: params.options, } @@ -2025,8 +2043,8 @@ Return ONLY the search query string - no explanations.`, * leftover value would filter this list while presenting it as complete. */ monitorTags: undefined, - pageSize: datadogPageNumber(params.listMonitorPageSize), - page: datadogPageNumber(params.listMonitorPage), + pageSize: datadogNumber(params.listMonitorPageSize), + page: datadogNumber(params.listMonitorPage), } case 'datadog_mute_monitor': @@ -2034,7 +2052,7 @@ Return ONLY the search query string - no explanations.`, ...baseParams, monitorId: params.muteMonitorId, scope: params.scope || undefined, - end: params.end ? Number(params.end) : undefined, + end: datadogNumber(params.end), } case 'datadog_unmute_monitor': @@ -2051,7 +2069,7 @@ Return ONLY the search query string - no explanations.`, query: params.logQuery, from: params.logFrom, to: params.logTo, - limit: params.logLimit ? Number(params.logLimit) : undefined, + limit: datadogNumber(params.logLimit), cursor: params.logCursor || undefined, } @@ -2063,8 +2081,8 @@ Return ONLY the search query string - no explanations.`, ...baseParams, scope: params.downtimeScope, message: params.downtimeMessage, - start: params.downtimeStart ? Number(params.downtimeStart) : undefined, - end: params.downtimeEnd ? Number(params.downtimeEnd) : undefined, + start: datadogNumber(params.downtimeStart), + end: datadogNumber(params.downtimeEnd), monitorId: params.downtimeMonitorId, monitorTags: params.downtimeMonitorTags || undefined, timezone: params.downtimeTimezone || undefined, @@ -2075,8 +2093,8 @@ Return ONLY the search query string - no explanations.`, return { ...baseParams, currentOnly: toSwitchBoolean(params.currentOnly), - limit: params.downtimeLimit ? Number(params.downtimeLimit) : undefined, - offset: params.downtimeOffset ? Number(params.downtimeOffset) : undefined, + limit: datadogNumber(params.downtimeLimit), + offset: datadogNumber(params.downtimeOffset), } case 'datadog_cancel_downtime': @@ -2086,8 +2104,8 @@ Return ONLY the search query string - no explanations.`, return { ...baseParams, include: params.incidentInclude || undefined, - pageSize: params.incidentPageSize ? Number(params.incidentPageSize) : undefined, - pageOffset: params.incidentPageOffset ? Number(params.incidentPageOffset) : undefined, + pageSize: datadogNumber(params.incidentPageSize), + pageOffset: datadogNumber(params.incidentPageOffset), } case 'datadog_get_incident': @@ -2141,8 +2159,8 @@ Return ONLY the search query string - no explanations.`, query: params.sloQuery || undefined, tagsQuery: params.sloTagsQuery || undefined, metricsQuery: params.sloMetricsQuery || undefined, - limit: params.sloLimit ? Number(params.sloLimit) : undefined, - offset: params.sloOffset ? Number(params.sloOffset) : undefined, + limit: datadogNumber(params.sloLimit), + offset: datadogNumber(params.sloOffset), } case 'datadog_get_slo': @@ -2163,12 +2181,8 @@ Return ONLY the search query string - no explanations.`, query: params.sloMetricQuery || undefined, monitorIds: params.sloMonitorIds || undefined, groups: params.sloGroups || undefined, - targetThreshold: params.sloTargetThreshold - ? Number(params.sloTargetThreshold) - : undefined, - warningThreshold: params.sloWarningThreshold - ? Number(params.sloWarningThreshold) - : undefined, + targetThreshold: datadogNumber(params.sloTargetThreshold), + warningThreshold: datadogNumber(params.sloWarningThreshold), timeframe: params.sloTimeframe || undefined, } @@ -2177,19 +2191,15 @@ Return ONLY the search query string - no explanations.`, ...baseParams, sloId: params.sloId, name: params.sloName || undefined, - type: params.sloType || undefined, + type: params.sloUpdateType || undefined, thresholds: params.sloThresholds || undefined, description: params.sloDescription || undefined, tags: params.sloTags || undefined, query: params.sloMetricQuery || undefined, monitorIds: params.sloMonitorIds || undefined, groups: params.sloGroups || undefined, - targetThreshold: params.sloTargetThreshold - ? Number(params.sloTargetThreshold) - : undefined, - warningThreshold: params.sloWarningThreshold - ? Number(params.sloWarningThreshold) - : undefined, + targetThreshold: datadogNumber(params.sloTargetThreshold), + warningThreshold: datadogNumber(params.sloWarningThreshold), timeframe: params.sloTimeframe || undefined, } @@ -2204,9 +2214,9 @@ Return ONLY the search query string - no explanations.`, return { ...baseParams, sloId: params.sloId, - fromTs: params.sloFromTs ? Number(params.sloFromTs) : undefined, - toTs: params.sloToTs ? Number(params.sloToTs) : undefined, - target: params.sloTarget ? Number(params.sloTarget) : undefined, + fromTs: datadogNumber(params.sloFromTs), + toTs: datadogNumber(params.sloToTs), + target: datadogNumber(params.sloTarget), applyCorrection: toSwitchBoolean(params.sloApplyCorrection), } @@ -2215,8 +2225,8 @@ Return ONLY the search query string - no explanations.`, ...baseParams, filterShared: toSwitchBoolean(params.dashboardFilterShared), filterDeleted: toSwitchBoolean(params.dashboardFilterDeleted), - count: params.dashboardCount ? Number(params.dashboardCount) : undefined, - start: params.dashboardStart ? Number(params.dashboardStart) : undefined, + count: datadogNumber(params.dashboardCount), + start: datadogNumber(params.dashboardStart), } case 'datadog_get_dashboard': @@ -2241,10 +2251,8 @@ Return ONLY the search query string - no explanations.`, case 'datadog_list_synthetics_tests': return { ...baseParams, - pageSize: params.syntheticsPageSize ? Number(params.syntheticsPageSize) : undefined, - pageNumber: params.syntheticsPageNumber - ? Number(params.syntheticsPageNumber) - : undefined, + pageSize: datadogNumber(params.syntheticsPageSize), + pageNumber: datadogNumber(params.syntheticsPageNumber), } case 'datadog_get_synthetics_test': @@ -2255,8 +2263,8 @@ Return ONLY the search query string - no explanations.`, return { ...baseParams, publicId: params.syntheticsPublicId, - fromTs: params.syntheticsFromTs ? Number(params.syntheticsFromTs) : undefined, - toTs: params.syntheticsToTs ? Number(params.syntheticsToTs) : undefined, + fromTs: datadogNumber(params.syntheticsFromTs), + toTs: datadogNumber(params.syntheticsToTs), probeDc: params.syntheticsProbeDc || undefined, } @@ -2278,7 +2286,7 @@ Return ONLY the search query string - no explanations.`, to: params.signalTo || undefined, sort: params.signalSort || undefined, cursor: params.signalCursor || undefined, - limit: params.signalLimit ? Number(params.signalLimit) : undefined, + limit: datadogNumber(params.signalLimit), } case 'datadog_get_security_signal': @@ -2305,8 +2313,8 @@ Return ONLY the search query string - no explanations.`, ...baseParams, query: params.ruleQuery || undefined, sort: params.ruleSort || undefined, - pageSize: params.rulePageSize ? Number(params.rulePageSize) : undefined, - pageNumber: params.rulePageNumber ? Number(params.rulePageNumber) : undefined, + pageSize: datadogNumber(params.rulePageSize), + pageNumber: datadogNumber(params.rulePageNumber), } case 'datadog_search_spans': @@ -2317,14 +2325,14 @@ Return ONLY the search query string - no explanations.`, to: params.spanTo || undefined, sort: params.spanSort || undefined, cursor: params.spanCursor || undefined, - limit: params.spanLimit ? Number(params.spanLimit) : undefined, + limit: datadogNumber(params.spanLimit), } case 'datadog_list_services': return { ...baseParams, - pageSize: params.servicePageSize ? Number(params.servicePageSize) : undefined, - pageNumber: params.servicePageNumber ? Number(params.servicePageNumber) : undefined, + pageSize: datadogNumber(params.servicePageSize), + pageNumber: datadogNumber(params.servicePageNumber), schemaVersion: params.serviceSchemaVersion || undefined, } @@ -2390,8 +2398,16 @@ Return ONLY the search query string - no explanations.`, downtimeId: { type: 'string', description: 'Downtime ID to cancel' }, listMonitorName: { type: 'string', description: 'Filter monitors by name' }, listMonitorTags: { type: 'string', description: 'Filter monitors by tags' }, - listMonitorPageSize: { type: 'number', description: 'Monitors to return per page' }, - listMonitorPage: { type: 'number', description: 'Monitor page number (0-indexed)' }, + listMonitorPageSize: { + type: 'number', + description: + 'Monitors to return per page (max 1000). Datadog only applies this when a page number is sent, so setting it alone uses page 0.', + }, + listMonitorPage: { + type: 'number', + description: + 'Monitor page number (0-indexed). With neither this nor the page size set, Datadog returns every monitor in the org without pagination.', + }, // Incidents incidentId: { type: 'string', description: 'Incident UUID' }, incidentTitle: { type: 'string', description: 'Incident title' }, @@ -2415,6 +2431,10 @@ Return ONLY the search query string - no explanations.`, sloId: { type: 'string', description: 'SLO ID' }, sloName: { type: 'string', description: 'SLO name' }, sloType: { type: 'string', description: 'SLO type' }, + sloUpdateType: { + type: 'string', + description: 'Replacement SLO type, or blank to keep current', + }, sloThresholds: { type: 'json', description: 'SLO thresholds' }, sloDescription: { type: 'string', description: 'SLO description' }, sloTags: { type: 'string', description: 'SLO tags' }, diff --git a/apps/sim/blocks/blocks/microsoft_ad.test.ts b/apps/sim/blocks/blocks/microsoft_ad.test.ts index efe7ef81e01..8d0b8c52ff2 100644 --- a/apps/sim/blocks/blocks/microsoft_ad.test.ts +++ b/apps/sim/blocks/blocks/microsoft_ad.test.ts @@ -2,7 +2,9 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { SCOPE_DESCRIPTIONS } from '@/lib/oauth/utils' import { MicrosoftAdBlock } from '@/blocks/blocks/microsoft_ad' +import * as microsoftAdTools from '@/tools/microsoft_ad' /** * The tri-state assertions run against `{ ...inputs, ...buildParams(inputs) }`, the shape the @@ -121,9 +123,22 @@ describe('MicrosoftAdBlock', () => { /** * `User.ReadWrite.All` is listed on every `/users` read this block performs — list, get, * licenseDetails, registeredDevices, and ownedDevices — so `User.Read.All` was pure consent - * noise. `Directory.Read.All` and `GroupMember.ReadWrite.All` are deliberately retained: - * `GET /subscribedSkus` names neither `LicenseAssignment.ReadWrite.All` nor any scope left in - * this list, and `POST /groups/{id}/members/$ref` accepts `GroupMember.ReadWrite.All` only. + * noise. + * + * `Directory.Read.All` was required by exactly one call, `GET /subscribedSkus`, whose + * permission table names `LicenseAssignment.Read.All` as least privileged and + * `Directory.Read.All` only as a higher-privileged alternative — and does **not** list + * `LicenseAssignment.ReadWrite.All`, so the write scope this block already holds does not + * cover the read. Every other call is covered by a narrower scope in the list: + * directory roles by `RoleManagement.ReadWrite.Directory`, group members by + * `Group.ReadWrite.All`/`GroupMember.ReadWrite.All`, devices by `Device.Read.All`, audits by + * `AuditLog.Read.All`, service principals by `Application.Read.All`, user app-role + * assignments by `AppRoleAssignment.ReadWrite.All`, and CA policies by `Policy.Read.All`. + * + * `GroupMember.ReadWrite.All` is deliberately retained: `POST /groups/{id}/members/$ref` + * accepts it and nothing else in this list for a user member. + * @see https://learn.microsoft.com/en-us/graph/api/subscribedsku-list + * @see https://learn.microsoft.com/en-us/graph/api/group-post-members */ describe('requested OAuth scopes', () => { const requiredScopes = @@ -135,10 +150,39 @@ describe('MicrosoftAdBlock', () => { expect(requiredScopes).not.toContain('User.Read.All') }) + it('requests the least-privileged scope for subscribedSkus, not Directory.Read.All', () => { + expect(requiredScopes).toContain('LicenseAssignment.Read.All') + expect(requiredScopes).not.toContain('Directory.Read.All') + }) + it('keeps the scopes no retained scope covers', () => { - expect(requiredScopes).toEqual( - expect.arrayContaining(['Directory.Read.All', 'GroupMember.ReadWrite.All']) - ) + expect(requiredScopes).toEqual(expect.arrayContaining(['GroupMember.ReadWrite.All'])) + }) + + it('describes every scope it requests', () => { + const undescribed = requiredScopes.filter((scope) => !SCOPE_DESCRIPTIONS[scope]) + expect(undescribed).toEqual([]) + }) + }) + + /** + * `getBlockOutputs` derives the referenceable schema from `blockConfig.outputs`, so a single + * `response` entry made the tag dropdown offer `` — which corresponds to + * nothing, since every tool puts its fields at the top level of `output` — and left the real + * outputs unreferenceable downstream. + */ + describe('declared outputs', () => { + const toolOutputKeys = new Set( + Object.values(microsoftAdTools).flatMap((tool) => Object.keys(tool.outputs ?? {})) + ) + const blockOutputKeys = new Set(Object.keys(MicrosoftAdBlock.outputs)) + + it('declares every key its tools emit', () => { + expect([...toolOutputKeys].filter((key) => !blockOutputKeys.has(key)).sort()).toEqual([]) + }) + + it('declares nothing no tool emits', () => { + expect([...blockOutputKeys].filter((key) => !toolOutputKeys.has(key)).sort()).toEqual([]) }) }) }) diff --git a/apps/sim/blocks/blocks/microsoft_ad.ts b/apps/sim/blocks/blocks/microsoft_ad.ts index 7769ca84f3b..7eb521e4ab0 100644 --- a/apps/sim/blocks/blocks/microsoft_ad.ts +++ b/apps/sim/blocks/blocks/microsoft_ad.ts @@ -919,10 +919,129 @@ export const MicrosoftAdBlock: BlockConfig = { policyFilter: { type: 'string' }, }, outputs: { - response: { + users: { + type: 'array', + description: + 'User objects (id, displayName, givenName, surname, userPrincipalName, mail, jobTitle, department, officeLocation, mobilePhone, accountEnabled)', + }, + userCount: { type: 'number', description: 'Number of users returned on this page' }, + user: { type: 'json', description: 'The single user this operation created or read' }, + userId: { type: 'string', description: 'Object ID of the user the operation acted on' }, + userPrincipalName: { type: 'string', description: 'User principal name of that user' }, + displayName: { type: 'string', description: 'Display name of the user the operation acted on' }, + groups: { + type: 'array', + description: + 'Group objects (id, displayName, description, mail, mailEnabled, securityEnabled, groupTypes, visibility)', + }, + groupCount: { type: 'number', description: 'Number of groups returned on this page' }, + group: { type: 'json', description: 'The single group this operation created or read' }, + groupId: { type: 'string', description: 'Object ID of the group the operation acted on' }, + members: { + type: 'array', + description: 'Group or directory role members (id, displayName, mail, odataType)', + }, + memberCount: { type: 'number', description: 'Number of members returned on this page' }, + memberId: { type: 'string', description: 'Object ID of the member the operation acted on' }, + roles: { + type: 'array', + description: 'Activated directory roles (id, displayName, description, roleTemplateId)', + }, + roleCount: { type: 'number', description: 'Number of directory roles returned' }, + directoryRoleId: { + type: 'string', + description: 'Object ID of the directory role the operation acted on', + }, + skus: { + type: 'array', + description: + 'Subscribed SKUs (skuId, skuPartNumber, consumedUnits, prepaidUnits, servicePlans)', + }, + skuCount: { type: 'number', description: 'Number of subscribed SKUs returned' }, + licenses: { + type: 'array', + description: 'License details assigned to the user (skuId, skuPartNumber, servicePlans)', + }, + licenseCount: { type: 'number', description: 'Number of license details returned' }, + assignedLicenses: { + type: 'array', + description: 'Licenses the user holds after the assign or remove operation', + }, + assignments: { + type: 'array', + description: + 'App role assignments (id, appRoleId, principalId, principalDisplayName, resourceId, resourceDisplayName)', + }, + assignmentCount: { type: 'number', description: 'Number of app role assignments returned' }, + assignment: { type: 'json', description: 'The app role assignment this operation created' }, + appRoleAssignmentId: { + type: 'string', + description: 'ID of the app role assignment that was removed', + }, + servicePrincipals: { + type: 'array', + description: + 'Service principals (id, appId, displayName, servicePrincipalType, accountEnabled, appRoles)', + }, + servicePrincipalCount: { type: 'number', description: 'Number of service principals returned' }, + devices: { + type: 'array', + description: + 'Devices (id, deviceId, displayName, operatingSystem, accountEnabled, isCompliant, isManaged, trustType)', + }, + deviceCount: { type: 'number', description: 'Number of devices returned on this page' }, + device: { type: 'json', description: 'The single device this operation read' }, + policies: { + type: 'array', + description: + 'Conditional access policies (id, displayName, state, conditions, grantControls, sessionControls)', + }, + policyCount: { type: 'number', description: 'Number of conditional access policies returned' }, + policy: { type: 'json', + description: 'The single conditional access policy this operation read', + }, + audits: { + type: 'array', + description: + 'Directory audit events (id, activityDateTime, activityDisplayName, initiatedBy, targetResources, result)', + }, + auditCount: { type: 'number', description: 'Number of directory audit events returned' }, + signIns: { + type: 'array', description: - 'Microsoft Entra ID operation response. User operations return id, displayName, userPrincipalName, mail, jobTitle, department. Group operations return id, displayName, description, mailEnabled, securityEnabled, groupTypes. Member operations return id, displayName, mail, odataType. Licensing operations return skuId, skuPartNumber, consumedUnits, prepaidUnits, and servicePlans. Sign-in and audit operations return the event id, timestamp, actor, target, and result. App role and directory role operations return assignment and role ids with their principals. Device operations return id, deviceId, displayName, operatingSystem, accountEnabled, isCompliant, isManaged, and trustType. Conditional access operations return id, displayName, state, conditions, grantControls, and sessionControls. List operations also return nextLink for fetching additional pages.', + 'Sign-in events (id, createdDateTime, userPrincipalName, appDisplayName, ipAddress, status)', + }, + signInCount: { type: 'number', description: 'Number of sign-in events returned' }, + methods: { + type: 'array', + description: 'Registered authentication methods for the user (id, odataType, and its detail)', + }, + methodCount: { type: 'number', description: 'Number of authentication methods returned' }, + newPassword: { + type: 'string', + description: 'Temporary password produced by the password reset, when Graph returned one', + }, + operationLocation: { + type: 'string', + description: 'URL for polling the long-running password reset operation', + }, + accepted: { + type: 'boolean', + description: 'True when Graph accepted the password reset for asynchronous processing', + }, + forceChangePasswordNextSignIn: { + type: 'boolean', + description: 'Whether the user must change the password at next sign-in', + }, + added: { type: 'boolean', description: 'True when the member was added' }, + removed: { type: 'boolean', description: 'True when the member or assignment was removed' }, + updated: { type: 'boolean', description: 'True when the resource was updated' }, + deleted: { type: 'boolean', description: 'True when the resource was deleted' }, + revoked: { type: 'boolean', description: "True when the user's sign-in sessions were revoked" }, + nextLink: { + type: 'string', + description: 'Continuation URL for the next page, present only when more results exist', }, }, } diff --git a/apps/sim/blocks/blocks/mssql.test.ts b/apps/sim/blocks/blocks/mssql.test.ts index 6a31a1ba4f9..af4229cd6d2 100644 --- a/apps/sim/blocks/blocks/mssql.test.ts +++ b/apps/sim/blocks/blocks/mssql.test.ts @@ -3,6 +3,7 @@ */ import { describe, expect, it } from 'vitest' import { MSSQLBlock } from '@/blocks/blocks/mssql' +import * as mssqlTools from '@/tools/mssql' /** * Every assertion here runs against `{ ...inputs, ...buildParams(inputs) }`, the @@ -137,3 +138,63 @@ describe('MSSQLBlock', () => { } }) }) + +describe('Microsoft SQL Server tool declarations', () => { + it('never lets an LLM choose which database to open', () => { + // Every other connection field, on every other tool, is user-only. A model + // picking the database means the user's credentials open something else. + for (const tool of Object.values(mssqlTools)) { + for (const field of ['host', 'port', 'database', 'username', 'password']) { + const param = tool.params[field] + if (!param) continue + expect(param.visibility, `${tool.id}.${field}`).toBe('user-only') + } + } + }) + + it('declares the introspection table shape so downstream blocks get field hints', () => { + const tables = mssqlTools.mssqlIntrospectTool.outputs?.tables as { + items?: { type: string; properties?: Record } + } + + expect(tables.items?.type).toBe('object') + expect(Object.keys(tables.items?.properties ?? {})).toEqual([ + 'name', + 'schema', + 'columns', + 'primaryKey', + 'foreignKeys', + 'indexes', + ]) + expect( + (tables.items?.properties?.columns as { items?: { properties?: Record } }) + ?.items?.properties + ).toHaveProperty('references') + }) + + /** + * `getBlockOutputs` derives the referenceable schema from `blockConfig.outputs`, so a key a + * tool emits but the block omits is unreferenceable downstream — and for the truncation pair + * that also leaves the block's advertised schema describing every result as complete. + */ + it('has a block that declares every output key its tools emit', () => { + const toolKeys = new Set( + Object.values(mssqlTools).flatMap((tool) => Object.keys(tool.outputs ?? {})) + ) + const blockKeys = new Set(Object.keys(MSSQLBlock.outputs)) + + expect([...toolKeys].filter((key) => !blockKeys.has(key)).sort()).toEqual([]) + expect(MSSQLBlock.outputs.truncated).toMatchObject({ type: 'boolean' }) + expect(MSSQLBlock.outputs.truncationReason).toMatchObject({ type: 'string' }) + }) + + it('does not present TLS encryption as guaranteed once enabled', () => { + // TDS 7.4 starts in-band TLS only if the prelogin response is ON/REQ; a + // server answering NOT_SUP yields an unencrypted session with no error. + for (const tool of Object.values(mssqlTools)) { + const encrypt = tool.params.encrypt + if (!encrypt) continue + expect(encrypt.description, tool.id).toMatch(/negotiat|not a guarantee/i) + } + }) +}) diff --git a/apps/sim/blocks/blocks/mssql.ts b/apps/sim/blocks/blocks/mssql.ts index 2b59254ccef..4b760331708 100644 --- a/apps/sim/blocks/blocks/mssql.ts +++ b/apps/sim/blocks/blocks/mssql.ts @@ -397,6 +397,14 @@ export const MSSQLBlock: BlockConfig = { type: 'array', description: 'List of available schemas in the database (introspect operation)', }, + truncated: { + type: 'boolean', + description: 'True when the result hit a row or byte ceiling and rows were dropped', + }, + truncationReason: { + type: 'string', + description: 'Explanation of the ceiling that truncated the result', + }, }, } diff --git a/apps/sim/blocks/blocks/servicenow.ts b/apps/sim/blocks/blocks/servicenow.ts index 561260caeea..4b4eb33cac9 100644 --- a/apps/sim/blocks/blocks/servicenow.ts +++ b/apps/sim/blocks/blocks/servicenow.ts @@ -5,6 +5,7 @@ import { normalizeFileInput } from '@/blocks/utils' import { APPROVAL_DECISION_OPTIONS, APPROVAL_STATE, + APPROVAL_STATE_OPTIONS, CHANGE_CLOSE_CODE_OPTIONS, CHANGE_STATE_OPTIONS, CHANGE_TYPE_OPTIONS, @@ -513,6 +514,8 @@ Output: {"short_description": "Network outage", "description": "Network connecti type: 'short-input', placeholder: '10', condition: { field: 'operation', value: ['servicenow_read_record', ...PAGINATED_OPS] }, + description: + 'Maximum number of records to return. Left blank, no limit is sent and the instance applies its own default, which is 10,000 on the Table API operations', mode: 'advanced', }, { @@ -890,16 +893,11 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st id: 'approvalState', title: 'Approval State', type: 'combobox', - options: [ - { label: 'Any (not set)', id: '' }, - { label: 'Requested (pending)', id: APPROVAL_STATE.REQUESTED }, - { label: 'Approved', id: APPROVAL_STATE.APPROVED }, - { label: 'Rejected', id: APPROVAL_STATE.REJECTED }, - ], + options: [{ label: 'Any (not set)', id: '' }, ...APPROVAL_STATE_OPTIONS], value: () => APPROVAL_STATE.REQUESTED, condition: { field: 'operation', value: 'servicenow_list_approvals' }, description: - 'ServiceNow publishes coded values only for these three. An instance that defines further approval states can be filtered by typing the raw value.', + 'The seven states ServiceNow publishes for the Ask for Approval action. An instance that defines further approval states can be filtered by typing the raw value.', }, // Prioritization { @@ -1677,10 +1675,29 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st rest.closeNotes = CHANGE_CLOSE_OPS.has(operation) ? changeCloseNotes : resolutionNotes if (operation === 'servicenow_search_knowledge') rest.query = knowledgeQuery - if (attachmentLimit != null && attachmentLimit !== '') rest.limit = Number(attachmentLimit) - if (rest.limit != null && rest.limit !== '') rest.limit = Number(rest.limit) - if (rest.offset != null && rest.offset !== '') rest.offset = Number(rest.offset) - if (rest.quantity != null && rest.quantity !== '') rest.quantity = Number(rest.quantity) + /** + * `attachmentLimit` exists as a separate subblock precisely so a limit + * typed on one operation cannot leak into another, but neither branch + * used to be scoped, so whichever ran last won: List Incidents took a + * stale `attachmentLimit`, and List Attachments took a stale `limit`. + * Every paginated operation reads exactly one of the two. + */ + if (operation === 'servicenow_list_attachments') { + rest.limit = + attachmentLimit != null && attachmentLimit !== '' ? Number(attachmentLimit) : undefined + } else if (rest.limit != null) { + rest.limit = rest.limit === '' ? undefined : Number(rest.limit) + } + /** + * A short-input stores `''` once a user types a value and clears it + * again, so a blank must resolve to `undefined` rather than stay in + * place — the tools only skip a param that is absent, and a retained + * `''` reaches ServiceNow as `sysparm_limit=`. + */ + if (rest.offset != null) rest.offset = rest.offset === '' ? undefined : Number(rest.offset) + if (rest.quantity != null) { + rest.quantity = rest.quantity === '' ? undefined : Number(rest.quantity) + } if (rest.inputDisplayValue != null) { rest.inputDisplayValue = @@ -1981,24 +1998,55 @@ export const ServiceNowBlockMeta = { skills: [ { name: 'create-incident', + description: 'File a ServiceNow incident with the right category, priority, and description.', + content: + '# Create Incident\n\nFile a new ServiceNow incident from a reported issue.\n\n## Steps\n1. Use the Create Incident operation. It targets the incident table for you — do not reach for the generic Create Record operation, which makes you name the table and hand-build the field payload.\n2. Set a clear short description, the longer description, and the category.\n3. Set priority through impact and urgency rather than writing priority directly; ServiceNow derives priority from those two and overwrites a directly written value.\n4. Set the caller and assignment group when they are known.\n\n## Output\nReturn the created sys_id and incident number so the reporter can track it, and echo the category and the derived priority.', + }, + { + name: 'triage-incidents', description: - 'Create a new ServiceNow incident record with the right category, priority, and description.', + 'Find the ServiceNow incidents matching a state, priority, or assignment and summarize them.', content: - '# Create Incident\n\nFile a new ServiceNow incident from a reported issue.\n\n## Steps\n1. Use the Create Record operation against the incident table.\n2. Populate the field values: a clear short description, the longer description, category, and priority or impact and urgency.\n3. Set caller or assignment group fields when known.\n\n## Output\nReturn the created record sys_id and incident number so the reporter can track it, and echo the category and priority that were set.', + '# Triage Incidents\n\nPull the incidents that need attention and summarize them.\n\n## Steps\n1. Use the List Incidents operation with the filters it exposes — state, priority, assignment group, or an encoded query for anything else. Reserve the generic Read Records operation for tables that have no semantic operation of their own.\n2. Always set the limit. Nothing sends one for you, and the ServiceNow Table API falls back to its own default of 10,000 records, so an unset limit pulls far more than you will read.\n3. Keep the display-value setting at "all" so reference fields come back with both their sys_id and a human-readable label.\n4. Read a single incident in full with Get Incident once you have picked one out.\n\n## Output\nSummarize the matched incidents by number, short description, state, and priority, and say how many matched.', }, { - name: 'search-records', + name: 'progress-incident', description: - 'Query a ServiceNow table for records matching a condition and return the matching rows.', + 'Move a ServiceNow incident forward — comment on it, update fields, resolve it, or close it.', content: - '# Search Records\n\nFind records in any ServiceNow table that match a condition.\n\n## Steps\n1. Use the Read Records operation against the target table (for example incident, change_request, or sc_task).\n2. Provide an encoded query to filter (for example active incidents in a category) and limit the number of rows returned.\n3. Choose the display-value setting so returned fields are human-readable rather than raw sys_ids when needed.\n\n## Output\nReturn the matched records with their key fields and sys_ids, and report how many matched the query.', + '# Progress an Incident\n\nAdvance an incident once work has happened on it.\n\n## Steps\n1. Identify the incident by number or sys_id, from List Incidents or from the trigger payload.\n2. Pick the operation that matches the action: Add Incident Comment for a note, Update Incident for field changes, Resolve Incident when a fix is in place, Close Incident to finish it.\n3. Resolve Incident requires a close code and close notes — ServiceNow rejects a resolution without them.\n4. When commenting, choose the journal field deliberately: work notes are internal, additional comments are visible to the caller.\n\n## Output\nConfirm the incident number, which operation ran, and the resulting state so the update is auditable.', }, { - name: 'update-record-status', + name: 'manage-change-request', description: - 'Update fields on an existing ServiceNow record, such as state, assignment, or work notes.', + 'Raise a ServiceNow change request and drive it through its approval state machine.', + content: + '# Manage a Change Request\n\nRaise a change and move it through its lifecycle.\n\n## Steps\n1. Use Create Change Request, choosing the type — normal, standard, or emergency — that matches the risk. Standard changes are pre-approved; emergency changes skip the usual review.\n2. Use Update Change Request for field edits such as the plans, schedule, or assignment group.\n3. Do not write the state field directly. Call Get Change Next States first to learn which transitions the state machine currently permits, then Move Change State to take one — the machine rejects a transition whose conditions have not been met, and the error will not say which condition failed.\n4. Use List Change Tasks to see the work items under the change.\n\n## Output\nReport the change number, its current state, and the transitions that are still available from here.', + }, + { + name: 'order-catalog-item', + description: 'Order a ServiceNow catalog item and track the requested item it produces.', + content: + '# Order a Catalog Item\n\nSubmit a service catalog request on behalf of a requester.\n\n## Steps\n1. Use List Catalog Items to find the item and its sys_id; ordering needs the sys_id, not the display name.\n2. Use Order Catalog Item with that sys_id, the quantity, and the item variables. Variables are item-specific — read them off the catalog item rather than assuming a shape.\n3. Track the result with List Requested Items or Get Requested Item, which is where the fulfillment state lives.\n\n## Output\nReturn the request number, the requested-item number, and the current fulfillment state.', + }, + { + name: 'handle-approvals', + description: 'Find pending ServiceNow approvals and record an approve or reject decision.', + content: + '# Handle Approvals\n\nWork the approval queue.\n\n## Steps\n1. Use List Approvals filtered by state. The pending state is `requested`; the state values are not uniformly punctuated — `not requested` has a space while `not_required` has an underscore — so pick from the offered list instead of typing one.\n2. Read what is being approved before deciding. The approval record points at a source record; fetch it with Get Change Request or Get Requested Item.\n3. Use Approve or Reject to record the decision. Only those two are writable here — every other approval state is set by the approval engine, not by an approver.\n4. Include a comment explaining the decision when the process expects a justification.\n\n## Output\nConfirm which approval record was acted on, the decision recorded, and what it unblocks.', + }, + { + name: 'investigate-cmdb', + description: + 'Look up a ServiceNow configuration item and map what it depends on and what depends on it.', + content: + '# Investigate the CMDB\n\nEstablish blast radius before a change or during an incident.\n\n## Steps\n1. Use Search Configuration Items to locate the CI by name or class, then Get Configuration Item for its full record.\n2. Use List CI Relationships to walk the dependency graph. Choose the direction deliberately: parents are what this CI depends on, children are what depends on it, and impact analysis usually wants the children.\n3. Follow the graph outward one hop at a time rather than pulling everything at once — CMDB relationship sets grow quickly.\n\n## Output\nName the CI, its class and operational status, and list the dependent CIs a change to it would affect.', + }, + { + name: 'search-knowledge', + description: 'Search the ServiceNow knowledge base for an existing fix before escalating.', content: - '# Update Record Status\n\nModify an existing ServiceNow record once a decision or action is taken.\n\n## Steps\n1. Identify the record by its sys_id (from a search step or a notification).\n2. Use the Update Record operation against the correct table, supplying only the fields to change such as state, assigned_to, or work_notes.\n3. Confirm the change by reading the record back.\n\n## Output\nConfirm the record number, the fields that changed, and their new values so the update is auditable.', + '# Search Knowledge\n\nCheck whether a documented fix already exists.\n\n## Steps\n1. Use Search Knowledge with the wording the reporter used; the search runs over article text, so their own phrasing usually matches better than a normalized restatement.\n2. Use Get Knowledge Article to pull the full body of a promising hit — the search result carries only a snippet.\n3. Prefer this before Create Incident when the issue looks routine; a linked article often resolves it outright.\n\n## Output\nCite the article number and title, summarize the fix, and say plainly if nothing relevant was found so the caller escalates instead of guessing.', }, ], } as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/splunk.test.ts b/apps/sim/blocks/blocks/splunk.test.ts index 4a3d502195b..6643269b080 100644 --- a/apps/sim/blocks/blocks/splunk.test.ts +++ b/apps/sim/blocks/blocks/splunk.test.ts @@ -7,7 +7,8 @@ vi.mock('@/triggers', () => ({ getTrigger: () => ({ subBlocks: [] }), })) -import { SplunkBlock } from '@/blocks/blocks/splunk' +import { SplunkBlock, SplunkBlockMeta } from '@/blocks/blocks/splunk' +import { buildSplunkFormBody, buildSplunkUrl } from '@/tools/splunk/utils' const toParams = SplunkBlock.tools.config?.params @@ -63,10 +64,10 @@ describe('SplunkBlock tools.config.params', () => { describe('pagination', () => { it('omits Max Results when untouched rather than asking for every row', () => { - const result = mapParams({ operation: 'splunk_list_indexes', count: null, offset: null }) + const merged = mergedInputs({ operation: 'splunk_list_indexes', count: null, offset: null }) - expect(result).not.toHaveProperty('count') - expect(result).not.toHaveProperty('offset') + expect(merged.count ?? undefined).toBeUndefined() + expect(merged.offset ?? undefined).toBeUndefined() }) it('coerces a typed Max Results, including an explicit 0', () => { @@ -193,28 +194,66 @@ describe('SplunkBlock numeric coercion', () => { * A bare `Number()` sent `NaN` for an unparseable value, which serializes as the * literal `NaN` and makes Splunk reject the request with an error that names the * field but not the cause. Omitting it lets Splunk apply its own default. + * + * "Omitting" has to mean omitted from the *merged* inputs the tool receives. + * Skipping the assignment only removes it from the mapper's return, which the + * executor then merges over the raw subBlock string — so the typo reaches Splunk + * anyway. Every assertion here therefore reads `mergedInputs`, not `mapParams`. */ - it.each(['abc', 'twenty', '12px'])('omits an unparseable Max Results (%s)', (count) => { - const merged = mergedInputs({ operation: 'splunk_list_indexes', count }) + it.each(['abc', 'twenty', '12px', '1,000', '50 rows', ''])( + 'erases an unparseable Max Results (%s) from the merged inputs', + (count) => { + const merged = mergedInputs({ operation: 'splunk_list_indexes', count }) - expect(merged.count).not.toBe(Number.NaN) - expect(mapParams({ operation: 'splunk_list_indexes', count })).not.toHaveProperty('count') - }) + expect(merged.count).toBeUndefined() + } + ) - it('omits an unparseable value on every numeric field it maps', () => { - const result = mapParams({ + it('erases an unparseable value on every numeric field it maps', () => { + const merged = mergedInputs({ operation: 'splunk_dispatch_saved_search', savedSearchName: 'Errors', - dispatchMaxCount: 'abc', + dispatchMaxCount: '1,000', dispatchMaxTime: 'abc', - dispatchTtl: 'abc', + dispatchTtl: '30 days', offset: 'abc', }) - expect(result).not.toHaveProperty('dispatchMaxCount') - expect(result).not.toHaveProperty('dispatchMaxTime') - expect(result).not.toHaveProperty('dispatchTtl') - expect(result).not.toHaveProperty('offset') + expect(merged.dispatchMaxCount).toBeUndefined() + expect(merged.dispatchMaxTime).toBeUndefined() + expect(merged.dispatchTtl).toBeUndefined() + expect(merged.offset).toBeUndefined() + }) + + it('erases an unparseable Max Stored Results on both search operations', () => { + for (const operation of ['splunk_run_search', 'splunk_create_search_job']) { + const merged = mergedInputs({ + operation, + search: 'index=main', + autoCancel: '5 minutes', + maxCount: '1,000', + }) + + expect(merged.autoCancel).toBeUndefined() + expect(merged.maxCount).toBeUndefined() + } + }) + + /** + * The erased value must actually disappear from the wire, not serialize as the + * string `'undefined'`. + */ + it('keeps an erased numeric field out of the request the tool builds', () => { + const merged = mergedInputs({ operation: 'splunk_list_indexes', count: '1,000', offset: '10' }) + + expect( + buildSplunkUrl({ baseUrl: 'https://splunk.example.com:8089' }, '/data/indexes', { + count: merged.count as number | undefined, + offset: merged.offset as number | undefined, + }) + ).toBe('https://splunk.example.com:8089/services/data/indexes?offset=10&output_mode=json') + + expect(buildSplunkFormBody({ max_count: merged.count as number | undefined })).toBe('') }) it('still coerces the numeric forms it is given', () => { @@ -229,4 +268,56 @@ describe('SplunkBlock outputs', () => { expect(SplunkBlock.outputs).toHaveProperty('total') expect(SplunkBlock.outputs).toHaveProperty('offset') }) + + /** + * The job entry documents this pair as bare numbers, unlike the ISO-string + * `earliestTime`/`latestTime`, and the block's union output must agree with the + * tool or the workflow is promised the wrong type. + */ + it('types the epoch search time bounds as numbers', () => { + expect(SplunkBlock.outputs.searchEarliestTime).toMatchObject({ type: 'number' }) + expect(SplunkBlock.outputs.searchLatestTime).toMatchObject({ type: 'number' }) + }) + + /** + * `[{type, text}]` holds for the search and job-control operations, but Get + * Search Job projects the job entry's `messages` object. One shared union + * output cannot promise the array shape for all of them. + */ + it('does not promise an array shape that get_search_job does not return', () => { + const description = String(SplunkBlock.outputs.messages.description) + + expect(description).toMatch(/Get Search Job/) + expect(description).toMatch(/object/i) + }) +}) + +describe('SplunkBlockMeta skills', () => { + it('suggests skills grounded in the operations the block exposes', () => { + const skills = SplunkBlockMeta.skills + + expect(skills?.length).toBeGreaterThanOrEqual(3) + for (const skill of skills ?? []) { + expect(skill.name).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/) + expect(skill.description.trim()).not.toBe('') + expect(skill.content.trim()).not.toBe('') + } + }) + + it('gives every skill a distinct name', () => { + const names = (SplunkBlockMeta.skills ?? []).map((skill) => skill.name) + expect(new Set(names).size).toBe(names.length) + }) + + /** + * Run Search applies no Sim-side `max_count`, so a skill that tells the model + * "at most 1000 rows by default" states a bound that does not exist. + */ + it('does not claim a row cap Run Search no longer applies', () => { + const skill = SplunkBlockMeta.skills?.find((entry) => entry.name === 'search-splunk-logs') + + expect(skill).toBeDefined() + expect(skill?.content).not.toMatch(/1000/) + expect(skill?.content).toMatch(/cannot page/) + }) }) diff --git a/apps/sim/blocks/blocks/splunk.ts b/apps/sim/blocks/blocks/splunk.ts index 689fce7e504..b197ac9f3f7 100644 --- a/apps/sim/blocks/blocks/splunk.ts +++ b/apps/sim/blocks/blocks/splunk.ts @@ -28,12 +28,16 @@ function toSplunkToggle(value: unknown): boolean | undefined { * An untouched subBlock resolves to `null` and an empty one to `''`; both are * omissions rather than zeros, so neither may reach `Number()` (which reads both * as `0`). + * + * The key is always written, never skipped. The executor merges this mapper's + * return *over* the raw serialized subBlock values, so a key left unwritten keeps + * the raw string (`'1,000'`) and forwards the typo to Splunk verbatim — the + * opposite of omitting it. Writing `undefined` erases it instead, and both + * `buildSplunkFormBody` and `buildSplunkUrl` drop nullish fields from the request. */ function assignSplunkNumber(target: Record, key: string, value: unknown): void { - if (value == null || value === '') return - const parsed = Number(value) - if (!Number.isFinite(parsed)) return - target[key] = parsed + const parsed = value == null || value === '' ? Number.NaN : Number(value) + target[key] = Number.isFinite(parsed) ? parsed : undefined } export const SplunkBlock: BlockConfig = { @@ -597,7 +601,11 @@ Examples: resultCount: { type: 'number', description: 'Number of result rows returned' }, preview: { type: 'boolean', description: 'Whether the results are previews' }, initOffset: { type: 'number', description: 'Offset of the first returned row' }, - messages: { type: 'json', description: 'Messages returned with the response ([{type, text}])' }, + messages: { + type: 'json', + description: + 'Messages returned with the response. An array of {type, text} for the search and job-control operations; Get Search Job instead returns the job entry messages object.', + }, sid: { type: 'string', description: 'Search ID of the job' }, label: { type: 'string', description: 'Custom name of the search job' }, dispatchState: { type: 'string', description: 'Current state of the search job' }, @@ -620,12 +628,12 @@ Examples: earliestTime: { type: 'string', description: 'Earliest time bound of the job' }, latestTime: { type: 'string', description: 'Latest time bound of the job' }, searchEarliestTime: { - type: 'string', - description: 'Earliest time as specified in the search command', + type: 'number', + description: 'Earliest time as specified in the search command, as an epoch timestamp', }, searchLatestTime: { - type: 'string', - description: 'Latest time as specified in the search command', + type: 'number', + description: 'Latest time as specified in the search command, as an epoch timestamp', }, savedSearches: { type: 'json', @@ -681,6 +689,50 @@ Examples: export const SplunkBlockMeta = { tags: ['monitoring', 'data-analytics'], url: 'https://www.splunk.com', + skills: [ + { + name: 'search-splunk-logs', + description: 'Answer a question about production behavior by running an SPL search.', + content: + '# Search Splunk Logs\n\nTurn a question about production into an SPL search and answer from the rows.\n\n## Steps\n1. Write a single SPL search scoped to one index and a bounded time range (for example `index=main error earliest=-1h`).\n2. Run the run search operation, which executes the search synchronously and returns the rows in one call.\n3. Read resultCount and results to gather the evidence.\n4. Summarize what the rows show, quoting the fields that matter.\n\n## Notes\nRun search buffers its whole result set in one response and cannot page. For a larger result set, create a search job and page through get search results with offset.\n\n## Output\nReturn the SPL that was run, the row count, and a short answer to the question.', + }, + { + name: 'long-running-search-job', + description: 'Dispatch a long Splunk search, poll it to completion, then page the results.', + content: + '# Long-Running Search Job\n\nRun a search that is too slow for a synchronous call.\n\n## Steps\n1. Create a search job with the SPL and time range. Keep the returned sid.\n2. Poll get search job with that sid until dispatchState is DONE. Check isFailed and isZombie on each poll and stop if either is true.\n3. Fetch results with get search results, paging with count and offset until the rows are exhausted.\n4. Cancel the job when abandoning it early so the result cache is released.\n\n## Output\nReport the sid, the final dispatch state, the number of rows fetched, and the summarized findings.', + }, + { + name: 'triage-fired-alerts', + description: 'Pull currently firing Splunk alerts and turn them into a triage summary.', + content: + '# Triage Fired Alerts\n\nTurn unexpired Splunk alerts into an actionable summary.\n\n## Steps\n1. List fired alerts to get every saved search with triggered alerts and its trigger count.\n2. For the noisiest saved searches, get fired alerts by name to read the individual instances with their severity, sid, and trigger time.\n3. Group the instances by saved search and severity, and rank by trigger count.\n4. Have an agent write a short triage note naming what is firing, how often, and what to look at first.\n\n## Output\nReturn the ranked alert list with trigger counts and the triage note.', + }, + { + name: 'run-saved-search', + description: 'Dispatch an existing Splunk saved search and report its results.', + content: + '# Run Saved Search\n\nExecute a saved search that already encodes the right SPL.\n\n## Steps\n1. List saved searches, or get one by name, to confirm the search exists and read its SPL and schedule.\n2. Dispatch the saved search. Set trigger actions only when the alert actions should really fire.\n3. Poll get search job with the returned sid until the job is done.\n4. Fetch and summarize the results.\n\n## Output\nReturn the saved search name, the sid of the dispatched job, and a summary of the rows it produced.', + }, + { + name: 'index-capacity-report', + description: 'Report on Splunk index size, retention, and event volume.', + content: + '# Index Capacity Report\n\nCheck which indexes are close to their limits.\n\n## Steps\n1. List indexes to read name, datatype, totalEventCount, currentDBSizeMB, maxTotalDataSizeMB, and frozenTimePeriodInSecs.\n2. Compute how full each index is against its maximum data size.\n3. Flag indexes above a threshold, and any whose retention window is shorter than the team expects.\n4. Page with count and offset when the instance has more indexes than one page returns.\n\n## Output\nReturn a table of indexes with size, usage percentage, and retention, plus the flagged entries.', + }, + { + name: 'audit-saved-search-hygiene', + description: 'Inventory Splunk saved searches and flag disabled or stale scheduled ones.', + content: + '# Audit Saved Search Hygiene\n\nFind saved searches that no longer earn their schedule.\n\n## Steps\n1. List saved searches, paging with count and offset until total is covered.\n2. Read disabled, isScheduled, cronSchedule, and nextScheduledTime on each entry.\n3. Flag scheduled searches that are disabled, searches with no next scheduled time, and duplicate SPL across entries.\n4. Write the cleanup candidates somewhere durable, such as a table or a file.\n\n## Output\nReturn the counts by category and the list of cleanup candidates with the reason each was flagged.', + }, + { + name: 'app-inventory-check', + description: 'Inventory the apps installed on a Splunk instance and flag disabled ones.', + content: + '# App Inventory Check\n\nRecord what is installed on the Splunk instance.\n\n## Steps\n1. List apps to read name, label, version, author, disabled, and configured.\n2. Flag apps that are installed but disabled, and apps that are not configured.\n3. Compare the versions against the versions the team expects to be running.\n\n## Output\nReturn the app inventory with versions and the list of disabled or unconfigured apps.', + }, + ], templates: [ { icon: SplunkIcon, diff --git a/apps/sim/lib/api/contracts/tools/crowdstrike.ts b/apps/sim/lib/api/contracts/tools/crowdstrike.ts index 3a9a295d42d..788983bd260 100644 --- a/apps/sim/lib/api/contracts/tools/crowdstrike.ts +++ b/apps/sim/lib/api/contracts/tools/crowdstrike.ts @@ -792,7 +792,10 @@ const queryIndicatorsSchema = baseRequestSchema .number() .int() .min(1, 'Limit must be at least 1') - .max(500, 'Limit must be at most 500') + .max( + 500, + 'Sim caps this request at 500 indicators; CrowdStrike publishes no limit for this endpoint' + ) .optional(), offset: z.number().int().nonnegative('Offset must be 0 or greater').optional(), after: nonBlankQuerySchema('After cursor'), diff --git a/apps/sim/lib/api/contracts/tools/databases/shared.ts b/apps/sim/lib/api/contracts/tools/databases/shared.ts index bf906e114ce..092fdddeb55 100644 --- a/apps/sim/lib/api/contracts/tools/databases/shared.ts +++ b/apps/sim/lib/api/contracts/tools/databases/shared.ts @@ -71,6 +71,13 @@ export const sqlRowsResponseSchema = z.object({ message: z.string(), rows: z.array(z.unknown()), rowCount: z.number(), + /** + * Present only when the driver returned more than the route was willing to + * serialize. Absent means the recordset is complete, so a caller that ignores + * these two fields still reads a whole result correctly. + */ + truncated: z.boolean().optional(), + truncationReason: z.string().optional(), }) export const mongoDocumentsResponseSchema = z diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index df98e42145b..4c92ad85541 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -3947,7 +3947,7 @@ }, { "name": "Get Zone Settings", - "description": "Gets all settings for a zone including SSL mode, caching level, and security settings." + "description": "Reads zone settings such as SSL mode, minimum TLS version, security level, and caching level. Cloudflare retired the endpoint that read every setting in one request, so each setting is read individually — name the ones you need to keep the read small. Defaults to ${DEFAULT_ZONE_SETTING_IDS.join(', ')}." }, { "name": "Update Zone Setting", @@ -4019,7 +4019,7 @@ }, { "name": "Update Access Application", - "description": "Updates a Cloudflare Access (Zero Trust) application. This replaces the application definition rather than merging it, so send every field the application should keep — anything you omit reverts to its default, which can widen or break access. Read the current configuration with \"Get Access Application\" first. Requires an API token with Account Access: Apps and Policies Edit." + "description": "Updates a Cloudflare Access (Zero Trust) application. Cloudflare does not document merge behavior for this PUT, so treat it as a replace: send every field the application should keep, because an omitted field may revert to its default and widen or break access. Read the current configuration with \"Get Access Application\" first. Requires an API token with Account Access: Apps and Policies Edit." }, { "name": "Delete Access Application", @@ -4035,7 +4035,7 @@ }, { "name": "Update Access Policy", - "description": "Updates a Cloudflare Access (Zero Trust) policy on an application. This replaces the policy definition rather than merging it, so send every rule the policy should keep — omitted exclude or require rules are dropped, which can widen who gets in. The change applies to live traffic immediately. Read the current policy with \"List Access Policies\" first. Requires an API token with Account Access: Apps and Policies Edit." + "description": "Updates a Cloudflare Access (Zero Trust) policy on an application. Cloudflare does not document merge behavior for this PUT, so treat it as a replace: send every rule the policy should keep, because an omitted exclude or require rule may be dropped and widen who gets in. The change applies to live traffic immediately. Read the current policy with \"List Access Policies\" first. Requires an API token with Account Access: Apps and Policies Edit." }, { "name": "Delete Access Policy", @@ -19624,7 +19624,7 @@ "operations": [ { "name": "Run Search", - "description": "Run an SPL search synchronously and return its results in a single call (oneshot mode). Use for short searches; use Create Search Job for long-running ones." + "description": "Run an SPL search synchronously and return its results in a single call (oneshot mode). A oneshot search buffers the whole result set in one response with no paging, so use it for short searches; for anything large use Create Search Job with Get Search Results, which defaults to 100 rows and pages with offset." }, { "name": "Create Search Job", diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index e8f43754cf9..7a8e7268af0 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -337,7 +337,7 @@ export const OAUTH_PROVIDERS: Record = { 'User.ReadWrite.All', 'Group.ReadWrite.All', 'GroupMember.ReadWrite.All', - 'Directory.Read.All', + 'LicenseAssignment.Read.All', 'LicenseAssignment.ReadWrite.All', 'UserAuthenticationMethod.ReadWrite.All', 'AuditLog.Read.All', diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts index 055a45b3586..eadf644c0ac 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -259,6 +259,7 @@ export const SCOPE_DESCRIPTIONS: Record = { 'User.ReadWrite.All': 'Read and write all user profiles', 'GroupMember.ReadWrite.All': 'Read and write all group memberships', 'Directory.Read.All': 'Read directory data', + 'LicenseAssignment.Read.All': 'Read license assignments and subscribed SKUs', 'LicenseAssignment.ReadWrite.All': 'Assign and remove user licenses', 'UserAuthenticationMethod.ReadWrite.All': 'Read and reset authentication methods and passwords for all users', diff --git a/apps/sim/tools/cloudflare/cloudflare.test.ts b/apps/sim/tools/cloudflare/cloudflare.test.ts index 04c740078d5..022ae323cc1 100644 --- a/apps/sim/tools/cloudflare/cloudflare.test.ts +++ b/apps/sim/tools/cloudflare/cloudflare.test.ts @@ -10,7 +10,7 @@ * These tests assert the seeded default reaching each tool for operations whose * control is deliberately not last, so re-introducing a collision goes red. */ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { CloudflareBlock } from '@/blocks/blocks/cloudflare' import * as cloudflareTools from '@/tools/cloudflare' @@ -341,6 +341,43 @@ describe('no hidden advanced control feeds an operation that cannot show it', () expect(leaks).toEqual([]) }) + + /** + * A subBlock can also be hidden by a guard on a field other than `operation` + * (purge_cache's targets are hidden once `purge_everything` is yes). An + * advanced control serializes on stored value alone, so the serializer never + * reaches that guard either — the mapper has to clear it. + */ + it('clears every advanced control whose own guard field hides it', () => { + const leaks: string[] = [] + + for (const subBlock of CloudflareBlock.subBlocks) { + if (subBlock.mode !== 'advanced') continue + const guard = ( + subBlock.condition as { and?: { field: string; value: unknown; not?: boolean } } | undefined + )?.and + if (!guard || guard.field === 'operation') continue + + const hidingValue = guard.not === true ? String(guard.value) : '__guard_off__' + + for (const operation of conditionOperations(subBlock)) { + const tool = toolsByOperation.get(operation) + if (!tool?.params || !(subBlock.id in tool.params)) continue + + const mapped = mapFor(operation, { + [guard.field]: hidingValue, + [subBlock.id]: STALE, + }) + if (mapped[subBlock.id] === STALE) { + leaks.push( + `"${subBlock.id}" reaches ${operation} when ${guard.field}=${hidingValue} hides it` + ) + } + } + } + + expect(leaks).toEqual([]) + }) }) /** @@ -630,3 +667,456 @@ describe('optional and per-API pagination params', () => { ) }) }) + +/** Builds the Cloudflare v4 envelope every tool's transformResponse reads. */ +function envelope(result: unknown, success = true): Response { + return new Response(JSON.stringify({ success, errors: [], messages: [], result }), { + headers: { 'Content-Type': 'application/json' }, + }) +} + +describe('DNS analytics does not fabricate metrics Cloudflare did not return', () => { + const tool = cloudflareTools.cloudflareDnsAnalyticsTool + + it('passes min and max through instead of filling seven zeroes', async () => { + // Cloudflare documents both as "currently always an empty object", so a + // seven-field numeric block is fabricated telemetry. + const out = (await tool.transformResponse!( + envelope({ totals: { queryCount: 12 }, min: {}, max: {}, data: [], rows: 0 }), + {} as never + )) as { output: { min: unknown; max: unknown } } + + expect(out.output.min).toEqual({}) + expect(out.output.max).toEqual({}) + }) + + it('reports an absent min or max as null rather than zeroes', async () => { + const out = (await tool.transformResponse!(envelope({ totals: {} }), {} as never)) as { + output: { min: unknown; max: unknown } + } + + expect(out.output.min).toBeNull() + expect(out.output.max).toBeNull() + }) + + it('leaves an unrequested total absent instead of reading it as zero', async () => { + const out = (await tool.transformResponse!( + envelope({ totals: { queryCount: 12 } }), + {} as never + )) as { output: { totals: Record } } + + expect(out.output.totals.queryCount).toBe(12) + expect(out.output.totals.responseTimeAvg).toBeUndefined() + expect(out.output.totals.uncachedCount).toBeUndefined() + }) + + it('declares min and max as opaque JSON, not a numeric object', () => { + expect(tool.outputs?.min).toMatchObject({ type: 'json' }) + expect(tool.outputs?.max).toMatchObject({ type: 'json' }) + expect((tool.outputs?.min as { description: string }).description).toMatch(/empty object/i) + }) + + it('keeps metrics optional in the block, matching the tool and the API', () => { + const metrics = CloudflareBlock.subBlocks.find((sub) => sub.id === 'metrics') + expect(metrics?.required).toBeUndefined() + expect(tool.params.metrics.required).toBe(false) + expect(tool.params.metrics.description).not.toMatch(/default metric set/i) + }) +}) + +describe('purge cache refuses an ambiguous whole-zone purge', () => { + const buildBody = cloudflareTools.cloudflarePurgeCacheTool.request.body! + + it('throws when purge_everything is combined with a target list', () => { + expect(() => + buildBody({ + zoneId: 'z1', + apiKey, + purge_everything: true, + files: 'https://example.com/a.css', + } as never) + ).toThrow(/cannot be combined with specific targets/) + }) + + it('still purges everything when no targets were given', () => { + expect(buildBody({ zoneId: 'z1', apiKey, purge_everything: true } as never)).toEqual({ + purge_everything: true, + }) + }) + + it('defaults the block dropdown to purging specific targets', () => { + const purgeEverything = CloudflareBlock.subBlocks.find((sub) => sub.id === 'purge_everything') + expect((purgeEverything?.value as () => string)()).toBe('false') + }) + + /** + * `tags`, `hosts`, and `prefixes` are advanced, so the serializer emits their + * stored value before evaluating the `purge_everything` guard that hides them. + * A target typed before switching to a whole-zone purge therefore survived and + * made the tool refuse the purge over a field the editor no longer renders. + */ + it('drops targets typed before the switch to a whole-zone purge', () => { + const mapped = mapFor('purge_cache', { + zoneId: 'z1', + purge_everything: 'true', + files: 'https://example.com/a.css', + tags: 'homepage', + hosts: 'example.com', + prefixes: 'https://example.com/assets/', + }) + + expect(mapped.files).toBeUndefined() + expect(mapped.tags).toBeUndefined() + expect(mapped.hosts).toBeUndefined() + expect(mapped.prefixes).toBeUndefined() + expect(buildBody(mapped as never)).toEqual({ purge_everything: true }) + }) + + it('still purges the named targets when purge everything is no', () => { + const mapped = mapFor('purge_cache', { + zoneId: 'z1', + purge_everything: 'false', + tags: 'homepage', + }) + + expect(mapped.tags).toBe('homepage') + expect(buildBody(mapped as never)).toEqual({ tags: ['homepage'] }) + }) +}) + +describe('a rate limiting rule update keeps the fields the endpoint would reset', () => { + const updateRule = cloudflareTools.cloudflareUpdateRateLimitRuleTool + + const base = { + zoneId: 'z1', + rulesetId: 'rs1', + ruleId: 'r1', + apiKey, + action: 'block', + expression: 'true', + characteristics: 'cf.colo.id,ip.src', + period: 60, + requestsPerPeriod: 100, + } + + /** + * The rulesets update-rule endpoint replaces the whole rule definition, so a + * custom block response and the reference tag have to be resent or Cloudflare + * resets them — the block page comes back and the ref regenerates. + */ + it('forwards the custom mitigation response, reference tag, and logging', () => { + const body = updateRule.request.body?.({ + ...base, + ref: 'api-throttle', + actionParameters: + '{"response":{"status_code":429,"content":"{\\"error\\":\\"rate limited\\"}","content_type":"application/json"}}', + logging: '{"enabled":true}', + } as never) as Record + + expect(body.ref).toBe('api-throttle') + expect(body.action_parameters).toEqual({ + response: { + status_code: 429, + content: '{"error":"rate limited"}', + content_type: 'application/json', + }, + }) + expect(body.logging).toEqual({ enabled: true }) + }) + + it('declares the three fields as tool params so a caller can resend them', () => { + expect(updateRule.params.ref).toBeDefined() + expect(updateRule.params.actionParameters).toBeDefined() + expect(updateRule.params.logging).toBeDefined() + expect(updateRule.params.actionParameters.description).toMatch(/resets action_parameters/) + expect(updateRule.params.ref.description).toMatch(/omitting it resets/) + }) + + it('omits them entirely when the caller sends nothing', () => { + const body = updateRule.request.body?.(base as never) as Record + + expect(body).not.toHaveProperty('ref') + expect(body).not.toHaveProperty('action_parameters') + expect(body).not.toHaveProperty('logging') + }) + + it('offers all three in the block for the rate limiting update', () => { + const shownFor = (id: string) => + CloudflareBlock.subBlocks.flatMap((subBlock) => { + if (subBlock.id !== id) return [] + const condition = subBlock.condition + if (!condition || typeof condition !== 'object' || !('field' in condition)) return [] + if (condition.field !== 'operation') return [] + const value = condition.value + return Array.isArray(value) ? value.map(String) : [String(value)] + }) + + expect(shownFor('ref')).toContain('update_rate_limit_rule') + expect(shownFor('logging')).toContain('update_rate_limit_rule') + expect(shownFor('rateLimitActionParameters')).toEqual(['update_rate_limit_rule']) + }) + + /** + * The WAF control named "Action Parameters" holds a managed-ruleset payload, + * which is not what a rate limiting rule takes, so the two must stay separate + * controls that map onto the same tool param. + */ + it('routes the rate limiting control onto the tool param without sharing the WAF one', () => { + expect( + mapFor('update_rate_limit_rule', { + zoneId: 'z1', + rulesetId: 'rs1', + ruleId: 'r1', + actionParameters: '{"id":"managed-1"}', + rateLimitActionParameters: '{"response":{"status_code":429}}', + }).actionParameters + ).toBe('{"response":{"status_code":429}}') + + expect( + mapFor('update_ruleset_rule', { + zoneId: 'z1', + rulesetId: 'rs1', + ruleId: 'r1', + actionParameters: '{"id":"managed-1"}', + }).actionParameters + ).toBe('{"id":"managed-1"}') + }) +}) + +describe('zone settings are read through the endpoints Cloudflare still supports', () => { + const tool = cloudflareTools.cloudflareGetZoneSettingsTool + + function settingEnvelope(id: string, value: unknown) { + return new Response( + JSON.stringify({ + success: true, + errors: [], + messages: [], + result: { id, value, editable: true, modified_on: '2026-01-01T00:00:00Z' }, + }), + { headers: { 'Content-Type': 'application/json' } } + ) + } + + afterEach(() => { + vi.restoreAllMocks() + }) + + /** + * Cloudflare deprecated the batch `GET /zones/{zone_id}/settings` endpoint, + * with end of life on 2027-03-31, and directs integrations at the per-setting + * endpoint instead. + */ + it('does not declare the deprecated batch settings endpoint', () => { + const declaredUrl = tool.request.url({ zoneId: 'z1', apiKey } as never) + expect(declaredUrl).not.toMatch(/\/zones\/z1\/settings$/) + expect(declaredUrl).toMatch(/\/zones\/z1\/settings\/[a-z0-9_]+$/) + }) + + it('issues one request per setting against the per-setting endpoint', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async (input) => settingEnvelope(String(input).split('/').pop()!, 'on')) + + await tool.directExecution!({ zoneId: 'z1', apiKey, settingIds: 'ssl,http3' } as never) + + expect(fetchMock.mock.calls.map((call) => String(call[0]))).toEqual([ + 'https://api.cloudflare.com/client/v4/zones/z1/settings/ssl', + 'https://api.cloudflare.com/client/v4/zones/z1/settings/http3', + ]) + }) + + it('returns each setting under the list shape the block already reads', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => + settingEnvelope(String(input).split('/').pop()!, 'full') + ) + + const out = (await tool.directExecution!({ + zoneId: 'z1', + apiKey, + settingIds: 'ssl', + } as never)) as { + success: boolean + output: { settings: Array>; unreadable: unknown[] } + } + + expect(out.success).toBe(true) + expect(out.output.settings).toEqual([ + { id: 'ssl', value: 'full', editable: true, modified_on: '2026-01-01T00:00:00Z' }, + ]) + expect(out.output.unreadable).toEqual([]) + }) + + /** + * A plan-gated setting answers with an error rather than a value, and one + * refusal must not lose the settings that did come back. + */ + it('reports a refused setting without dropping the readable ones', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const settingId = String(input).split('/').pop()! + if (settingId === 'http3') { + return new Response( + JSON.stringify({ + success: false, + errors: [{ code: 1006, message: 'Not available for this plan' }], + }), + { headers: { 'Content-Type': 'application/json' } } + ) + } + return settingEnvelope(settingId, 'full') + }) + + const out = (await tool.directExecution!({ + zoneId: 'z1', + apiKey, + settingIds: 'ssl,http3', + } as never)) as { + success: boolean + output: { settings: Array<{ id: string }>; unreadable: Array<{ id: string; error: string }> } + } + + expect(out.success).toBe(true) + expect(out.output.settings.map((setting) => setting.id)).toEqual(['ssl']) + expect(out.output.unreadable).toEqual([{ id: 'http3', error: 'Not available for this plan' }]) + }) + + it('fails only when nothing at all could be read', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ success: false, errors: [{ message: 'Invalid zone identifier' }] }), + { headers: { 'Content-Type': 'application/json' } } + ) + ) + + const out = (await tool.directExecution!({ + zoneId: 'z1', + apiKey, + settingIds: 'ssl', + } as never)) as { success: boolean; error?: string } + + expect(out.success).toBe(false) + expect(out.error).toBe('Invalid zone identifier') + }) + + it('refuses an unbounded fan-out instead of issuing the requests', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + + const out = (await tool.directExecution!({ + zoneId: 'z1', + apiKey, + settingIds: Array.from({ length: 41 }, (_, index) => `setting_${index}`).join(','), + } as never)) as { success: boolean; error?: string } + + expect(out.success).toBe(false) + expect(out.error).toMatch(/at most 40/) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) + +describe('the top-level priority field is described the way Cloudflare defines it', () => { + /** + * Cloudflare accepts the top-level `priority` for MX and URI records and + * ignores it for every other type, SRV included — an SRV record carries its + * priority inside the record content. Saying "MX and SRV" invites a model to + * send a value Cloudflare drops while returning 200. + */ + it.each([ + ['update_dns_record param', cloudflareTools.cloudflareUpdateDnsRecordTool.params.priority], + [ + 'update_dns_record output', + cloudflareTools.cloudflareUpdateDnsRecordTool.outputs!.priority as { description: string }, + ], + [ + 'list_dns_records output', + ( + cloudflareTools.cloudflareListDnsRecordsTool.outputs!.records as never as { + items: { properties: Record } + } + ).items.properties.priority, + ], + ])('%s does not claim SRV uses the top-level priority', (_name, described) => { + expect(described.description).not.toMatch(/MX and SRV|MX\/SRV/) + expect(described.description).toMatch(/URI/) + }) + + it('keeps the block placeholder and input description off SRV too', () => { + for (const subBlock of CloudflareBlock.subBlocks) { + if (subBlock.id !== 'priority') continue + expect(subBlock.placeholder).not.toMatch(/MX and SRV|MX\/SRV/) + } + expect(CloudflareBlock.inputs.priority.description).not.toMatch(/MX and SRV|MX\/SRV/) + expect(CloudflareBlock.inputs.priority.description).toMatch(/URI/) + }) +}) + +describe('a blank optional number never reaches a tool as zero', () => { + /** + * `Number('')` is `0`, and the DNS tools forward `priority`/`ttl` on presence + * rather than truthiness — so a truthiness guard turned a blank advanced field + * into a TTL of 0 (out of Cloudflare's 30-86400 range) and a priority of 0. + */ + it.each([ + ['create_dns_record', 'ttl'], + ['create_dns_record', 'priority'], + ['list_zones', 'page'], + ['list_zones', 'per_page'], + ['dns_analytics', 'limit'], + ])('%s leaves a blank %s undefined rather than 0', (operation, field) => { + for (const blank of ['', null]) { + const mapped = mapFor(operation, { zoneId: 'z1', accountId: 'a1', [field]: blank }) + expect(mapped[field], `${operation}.${field} from ${JSON.stringify(blank)}`).toBeUndefined() + expect(mapped[field]).not.toBe(0) + } + }) + + /** + * The mapper handing on `''` is what produces the `0`: the DNS tools forward + * on `!== undefined`, and `Number('')` is `0`. This walks that last step so + * the assertion is about the request Cloudflare actually receives. + */ + it.each([ + ['create_dns_record', cloudflareTools.cloudflareCreateDnsRecordTool], + ['update_dns_record', cloudflareTools.cloudflareUpdateDnsRecordTool], + ])('%s sends no ttl or priority at all when both are blank', (operation, tool) => { + const mapped = mapFor(operation, { + zoneId: 'z1', + recordId: 'rec1', + name: 'mail.example.com', + content: 'mx.example.com', + recordType: 'MX', + updateRecordType: 'MX', + ttl: '', + priority: '', + }) + + const body = tool.request.body?.(mapped as never) as Record + expect(body).not.toHaveProperty('ttl') + expect(body).not.toHaveProperty('priority') + expect(body.ttl).not.toBe(0) + expect(body.priority).not.toBe(0) + }) + + it('still coerces a supplied value to a number', () => { + expect(mapFor('create_dns_record', { zoneId: 'z1', ttl: '3600' }).ttl).toBe(3600) + expect(mapFor('create_dns_record', { zoneId: 'z1', priority: '10' }).priority).toBe(10) + expect(mapFor('list_zones', { page: '2', per_page: '50' })).toMatchObject({ + page: 2, + per_page: 50, + }) + }) +}) + +describe('list transforms survive a non-array result', () => { + it.each([ + ['list_zones', cloudflareTools.cloudflareListZonesTool, 'zones'], + ['list_dns_records', cloudflareTools.cloudflareListDnsRecordsTool, 'records'], + ['list_certificates', cloudflareTools.cloudflareListCertificatesTool, 'certificates'], + ])('%s returns an empty list rather than throwing', async (_name, tool, key) => { + // A success:true body whose result is an object (or null) must not throw a + // raw TypeError out of transformResponse. + const out = (await tool.transformResponse!(envelope({ unexpected: true }), {} as never)) as { + output: Record + } + expect(out.output[key]).toEqual([]) + }) +}) diff --git a/apps/sim/tools/cloudflare/create_rate_limit_rule.ts b/apps/sim/tools/cloudflare/create_rate_limit_rule.ts index 58e69f7c58b..d75a3246bde 100644 --- a/apps/sim/tools/cloudflare/create_rate_limit_rule.ts +++ b/apps/sim/tools/cloudflare/create_rate_limit_rule.ts @@ -47,7 +47,7 @@ export const createRateLimitRuleTool: ToolConfig< required: true, visibility: 'user-or-llm', description: - 'Comma-separated counting characteristics. cf.colo.id is mandatory, plus exactly one of ip.src or cf.unique_visitor_id. Example: cf.colo.id,ip.src', + 'Comma-separated counting characteristics. cf.colo.id is mandatory. ip.src and cf.unique_visitor_id are mutually exclusive — include at most one. Example: cf.colo.id,ip.src', }, period: { type: 'number', diff --git a/apps/sim/tools/cloudflare/create_zone.ts b/apps/sim/tools/cloudflare/create_zone.ts index 4cb778bd8c0..39c71742d82 100644 --- a/apps/sim/tools/cloudflare/create_zone.ts +++ b/apps/sim/tools/cloudflare/create_zone.ts @@ -29,7 +29,7 @@ export const createZoneTool: ToolConfig ({ dimensions: entry.dimensions ?? [], @@ -203,92 +168,58 @@ export const dnsAnalyticsTool: ToolConfig< outputs: { totals: { type: 'object', - description: 'Aggregate DNS analytics totals for the entire queried period', + description: + 'Aggregate DNS analytics totals for the entire queried period. Only the metrics that were requested are present.', properties: { - queryCount: { type: 'number', description: 'Total number of DNS queries' }, - uncachedCount: { type: 'number', description: 'Number of uncached DNS queries' }, - staleCount: { type: 'number', description: 'Number of stale DNS queries' }, - responseTimeAvg: { + queryCount: { type: 'number', - description: 'Average response time in milliseconds', - optional: true, - }, - responseTimeMedian: { - type: 'number', - description: 'Median response time in milliseconds', + description: 'Total number of DNS queries. Absent when queryCount was not requested', optional: true, }, - responseTime90th: { + uncachedCount: { type: 'number', - description: '90th percentile response time in milliseconds', + description: + 'Number of uncached DNS queries. Absent when uncachedCount was not requested', optional: true, }, - responseTime99th: { + staleCount: { type: 'number', - description: '99th percentile response time in milliseconds', + description: 'Number of stale DNS queries. Absent when staleCount was not requested', optional: true, }, - }, - }, - min: { - type: 'object', - description: 'Minimum values across the analytics period', - optional: true, - properties: { - queryCount: { type: 'number', description: 'Minimum number of DNS queries' }, - uncachedCount: { type: 'number', description: 'Minimum number of uncached DNS queries' }, - staleCount: { type: 'number', description: 'Minimum number of stale DNS queries' }, responseTimeAvg: { type: 'number', - description: 'Minimum average response time in milliseconds', + description: 'Average response time in milliseconds', optional: true, }, responseTimeMedian: { type: 'number', - description: 'Minimum median response time in milliseconds', + description: 'Median response time in milliseconds', optional: true, }, responseTime90th: { type: 'number', - description: 'Minimum 90th percentile response time in milliseconds', + description: '90th percentile response time in milliseconds', optional: true, }, responseTime99th: { type: 'number', - description: 'Minimum 99th percentile response time in milliseconds', + description: '99th percentile response time in milliseconds', optional: true, }, }, }, + min: { + type: 'json', + description: + 'Per-metric minimums. Cloudflare documents this field as currently always an empty object, so treat a populated value as unexpected rather than relied upon.', + optional: true, + }, max: { - type: 'object', - description: 'Maximum values across the analytics period', + type: 'json', + description: + 'Per-metric maximums. Cloudflare documents this field as currently always an empty object, so treat a populated value as unexpected rather than relied upon.', optional: true, - properties: { - queryCount: { type: 'number', description: 'Maximum number of DNS queries' }, - uncachedCount: { type: 'number', description: 'Maximum number of uncached DNS queries' }, - staleCount: { type: 'number', description: 'Maximum number of stale DNS queries' }, - responseTimeAvg: { - type: 'number', - description: 'Maximum average response time in milliseconds', - optional: true, - }, - responseTimeMedian: { - type: 'number', - description: 'Maximum median response time in milliseconds', - optional: true, - }, - responseTime90th: { - type: 'number', - description: 'Maximum 90th percentile response time in milliseconds', - optional: true, - }, - responseTime99th: { - type: 'number', - description: 'Maximum 99th percentile response time in milliseconds', - optional: true, - }, - }, }, data: { type: 'array', diff --git a/apps/sim/tools/cloudflare/get_zone.ts b/apps/sim/tools/cloudflare/get_zone.ts index 398c05f6a69..5d58a922991 100644 --- a/apps/sim/tools/cloudflare/get_zone.ts +++ b/apps/sim/tools/cloudflare/get_zone.ts @@ -132,7 +132,7 @@ export const getZoneTool: ToolConfig = { id: 'cloudflare_get_zone_settings', name: 'Cloudflare Get Zone Settings', - description: - 'Gets all settings for a zone including SSL mode, caching level, and security settings.', + description: `Reads zone settings such as SSL mode, minimum TLS version, security level, and caching level. Cloudflare retired the endpoint that read every setting in one request, so each setting is read individually — name the ones you need to keep the read small. Defaults to ${DEFAULT_ZONE_SETTING_IDS.join(', ')}.`, version: '1.0.0', params: { @@ -21,6 +53,12 @@ export const getZoneSettingsTool: ToolConfig< visibility: 'user-or-llm', description: 'The zone ID to get settings for', }, + settingIds: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: `Comma-separated setting IDs to read, e.g. "ssl,min_tls_version,security_level". Leave blank to read the default set (${DEFAULT_ZONE_SETTING_IDS.join(', ')}). At most ${MAX_ZONE_SETTING_IDS} settings per call.`, + }, apiKey: { type: 'string', required: true, @@ -30,49 +68,82 @@ export const getZoneSettingsTool: ToolConfig< }, request: { - url: (params) => `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}/settings`, + url: (params) => + zoneSettingUrl(params.zoneId.trim(), requestedZoneSettingIds(params.settingIds)[0]), method: 'GET', - headers: (params) => ({ - Authorization: `Bearer ${params.apiKey}`, - 'Content-Type': 'application/json', - }), + headers: (params) => cloudflareHeaders(params.apiKey), }, - transformResponse: async (response: Response) => { - const data = await response.json() - - if (!data.success) { + /** + * Cloudflare deprecated the batch `GET /zones/{zone_id}/settings` endpoint, + * which reaches end of life on 2027-03-31, in favour of one request per + * setting. The reads are fanned out and gathered back into the single list + * this tool has always returned. + * + * A setting the zone's plan does not expose answers with an error rather than + * a value, so one refusal must not lose the settings that did come back. Those + * ids are reported in `unreadable` instead, and only a read where nothing at + * all was readable fails. + * https://developers.cloudflare.com/fundamentals/api/reference/deprecations/ + */ + directExecution: async (params, signal) => { + const settingIds = requestedZoneSettingIds(params.settingIds) + if (settingIds.length > MAX_ZONE_SETTING_IDS) { return { success: false, - output: { settings: [] }, - error: data.errors?.[0]?.message ?? 'Failed to get zone settings', + output: { settings: [], unreadable: [] }, + error: `Too many settings requested: ${settingIds.length}. Cloudflare reads one setting per request, so at most ${MAX_ZONE_SETTING_IDS} can be read in a single call.`, } } - return { - success: true, - output: { - settings: - data.result?.map((setting: Record) => ({ - id: (setting.id as string) ?? '', - value: - typeof setting.value === 'object' && setting.value !== null - ? JSON.stringify(setting.value) - : String(setting.value ?? ''), - editable: (setting.editable as boolean) ?? false, - modified_on: (setting.modified_on as string) ?? '', - ...(setting.time_remaining != null - ? { time_remaining: setting.time_remaining as number } - : {}), - })) ?? [], - }, + const zoneId = params.zoneId.trim() + const headers = cloudflareHeaders(params.apiKey) + + const reads = await Promise.all( + settingIds.map(async (settingId) => { + try { + const response = await fetch(zoneSettingUrl(zoneId, settingId), { + method: 'GET', + headers, + signal, + }) + const data = (await response.json()) as CloudflareEnvelope + if (!data.success) { + return { + settingId, + error: cloudflareErrorMessage(data, `Failed to read zone setting ${settingId}`), + } + } + return { settingId, setting: mapZoneSetting(settingId, data.result) } + } catch (error) { + return { + settingId, + error: getErrorMessage(error, `Failed to read zone setting ${settingId}`), + } + } + }) + ) + + const settings = reads.flatMap((read) => (read.setting ? [read.setting] : [])) + const unreadable = reads.flatMap((read) => + read.error ? [{ id: read.settingId, error: read.error }] : [] + ) + + if (settings.length === 0) { + return { + success: false, + output: { settings, unreadable }, + error: unreadable[0]?.error ?? 'Failed to get zone settings', + } } + + return { success: true, output: { settings, unreadable } } }, outputs: { settings: { type: 'array', - description: 'List of zone settings', + description: 'The zone settings that were readable', items: { type: 'object', properties: { @@ -96,11 +167,23 @@ export const getZoneSettingsTool: ToolConfig< time_remaining: { type: 'number', description: - 'Seconds remaining until the setting can be modified again (only present for rate-limited settings)', + 'Development mode countdown, in seconds. Cloudflare documents this only on the zones_development_mode setting, where it is the interval from when development mode expires (positive) or last expired (negative)', optional: true, }, }, }, }, + unreadable: { + type: 'array', + description: + 'Requested settings Cloudflare refused, typically because the zone plan does not expose them or the setting ID does not exist', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'The requested setting identifier' }, + error: { type: 'string', description: 'Why Cloudflare would not return the setting' }, + }, + }, + }, }, } diff --git a/apps/sim/tools/cloudflare/list_certificates.ts b/apps/sim/tools/cloudflare/list_certificates.ts index 3bec7e6799a..4a8d104b289 100644 --- a/apps/sim/tools/cloudflare/list_certificates.ts +++ b/apps/sim/tools/cloudflare/list_certificates.ts @@ -87,59 +87,58 @@ export const listCertificatesTool: ToolConfig< return { success: true, output: { - certificates: - data.result?.map((cert) => ({ - id: cert.id ?? '', - type: cert.type ?? '', - hosts: cert.hosts ?? [], - primary_certificate: cert.primary_certificate ?? '', - status: cert.status ?? '', - certificates: - cert.certificates?.map((c) => ({ - id: c.id ?? '', - hosts: c.hosts ?? [], - issuer: c.issuer ?? '', - signature: c.signature ?? '', - status: c.status ?? '', - bundle_method: c.bundle_method ?? '', - zone_id: c.zone_id ?? '', - uploaded_on: c.uploaded_on ?? '', - modified_on: c.modified_on ?? '', - expires_on: c.expires_on ?? '', - priority: c.priority ?? 0, - geo_restrictions: c.geo_restrictions ?? undefined, - })) ?? [], - cloudflare_branding: cert.cloudflare_branding ?? false, - validation_method: cert.validation_method ?? '', - validity_days: cert.validity_days ?? 0, - certificate_authority: cert.certificate_authority ?? '', - validation_errors: - cert.validation_errors?.map((e) => ({ - message: e.message ?? '', - })) ?? [], - validation_records: - cert.validation_records?.map((r) => ({ - cname: r.cname ?? '', - cname_target: r.cname_target ?? '', - emails: r.emails ?? [], - http_body: r.http_body ?? '', - http_url: r.http_url ?? '', - status: r.status ?? '', - txt_name: r.txt_name ?? '', - txt_value: r.txt_value ?? '', - })) ?? [], - dcv_delegation_records: - cert.dcv_delegation_records?.map((r) => ({ - cname: r.cname ?? '', - cname_target: r.cname_target ?? '', - emails: r.emails ?? [], - http_body: r.http_body ?? '', - http_url: r.http_url ?? '', - status: r.status ?? '', - txt_name: r.txt_name ?? '', - txt_value: r.txt_value ?? '', - })) ?? [], - })) ?? [], + certificates: (Array.isArray(data.result) ? data.result : []).map((cert) => ({ + id: cert.id ?? '', + type: cert.type ?? '', + hosts: cert.hosts ?? [], + primary_certificate: cert.primary_certificate ?? '', + status: cert.status ?? '', + certificates: + cert.certificates?.map((c) => ({ + id: c.id ?? '', + hosts: c.hosts ?? [], + issuer: c.issuer ?? '', + signature: c.signature ?? '', + status: c.status ?? '', + bundle_method: c.bundle_method ?? '', + zone_id: c.zone_id ?? '', + uploaded_on: c.uploaded_on ?? '', + modified_on: c.modified_on ?? '', + expires_on: c.expires_on ?? '', + priority: c.priority ?? 0, + geo_restrictions: c.geo_restrictions ?? undefined, + })) ?? [], + cloudflare_branding: cert.cloudflare_branding ?? false, + validation_method: cert.validation_method ?? '', + validity_days: cert.validity_days ?? 0, + certificate_authority: cert.certificate_authority ?? '', + validation_errors: + cert.validation_errors?.map((e) => ({ + message: e.message ?? '', + })) ?? [], + validation_records: + cert.validation_records?.map((r) => ({ + cname: r.cname ?? '', + cname_target: r.cname_target ?? '', + emails: r.emails ?? [], + http_body: r.http_body ?? '', + http_url: r.http_url ?? '', + status: r.status ?? '', + txt_name: r.txt_name ?? '', + txt_value: r.txt_value ?? '', + })) ?? [], + dcv_delegation_records: + cert.dcv_delegation_records?.map((r) => ({ + cname: r.cname ?? '', + cname_target: r.cname_target ?? '', + emails: r.emails ?? [], + http_body: r.http_body ?? '', + http_url: r.http_url ?? '', + status: r.status ?? '', + txt_name: r.txt_name ?? '', + txt_value: r.txt_value ?? '', + })) ?? [], + })), total_count: data.result_info?.total_count ?? data.result?.length ?? 0, }, } diff --git a/apps/sim/tools/cloudflare/list_dns_records.ts b/apps/sim/tools/cloudflare/list_dns_records.ts index 36b91d12dce..1c726d4c990 100644 --- a/apps/sim/tools/cloudflare/list_dns_records.ts +++ b/apps/sim/tools/cloudflare/list_dns_records.ts @@ -150,27 +150,26 @@ export const listDnsRecordsTool: ToolConfig< return { success: true, output: { - records: - data.result?.map((record) => ({ - id: record.id ?? '', - zone_id: record.zone_id ?? '', - zone_name: record.zone_name ?? '', - type: record.type ?? '', - name: record.name ?? '', - content: record.content ?? '', - proxiable: record.proxiable ?? false, - proxied: record.proxied ?? false, - ttl: record.ttl ?? 0, - locked: record.locked ?? false, - priority: record.priority ?? null, - comment: record.comment ?? null, - tags: record.tags ?? [], - comment_modified_on: record.comment_modified_on ?? null, - tags_modified_on: record.tags_modified_on ?? null, - meta: record.meta ?? null, - created_on: record.created_on ?? '', - modified_on: record.modified_on ?? '', - })) ?? [], + records: (Array.isArray(data.result) ? data.result : []).map((record) => ({ + id: record.id ?? '', + zone_id: record.zone_id ?? '', + zone_name: record.zone_name ?? '', + type: record.type ?? '', + name: record.name ?? '', + content: record.content ?? '', + proxiable: record.proxiable ?? false, + proxied: record.proxied ?? false, + ttl: record.ttl ?? 0, + locked: record.locked ?? false, + priority: record.priority ?? null, + comment: record.comment ?? null, + tags: record.tags ?? [], + comment_modified_on: record.comment_modified_on ?? null, + tags_modified_on: record.tags_modified_on ?? null, + meta: record.meta ?? null, + created_on: record.created_on ?? '', + modified_on: record.modified_on ?? '', + })), total_count: data.result_info?.total_count ?? data.result?.length ?? 0, }, } @@ -193,7 +192,11 @@ export const listDnsRecordsTool: ToolConfig< proxied: { type: 'boolean', description: 'Whether Cloudflare proxy is enabled' }, ttl: { type: 'number', description: 'TTL in seconds (1 = automatic)' }, locked: { type: 'boolean', description: 'Whether the record is locked' }, - priority: { type: 'number', description: 'MX/SRV record priority', optional: true }, + priority: { + type: 'number', + description: 'Record priority, returned for MX and URI records', + optional: true, + }, comment: { type: 'string', description: 'Comment associated with the record', diff --git a/apps/sim/tools/cloudflare/list_zones.ts b/apps/sim/tools/cloudflare/list_zones.ts index f9328127388..89615fed4e0 100644 --- a/apps/sim/tools/cloudflare/list_zones.ts +++ b/apps/sim/tools/cloudflare/list_zones.ts @@ -103,49 +103,48 @@ export const listZonesTool: ToolConfig ({ - id: zone.id ?? '', - name: zone.name ?? '', - status: zone.status ?? '', - paused: zone.paused ?? false, - type: zone.type ?? '', - name_servers: zone.name_servers ?? [], - original_name_servers: zone.original_name_servers ?? [], - created_on: zone.created_on ?? '', - modified_on: zone.modified_on ?? '', - activated_on: zone.activated_on ?? '', - development_mode: zone.development_mode ?? 0, - plan: { - id: zone.plan?.id ?? '', - name: zone.plan?.name ?? '', - price: zone.plan?.price ?? 0, - is_subscribed: zone.plan?.is_subscribed ?? false, - frequency: zone.plan?.frequency ?? '', - currency: zone.plan?.currency ?? '', - legacy_id: zone.plan?.legacy_id ?? '', - }, - account: { - id: zone.account?.id ?? '', - name: zone.account?.name ?? '', - }, - owner: { - id: zone.owner?.id ?? '', - name: zone.owner?.name ?? '', - type: zone.owner?.type ?? '', - }, - meta: { - cdn_only: zone.meta?.cdn_only ?? false, - custom_certificate_quota: zone.meta?.custom_certificate_quota ?? 0, - dns_only: zone.meta?.dns_only ?? false, - foundation_dns: zone.meta?.foundation_dns ?? false, - page_rule_quota: zone.meta?.page_rule_quota ?? 0, - phishing_detected: zone.meta?.phishing_detected ?? false, - step: zone.meta?.step ?? 0, - }, - vanity_name_servers: zone.vanity_name_servers ?? [], - permissions: zone.permissions ?? [], - })) ?? [], + zones: (Array.isArray(data.result) ? data.result : []).map((zone) => ({ + id: zone.id ?? '', + name: zone.name ?? '', + status: zone.status ?? '', + paused: zone.paused ?? false, + type: zone.type ?? '', + name_servers: zone.name_servers ?? [], + original_name_servers: zone.original_name_servers ?? [], + created_on: zone.created_on ?? '', + modified_on: zone.modified_on ?? '', + activated_on: zone.activated_on ?? '', + development_mode: zone.development_mode ?? 0, + plan: { + id: zone.plan?.id ?? '', + name: zone.plan?.name ?? '', + price: zone.plan?.price ?? 0, + is_subscribed: zone.plan?.is_subscribed ?? false, + frequency: zone.plan?.frequency ?? '', + currency: zone.plan?.currency ?? '', + legacy_id: zone.plan?.legacy_id ?? '', + }, + account: { + id: zone.account?.id ?? '', + name: zone.account?.name ?? '', + }, + owner: { + id: zone.owner?.id ?? '', + name: zone.owner?.name ?? '', + type: zone.owner?.type ?? '', + }, + meta: { + cdn_only: zone.meta?.cdn_only ?? false, + custom_certificate_quota: zone.meta?.custom_certificate_quota ?? 0, + dns_only: zone.meta?.dns_only ?? false, + foundation_dns: zone.meta?.foundation_dns ?? false, + page_rule_quota: zone.meta?.page_rule_quota ?? 0, + phishing_detected: zone.meta?.phishing_detected ?? false, + step: zone.meta?.step ?? 0, + }, + vanity_name_servers: zone.vanity_name_servers ?? [], + permissions: zone.permissions ?? [], + })), total_count: data.result_info?.total_count ?? data.result?.length ?? 0, }, } @@ -165,7 +164,10 @@ export const listZonesTool: ToolConfig { - if (params.purge_everything) { - return { purge_everything: true } - } - const body: Record = {} if (params.files) { const fileList = String(params.files) @@ -101,20 +97,36 @@ export const purgeCacheTool: ToolConfig 0) body.prefixes = prefixList } - if (Object.keys(body).length === 0) { + const targets = Object.keys(body) + + /** + * `purge_everything` wipes the zone's entire cache, so a request that + * also names specific targets is ambiguous about what the caller wanted. + * Erroring is safer than silently discarding the target list and purging + * everything anyway. + */ + if (params.purge_everything) { + if (targets.length > 0) { + throw new Error( + `purge_everything purges the entire zone cache and cannot be combined with specific targets, but ${targets.join(' and ')} were also provided. Set purge_everything to false to purge only those targets.` + ) + } + return { purge_everything: true } + } + + if (targets.length === 0) { throw new Error( 'No purge targets specified. Provide at least one of: files, tags, hosts, or prefixes, or set purge_everything to true.' ) } /** - * Cloudflare's purge body is a one-of over the five target kinds — each - * is its own request schema, and combining two in a single call is not a - * documented shape. Rejecting here names the conflicting fields instead - * of letting the API answer with a generic parse error. + * Cloudflare's purge request body is an `anyOf` over the target kinds and + * no Cloudflare page documents whether mixing them is supported. Sim + * refuses the combination conservatively so the caller gets a named error + * instead of a generic 400. * https://developers.cloudflare.com/api/resources/cache/methods/purge/ */ - const targets = Object.keys(body) if (targets.length > 1) { throw new Error( `Only one purge target kind is allowed per request, but ${targets.join(' and ')} were provided. Run a separate purge for each.` diff --git a/apps/sim/tools/cloudflare/types.ts b/apps/sim/tools/cloudflare/types.ts index fa827e17f9c..fe6cd87349b 100644 --- a/apps/sim/tools/cloudflare/types.ts +++ b/apps/sim/tools/cloudflare/types.ts @@ -309,8 +309,8 @@ export interface CloudflareRawDnsAnalyticsAggregate { /** Raw DNS analytics report payload. */ export interface CloudflareRawDnsAnalyticsReport { totals?: CloudflareRawDnsAnalyticsAggregate - min?: CloudflareRawDnsAnalyticsAggregate - max?: CloudflareRawDnsAnalyticsAggregate + min?: Record + max?: Record data?: Array<{ dimensions?: string[]; metrics?: number[] }> data_lag?: number rows?: number @@ -683,17 +683,22 @@ export interface CloudflareDnsAnalyticsParams extends CloudflareBaseParams { zoneId: string since?: string until?: string - metrics: string + metrics?: string dimensions?: string filters?: string sort?: string limit?: number } +/** + * Aggregate metric block. Cloudflare only populates the metrics that were + * requested, so every field is optional — an absent field means "not requested" + * rather than zero. + */ interface CloudflareDnsAnalyticsTotals { - queryCount: number - uncachedCount: number - staleCount: number + queryCount?: number + uncachedCount?: number + staleCount?: number responseTimeAvg?: number responseTimeMedian?: number responseTime90th?: number @@ -713,8 +718,10 @@ interface CloudflareDnsAnalyticsQuery { export interface CloudflareDnsAnalyticsResponse extends ToolResponse { output: { totals: CloudflareDnsAnalyticsTotals - min: CloudflareDnsAnalyticsTotals - max: CloudflareDnsAnalyticsTotals + /** Cloudflare documents this as "currently always an empty object". */ + min: Record | null + /** Cloudflare documents this as "currently always an empty object". */ + max: Record | null data: Array<{ dimensions: string[] metrics: number[] @@ -727,6 +734,7 @@ export interface CloudflareDnsAnalyticsResponse extends ToolResponse { export interface CloudflareGetZoneSettingsParams extends CloudflareBaseParams { zoneId: string + settingIds?: string } interface CloudflareZoneSetting { @@ -737,9 +745,25 @@ interface CloudflareZoneSetting { time_remaining?: number } +/** Raw zone setting payload, as returned by the per-setting endpoint. */ +export interface CloudflareRawZoneSetting { + id?: string + value?: unknown + editable?: boolean + modified_on?: string | null + time_remaining?: number | null +} + +/** A setting Cloudflare refused, so the caller sees the gap rather than a silent omission. */ +interface CloudflareUnreadableZoneSetting { + id: string + error: string +} + export interface CloudflareGetZoneSettingsResponse extends ToolResponse { output: { settings: CloudflareZoneSetting[] + unreadable: CloudflareUnreadableZoneSetting[] } } @@ -909,6 +933,9 @@ export interface CloudflareUpdateRateLimitRuleParams extends CloudflareBaseParam requestsToOrigin?: boolean description?: string enabled?: boolean + ref?: string + actionParameters?: string + logging?: string } interface CloudflareAccessApplication { diff --git a/apps/sim/tools/cloudflare/update_access_application.ts b/apps/sim/tools/cloudflare/update_access_application.ts index 8204a2eb317..db78a62f8f4 100644 --- a/apps/sim/tools/cloudflare/update_access_application.ts +++ b/apps/sim/tools/cloudflare/update_access_application.ts @@ -20,7 +20,7 @@ export const updateAccessApplicationTool: ToolConfig< id: 'cloudflare_update_access_application', name: 'Cloudflare Update Access Application', description: - 'Updates a Cloudflare Access (Zero Trust) application. This replaces the application definition rather than merging it, so send every field the application should keep — anything you omit reverts to its default, which can widen or break access. Read the current configuration with "Get Access Application" first. Requires an API token with Account Access: Apps and Policies Edit.', + 'Updates a Cloudflare Access (Zero Trust) application. Cloudflare does not document merge behavior for this PUT, so treat it as a replace: send every field the application should keep, because an omitted field may revert to its default and widen or break access. Read the current configuration with "Get Access Application" first. Requires an API token with Account Access: Apps and Policies Edit.', version: '1.0.0', params: { diff --git a/apps/sim/tools/cloudflare/update_access_policy.ts b/apps/sim/tools/cloudflare/update_access_policy.ts index 2c6b2357b38..529155a1d05 100644 --- a/apps/sim/tools/cloudflare/update_access_policy.ts +++ b/apps/sim/tools/cloudflare/update_access_policy.ts @@ -18,7 +18,7 @@ export const updateAccessPolicyTool: ToolConfig< id: 'cloudflare_update_access_policy', name: 'Cloudflare Update Access Policy', description: - 'Updates a Cloudflare Access (Zero Trust) policy on an application. This replaces the policy definition rather than merging it, so send every rule the policy should keep — omitted exclude or require rules are dropped, which can widen who gets in. The change applies to live traffic immediately. Read the current policy with "List Access Policies" first. Requires an API token with Account Access: Apps and Policies Edit.', + 'Updates a Cloudflare Access (Zero Trust) policy on an application. Cloudflare does not document merge behavior for this PUT, so treat it as a replace: send every rule the policy should keep, because an omitted exclude or require rule may be dropped and widen who gets in. The change applies to live traffic immediately. Read the current policy with "List Access Policies" first. Requires an API token with Account Access: Apps and Policies Edit.', version: '1.0.0', params: { diff --git a/apps/sim/tools/cloudflare/update_dns_record.ts b/apps/sim/tools/cloudflare/update_dns_record.ts index 36c21df8e7b..7f86743d0a0 100644 --- a/apps/sim/tools/cloudflare/update_dns_record.ts +++ b/apps/sim/tools/cloudflare/update_dns_record.ts @@ -60,7 +60,8 @@ export const updateDnsRecordTool: ToolConfig< type: 'number', required: false, visibility: 'user-or-llm', - description: 'Priority for MX and SRV records', + description: + 'Record priority. Cloudflare accepts this top-level field for MX and URI records only; an SRV record carries its priority, weight, port, and target inside the record content instead', }, comment: { type: 'string', @@ -190,7 +191,11 @@ export const updateDnsRecordTool: ToolConfig< proxied: { type: 'boolean', description: 'Whether Cloudflare proxy is enabled' }, ttl: { type: 'number', description: 'Time to live in seconds (1 = automatic)' }, locked: { type: 'boolean', description: 'Whether the record is locked' }, - priority: { type: 'number', description: 'Priority for MX and SRV records', optional: true }, + priority: { + type: 'number', + description: 'Record priority, returned for MX and URI records', + optional: true, + }, comment: { type: 'string', description: 'Comment associated with the record', optional: true }, tags: { type: 'array', diff --git a/apps/sim/tools/cloudflare/update_rate_limit_rule.ts b/apps/sim/tools/cloudflare/update_rate_limit_rule.ts index 495cc5f3804..bedd37d6108 100644 --- a/apps/sim/tools/cloudflare/update_rate_limit_rule.ts +++ b/apps/sim/tools/cloudflare/update_rate_limit_rule.ts @@ -8,6 +8,7 @@ import { emptyRuleset, mapRuleset, parseCsvParam, + parseJsonObjectParam, } from '@/tools/cloudflare/utils' import type { ToolConfig } from '@/tools/types' @@ -52,7 +53,7 @@ export const updateRateLimitRuleTool: ToolConfig< required: true, visibility: 'user-or-llm', description: - 'Comma-separated counting characteristics. cf.colo.id is mandatory, plus exactly one of ip.src or cf.unique_visitor_id', + 'Comma-separated counting characteristics. cf.colo.id is mandatory. ip.src and cf.unique_visitor_id are mutually exclusive — include at most one.', }, period: { type: 'number', @@ -105,6 +106,27 @@ export const updateRateLimitRuleTool: ToolConfig< visibility: 'user-or-llm', description: 'Whether the rule is enabled', }, + ref: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Reference tag that stays stable across rule updates. Because the update replaces the rule, omitting it resets the tag to the rule ID and breaks anything matching on the old value', + }, + actionParameters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON object of action-specific parameters for the mitigation action, e.g. {"response":{"status_code":429,"content":"{\\"error\\":\\"rate limited\\"}","content_type":"application/json"}} for a custom block response. Because the update replaces the rule, omitting it resets action_parameters to {} and the rule falls back to Cloudflare\'s default block page', + }, + logging: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON logging configuration to preserve, e.g. {"enabled":true}. Omitting it on a rule that had logging configured resets it to the default', + }, apiKey: { type: 'string', required: true, @@ -144,6 +166,20 @@ export const updateRateLimitRuleTool: ToolConfig< } if (params.description !== undefined) body.description = params.description if (params.enabled !== undefined) body.enabled = params.enabled + if (params.ref) body.ref = params.ref + + /** + * PATCH replaces the whole rule definition, so any of these left out falls + * back to its schema default: a custom block response reverts to + * Cloudflare's default block page and the logging configuration resets. + * Forward them whenever the caller resends them. + * https://developers.cloudflare.com/ruleset-engine/rulesets-api/update-rule/ + */ + const actionParameters = parseJsonObjectParam(params.actionParameters, 'Action Parameters') + if (actionParameters) body.action_parameters = actionParameters + + const logging = parseJsonObjectParam(params.logging, 'Logging Configuration') + if (logging) body.logging = logging return body }, diff --git a/apps/sim/tools/cloudflare/update_zone_setting.ts b/apps/sim/tools/cloudflare/update_zone_setting.ts index daac2b6c724..c7ba09db206 100644 --- a/apps/sim/tools/cloudflare/update_zone_setting.ts +++ b/apps/sim/tools/cloudflare/update_zone_setting.ts @@ -136,7 +136,7 @@ export const updateZoneSettingTool: ToolConfig< time_remaining: { type: 'number', description: - 'Seconds remaining until the setting can be modified again (only present for rate-limited settings)', + 'Development mode countdown, in seconds. Cloudflare documents this only on the zones_development_mode setting, where it is the interval from when development mode expires (positive) or last expired (negative)', optional: true, }, }, diff --git a/apps/sim/tools/cloudflare/utils.ts b/apps/sim/tools/cloudflare/utils.ts index e9b5678311d..f92b4bc7a36 100644 --- a/apps/sim/tools/cloudflare/utils.ts +++ b/apps/sim/tools/cloudflare/utils.ts @@ -216,6 +216,42 @@ export function emptyAccessPolicy() { } } +/** + * The zone settings "Get Zone Settings" reads when the caller names none. + * + * Cloudflare deprecated the batch `GET /zones/{zone_id}/settings` endpoint, + * which reaches end of life on 2027-03-31, and directs integrations at the + * per-setting `GET /zones/{zone_id}/settings/{setting_id}` endpoint. "Read the + * zone's settings" is therefore a fan-out over an explicit, bounded list rather + * than one request. + * https://developers.cloudflare.com/fundamentals/api/reference/deprecations/ + */ +export const DEFAULT_ZONE_SETTING_IDS = [ + 'ssl', + 'always_use_https', + 'min_tls_version', + 'tls_1_3', + 'security_level', + 'cache_level', + 'browser_cache_ttl', + 'development_mode', + 'rocket_loader', + 'email_obfuscation', + 'hotlink_protection', + 'ip_geolocation', + 'http2', + 'http3', + 'websockets', +] + +/** Upper bound on one zone-settings fan-out, so a pasted list cannot become an unbounded burst. */ +export const MAX_ZONE_SETTING_IDS = 40 + +/** Resolves the requested zone setting ids, falling back to the default read set. */ +export function requestedZoneSettingIds(settingIds: unknown): string[] { + return parseCsvParam(settingIds) ?? DEFAULT_ZONE_SETTING_IDS +} + /** Appends a query param when the value is present and non-empty. */ export function appendParam(url: URL, key: string, value: unknown): void { if (value === undefined || value === null || value === '') return diff --git a/apps/sim/tools/crowdstrike/create_indicators.ts b/apps/sim/tools/crowdstrike/create_indicators.ts index 74d4a58e9f6..be5d19ce459 100644 --- a/apps/sim/tools/crowdstrike/create_indicators.ts +++ b/apps/sim/tools/crowdstrike/create_indicators.ts @@ -38,7 +38,7 @@ export const crowdstrikeCreateIndicatorsTool: ToolConfig< required: true, visibility: 'user-or-llm', description: - 'JSON array of indicators to create. Each entry requires type, value, and applied_globally (boolean). type is one of sha256, md5, domain, ipv4, ipv6; action is one of no_action, allow, prevent_no_ui, prevent, detect; severity is one of informational, low, medium, high, critical; platforms entries are windows, mac, or linux. Other documented fields: host_groups (array), description, source, tags (array), expiration (ISO 8601), mobile_action, metadata ({ filename }). Either applied_globally must be true or host_groups must be supplied. Tenants can extend these value sets, so treat them as the documented defaults rather than a closed list.', + 'JSON array of indicators to create. Each entry requires type, value, and applied_globally (boolean). type is one of sha256, md5, domain, ipv4, ipv6; action is one of no_action, allow, prevent, detect (prevent_no_ui is widely reported and appears in the Falcon console, but CrowdStrike does not enumerate it in the IOC API docs - call GET /iocs/queries/actions/v1 to read the actions your tenant actually accepts); severity is one of informational, low, medium, high, critical; platforms entries are windows, mac, or linux. Other documented fields: host_groups (array), description, source, tags (array), expiration (ISO 8601), mobile_action, metadata ({ filename }). Either applied_globally must be true or host_groups must be supplied. Tenants can extend these value sets, so treat them as the documented defaults rather than a closed list.', }, comment: { type: 'string', diff --git a/apps/sim/tools/crowdstrike/crowdstrike.test.ts b/apps/sim/tools/crowdstrike/crowdstrike.test.ts index de133bed54c..1e083ad41ac 100644 --- a/apps/sim/tools/crowdstrike/crowdstrike.test.ts +++ b/apps/sim/tools/crowdstrike/crowdstrike.test.ts @@ -4,10 +4,12 @@ import { describe, expect, it } from 'vitest' import { crowdstrikeQueryBodySchema } from '@/lib/api/contracts/tools/crowdstrike' import { CrowdStrikeBlock } from '@/blocks/blocks/crowdstrike' +import { crowdstrikeCreateIndicatorsTool } from '@/tools/crowdstrike/create_indicators' import { crowdstrikeExecuteRtrCommandTool } from '@/tools/crowdstrike/execute_rtr_command' import { crowdstrikeGetSensorAggregatesTool } from '@/tools/crowdstrike/get_sensor_aggregates' import { crowdstrikeGetSensorDetailsTool } from '@/tools/crowdstrike/get_sensor_details' import { crowdstrikeQueryAlertsTool } from '@/tools/crowdstrike/query_alerts' +import { crowdstrikeQueryIndicatorsTool } from '@/tools/crowdstrike/query_indicators' import { crowdstrikeQuerySensorsTool } from '@/tools/crowdstrike/query_sensors' import type { CrowdStrikeGetSensorAggregatesResponse, @@ -168,27 +170,81 @@ describe('CrowdStrike RTR read-only base commands', () => { }) describe('CrowdStrike sort placeholders', () => { + const sortBlocks = CrowdStrikeBlock.subBlocks.filter((block) => block.id === 'sort') + + function placeholderFor(operation: string) { + const match = sortBlocks.find((block) => { + const declared = (block.condition as { value: string | string[] }).value + return Array.isArray(declared) ? declared.includes(operation) : declared === operation + }) + return match?.placeholder + } + it('shows the dot form for the collections that document it and the pipe form elsewhere', () => { - const sortBlocks = CrowdStrikeBlock.subBlocks.filter((block) => block.id === 'sort') - expect(sortBlocks).toHaveLength(2) + expect(sortBlocks).toHaveLength(3) for (const operation of ['crowdstrike_query_host_groups', 'crowdstrike_query_sensors']) { - const match = sortBlocks.find((block) => - (block.condition as { value: string[] }).value.includes(operation) - ) - expect(match?.placeholder).toBe('name.asc') + expect(placeholderFor(operation)).toBe('name.asc') } for (const operation of [ 'crowdstrike_query_alerts', - 'crowdstrike_query_indicators', 'crowdstrike_query_vulnerabilities', 'crowdstrike_query_cases', ]) { - const match = sortBlocks.find((block) => - (block.condition as { value: string[] }).value.includes(operation) - ) - expect(match?.placeholder).toBe('created_timestamp|desc') + expect(placeholderFor(operation)).toBe('created_timestamp|desc') } }) + + it('steers IOC Management to the dot form and away from created_timestamp', () => { + // PSFalcon's Get-FalconIoc -Sort ValidateSet is the dot form, and the IOC + // sort enum has no created_timestamp - the timestamp fields are created_on + // and modified_on. The pipe form here sent users toward a sort Falcon rejects. + const placeholder = placeholderFor('crowdstrike_query_indicators') + expect(placeholder).toBe('created_on.desc') + expect(placeholder).not.toContain('|') + expect(placeholder).not.toContain('created_timestamp') + }) +}) + +describe('CrowdStrike limit caps name their real source', () => { + it('does not attribute the IOC indicator cap to CrowdStrike', () => { + // GET /iocs/queries/indicators/v1 publishes no `maximum`, unlike host groups + // (5000) and Spotlight (400), and PSFalcon clamps to 2000 on its own. + const messages = issueMessages( + crowdstrikeQueryBodySchema.safeParse({ + ...credentials, + operation: 'crowdstrike_query_indicators', + limit: 501, + }) + ) + + expect( + messages.some((message) => /Sim caps this request at 500 indicators/.test(message)) + ).toBe(true) + expect(messages.some((message) => /CrowdStrike accepts at most 500/.test(message))).toBe(false) + expect(crowdstrikeQueryIndicatorsTool.params.limit.description).toMatch( + /Sim caps it at 500|publishes no maximum/i + ) + }) + + it('still accepts a documented cap at its published maximum', () => { + expect( + crowdstrikeQueryBodySchema.safeParse({ + ...credentials, + operation: 'crowdstrike_query_vulnerabilities', + filter: "status:'open'", + limit: 400, + }).success + ).toBe(true) + }) +}) + +describe('CrowdStrike IOC action list stays inside what CrowdStrike documents', () => { + it('does not present prevent_no_ui as a documented action value', () => { + const description = crowdstrikeCreateIndicatorsTool.params.indicators.description ?? '' + expect(description).toMatch(/no_action, allow, prevent, detect/) + expect(description).not.toMatch(/allow, prevent_no_ui, prevent/) + expect(description).toMatch(/\/iocs\/queries\/actions\/v1/) + }) }) diff --git a/apps/sim/tools/crowdstrike/query_indicators.ts b/apps/sim/tools/crowdstrike/query_indicators.ts index 2e36d7547f1..4e788d2e91e 100644 --- a/apps/sim/tools/crowdstrike/query_indicators.ts +++ b/apps/sim/tools/crowdstrike/query_indicators.ts @@ -43,7 +43,8 @@ export const crowdstrikeQueryIndicatorsTool: ToolConfig< type: 'number', required: false, visibility: 'user-or-llm', - description: 'Maximum number of IOC IDs to return (1-500, default 100)', + description: + 'Maximum number of IOC IDs to return (default 100). CrowdStrike publishes no maximum for this endpoint; Sim caps it at 500 to keep a single request bounded', }, offset: { type: 'number', diff --git a/apps/sim/tools/datadog/add_incident_todo.ts b/apps/sim/tools/datadog/add_incident_todo.ts index c82dc16b63e..85a41923c0e 100644 --- a/apps/sim/tools/datadog/add_incident_todo.ts +++ b/apps/sim/tools/datadog/add_incident_todo.ts @@ -3,6 +3,7 @@ import { datadogApiUrl, datadogErrorMessage, datadogHeaders, + datadogPathSegment, splitCommaList, } from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' @@ -64,7 +65,7 @@ export const addIncidentTodoTool: ToolConfig datadogApiUrl( params.site, - `/api/v2/incidents/${encodeURIComponent(params.incidentId)}/relationships/todos` + `/api/v2/incidents/${datadogPathSegment(params.incidentId)}/relationships/todos` ), method: 'POST', headers: datadogHeaders, diff --git a/apps/sim/tools/datadog/cancel_downtime.ts b/apps/sim/tools/datadog/cancel_downtime.ts index 892ddbc60bc..e46b1a3bb1c 100644 --- a/apps/sim/tools/datadog/cancel_downtime.ts +++ b/apps/sim/tools/datadog/cancel_downtime.ts @@ -1,5 +1,5 @@ import type { CancelDowntimeParams, CancelDowntimeResponse } from '@/tools/datadog/types' -import { datadogErrorMessage } from '@/tools/datadog/utils' +import { datadogErrorMessage, datadogPathSegment } from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' export const cancelDowntimeTool: ToolConfig = { @@ -38,7 +38,7 @@ export const cancelDowntimeTool: ToolConfig { const site = params.site || 'datadoghq.com' - const downtimeId = encodeURIComponent(String(params.downtimeId).trim()) + const downtimeId = datadogPathSegment(params.downtimeId) return `https://api.${site}/api/v2/downtime/${downtimeId}` }, method: 'DELETE', diff --git a/apps/sim/tools/datadog/create_event.ts b/apps/sim/tools/datadog/create_event.ts index c89cd6f4612..f993e96977c 100644 --- a/apps/sim/tools/datadog/create_event.ts +++ b/apps/sim/tools/datadog/create_event.ts @@ -70,7 +70,7 @@ export const createEventTool: ToolConfig required: false, visibility: 'user-or-llm', description: - 'Unix timestamp in seconds when the event occurred (e.g., 1705320000, defaults to now)', + 'Unix timestamp in seconds when the event occurred (e.g., 1705320000, defaults to now). Datadog limits this to events no older than 18 hours.', }, apiKey: { type: 'string', diff --git a/apps/sim/tools/datadog/datadog.test.ts b/apps/sim/tools/datadog/datadog.test.ts index e8b31df7daa..620aa992f79 100644 --- a/apps/sim/tools/datadog/datadog.test.ts +++ b/apps/sim/tools/datadog/datadog.test.ts @@ -348,37 +348,51 @@ describe('pagination wiring', () => { }) }) +/** + * `MuteMonitor` and `UnmuteMonitor` declare no `requestBody` in the authoritative + * spec (`docs.datadoghq.com/resources/json/full_spec_v1.json`); `scope`, `end`, + * and `all_scopes` are all `in: query`. Sent as a JSON body they are dropped, and + * a scoped, time-boxed mute silently becomes an indefinite mute across every + * scope — answered with a 200 and the full monitor object, so nothing surfaces. + * + * Note the generated `datadog-api-client-go` v1 schema omits these operations + * entirely; it is a subset, not the authority. + */ describe('monitor mute and unmute', () => { - it('mutes with the scope and end datadogpy documents', () => { - const body = callBody(muteMonitorTool, { + it('mutes with scope and end in the query string, not a body', () => { + const url = callUrl(muteMonitorTool, { ...auth, monitorId: '123', scope: 'host:web-1', end: 1705323600, } as any) - expect(body).toEqual({ scope: 'host:web-1', end: 1705323600 }) - expect(callUrl(muteMonitorTool, { ...auth, monitorId: '123' } as any)).toContain( - '/api/v1/monitor/123/mute' - ) + expect(url).toContain('/api/v1/monitor/123/mute?') + expect(url).toContain('scope=host%3Aweb-1') + expect(url).toContain('end=1705323600') + expect(muteMonitorTool.request.body).toBeUndefined() }) - /** An indefinite mute sends no `end`, so the monitor stays muted until unmuted. */ - it('omits end when the caller wants an indefinite mute', () => { - const body = callBody(muteMonitorTool, { ...auth, monitorId: '123' } as any) - expect(body).not.toHaveProperty('end') + /** An indefinite, unscoped mute sends neither parameter and no stray `?`. */ + it('omits end and scope when the caller wants an indefinite mute', () => { + const url = callUrl(muteMonitorTool, { ...auth, monitorId: '123' } as any) + expect(url).toBe('https://api.datadoghq.com/api/v1/monitor/123/mute') }) /** Muting is only safe to ship because it can be reversed from Sim. */ - it('ships an unmute counterpart that can clear every scope', () => { - const body = callBody(unmuteMonitorTool, { + it('ships an unmute counterpart that can clear every scope, also via query', () => { + const url = callUrl(unmuteMonitorTool, { ...auth, monitorId: '123', allScopes: true, } as any) - expect(body).toEqual({ all_scopes: true }) - expect(callUrl(unmuteMonitorTool, { ...auth, monitorId: '123' } as any)).toContain( - '/api/v1/monitor/123/unmute' - ) + expect(url).toContain('/api/v1/monitor/123/unmute?') + expect(url).toContain('all_scopes=true') + expect(unmuteMonitorTool.request.body).toBeUndefined() + }) + + it('omits all_scopes and scope when the caller sets neither', () => { + const url = callUrl(unmuteMonitorTool, { ...auth, monitorId: '123' } as any) + expect(url).toBe('https://api.datadoghq.com/api/v1/monitor/123/unmute') }) }) @@ -482,14 +496,42 @@ describe('list_downtimes limit description', () => { describe('list_monitors pagination', () => { /** - * Datadog returns every monitor when `page` is absent, so both page params have to reach - * the request for the page size to have any effect. + * Datadog: `page_size` — "If the page argument is not specified, the default + * behavior returns all monitors without a `page_size` limit." So a page size on + * its own is inert, and a user who set one from a control that reads as a bound + * would get every monitor in the org buffered whole. */ it('sends page and page_size', () => { const url = callUrl(listMonitorsTool, { ...auth, page: 2, pageSize: 50 } as any) expect(url).toContain('page=2') expect(url).toContain('page_size=50') }) + + it('implies page 0 when only a page size is set, so the bound actually applies', () => { + const url = callUrl(listMonitorsTool, { ...auth, pageSize: 50 } as any) + expect(url).toContain('page=0') + expect(url).toContain('page_size=50') + }) + + /** An explicit page 0 is Datadog's first page, not an omission. */ + it('keeps an explicit page 0', () => { + expect(callUrl(listMonitorsTool, { ...auth, page: 0 } as any)).toContain('page=0') + }) + + /** + * Neither set stays unpaginated: defaulting `page` unconditionally would + * silently truncate a caller relying on the documented return-everything + * behavior, which is the same class of bug as a house `max_count` default. + */ + it('sends no pagination when the caller sets neither', () => { + const url = callUrl(listMonitorsTool, { ...auth } as any) + expect(url).not.toContain('page') + }) + + it('states the page-dependency rule in both parameter descriptions', () => { + expect(listMonitorsTool.params.page.description).toMatch(/without pagination/) + expect(listMonitorsTool.params.pageSize.description).toMatch(/only applies this when a page/) + }) }) describe('list_dashboards filters', () => { @@ -531,3 +573,34 @@ describe('registry surface', () => { expect(createEventTool.params.applicationKey).toBeUndefined() }) }) + +describe('undisclosed vendor limits and Sim defaults', () => { + /** + * `EventCreateRequest.date_happened` is documented "Limited to events no older + * than 18 hours". A backfill outside that window is rejected, or accepted and + * clamped, for a reason nothing in the tool explained. + */ + it('discloses the 18-hour ceiling on create_event date_happened', () => { + expect(createEventTool.params.dateHappened.description).toMatch(/18 hours/) + }) + + /** + * `ddsource: 'custom'` is injected by Sim, not by Datadog — and it decides + * which log pipeline Datadog applies, so it must not read as a vendor default. + */ + it('discloses that ddsource="custom" is a Sim default', () => { + const description = String(sendLogsTool.params.logs.description) + + expect(description).toMatch(/ddsource="custom"/) + expect(description).toMatch(/Sim default, not a Datadog one/) + }) + + it('still applies that default so an entry without ddsource is not sent bare', () => { + const body = callBody(sendLogsTool, { + ...auth, + logs: JSON.stringify([{ message: 'hello' }]), + } as any) + + expect(body[0].ddsource).toBe('custom') + }) +}) diff --git a/apps/sim/tools/datadog/delete_dashboard.ts b/apps/sim/tools/datadog/delete_dashboard.ts index 1d9411d69db..ca35597fd33 100644 --- a/apps/sim/tools/datadog/delete_dashboard.ts +++ b/apps/sim/tools/datadog/delete_dashboard.ts @@ -1,5 +1,10 @@ import type { DeleteDashboardParams, DeleteDashboardResponse } from '@/tools/datadog/types' -import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' +import { + datadogApiUrl, + datadogErrorMessage, + datadogHeaders, + datadogPathSegment, +} from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' export const deleteDashboardTool: ToolConfig = { @@ -37,7 +42,7 @@ export const deleteDashboardTool: ToolConfig - datadogApiUrl(params.site, `/api/v1/dashboard/${encodeURIComponent(params.dashboardId)}`), + datadogApiUrl(params.site, `/api/v1/dashboard/${datadogPathSegment(params.dashboardId)}`), method: 'DELETE', headers: datadogHeaders, }, diff --git a/apps/sim/tools/datadog/delete_slo.ts b/apps/sim/tools/datadog/delete_slo.ts index f0c9b672a46..3058db93a71 100644 --- a/apps/sim/tools/datadog/delete_slo.ts +++ b/apps/sim/tools/datadog/delete_slo.ts @@ -1,5 +1,10 @@ import type { DeleteSloParams, DeleteSloResponse } from '@/tools/datadog/types' -import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' +import { + datadogApiUrl, + datadogErrorMessage, + datadogHeaders, + datadogPathSegment, +} from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' export const deleteSloTool: ToolConfig = { @@ -47,7 +52,7 @@ export const deleteSloTool: ToolConfig = { const queryString = params.force ? '?force=true' : '' return datadogApiUrl( params.site, - `/api/v1/slo/${encodeURIComponent(params.sloId)}${queryString}` + `/api/v1/slo/${datadogPathSegment(params.sloId)}${queryString}` ) }, method: 'DELETE', diff --git a/apps/sim/tools/datadog/get_browser_synthetics_results.ts b/apps/sim/tools/datadog/get_browser_synthetics_results.ts index d4986798ca1..afe8ad6521a 100644 --- a/apps/sim/tools/datadog/get_browser_synthetics_results.ts +++ b/apps/sim/tools/datadog/get_browser_synthetics_results.ts @@ -6,6 +6,7 @@ import { datadogApiUrl, datadogErrorMessage, datadogHeaders, + datadogPathSegment, splitCommaList, } from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' @@ -76,7 +77,7 @@ export const getBrowserSyntheticsResultsTool: ToolConfig< const queryString = queryParams.toString() return datadogApiUrl( params.site, - `/api/v1/synthetics/tests/browser/${encodeURIComponent(params.publicId)}/results${ + `/api/v1/synthetics/tests/browser/${datadogPathSegment(params.publicId)}/results${ queryString ? `?${queryString}` : '' }` ) diff --git a/apps/sim/tools/datadog/get_dashboard.ts b/apps/sim/tools/datadog/get_dashboard.ts index b3eb9c4ad3f..9f94918077e 100644 --- a/apps/sim/tools/datadog/get_dashboard.ts +++ b/apps/sim/tools/datadog/get_dashboard.ts @@ -1,5 +1,10 @@ import type { GetDashboardParams, GetDashboardResponse } from '@/tools/datadog/types' -import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' +import { + datadogApiUrl, + datadogErrorMessage, + datadogHeaders, + datadogPathSegment, +} from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' export const getDashboardTool: ToolConfig = { @@ -37,7 +42,7 @@ export const getDashboardTool: ToolConfig - datadogApiUrl(params.site, `/api/v1/dashboard/${encodeURIComponent(params.dashboardId)}`), + datadogApiUrl(params.site, `/api/v1/dashboard/${datadogPathSegment(params.dashboardId)}`), method: 'GET', headers: datadogHeaders, }, diff --git a/apps/sim/tools/datadog/get_incident.ts b/apps/sim/tools/datadog/get_incident.ts index 8574c6438c8..7da709d961d 100644 --- a/apps/sim/tools/datadog/get_incident.ts +++ b/apps/sim/tools/datadog/get_incident.ts @@ -3,6 +3,7 @@ import { datadogApiUrl, datadogErrorMessage, datadogHeaders, + datadogPathSegment, splitCommaList, } from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' @@ -53,7 +54,7 @@ export const getIncidentTool: ToolConfig const queryString = include ? `?${new URLSearchParams({ include }).toString()}` : '' return datadogApiUrl( params.site, - `/api/v2/incidents/${encodeURIComponent(params.incidentId)}${queryString}` + `/api/v2/incidents/${datadogPathSegment(params.incidentId)}${queryString}` ) }, method: 'GET', diff --git a/apps/sim/tools/datadog/get_monitor.ts b/apps/sim/tools/datadog/get_monitor.ts index 2d69e114e39..2cd3fdab2a4 100644 --- a/apps/sim/tools/datadog/get_monitor.ts +++ b/apps/sim/tools/datadog/get_monitor.ts @@ -1,5 +1,5 @@ import type { GetMonitorParams, GetMonitorResponse } from '@/tools/datadog/types' -import { datadogErrorMessage } from '@/tools/datadog/utils' +import { datadogErrorMessage, datadogPathSegment } from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' export const getMonitorTool: ToolConfig = { @@ -56,7 +56,7 @@ export const getMonitorTool: ToolConfig = if (params.groupStates) queryParams.set('group_states', params.groupStates) if (params.withDowntimes) queryParams.set('with_downtimes', 'true') - const monitorId = encodeURIComponent(String(params.monitorId).trim()) + const monitorId = datadogPathSegment(params.monitorId) const queryString = queryParams.toString() return `https://api.${site}/api/v1/monitor/${monitorId}${queryString ? `?${queryString}` : ''}` }, diff --git a/apps/sim/tools/datadog/get_security_signal.ts b/apps/sim/tools/datadog/get_security_signal.ts index d5eee359db6..3f8527445de 100644 --- a/apps/sim/tools/datadog/get_security_signal.ts +++ b/apps/sim/tools/datadog/get_security_signal.ts @@ -1,5 +1,10 @@ import type { GetSecuritySignalParams, GetSecuritySignalResponse } from '@/tools/datadog/types' -import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' +import { + datadogApiUrl, + datadogErrorMessage, + datadogHeaders, + datadogPathSegment, +} from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' export const getSecuritySignalTool: ToolConfig = @@ -41,7 +46,7 @@ export const getSecuritySignalTool: ToolConfig datadogApiUrl( params.site, - `/api/v2/security_monitoring/signals/${encodeURIComponent(params.signalId)}` + `/api/v2/security_monitoring/signals/${datadogPathSegment(params.signalId)}` ), method: 'GET', headers: datadogHeaders, diff --git a/apps/sim/tools/datadog/get_slo.ts b/apps/sim/tools/datadog/get_slo.ts index f902b503ca6..6a01d0347af 100644 --- a/apps/sim/tools/datadog/get_slo.ts +++ b/apps/sim/tools/datadog/get_slo.ts @@ -1,5 +1,10 @@ import type { GetSloParams, GetSloResponse } from '@/tools/datadog/types' -import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' +import { + datadogApiUrl, + datadogErrorMessage, + datadogHeaders, + datadogPathSegment, +} from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' export const getSloTool: ToolConfig = { @@ -46,7 +51,7 @@ export const getSloTool: ToolConfig = { const queryString = params.withConfiguredAlertIds ? '?with_configured_alert_ids=true' : '' return datadogApiUrl( params.site, - `/api/v1/slo/${encodeURIComponent(params.sloId)}${queryString}` + `/api/v1/slo/${datadogPathSegment(params.sloId)}${queryString}` ) }, method: 'GET', diff --git a/apps/sim/tools/datadog/get_slo_history.ts b/apps/sim/tools/datadog/get_slo_history.ts index 01199c0322c..a047d95d254 100644 --- a/apps/sim/tools/datadog/get_slo_history.ts +++ b/apps/sim/tools/datadog/get_slo_history.ts @@ -1,5 +1,10 @@ import type { GetSloHistoryParams, GetSloHistoryResponse } from '@/tools/datadog/types' -import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' +import { + datadogApiUrl, + datadogErrorMessage, + datadogHeaders, + datadogPathSegment, +} from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' export const getSloHistoryTool: ToolConfig = { @@ -72,7 +77,7 @@ export const getSloHistoryTool: ToolConfig = @@ -41,7 +46,7 @@ export const getSyntheticsTestTool: ToolConfig datadogApiUrl( params.site, - `/api/v1/synthetics/tests/${encodeURIComponent(params.publicId)}` + `/api/v1/synthetics/tests/${datadogPathSegment(params.publicId)}` ), method: 'GET', headers: datadogHeaders, diff --git a/apps/sim/tools/datadog/list_monitors.ts b/apps/sim/tools/datadog/list_monitors.ts index f910ab3eac3..ca4ec09a95e 100644 --- a/apps/sim/tools/datadog/list_monitors.ts +++ b/apps/sim/tools/datadog/list_monitors.ts @@ -45,13 +45,15 @@ export const listMonitorsTool: ToolConfig = { @@ -50,17 +55,28 @@ export const muteMonitorTool: ToolConfig }, }, + /** + * `scope` and `end` are query parameters, not a request body: the MuteMonitor + * operation declares no `requestBody` and documents both under "Query + * Strings". Sent as a body they are dropped, turning a scoped, time-boxed + * mute into an indefinite mute across every scope — with a 200 and the full + * monitor object back, so the caller never sees it. + */ request: { - url: (params) => - datadogApiUrl(params.site, `/api/v1/monitor/${encodeURIComponent(params.monitorId)}/mute`), + url: (params) => { + const queryParams = new URLSearchParams() + if (params.scope) queryParams.set('scope', params.scope) + if (params.end !== undefined && params.end !== null) + queryParams.set('end', String(params.end)) + + const queryString = queryParams.toString() + return datadogApiUrl( + params.site, + `/api/v1/monitor/${datadogPathSegment(params.monitorId)}/mute${queryString ? `?${queryString}` : ''}` + ) + }, method: 'POST', headers: datadogHeaders, - body: (params) => { - const body: { scope?: string; end?: number } = {} - if (params.scope) body.scope = params.scope - if (params.end !== undefined) body.end = params.end - return body - }, }, transformResponse: async (response: Response) => { diff --git a/apps/sim/tools/datadog/send_logs.ts b/apps/sim/tools/datadog/send_logs.ts index 39f0a41ae00..9b4c7b31700 100644 --- a/apps/sim/tools/datadog/send_logs.ts +++ b/apps/sim/tools/datadog/send_logs.ts @@ -15,7 +15,7 @@ export const sendLogsTool: ToolConfig = { required: true, visibility: 'user-or-llm', description: - 'JSON array of log entries. Each entry should have message and optionally ddsource, ddtags, hostname, service.', + 'JSON array of log entries. Each entry should have message and optionally ddsource, ddtags, hostname, service. Sim fills in ddsource="custom" when an entry omits it — that is a Sim default, not a Datadog one; set ddsource yourself to have Datadog apply the matching integration log pipeline.', }, apiKey: { type: 'string', diff --git a/apps/sim/tools/datadog/types.ts b/apps/sim/tools/datadog/types.ts index 9932f838355..d642521ce74 100644 --- a/apps/sim/tools/datadog/types.ts +++ b/apps/sim/tools/datadog/types.ts @@ -510,8 +510,16 @@ export interface CreateSloResponse extends ToolResponse { output: CreateSloOutput } -export interface UpdateSloParams extends CreateSloParams { +/** + * `PUT /api/v1/slo/{slo_id}` is a full replacement that Sim fills from the stored SLO, + * so every identifying field is optional here: an omitted `type` means "keep the stored + * type" rather than "rewrite this SLO as a metric SLO". + */ +export interface UpdateSloParams extends Omit { sloId: string + name?: string + type?: SloType + thresholds?: string } interface UpdateSloOutput { diff --git a/apps/sim/tools/datadog/unmute_monitor.ts b/apps/sim/tools/datadog/unmute_monitor.ts index 5fc48af2b56..6c6b2cf1aa6 100644 --- a/apps/sim/tools/datadog/unmute_monitor.ts +++ b/apps/sim/tools/datadog/unmute_monitor.ts @@ -1,5 +1,10 @@ import type { UnmuteMonitorParams, UnmuteMonitorResponse } from '@/tools/datadog/types' -import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' +import { + datadogApiUrl, + datadogErrorMessage, + datadogHeaders, + datadogPathSegment, +} from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' export const unmuteMonitorTool: ToolConfig = { @@ -49,17 +54,27 @@ export const unmuteMonitorTool: ToolConfig - datadogApiUrl(params.site, `/api/v1/monitor/${encodeURIComponent(params.monitorId)}/unmute`), + url: (params) => { + const queryParams = new URLSearchParams() + if (params.scope) queryParams.set('scope', params.scope) + if (params.allScopes !== undefined && params.allScopes !== null) + queryParams.set('all_scopes', String(params.allScopes)) + + const queryString = queryParams.toString() + return datadogApiUrl( + params.site, + `/api/v1/monitor/${datadogPathSegment(params.monitorId)}/unmute${queryString ? `?${queryString}` : ''}` + ) + }, method: 'POST', headers: datadogHeaders, - body: (params) => { - const body: { scope?: string; all_scopes?: boolean } = {} - if (params.scope) body.scope = params.scope - if (params.allScopes !== undefined) body.all_scopes = params.allScopes - return body - }, }, transformResponse: async (response: Response) => { diff --git a/apps/sim/tools/datadog/update_incident.ts b/apps/sim/tools/datadog/update_incident.ts index 7be32b485cc..1e3f6498353 100644 --- a/apps/sim/tools/datadog/update_incident.ts +++ b/apps/sim/tools/datadog/update_incident.ts @@ -3,6 +3,7 @@ import { datadogApiUrl, datadogErrorMessage, datadogHeaders, + datadogPathSegment, parseJsonParam, splitCommaList, } from '@/tools/datadog/utils' @@ -99,7 +100,7 @@ export const updateIncidentTool: ToolConfig - datadogApiUrl(params.site, `/api/v2/incidents/${encodeURIComponent(params.incidentId)}`), + datadogApiUrl(params.site, `/api/v2/incidents/${datadogPathSegment(params.incidentId)}`), method: 'PATCH', headers: datadogHeaders, body: (params) => { diff --git a/apps/sim/tools/datadog/update_security_signal_assignee.ts b/apps/sim/tools/datadog/update_security_signal_assignee.ts index 00a41db112b..290eefd07e9 100644 --- a/apps/sim/tools/datadog/update_security_signal_assignee.ts +++ b/apps/sim/tools/datadog/update_security_signal_assignee.ts @@ -6,6 +6,7 @@ import { datadogApiUrl, datadogErrorMessage, datadogHeaders, + datadogPathSegment, mapSignalTriageData, } from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' @@ -58,7 +59,7 @@ export const updateSecuritySignalAssigneeTool: ToolConfig< url: (params) => datadogApiUrl( params.site, - `/api/v2/security_monitoring/signals/${encodeURIComponent(params.signalId)}/assignee` + `/api/v2/security_monitoring/signals/${datadogPathSegment(params.signalId)}/assignee` ), method: 'PATCH', headers: datadogHeaders, diff --git a/apps/sim/tools/datadog/update_security_signal_state.ts b/apps/sim/tools/datadog/update_security_signal_state.ts index 78a87fb082c..eb5128751db 100644 --- a/apps/sim/tools/datadog/update_security_signal_state.ts +++ b/apps/sim/tools/datadog/update_security_signal_state.ts @@ -6,6 +6,7 @@ import { datadogApiUrl, datadogErrorMessage, datadogHeaders, + datadogPathSegment, mapSignalTriageData, } from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' @@ -70,7 +71,7 @@ export const updateSecuritySignalStateTool: ToolConfig< url: (params) => datadogApiUrl( params.site, - `/api/v2/security_monitoring/signals/${encodeURIComponent(params.signalId)}/state` + `/api/v2/security_monitoring/signals/${datadogPathSegment(params.signalId)}/state` ), method: 'PATCH', headers: datadogHeaders, diff --git a/apps/sim/tools/datadog/update_slo.ts b/apps/sim/tools/datadog/update_slo.ts index 9d01b1da86c..4df6b11db55 100644 --- a/apps/sim/tools/datadog/update_slo.ts +++ b/apps/sim/tools/datadog/update_slo.ts @@ -3,6 +3,7 @@ import { datadogApiUrl, datadogErrorMessage, datadogHeaders, + datadogPathSegment, mergeSloUpdatePayload, } from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' @@ -111,7 +112,7 @@ export const updateSloTool: ToolConfig = { }, request: { - url: (params) => datadogApiUrl(params.site, `/api/v1/slo/${encodeURIComponent(params.sloId)}`), + url: (params) => datadogApiUrl(params.site, `/api/v1/slo/${datadogPathSegment(params.sloId)}`), method: 'PUT', headers: datadogHeaders, }, @@ -122,7 +123,7 @@ export const updateSloTool: ToolConfig = { * erase every field the caller left blank. */ directExecution: async (params, signal) => { - const url = datadogApiUrl(params.site, `/api/v1/slo/${encodeURIComponent(params.sloId)}`) + const url = datadogApiUrl(params.site, `/api/v1/slo/${datadogPathSegment(params.sloId)}`) const headers = datadogHeaders(params) const existingResponse = await fetch(url, { method: 'GET', headers, signal }) diff --git a/apps/sim/tools/datadog/update_synthetics_status.ts b/apps/sim/tools/datadog/update_synthetics_status.ts index d504dc34226..af63fc2da9d 100644 --- a/apps/sim/tools/datadog/update_synthetics_status.ts +++ b/apps/sim/tools/datadog/update_synthetics_status.ts @@ -2,7 +2,12 @@ import type { UpdateSyntheticsStatusParams, UpdateSyntheticsStatusResponse, } from '@/tools/datadog/types' -import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' +import { + datadogApiUrl, + datadogErrorMessage, + datadogHeaders, + datadogPathSegment, +} from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' export const updateSyntheticsStatusTool: ToolConfig< @@ -51,7 +56,7 @@ export const updateSyntheticsStatusTool: ToolConfig< url: (params) => datadogApiUrl( params.site, - `/api/v1/synthetics/tests/${encodeURIComponent(params.publicId)}/status` + `/api/v1/synthetics/tests/${datadogPathSegment(params.publicId)}/status` ), method: 'PUT', headers: datadogHeaders, diff --git a/apps/sim/tools/datadog/utils.ts b/apps/sim/tools/datadog/utils.ts index 45d744a78a5..f293c03cb68 100644 --- a/apps/sim/tools/datadog/utils.ts +++ b/apps/sim/tools/datadog/utils.ts @@ -14,6 +14,18 @@ export function datadogApiUrl(site: DatadogSite | undefined, path: string): stri return `https://api.${site || 'datadoghq.com'}${path}` } +/** + * Encodes one user-supplied identifier for use as a URL path segment. + * + * IDs reach Sim by copy/paste and from `` references, so they arrive with + * stray whitespace and as non-strings (a monitor ID is a number). `encodeURIComponent` + * preserves the whitespace as `%20`, which Datadog treats as part of the ID and answers + * with a 404 that names nothing the user typed — so trim before encoding. + */ +export function datadogPathSegment(value: unknown): string { + return encodeURIComponent(String(value ?? '').trim()) +} + /** Standard Datadog authentication headers for API + application key auth. */ export function datadogHeaders(params: { apiKey: string diff --git a/apps/sim/tools/error-extractors.ts b/apps/sim/tools/error-extractors.ts index 2372a72d11a..5ec60448cbe 100644 --- a/apps/sim/tools/error-extractors.ts +++ b/apps/sim/tools/error-extractors.ts @@ -298,6 +298,31 @@ const ERROR_EXTRACTORS: ErrorExtractorConfig[] = [ return typeof attr === 'string' && attr ? `${detail} (${attr})` : detail }, }, + { + id: 'splunk-errors', + description: + 'Splunk REST message envelope: {messages: [{type, text}]}. Under the output_mode=json every Splunk request pins, this is where a rejected SPL string explains itself — without it the failure reports only the HTTP status text', + examples: ['Splunk Enterprise', 'Splunk Cloud'], + extract: (errorInfo) => { + const messages = errorInfo?.data?.messages + if (!Array.isArray(messages) || messages.length === 0) return undefined + + const texts = messages + .map((message: { type?: unknown; text?: unknown }) => ({ + type: typeof message?.type === 'string' ? message.type.toUpperCase() : '', + text: typeof message?.text === 'string' ? message.text.trim() : '', + })) + .filter((message) => message.text) + + if (texts.length === 0) return undefined + + // A failing request carries the cause on the ERROR/FATAL entries; the rest + // are the INFO/WARN/DEBUG chatter Splunk attaches to every response. + const fatal = texts.filter((message) => message.type === 'ERROR' || message.type === 'FATAL') + const selected = fatal.length > 0 ? fatal : texts + return selected.map((message) => message.text).join('; ') + }, + }, { id: 'plain-text-data', description: 'Plain text error response', @@ -378,6 +403,7 @@ export const ErrorExtractorId = { DYNATRACE_ERRORS: 'dynatrace-errors', SMARTLEAD_ERRORS: 'smartlead-errors', POSTHOG_ERRORS: 'posthog-errors', + SPLUNK_ERRORS: 'splunk-errors', PLAIN_TEXT_DATA: 'plain-text-data', HTTP_STATUS_TEXT: 'http-status-text', } as const diff --git a/apps/sim/tools/generated/tool-metadata.ts b/apps/sim/tools/generated/tool-metadata.ts index f76ac0d4b79..26d3e1fb2d9 100644 --- a/apps/sim/tools/generated/tool-metadata.ts +++ b/apps/sim/tools/generated/tool-metadata.ts @@ -3,7 +3,7 @@ /** Serializable metadata for every built-in tool, keyed by tool id. */ const toolMetadata: Record = JSON.parse( - '{"a2a_cancel_task":{"id":"a2a_cancel_task","name":"A2A Cancel Task","description":"Request cancellation of an in-progress A2A task.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The task ID to cancel"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}}},"a2a_get_agent_card":{"id":"a2a_get_agent_card","name":"A2A Get Agent Card","description":"Fetch the Agent Card (discovery document) for an external A2A agent.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}}},"a2a_get_task":{"id":"a2a_get_task","name":"A2A Get Task","description":"Retrieve the current state and result of an A2A task.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The task ID to retrieve"},"historyLength":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of history messages to include"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}}},"a2a_send_message":{"id":"a2a_send_message","name":"A2A Send Message","description":"Send a message to an external A2A agent and return its response.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"message":{"type":"string","required":true,"visibility":"user-or-llm","description":"The message text to send"},"data":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional structured JSON data to attach"},"files":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional files to attach"},"taskId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Existing task ID to continue"},"contextId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Conversation context ID to continue"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}}},"agentmail_create_draft":{"id":"agentmail_create_draft","name":"Create Draft","description":"Create a new email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to create the draft in"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Draft subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text draft body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML draft body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"},"inReplyTo":{"type":"string","required":false,"visibility":"user-or-llm","description":"ID of message being replied to"},"sendAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to schedule sending"}}},"agentmail_create_inbox":{"id":"agentmail_create_inbox","name":"Create Inbox","description":"Create a new email inbox with AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"username":{"type":"string","required":false,"visibility":"user-or-llm","description":"Username for the inbox email address"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Domain for the inbox email address"},"displayName":{"type":"string","required":false,"visibility":"user-or-llm","description":"Display name for the inbox"}}},"agentmail_delete_draft":{"id":"agentmail_delete_draft","name":"Delete Draft","description":"Delete an email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to delete"}}},"agentmail_delete_inbox":{"id":"agentmail_delete_inbox","name":"Delete Inbox","description":"Delete an email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to delete"}}},"agentmail_delete_thread":{"id":"agentmail_delete_thread","name":"Delete Thread","description":"Delete an email thread in AgentMail (moves to trash, or permanently deletes if already in trash)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to delete"},"permanent":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Force permanent deletion instead of moving to trash"}}},"agentmail_forward_message":{"id":"agentmail_forward_message","name":"Forward Message","description":"Forward an email message to new recipients in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to forward"},"to":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Override subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Additional plain text to prepend"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"Additional HTML to prepend"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"}}},"agentmail_get_draft":{"id":"agentmail_get_draft","name":"Get Draft","description":"Get details of a specific email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox the draft belongs to"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to retrieve"}}},"agentmail_get_inbox":{"id":"agentmail_get_inbox","name":"Get Inbox","description":"Get details of a specific email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to retrieve"}}},"agentmail_get_message":{"id":"agentmail_get_message","name":"Get Message","description":"Get details of a specific email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to retrieve"}}},"agentmail_get_thread":{"id":"agentmail_get_thread","name":"Get Thread","description":"Get details of a specific email thread including messages in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to retrieve"}}},"agentmail_list_drafts":{"id":"agentmail_list_drafts","name":"List Drafts","description":"List email drafts in an inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list drafts from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of drafts to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}}},"agentmail_list_inboxes":{"id":"agentmail_list_inboxes","name":"List Inboxes","description":"List all email inboxes in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of inboxes to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}}},"agentmail_list_messages":{"id":"agentmail_list_messages","name":"List Messages","description":"List messages in an inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list messages from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of messages to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}}},"agentmail_list_threads":{"id":"agentmail_list_threads","name":"List Threads","description":"List email threads in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list threads from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of threads to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"},"labels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to filter threads by"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter threads before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter threads after this ISO 8601 timestamp"}}},"agentmail_reply_message":{"id":"agentmail_reply_message","name":"Reply to Message","description":"Reply to an existing email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to reply from"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to reply to"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text reply body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML reply body"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Override recipient email addresses (comma-separated)"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC email addresses (comma-separated)"},"replyAll":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Reply to all recipients of the original message"}}},"agentmail_send_draft":{"id":"agentmail_send_draft","name":"Send Draft","description":"Send an existing email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to send"}}},"agentmail_send_message":{"id":"agentmail_send_message","name":"Send Message","description":"Send an email message from an AgentMail inbox","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to send from"},"to":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient email address (comma-separated for multiple)"},"subject":{"type":"string","required":true,"visibility":"user-or-llm","description":"Email subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text email body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML email body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"}}},"agentmail_update_draft":{"id":"agentmail_update_draft","name":"Update Draft","description":"Update an existing email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to update"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Draft subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text draft body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML draft body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"},"sendAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to schedule sending"}}},"agentmail_update_inbox":{"id":"agentmail_update_inbox","name":"Update Inbox","description":"Update the display name of an email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to update"},"displayName":{"type":"string","required":true,"visibility":"user-or-llm","description":"New display name for the inbox"}}},"agentmail_update_message":{"id":"agentmail_update_message","name":"Update Message","description":"Add or remove labels on an email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to update"},"addLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to add to the message"},"removeLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to remove from the message"}}},"agentmail_update_thread":{"id":"agentmail_update_thread","name":"Update Thread Labels","description":"Add or remove labels on an email thread in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to update"},"addLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to add to the thread"},"removeLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to remove from the thread"}}},"agentphone_create_call":{"id":"agentphone_create_call","name":"Create Outbound Call","description":"Initiate an outbound voice call from an AgentPhone agent","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"agentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Agent that will handle the call"},"toNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Phone number to call in E.164 format (e.g. +14155551234)"},"fromNumberId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Phone number ID to use as caller ID. Must belong to the agent. If omitted, the agent\'s first assigned number is used."},"initialGreeting":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optional greeting spoken when the recipient answers"},"voice":{"type":"string","required":false,"visibility":"user-or-llm","description":"Voice ID override for this call (defaults to the agent\'s configured voice)"},"systemPrompt":{"type":"string","required":false,"visibility":"user-or-llm","description":"When provided, uses a built-in LLM for the conversation instead of forwarding to your webhook"}}},"agentphone_create_contact":{"id":"agentphone_create_contact","name":"Create Contact","description":"Create a new contact in AgentPhone","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"phoneNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Phone number in E.164 format (e.g. +14155551234)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact\'s full name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Contact\'s email address"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Freeform notes stored on the contact"}}},"agentphone_create_number":{"id":"agentphone_create_number","name":"Create Phone Number","description":"Provision a new SMS- and voice-enabled phone number","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Two-letter country code (e.g. US, CA). Defaults to US."},"areaCode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Preferred area code (US/CA only, e.g. \\"415\\"). Best-effort — may be ignored if unavailable."},"agentId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optionally attach the number to an agent immediately"}}},"agentphone_delete_contact":{"id":"agentphone_delete_contact","name":"Delete Contact","description":"Delete a contact by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"}}},"agentphone_get_call":{"id":"agentphone_get_call","name":"Get Call","description":"Fetch a call and its full transcript","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"callId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the call to retrieve"}}},"agentphone_get_call_transcript":{"id":"agentphone_get_call_transcript","name":"Get Call Transcript","description":"Get the full ordered transcript for a call","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"callId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the call to retrieve the transcript for"}}},"agentphone_get_contact":{"id":"agentphone_get_contact","name":"Get Contact","description":"Fetch a single contact by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"}}},"agentphone_get_conversation":{"id":"agentphone_get_conversation","name":"Get Conversation","description":"Get a conversation along with its recent messages","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"messageLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of recent messages to include (default 50, max 100)"}}},"agentphone_get_conversation_messages":{"id":"agentphone_get_conversation_messages","name":"Get Conversation Messages","description":"Get paginated messages for a conversation","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of messages to return (default 50, max 200)"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received after this ISO 8601 timestamp"}}},"agentphone_get_number_messages":{"id":"agentphone_get_number_messages","name":"Get Phone Number Messages","description":"Fetch messages received on a specific phone number","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"numberId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the phone number"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of messages to return (default 50, max 200)"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received after this ISO 8601 timestamp"}}},"agentphone_get_usage":{"id":"agentphone_get_usage","name":"Get Usage","description":"Retrieve current usage statistics for the AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"}}},"agentphone_get_usage_daily":{"id":"agentphone_get_usage_daily","name":"Get Daily Usage","description":"Get a daily breakdown of usage (messages, calls, webhooks) for the last N days","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"days":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of days to return (1-365, default 30)"}}},"agentphone_get_usage_monthly":{"id":"agentphone_get_usage_monthly","name":"Get Monthly Usage","description":"Get monthly usage aggregation (messages, calls, webhooks) for the last N months","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"months":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of months to return (1-24, default 6)"}}},"agentphone_list_calls":{"id":"agentphone_list_calls","name":"List Calls","description":"List voice calls for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"},"status":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by status (completed, in-progress, failed)"},"direction":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by direction (inbound, outbound)"},"type":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by call type (pstn, web)"},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search by phone number (matches fromNumber or toNumber)"}}},"agentphone_list_contacts":{"id":"agentphone_list_contacts","name":"List Contacts","description":"List contacts for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by name or phone number (case-insensitive contains)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 50, max 200)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}}},"agentphone_list_conversations":{"id":"agentphone_list_conversations","name":"List Conversations","description":"List conversations (message threads) for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}}},"agentphone_list_numbers":{"id":"agentphone_list_numbers","name":"List Phone Numbers","description":"List all phone numbers provisioned for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}}},"agentphone_react_to_message":{"id":"agentphone_react_to_message","name":"React to Message","description":"Send an iMessage tapback reaction to a message (iMessage only)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to react to"},"reaction":{"type":"string","required":true,"visibility":"user-or-llm","description":"Reaction type: love, like, dislike, laugh, emphasize, or question"}}},"agentphone_release_number":{"id":"agentphone_release_number","name":"Release Phone Number","description":"Release (delete) a phone number. This action is irreversible.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"numberId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the phone number to release"}}},"agentphone_send_message":{"id":"agentphone_send_message","name":"Send Message","description":"Send an outbound SMS or iMessage from an AgentPhone agent","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"agentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Agent sending the message"},"toNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient phone number in E.164 format (e.g. +14155551234)"},"body":{"type":"string","required":true,"visibility":"user-or-llm","description":"Message text to send"},"mediaUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optional URL of an image, video, or file to attach"},"numberId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Phone number ID to send from. If omitted, the agent\'s first assigned number is used."}}},"agentphone_update_contact":{"id":"agentphone_update_contact","name":"Update Contact","description":"Update a contact\'s fields","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"},"phoneNumber":{"type":"string","required":false,"visibility":"user-or-llm","description":"New phone number in E.164 format"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New contact name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"New email address"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"New freeform notes"}}},"agentphone_update_conversation":{"id":"agentphone_update_conversation","name":"Update Conversation","description":"Update conversation metadata (stored state). Pass null to clear existing metadata.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"metadata":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom key-value metadata to store on the conversation. Pass null to clear existing metadata."}}},"agiloft_async_status":{"id":"agiloft_async_status","name":"Agiloft Async Status","description":"Check whether an asynchronous Agiloft call, such as a run action button, has completed.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table the asynchronous call was made against"},"callbackId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Callback ID returned by the asynchronous call, e.g. from Run Action Button"}}},"agiloft_attach_file":{"id":"agiloft_attach_file","name":"Agiloft Attach File","description":"Attach a file to a field in an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to attach the file to"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"file":{"type":"file","required":true,"visibility":"user-or-llm","description":"File to attach"},"fileName":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name to assign to the file (defaults to original file name)"},"overwrite":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Replace the contents of the field instead of adding another file to it"}}},"agiloft_attachment_info":{"id":"agiloft_attachment_info","name":"Agiloft Attachment Info","description":"Get information about file attachments on a record field.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to check attachments on"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field to inspect"}}},"agiloft_create_record":{"id":"agiloft_create_record","name":"Agiloft Create Record","description":"Create a new record in an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record field values as a JSON object (e.g., {\\"first_name\\": \\"John\\", \\"status\\": \\"Active\\"})"}}},"agiloft_delete_record":{"id":"agiloft_delete_record","name":"Agiloft Delete Record","description":"Delete a record from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to delete"},"substituteIds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated IDs of records that adopt the dependants of the deleted record. Read only when the delete rule is REPLACE_WITH_ANOTHER."},"deleteRule":{"type":"string","required":false,"visibility":"user-or-llm","description":"How to treat records that depend on this one: ERROR_IF_DEPENDANTS (default — fails rather than cascading), APPLY_DELETE_WHERE_POSSIBLE, DELETE_WHERE_POSSIBLE_OTHERWISE_UNLINK, APPLY_UNLINK, UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE, or REPLACE_WITH_ANOTHER"}}},"agiloft_get_choice_line_id":{"id":"agiloft_get_choice_line_id","name":"Agiloft Get Choice Line ID","description":"Resolve the internal numeric ID of a choice-list value, for use in EWSelect WHERE clauses against choice fields.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"case\\", \\"contracts\\")"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Choice field name (e.g., \\"priority\\", \\"status\\")"},"value":{"type":"string","required":true,"visibility":"user-or-llm","description":"Choice display value to resolve (e.g., \\"High\\", \\"Active\\")"}}},"agiloft_list_tables":{"id":"agiloft_list_tables","name":"Agiloft List Tables","description":"List the tables and fields in an Agiloft knowledge base, to discover the logical names other operations need.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":false,"visibility":"user-or-llm","description":"Logical name of a single table to describe (e.g., \\"contacts\\"). Leave empty to list every table in the knowledge base."},"includeLinkedInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the source table and column behind each linked field"},"skipColumnsInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Return table names only, omitting field details, for a much smaller response"}}},"agiloft_lock_record":{"id":"agiloft_lock_record","name":"Agiloft Lock Record","description":"Lock, unlock, or check the lock status of an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to lock, unlock, or check"},"lockAction":{"type":"string","required":true,"visibility":"user-or-llm","description":"Action to perform: \\"lock\\", \\"unlock\\", or \\"check\\""},"force":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Unlock only: release a lock held by another user."}}},"agiloft_nlp_search":{"id":"agiloft_nlp_search","name":"Agiloft Natural Language Search","description":"Search Agiloft records by describing what you want in plain language, such as \\"active NDAs submitted last month\\".","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"nlpQuery":{"type":"string","required":true,"visibility":"user-or-llm","description":"The request in plain language, e.g. \\"Show me open, high-priority contracts\\". Structured field filters are not accepted — use Search Records for those."},"fields":{"type":"string","required":true,"visibility":"user-or-llm","description":"Comma-separated field names to return, e.g. \\"id, contract_title1, company_name\\""},"page":{"type":"string","required":false,"visibility":"user-or-llm","description":"Page number, starting from 0"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Records per page"}}},"agiloft_read_record":{"id":"agiloft_read_record","name":"Agiloft Read Record","description":"Read a record by ID from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to read"},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of field names to include in the response"}}},"agiloft_remove_attachment":{"id":"agiloft_remove_attachment","name":"Agiloft Remove Attachment","description":"Remove an attached file from a field in an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record containing the attachment"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"position":{"type":"string","required":true,"visibility":"user-or-llm","description":"Position index of the file to remove (starting from 0)"}}},"agiloft_retrieve_attachment":{"id":"agiloft_retrieve_attachment","name":"Agiloft Retrieve Attachment","description":"Download an attached file from an Agiloft record field.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record containing the attachment"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"position":{"type":"string","required":true,"visibility":"user-or-llm","description":"Position index of the file in the field (starting from 0)"}}},"agiloft_run_action_button":{"id":"agiloft_run_action_button","name":"Agiloft Run Action Button","description":"Run an action button on an Agiloft record, such as an approval or send-for-signature step.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"case\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to run the action button on"},"actionButtonField":{"type":"string","required":true,"visibility":"user-or-llm","description":"Logical name of the field holding the action button (e.g., \\"ab_field\\")"}}},"agiloft_saved_search":{"id":"agiloft_saved_search","name":"Agiloft Saved Search","description":"List the saved searches defined for an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Logical table name to list saved searches for (e.g., \\"contract\\")"}}},"agiloft_search_records":{"id":"agiloft_search_records","name":"Agiloft Search Records","description":"Search for records in an Agiloft table using a query.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name to search in (e.g., \\"contracts\\", \\"contacts.employees\\")"},"query":{"type":"string","required":false,"visibility":"user-or-llm","description":"Ad hoc EWSearch query. Combine conditions with && (and) or || (or) and quote every value — e.g. \\"summary~=\'test\'&&priority=\'High\'\\". Required unless a saved search is given."},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Label of a saved search defined on the table (e.g., \\"C: Status is Closed\\"). Can be combined with a query to narrow it further."},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of field names to include in the results"},"page":{"type":"string","required":false,"visibility":"user-or-llm","description":"Page number for paginated results (starting from 0)"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of records to return per page. Agiloft treats 0 as \\"all records\\", so leave it unset or use a positive value to keep result sizes bounded."}}},"agiloft_select_records":{"id":"agiloft_select_records","name":"Agiloft Select Records","description":"Select record IDs matching a SQL WHERE clause from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"where":{"type":"string","required":true,"visibility":"user-or-llm","description":"SQL WHERE clause using database column names (e.g., \\"summary like \'%new%\'\\" or \\"assigned_person=\'John Doe\'\\"). EWSelect has no page size and returns every matching ID, so append a database limit such as \\"limit 0,200\\" to bound the result."}}},"agiloft_update_record":{"id":"agiloft_update_record","name":"Agiloft Update Record","description":"Update an existing record in an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to update"},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Updated field values as a JSON object (e.g., {\\"status\\": \\"Active\\", \\"priority\\": \\"High\\"})"}}},"agiloft_upsert_record":{"id":"agiloft_upsert_record","name":"Agiloft Upsert Record","description":"Create an Agiloft record, or update it when a record already matches the given fields.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"match":{"type":"string","required":true,"visibility":"user-or-llm","description":"Field used to find an existing record (e.g., \\"ext_id\\"). Pick something that identifies a record uniquely — if more than one record matches, Agiloft writes nothing and returns a conflict."},"async":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Queue the write instead of waiting for it. Returns a callback ID instead of a record ID; pass that to Async Status to poll the result."},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Field values as a JSON object. On create these populate the new record; on update only the supplied fields change."}}},"ahrefs_anchors":{"id":"ahrefs_anchors","name":"Ahrefs Anchors","description":"Get the anchor text distribution for a target domain or URL\'s backlinks, showing how many links and referring domains use each anchor text.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live), \\"all_time\\" (default, includes lost backlinks), or \\"since:YYYY-MM-DD\\" (backlinks found since a date)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_backlinks":{"id":"ahrefs_backlinks","name":"Ahrefs Backlinks","description":"Get a list of backlinks pointing to a target domain or URL. Returns details about each backlink including source URL, anchor text, and domain rating.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live backlinks), \\"all_time\\" (default, includes lost backlinks), or \\"since:YYYY-MM-DD\\" (backlinks found since a date)."},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_backlinks_stats":{"id":"ahrefs_backlinks_stats","name":"Ahrefs Backlinks Stats","description":"Get backlink and referring domain totals for a target domain or URL, both currently live and across all time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_batch_analysis":{"id":"ahrefs_batch_analysis","name":"Ahrefs Batch Analysis","description":"Get bulk SEO metrics (Domain Rating, backlinks, referring domains, organic traffic, and more) for multiple domains or URLs in a single request. Useful for comparing many competitors at once.","version":"1.0.0","params":{"targets":{"type":"string","required":true,"visibility":"user-or-llm","description":"Comma-separated list of domains or URLs to analyze. Example: \\"example.com,competitor.com\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode applied to every target: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"protocol":{"type":"string","required":false,"visibility":"user-or-llm","description":"Protocol applied to every target: \\"both\\" (default), \\"http\\", or \\"https\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_broken_backlinks":{"id":"ahrefs_broken_backlinks","name":"Ahrefs Broken Backlinks","description":"Get a list of broken backlinks pointing to a target domain or URL. Useful for identifying link reclamation opportunities.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_domain_rating":{"id":"ahrefs_domain_rating","name":"Ahrefs Domain Rating","description":"Get the Domain Rating (DR) and Ahrefs Rank for a target domain. Domain Rating shows the strength of a website\'s backlink profile on a scale from 0 to 100.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain to analyze (e.g., example.com)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date for historical data in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_domain_rating_history":{"id":"ahrefs_domain_rating_history","name":"Ahrefs Domain Rating History","description":"Get the historical Domain Rating (DR) trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_keyword_overview":{"id":"ahrefs_keyword_overview","name":"Ahrefs Keyword Overview","description":"Get detailed metrics for a keyword including search volume, keyword difficulty, CPC, clicks, and traffic potential.","version":"1.0.0","params":{"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The keyword to analyze"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for keyword data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_keywords_history":{"id":"ahrefs_keywords_history","name":"Ahrefs Keywords History","description":"Get the historical organic keyword ranking distribution for a target domain or URL over a date range: how many keywords rank in each position bucket at each point in time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_metrics":{"id":"ahrefs_metrics","name":"Ahrefs Metrics","description":"Get a one-call organic and paid search overview for a target domain or URL: organic traffic, organic keywords, paid traffic, paid keywords, and estimated traffic cost.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_metrics_history":{"id":"ahrefs_metrics_history","name":"Ahrefs Metrics History","description":"Get the historical organic and paid traffic trend for a target domain or URL over a date range: organic traffic/cost and paid traffic/cost at each point in time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_organic_competitors":{"id":"ahrefs_organic_competitors","name":"Ahrefs Organic Competitors","description":"Get domains that compete with a target domain or URL for the same organic keywords, ranked by keyword overlap.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_organic_keywords":{"id":"ahrefs_organic_keywords","name":"Ahrefs Organic Keywords","description":"Get organic keywords that a target domain or URL ranks for in Google search results. Returns keyword details including search volume, ranking position, and estimated traffic.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_paid_pages":{"id":"ahrefs_paid_pages","name":"Ahrefs Paid Pages","description":"Get a target domain\'s pages that receive paid search traffic, sorted by estimated paid traffic. Returns page URLs with their paid traffic, keyword counts, and estimated spend.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_rank_tracker_competitors_overview":{"id":"ahrefs_rank_tracker_competitors_overview","name":"Ahrefs Rank Tracker Competitors Overview","description":"Get competitor rankings for the keywords tracked in an Ahrefs Rank Tracker project: each tracked keyword\'s volume and difficulty alongside every competitor\'s position, traffic, and traffic value. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report rankings for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"dateCompared":{"type":"string","required":false,"visibility":"user-only","description":"Comparison date in YYYY-MM-DD format, to compute position/traffic deltas"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_rank_tracker_competitors_stats":{"id":"ahrefs_rank_tracker_competitors_stats","name":"Ahrefs Rank Tracker Competitors Stats","description":"Get aggregate competitor stats for an Ahrefs Rank Tracker project: each competitor\'s traffic, traffic value, average position, and share of voice across all tracked keywords. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report metrics for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_rank_tracker_overview":{"id":"ahrefs_rank_tracker_overview","name":"Ahrefs Rank Tracker Overview","description":"Get ranking overview metrics for the keywords tracked in an Ahrefs Rank Tracker project: position, search volume, keyword difficulty, and estimated traffic. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report rankings for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"dateCompared":{"type":"string","required":false,"visibility":"user-only","description":"Comparison date in YYYY-MM-DD format, to compute position/traffic deltas"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_rank_tracker_serp_overview":{"id":"ahrefs_rank_tracker_serp_overview","name":"Ahrefs Rank Tracker SERP Overview","description":"Get the full SERP (search engine results page) for a keyword tracked in an Ahrefs Rank Tracker project, including every ranking URL with its position, title, and authority metrics. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The tracked keyword to retrieve SERP data for"},"country":{"type":"string","required":true,"visibility":"user-or-llm","description":"Country code for the tracked keyword. Example: \\"us\\", \\"gb\\", \\"de\\""},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"topPositions":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of top organic positions to return (defaults to all available)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Timestamp to return the last available SERP Overview at, in YYYY-MM-DDThh:mm:ss format"},"locationId":{"type":"number","required":false,"visibility":"user-or-llm","description":"Location ID of the tracked keyword, if tracked at a specific location"},"languageCode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Language code of the tracked keyword"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_refdomains_history":{"id":"ahrefs_refdomains_history","name":"Ahrefs Referring Domains History","description":"Get the historical referring domains trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_referring_domains":{"id":"ahrefs_referring_domains","name":"Ahrefs Referring Domains","description":"Get a list of domains that link to a target domain or URL. Returns unique referring domains with their domain rating, backlink counts, and discovery dates.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live), \\"all_time\\" (default, includes lost domains), or \\"since:YYYY-MM-DD\\" (domains found since a date)."},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_related_terms":{"id":"ahrefs_related_terms","name":"Ahrefs Related Terms","description":"Get keyword ideas related to a seed keyword: terms the same top-ranking pages also rank for (\\"also rank for\\") or also discuss (\\"also talk about\\"), with volume, difficulty, and CPC.","version":"1.0.0","params":{"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The seed keyword to find related terms for"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for keyword data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"terms":{"type":"string","required":false,"visibility":"user-or-llm","description":"Type of related keywords to return: \\"also_rank_for\\", \\"also_talk_about\\", or \\"all\\" (default: \\"all\\")"},"viewFor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Whether to derive related terms from the top 10 or top 100 ranking pages (default: \\"top_10\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_site_audit_page_explorer":{"id":"ahrefs_site_audit_page_explorer","name":"Ahrefs Site Audit Page Explorer","description":"Get crawled pages from an Ahrefs Site Audit project with health and SEO metrics: HTTP status, title, link counts, backlinks, indexability, and traffic. Optionally filter to pages affected by a specific issue.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Site Audit project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Crawl date in YYYY-MM-DDThh:mm:ss format (defaults to the most recent crawl)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip, for pagination"},"issueId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Only return pages affected by this issue ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_top_pages":{"id":"ahrefs_top_pages","name":"Ahrefs Top Pages","description":"Get the top pages of a target domain sorted by organic traffic. Returns page URLs with their traffic, keyword counts, and estimated traffic value.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"airtable_create_records":{"id":"airtable_create_records","name":"Airtable Create Records","description":"Write new records to an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to create, each with a `fields` object"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_delete_records":{"id":"airtable_delete_records","name":"Airtable Delete Records","description":"Delete one or more records from an Airtable table by ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordIds":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of record IDs to delete (each starts with \\"rec\\", e.g., [\\"recXXXXXXXXXXXXXX\\"]). Pass a single-element array to delete one record."}},"oauth":{"required":true,"provider":"airtable"}},"airtable_get_base_schema":{"id":"airtable_get_base_schema","name":"Airtable Get Base Schema","description":"Get the schema of all tables, fields, and views in an Airtable base","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_get_record":{"id":"airtable_get_record","name":"Airtable Get Record","description":"Retrieve a single record from an Airtable table by its ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record ID to retrieve (starts with \\"rec\\", e.g., \\"recXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_list_bases":{"id":"airtable_list_bases","name":"Airtable List Bases","description":"List all bases the authenticated user has access to","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"offset":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination offset for retrieving additional bases"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_list_records":{"id":"airtable_list_records","name":"Airtable List Records","description":"Read records from an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"maxRecords":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of records to return (default: all records)"},"filterFormula":{"type":"string","required":false,"visibility":"user-or-llm","description":"Formula to filter records (e.g., \\"({Field Name} = \'Value\')\\")"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_list_tables":{"id":"airtable_list_tables","name":"Airtable List Tables","description":"List all tables and their schema in an Airtable base","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_update_multiple_records":{"id":"airtable_update_multiple_records","name":"Airtable Update Multiple Records","description":"Update multiple existing records in an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to update, each with an `id` and a `fields` object"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_update_record":{"id":"airtable_update_record","name":"Airtable Update Record","description":"Update an existing record in an Airtable table by ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record ID to update (starts with \\"rec\\", e.g., \\"recXXXXXXXXXXXXXX\\")"},"fields":{"type":"json","required":true,"visibility":"user-or-llm","description":"An object containing the field names and their new values"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_upsert_records":{"id":"airtable_upsert_records","name":"Airtable Upsert Records","description":"Update existing records or create new ones in an Airtable table, matching on the specified merge fields","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to upsert, each with a `fields` object"},"fieldsToMergeOn":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of field names used to match existing records (max 3). A record is updated when all merge fields match, otherwise it is created. Example: [\\"Name\\"]"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"}},"airweave_search":{"id":"airweave_search","name":"Airweave Search","description":"Search your synced data collections using Airweave. Supports semantic search with hybrid, neural, or keyword retrieval strategies. Optionally generate AI-powered answers from search results.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Airweave API Key for authentication"},"collectionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The readable ID of the collection to search"},"query":{"type":"string","required":true,"visibility":"user-or-llm","description":"The search query text"},"limit":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 100)"},"retrievalStrategy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Retrieval strategy: hybrid (default), neural, or keyword"},"expandQuery":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Generate query variations to improve recall"},"rerank":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Reorder results for improved relevance using LLM"},"generateAnswer":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Generate a natural-language answer to the query"}}},"algolia_add_record":{"id":"algolia_add_record","name":"Algolia Add Record","description":"Add or replace a record in an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":false,"visibility":"user-or-llm","description":"Object ID for the record (auto-generated if not provided)"},"record":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object representing the record to add"}}},"algolia_batch_operations":{"id":"algolia_batch_operations","name":"Algolia Batch Operations","description":"Perform batch add, update, partial update, or delete operations on records in an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"requests":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of batch operations. Each item has \\"action\\" (addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject, delete, clear) and \\"body\\" (the record data; must include objectID for update/delete; use an empty object {} for the index-level delete/clear actions)"}}},"algolia_browse_records":{"id":"algolia_browse_records","name":"Algolia Browse Records","description":"Browse and iterate over all records in an Algolia index using cursor pagination","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key (must have browse ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to browse"},"query":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search query to filter browsed records"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter string to narrow down results"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of hits per page (default: 1000, max: 1000)"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous browse response for pagination"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search"}}},"algolia_clear_records":{"id":"algolia_clear_records","name":"Algolia Clear Records","description":"Clear all records from an Algolia index while keeping settings, synonyms, and rules","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to clear"}}},"algolia_copy_move_index":{"id":"algolia_copy_move_index","name":"Algolia Copy/Move Index","description":"Copy or move an Algolia index to a new destination","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the source index"},"operation":{"type":"string","required":true,"visibility":"user-or-llm","description":"Operation to perform: \\"copy\\" or \\"move\\""},"destination":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the destination index"},"scope":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of scopes to copy (only for \\"copy\\" operation): [\\"settings\\", \\"synonyms\\", \\"rules\\"]. Omit to copy everything including records."}}},"algolia_delete_by_filter":{"id":"algolia_delete_by_filter","name":"Algolia Delete By Filter","description":"Delete all records matching a filter from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter expression to match records for deletion (e.g., \\"category:outdated\\")"},"facetFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of facet filters (e.g., [\\"brand:Acme\\"])"},"numericFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of numeric filters (e.g., [\\"price > 100\\"])"},"tagFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of tag filters using the _tags attribute (e.g., [\\"published\\"])"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search filter (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search filter"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search filter"}}},"algolia_delete_index":{"id":"algolia_delete_index","name":"Algolia Delete Index","description":"Delete an entire Algolia index and all its records","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to delete"}}},"algolia_delete_record":{"id":"algolia_delete_record","name":"Algolia Delete Record","description":"Delete a record by objectID from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to delete"}}},"algolia_get_record":{"id":"algolia_get_record","name":"Algolia Get Record","description":"Get a record by objectID from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to retrieve"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"}}},"algolia_get_records":{"id":"algolia_get_records","name":"Algolia Get Records","description":"Retrieve multiple records by objectID from one or more Algolia indices","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Default index name for all requests"},"requests":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of objects specifying records to retrieve. Each must have \\"objectID\\" and optionally \\"indexName\\" and \\"attributesToRetrieve\\"."}}},"algolia_get_settings":{"id":"algolia_get_settings","name":"Algolia Get Settings","description":"Retrieve the settings of an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"}}},"algolia_get_task_status":{"id":"algolia_get_task_status","name":"Algolia Get Task Status","description":"Check whether an Algolia indexing task has finished publishing","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index the task ran against"},"taskID":{"type":"number","required":true,"visibility":"user-or-llm","description":"The taskID returned by a previous write operation"}}},"algolia_list_indices":{"id":"algolia_list_indices","name":"Algolia List Indices","description":"List all indices in an Algolia application","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for paginating indices (default: not paginated)"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of indices per page (default: 100)"}}},"algolia_partial_update_record":{"id":"algolia_partial_update_record","name":"Algolia Partial Update Record","description":"Partially update a record in an Algolia index without replacing it entirely","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to update"},"attributes":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object with attributes to update. Supports built-in operations like {\\"stock\\": {\\"_operation\\": \\"Decrement\\", \\"value\\": 1}}"},"createIfNotExists":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to create the record if it does not exist (default: true)"}}},"algolia_search":{"id":"algolia_search","name":"Algolia Search","description":"Search an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to search"},"query":{"type":"string","required":true,"visibility":"user-or-llm","description":"Search query text"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of hits per page (default: 20)"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number to retrieve (default: 0)"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter string (e.g., \\"category:electronics AND price < 100\\")"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"},"facets":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of facet attribute names to retrieve counts for (use \\"*\\" for all)"},"getRankingInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to include detailed ranking information in each hit"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search"}}},"algolia_update_settings":{"id":"algolia_update_settings","name":"Algolia Update Settings","description":"Update the settings of an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have editSettings ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"settings":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object with settings to update (e.g., {\\"searchableAttributes\\": [\\"name\\", \\"description\\"], \\"customRanking\\": [\\"desc(popularity)\\"]})"},"forwardToReplicas":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to apply changes to replica indices (default: false)"}}},"amplitude_event_segmentation":{"id":"amplitude_event_segmentation","name":"Amplitude Event Segmentation","description":"Query event analytics data with segmentation. Get event counts, uniques, averages, and more.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"eventType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Event type name to analyze"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric type: uniques, totals, pct_dau, average, histogram, sums, value_avg, or formula (default: uniques)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by (prefix custom user properties with \\"gp:\\")"},"groupBy2":{"type":"string","required":false,"visibility":"user-or-llm","description":"Second property name to group by (prefix custom user properties with \\"gp:\\")"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of group-by values (max 1000)"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON array of filter objects applied to the event, e.g. [{\\"subprop_type\\":\\"event\\",\\"subprop_key\\":\\"city\\",\\"subprop_op\\":\\"is\\",\\"subprop_value\\":[\\"San Francisco\\"]}]"},"formula":{"type":"string","required":false,"visibility":"user-or-llm","description":"Required when metric is \\"formula\\", e.g. \\"UNIQUES(A)/UNIQUES(B)\\""},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_funnels":{"id":"amplitude_funnels","name":"Amplitude Funnels","description":"Analyze conversion rates and drop-off between a sequence of events.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"events":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON array of event objects, one per funnel step in order, e.g. [{\\"event_type\\":\\"signup\\"},{\\"event_type\\":\\"purchase\\"}]"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Funnel ordering: \\"ordered\\", \\"unordered\\", or \\"sequential\\" (default: ordered)"},"userType":{"type":"string","required":false,"visibility":"user-or-llm","description":"User type: \\"new\\" or \\"active\\" (default: active)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: -300000 (real-time), -3600000 (hourly), 1 (daily), 7 (weekly), or 30 (monthly)"},"conversionWindowSeconds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Conversion window in seconds (default: 2592000, i.e. 30 days)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property to group by (limit: one; prefix custom properties with \\"gp:\\")"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of group-by values (default: 100, max: 1000)"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_get_active_users":{"id":"amplitude_get_active_users","name":"Amplitude Get Active Users","description":"Get active or new user counts over a date range from the Dashboard REST API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric type: \\"active\\" or \\"new\\" (default: active)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_get_revenue":{"id":"amplitude_get_revenue","name":"Amplitude Get Revenue","description":"Get revenue LTV data including ARPU, ARPPU, total revenue, and paying user counts.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric: 0 (ARPU), 1 (ARPPU), 2 (Total Revenue), 3 (Paying Users)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by (limit: one)"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_group_identify":{"id":"amplitude_group_identify","name":"Amplitude Group Identify","description":"Set group-level properties in Amplitude. Supports $set, $setOnce, $add, $append, $unset operations.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"groupType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Group classification (e.g., \\"company\\", \\"org_id\\")"},"groupValue":{"type":"string","required":true,"visibility":"user-or-llm","description":"Specific group identifier (e.g., \\"Acme Corp\\")"},"groupProperties":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON object of group properties. Use operations like $set, $setOnce, $add, $append, $unset."},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_identify_user":{"id":"amplitude_identify_user","name":"Amplitude Identify User","description":"Set user properties in Amplitude using the Identify API. Supports $set, $setOnce, $add, $append, $unset operations.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"User ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"userProperties":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON object of user properties. Use operations like $set, $setOnce, $add, $append, $unset."},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_list_events":{"id":"amplitude_list_events","name":"Amplitude List Events","description":"List all event types in the Amplitude project with their weekly totals and unique counts.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_realtime_active_users":{"id":"amplitude_realtime_active_users","name":"Amplitude Real-time Active Users","description":"Get real-time active user counts at 5-minute granularity for the last 2 days.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_retention":{"id":"amplitude_retention","name":"Amplitude Retention","description":"Measure how many users return to perform an action after a starting action.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"startEvent":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON starting event object, e.g. {\\"event_type\\":\\"_new\\"} or {\\"event_type\\":\\"_active\\"}"},"returnEvent":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON returning event object, e.g. {\\"event_type\\":\\"_all\\"} or {\\"event_type\\":\\"_active\\"}"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"retentionMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Retention type: \\"bracket\\", \\"rolling\\", or \\"n-day\\" (default: n-day)"},"retentionBrackets":{"type":"string","required":false,"visibility":"user-or-llm","description":"Required when Retention Mode is \\"bracket\\". Day ranges, e.g. [[0,4]]"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property to group by (limit: one; prefix custom properties with \\"gp:\\")"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_send_event":{"id":"amplitude_send_event","name":"Amplitude Send Event","description":"Track an event in Amplitude using the HTTP V2 API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"User ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"eventType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the event (e.g., \\"page_view\\", \\"purchase\\")"},"eventProperties":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON object of custom event properties"},"userProperties":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON object of user properties to set (supports $set, $setOnce, $add, $append, $unset)"},"time":{"type":"string","required":false,"visibility":"user-or-llm","description":"Event timestamp in milliseconds since epoch"},"sessionId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Session start time in milliseconds since epoch"},"insertId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Unique ID for deduplication (within 7-day window)"},"appVersion":{"type":"string","required":false,"visibility":"user-or-llm","description":"Application version string"},"platform":{"type":"string","required":false,"visibility":"user-or-llm","description":"Platform (e.g., \\"Web\\", \\"iOS\\", \\"Android\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Two-letter country code"},"language":{"type":"string","required":false,"visibility":"user-or-llm","description":"Language code (e.g., \\"en\\")"},"ip":{"type":"string","required":false,"visibility":"user-or-llm","description":"IP address for geo-location"},"price":{"type":"string","required":false,"visibility":"user-or-llm","description":"Price of the item purchased"},"quantity":{"type":"string","required":false,"visibility":"user-or-llm","description":"Quantity of items purchased"},"revenue":{"type":"string","required":false,"visibility":"user-or-llm","description":"Revenue amount"},"productId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Product identifier"},"revenueType":{"type":"string","required":false,"visibility":"user-or-llm","description":"Revenue type (e.g., \\"purchase\\", \\"refund\\")"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_user_activity":{"id":"amplitude_user_activity","name":"Amplitude User Activity","description":"Get the event stream for a specific user by their Amplitude ID.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"amplitudeId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Amplitude internal user ID"},"offset":{"type":"string","required":false,"visibility":"user-or-llm","description":"Offset for pagination (default 0)"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of events to return (default 1000, max 1000)"},"direction":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort direction: \\"latest\\" or \\"earliest\\" (default: latest)"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_user_profile":{"id":"amplitude_user_profile","name":"Amplitude User Profile","description":"Get a user profile including properties, cohort memberships, and computed properties. Not available for EU data-residency projects.","version":"1.0.0","params":{"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"External user ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"getAmpProps":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include Amplitude user properties (true/false, default: false)"},"getCohortIds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include cohort IDs the user belongs to (true/false, default: false)"},"getComputations":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include computed user properties (true/false, default: false)"}}},"amplitude_user_search":{"id":"amplitude_user_search","name":"Amplitude User Search","description":"Search for a user by User ID, Device ID, or Amplitude ID using the Dashboard REST API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"user":{"type":"string","required":true,"visibility":"user-or-llm","description":"User ID, Device ID, or Amplitude ID to search for"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"apify_get_dataset_items":{"id":"apify_get_dataset_items","name":"APIFY Get Dataset Items","description":"Retrieve items stored in an APIFY dataset","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"datasetId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Dataset ID to read items from. Example: \\"9RnD3Pql2vGZkc5H5\\""},"itemLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Max items to return (1-250000). Default: all items. Example: 500"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to skip at the start. Default: 0"},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of fields to include. Example: \\"title,url,price\\""}}},"apify_get_run":{"id":"apify_get_run","name":"APIFY Get Run","description":"Get the status and details of an APIFY actor run","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"runId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor run ID to fetch. Example: \\"HG7ML7M8z78YcAPEB\\""}}},"apify_run_actor_async":{"id":"apify_run_actor_async","name":"APIFY Run Actor (Async)","description":"Run an APIFY actor asynchronously with polling for long-running tasks","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"actorId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor ID or username/actor-name. Examples: \\"apify/web-scraper\\", \\"janedoe/my-actor\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor input as JSON string. Example: {\\"startUrls\\": [{\\"url\\": \\"https://example.com\\"}], \\"maxPages\\": 10}"},"waitForFinish":{"type":"number","required":false,"visibility":"user-or-llm","description":"Initial wait time in seconds (0-60) before polling starts. Example: 30"},"itemLimit":{"type":"number","required":false,"default":100,"visibility":"user-or-llm","description":"Max dataset items to fetch (1-250000). Default: 100. Example: 500"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the actor run (128-32768). Example: 1024 for 1GB, 2048 for 2GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the actor run. Example: 300 for 5 minutes, 3600 for 1 hour"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\", \\"build-tag-name\\""}}},"apify_run_actor_sync":{"id":"apify_run_actor_sync","name":"APIFY Run Actor (Sync)","description":"Run an APIFY actor synchronously and get results (max 5 minutes)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"actorId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor ID or username/actor-name. Examples: \\"apify/web-scraper\\", \\"janedoe/my-actor\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor input as JSON string. Example: {\\"startUrls\\": [{\\"url\\": \\"https://example.com\\"}], \\"maxPages\\": 10}"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the actor run (128-32768). Example: 1024 for 1GB, 2048 for 2GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the actor run. Example: 300 for 5 minutes, 3600 for 1 hour"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\", \\"build-tag-name\\""}}},"apify_run_task":{"id":"apify_run_task","name":"APIFY Run Task","description":"Run a saved APIFY actor task synchronously and get dataset items (max 5 minutes)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task ID or username/task-name. Examples: \\"janedoe/my-task\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON string that overrides the task\'s saved input. Example: {\\"startUrls\\": [{\\"url\\": \\"https://example.com\\"}]}"},"itemLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Max dataset items to return (1-250000). Example: 500"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the run (128-32768). Example: 1024 for 1GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the run. Example: 300 for 5 minutes"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\""}}},"apollo_account_bulk_create":{"id":"apollo_account_bulk_create","name":"Apollo Bulk Create Accounts","description":"Create up to 100 accounts at once in your Apollo database. Set run_dedupe=true to deduplicate by domain, organization_id, and name. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"accounts":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of accounts to create (max 100). Each account should include a name, and may optionally include domain, phone, phone_status_cd, raw_address, owner_id, linkedin_url, facebook_url, twitter_url, salesforce_id, and hubspot_id."},"append_label_names":{"type":"array","required":false,"visibility":"user-only","description":"Array of label names to add to ALL accounts in this request"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, performs aggressive deduplication by domain, organization_id, and name (defaults to false)"}}},"apollo_account_bulk_update":{"id":"apollo_account_bulk_update","name":"Apollo Bulk Update Accounts","description":"Update up to 1000 existing accounts at once in your Apollo database (higher limit than contacts!). Each account must include an id field. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"account_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of account IDs to update with the same values (max 1000). Use with name/owner_id for uniform updates. Use either this OR account_attributes."},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this name to all accounts"},"owner_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this owner to all accounts"},"account_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this account stage to all accounts"},"account_attributes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of account objects with individual updates (each must include id). Example: [{\\"id\\": \\"acc1\\", \\"name\\": \\"Acme\\", \\"owner_id\\": \\"u1\\", \\"account_stage_id\\": \\"s1\\", \\"typed_custom_fields\\": {\\"field_id\\": \\"value\\"}}]"},"async":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, processes the update asynchronously. Only supported when using account_ids; returns 422 if used with account_attributes."}}},"apollo_account_create":{"id":"apollo_account_create","name":"Apollo Create Account","description":"Create a new account (company) in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Company name (e.g., \\"Acme Corporation\\")"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain without www. prefix (e.g., \\"acme.com\\")"},"phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number for the account"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo user ID of the account owner"},"account_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo ID for the account stage to assign this account to"},"raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate location (e.g., \\"San Francisco, CA, USA\\")"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}}},"apollo_account_search":{"id":"apollo_account_search","name":"Apollo Search Accounts","description":"Search your team\'s accounts in Apollo. Display limit: 50,000 records (100 records per page, 500 pages max). Use filters to narrow results. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"q_organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter accounts by organization name (partial-match search)"},"account_stage_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by account stage IDs"},"account_label_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by account label IDs"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"account_last_activity_date\\", \\"account_created_at\\", or \\"account_updated_at\\""},"sort_ascending":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Sort ascending when true. Defaults to descending."},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_account_update":{"id":"apollo_account_update","name":"Apollo Update Account","description":"Update an existing account in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"account_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the account to update (e.g., \\"acc_abc123\\")"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company name (e.g., \\"Acme Corporation\\")"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain (e.g., \\"acme.com\\")"},"phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company phone number"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo user ID of the account owner"},"account_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo ID for the account stage to assign this account to"},"raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate location (e.g., \\"San Francisco, CA, USA\\")"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}}},"apollo_contact_bulk_create":{"id":"apollo_contact_bulk_create","name":"Apollo Bulk Create Contacts","description":"Create up to 100 contacts at once in your Apollo database. Supports deduplication to prevent creating duplicate contacts. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"contacts":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of contacts to create (max 100). Each contact may include first_name, last_name, email, title, organization_name, account_id, owner_id, contact_stage_id, linkedin_url, phone (single string) or phone_numbers (array of {raw_number, position}), contact_emails, typed_custom_fields, and CRM IDs (salesforce_contact_id, hubspot_id, team_id) for cross-system matching"},"append_label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Label names to add to all contacts in this request (e.g., [\\"Hot Lead\\"])"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-only","description":"Enable deduplication to prevent creating duplicate contacts. When true, existing contacts are returned without modification"}}},"apollo_contact_bulk_update":{"id":"apollo_contact_bulk_update","name":"Apollo Bulk Update Contacts","description":"Update up to 100 existing contacts at once in your Apollo database. Each contact must include an id field. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"contact_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of contact IDs to update. Must be paired with an object-form contact_attributes specifying the fields to apply uniformly to all listed contacts."},"contact_attributes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Required. Either an array of per-contact updates (each with id) — used standalone — or a single object of attributes to apply to all contact_ids. Supported fields: owner_id, email, organization_name, title, first_name, last_name, account_id, present_raw_address, linkedin_url, typed_custom_fields"},"async":{"type":"boolean","required":false,"visibility":"user-only","description":"Force asynchronous processing. Automatically enabled for >100 contacts"}}},"apollo_contact_create":{"id":"apollo_contact_create","name":"Apollo Create Contact","description":"Create a new contact in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"first_name":{"type":"string","required":true,"visibility":"user-or-llm","description":"First name of the contact"},"last_name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Last name of the contact"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address of the contact"},"title":{"type":"string","required":false,"visibility":"user-or-llm","description":"Job title (e.g., \\"VP of Sales\\", \\"Software Engineer\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo account ID to associate with (e.g., \\"acc_abc123\\")"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the contact owner (accepted by Apollo but not officially documented for POST /contacts)"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the contact\'s employer (e.g., \\"Apollo\\")"},"website_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate website URL (e.g., \\"https://www.apollo.io/\\")"},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Lists/labels to add the contact to (e.g., [\\"Prospects\\"])"},"contact_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the contact stage"},"present_raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal location for the contact (e.g., \\"Atlanta, United States\\")"},"direct_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number"},"corporate_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Work/office phone number"},"mobile_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Mobile phone number"},"home_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Home phone number"},"other_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Alternative phone number"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom field values keyed by custom field ID"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, Apollo deduplicates against existing contacts"}}},"apollo_contact_search":{"id":"apollo_contact_search","name":"Apollo Search Contacts","description":"Search your team\'s contacts in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"q_keywords":{"type":"string","required":false,"visibility":"user-or-llm","description":"Keywords to search for"},"contact_stage_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by contact stage IDs"},"contact_label_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by Apollo label IDs (lists)"},"sort_by_field":{"type":"string","required":false,"visibility":"user-only","description":"Sort field: contact_last_activity_date, contact_email_last_opened_at, contact_email_last_clicked_at, contact_created_at, or contact_updated_at"},"sort_ascending":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, sort ascending. Must be used together with sort_by_field"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_contact_update":{"id":"apollo_contact_update","name":"Apollo Update Contact","description":"Update an existing contact in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"contact_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the contact to update (e.g., \\"con_abc123\\")"},"first_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"First name of the contact"},"last_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Last name of the contact"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address"},"title":{"type":"string","required":false,"visibility":"user-or-llm","description":"Job title (e.g., \\"VP of Sales\\", \\"Software Engineer\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo account ID (e.g., \\"acc_abc123\\")"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the contact owner (accepted by Apollo but not officially documented for PATCH /contacts/{id})"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the contact\'s employer (e.g., \\"Apollo\\")"},"website_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate website URL (e.g., \\"https://www.apollo.io/\\")"},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Lists/labels to add the contact to (e.g., [\\"Prospects\\"])"},"contact_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the contact stage"},"present_raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal location for the contact (e.g., \\"Atlanta, United States\\")"},"direct_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number"},"corporate_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Work/office phone number"},"mobile_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Mobile phone number"},"home_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Home phone number"},"other_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Alternative phone number"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom field values keyed by custom field ID"}}},"apollo_email_accounts":{"id":"apollo_email_accounts","name":"Apollo Get Email Accounts","description":"Get list of team\'s linked email accounts in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"}}},"apollo_opportunity_create":{"id":"apollo_opportunity_create","name":"Apollo Create Opportunity","description":"Create a new deal for an account in your Apollo database (master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the opportunity/deal (e.g., \\"Enterprise License - Q1\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"ID of the account this opportunity belongs to (e.g., \\"acc_abc123\\")"},"amount":{"type":"string","required":false,"visibility":"user-or-llm","description":"Monetary value as a plain number string with no commas or currency symbols"},"opportunity_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the opportunity stage"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the opportunity owner"},"closed_date":{"type":"string","required":false,"visibility":"user-or-llm","description":"Expected close date in YYYY-MM-DD format"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}}},"apollo_opportunity_get":{"id":"apollo_opportunity_get","name":"Apollo Get Opportunity","description":"Retrieve complete details of a specific deal/opportunity by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"opportunity_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the opportunity to retrieve (e.g., \\"opp_abc123\\")"}}},"apollo_opportunity_search":{"id":"apollo_opportunity_search","name":"Apollo Search Opportunities","description":"Search and list all deals/opportunities in your team\'s Apollo account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"amount\\", \\"is_closed\\", or \\"is_won\\""},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_opportunity_update":{"id":"apollo_opportunity_update","name":"Apollo Update Opportunity","description":"Update an existing deal/opportunity in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"opportunity_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the opportunity to update (e.g., \\"opp_abc123\\")"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the opportunity/deal (e.g., \\"Enterprise License - Q1\\")"},"amount":{"type":"string","required":false,"visibility":"user-or-llm","description":"Monetary value as a plain number string with no commas or currency symbols"},"opportunity_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the opportunity stage"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the opportunity owner"},"closed_date":{"type":"string","required":false,"visibility":"user-or-llm","description":"Expected close date in YYYY-MM-DD format"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}}},"apollo_organization_bulk_enrich":{"id":"apollo_organization_bulk_enrich","name":"Apollo Bulk Organization Enrichment","description":"Enrich data for up to 10 organizations at once using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"domains":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of company domains to enrich (max 10, no www. or @, e.g., [\\"apollo.io\\", \\"stripe.com\\"])"}}},"apollo_organization_enrich":{"id":"apollo_organization_enrich","name":"Apollo Organization Enrichment","description":"Enrich data for a single organization using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"domain":{"type":"string","required":true,"visibility":"user-or-llm","description":"Company domain (e.g., \\"apollo.io\\", \\"acme.com\\")"}}},"apollo_organization_search":{"id":"apollo_organization_search","name":"Apollo Organization Search","description":"Search Apollo\'s database for companies using filters","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"organization_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Company HQ locations (cities, US states, or countries)"},"organization_not_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Exclude companies whose HQ is in these locations"},"organization_num_employees_ranges":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employee count ranges as \\"min,max\\" strings (e.g., [\\"1,10\\", \\"250,500\\", \\"10000,20000\\"])"},"q_organization_keyword_tags":{"type":"array","required":false,"visibility":"user-or-llm","description":"Industry or keyword tags"},"q_organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Organization name to search for (e.g., \\"Acme\\", \\"TechCorp\\")"},"organization_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Apollo organization IDs to include (e.g., [\\"5e66b6381e05b4008c8331b8\\"])"},"q_organization_domains_list":{"type":"array","required":false,"visibility":"user-or-llm","description":"Domain names to filter by (no www. or @, up to 1,000)"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_people_bulk_enrich":{"id":"apollo_people_bulk_enrich","name":"Apollo Bulk People Enrichment","description":"Enrich data for up to 10 people at once using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"people":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of people to enrich (max 10)"},"reveal_personal_emails":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal personal email addresses (uses credits)"},"reveal_phone_number":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal phone numbers (uses credits, requires webhook_url)"},"webhook_url":{"type":"string","required":false,"visibility":"user-only","description":"Webhook URL for async phone number delivery (required when reveal_phone_number is true)"}}},"apollo_people_enrich":{"id":"apollo_people_enrich","name":"Apollo People Enrichment","description":"Enrich data for a single person using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"first_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"First name of the person"},"last_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Last name of the person"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Full name of the person (alternative to first_name/last_name)"},"id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the person"},"hashed_email":{"type":"string","required":false,"visibility":"user-or-llm","description":"MD5 or SHA-256 hashed email"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address of the person"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company name where the person works"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain (e.g., \\"apollo.io\\", \\"acme.com\\")"},"linkedin_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"LinkedIn profile URL"},"reveal_personal_emails":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal personal email addresses (uses credits)"},"reveal_phone_number":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal phone numbers (uses credits, requires webhook_url)"},"webhook_url":{"type":"string","required":false,"visibility":"user-only","description":"Webhook URL for async phone number delivery (required when reveal_phone_number is true)"}}},"apollo_people_search":{"id":"apollo_people_search","name":"Apollo People Search","description":"Search Apollo\'s database for people using demographic filters","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"person_titles":{"type":"array","required":false,"visibility":"user-or-llm","description":"Job titles to search for (e.g., [\\"CEO\\", \\"VP of Sales\\"])"},"include_similar_titles":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to return people with job titles similar to person_titles"},"person_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Locations to search in (e.g., [\\"San Francisco, CA\\", \\"New York, NY\\"])"},"person_seniorities":{"type":"array","required":false,"visibility":"user-or-llm","description":"Seniority levels (one of: owner, founder, c_suite, partner, vp, head, director, manager, senior, entry, intern)"},"organization_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Apollo organization IDs to filter by (e.g., [\\"5e66b6381e05b4008c8331b8\\"])"},"organization_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Company names to search within (legacy filter)"},"organization_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Headquarters locations of the people\'s current employer (e.g., [\'texas\', \'tokyo\', \'spain\'])"},"q_organization_domains_list":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employer domain names (e.g., [\\"apollo.io\\", \\"microsoft.com\\"]) — up to 1,000, no www. or @"},"organization_num_employees_ranges":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employee count ranges for the person\'s current employer. Each entry is \\"min,max\\" (e.g., [\\"1,10\\", \\"250,500\\", \\"10000,20000\\"])"},"contact_email_status":{"type":"array","required":false,"visibility":"user-or-llm","description":"Email statuses to filter by: \\"verified\\", \\"unverified\\", \\"likely to engage\\", \\"unavailable\\""},"q_keywords":{"type":"string","required":false,"visibility":"user-or-llm","description":"Keywords to search for"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination, default 1 (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, default 25, max 100 (e.g., 25, 50, 100)"}}},"apollo_sequence_add_contacts":{"id":"apollo_sequence_add_contacts","name":"Apollo Add Contacts to Sequence","description":"Add contacts to an Apollo sequence","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"sequence_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the sequence to add contacts to (e.g., \\"seq_abc123\\")"},"contact_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of contact IDs to add to the sequence (e.g., [\\"con_abc123\\", \\"con_def456\\"]). Either contact_ids or label_names must be provided."},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of label names to identify contacts to add to the sequence. Either contact_ids or label_names must be provided."},"send_email_from_email_account_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the email account to send from. Use the Get Email Accounts operation to look this up."},"send_email_from_email_address":{"type":"string","required":false,"visibility":"user-only","description":"Specific email address to send from within the email account."},"sequence_no_email":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if they have no email address"},"sequence_unverified_email":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts with unverified email addresses"},"sequence_job_change":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts who recently changed jobs"},"sequence_active_in_other_campaigns":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts active in other campaigns"},"sequence_finished_in_other_campaigns":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts who finished other campaigns"},"sequence_same_company_in_same_campaign":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if others from the same company are in the sequence"},"contacts_without_ownership_permission":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts without ownership permission"},"add_if_in_queue":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if they are in the queue"},"contact_verification_skipped":{"type":"boolean","required":false,"visibility":"user-only","description":"Skip contact verification when adding"},"user_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the user performing the action"},"status":{"type":"string","required":false,"visibility":"user-only","description":"Initial status for added contacts: \\"active\\" or \\"paused\\""},"auto_unpause_at":{"type":"string","required":false,"visibility":"user-only","description":"ISO 8601 datetime to automatically unpause contacts"}}},"apollo_sequence_search":{"id":"apollo_sequence_search","name":"Apollo Search Sequences","description":"Search for sequences/campaigns in your team\'s Apollo account (master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"q_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search sequences by name (e.g., \\"Outbound Q1\\", \\"Follow-up\\")"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_task_create":{"id":"apollo_task_create","name":"Apollo Create Task","description":"Create one or more tasks in Apollo (one task per contact_id, master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"user_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the Apollo user the task is assigned to"},"contact_ids":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of contact IDs. One task is created per contact."},"priority":{"type":"string","required":false,"visibility":"user-or-llm","description":"Task priority: \\"high\\", \\"medium\\", or \\"low\\" (defaults to \\"medium\\")"},"due_at":{"type":"string","required":true,"visibility":"user-or-llm","description":"Due date/time in ISO 8601 format (e.g., \\"2024-12-31T23:59:59Z\\")"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task type: \\"call\\", \\"outreach_manual_email\\", \\"linkedin_step_connect\\", \\"linkedin_step_message\\", \\"linkedin_step_view_profile\\", \\"linkedin_step_interact_post\\", or \\"action_item\\""},"status":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task status: \\"scheduled\\", \\"completed\\", or \\"skipped\\""},"note":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-form note providing context for the task"}}},"apollo_task_search":{"id":"apollo_task_search","name":"Apollo Search Tasks","description":"Search for tasks in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"task_due_at\\" or \\"task_priority\\""},"open_factor_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Filter by status. Common values: [\\"task_types\\"] for open tasks, [\\"task_completed_at\\"] for completed tasks."},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"appconfig_create_application":{"id":"appconfig_create_application","name":"AppConfig Create Application","description":"Create an application in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the application to create"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the application"}}},"appconfig_create_configuration_profile":{"id":"appconfig_create_configuration_profile","name":"AppConfig Create Configuration Profile","description":"Create a configuration profile in an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to create the configuration profile in"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the configuration profile"},"locationUri":{"type":"string","required":true,"visibility":"user-or-llm","description":"Where the configuration is stored. Use \\"hosted\\" for AppConfig-hosted configurations, or an SSM/S3 URI"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the configuration profile"},"retrievalRoleArn":{"type":"string","required":false,"visibility":"user-or-llm","description":"ARN of an IAM role to retrieve the configuration (required for non-hosted URIs)"},"type":{"type":"string","required":false,"visibility":"user-or-llm","description":"Profile type: AWS.Freeform (default) or AWS.AppConfig.FeatureFlags"}}},"appconfig_create_environment":{"id":"appconfig_create_environment","name":"AppConfig Create Environment","description":"Create an environment for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to create the environment in"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the environment to create"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the environment"}}},"appconfig_create_hosted_configuration_version":{"id":"appconfig_create_hosted_configuration_version","name":"AppConfig Create Hosted Configuration Version","description":"Create a new hosted configuration version for an AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to add the version to"},"content":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration content (e.g., a JSON or YAML document)"},"contentType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Content type of the configuration (e.g., application/json, text/plain)"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the configuration version"},"latestVersionNumber":{"type":"number","required":false,"visibility":"user-or-llm","description":"The version number of the latest version, used for optimistic concurrency"},"versionLabel":{"type":"string","required":false,"visibility":"user-or-llm","description":"A user-defined label for the configuration version"}}},"appconfig_delete_application":{"id":"appconfig_delete_application","name":"AppConfig Delete Application","description":"Delete an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to delete"}}},"appconfig_delete_configuration_profile":{"id":"appconfig_delete_configuration_profile","name":"AppConfig Delete Configuration Profile","description":"Delete an AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to delete"}}},"appconfig_delete_environment":{"id":"appconfig_delete_environment","name":"AppConfig Delete Environment","description":"Delete an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to delete"}}},"appconfig_delete_hosted_configuration_version":{"id":"appconfig_delete_hosted_configuration_version","name":"AppConfig Delete Hosted Configuration Version","description":"Delete a specific hosted configuration version from an AppConfig profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID that owns the version"},"versionNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The version number to delete"}}},"appconfig_get_application":{"id":"appconfig_get_application","name":"AppConfig Get Application","description":"Get details about a single AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to retrieve"}}},"appconfig_get_configuration":{"id":"appconfig_get_configuration","name":"AppConfig Get Configuration","description":"Retrieve the latest deployed configuration for an AppConfig application, environment, and profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID or name to retrieve configuration for"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID or name to retrieve configuration for"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID or name to retrieve"}}},"appconfig_get_configuration_profile":{"id":"appconfig_get_configuration_profile","name":"AppConfig Get Configuration Profile","description":"Get details about a single AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to retrieve"}}},"appconfig_get_deployment":{"id":"appconfig_get_deployment","name":"AppConfig Get Deployment","description":"Get details about a specific AWS AppConfig deployment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployment"},"deploymentNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The sequence number of the deployment"}}},"appconfig_get_environment":{"id":"appconfig_get_environment","name":"AppConfig Get Environment","description":"Get details about a single AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to retrieve"}}},"appconfig_get_hosted_configuration_version":{"id":"appconfig_get_hosted_configuration_version","name":"AppConfig Get Hosted Configuration Version","description":"Retrieve a specific hosted configuration version from an AppConfig profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to read the version from"},"versionNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The version number to retrieve"}}},"appconfig_list_applications":{"id":"appconfig_list_applications","name":"AppConfig List Applications","description":"List applications in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of applications to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_configuration_profiles":{"id":"appconfig_list_configuration_profiles","name":"AppConfig List Configuration Profiles","description":"List configuration profiles for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profiles"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of configuration profiles to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_deployment_strategies":{"id":"appconfig_list_deployment_strategies","name":"AppConfig List Deployment Strategies","description":"List deployment strategies available in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of deployment strategies to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_deployments":{"id":"appconfig_list_deployments","name":"AppConfig List Deployments","description":"List deployments for an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployments"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployments"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of deployments to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_environments":{"id":"appconfig_list_environments","name":"AppConfig List Environments","description":"List environments for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environments"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of environments to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_hosted_configuration_versions":{"id":"appconfig_list_hosted_configuration_versions","name":"AppConfig List Hosted Configuration Versions","description":"List hosted configuration versions for an AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to list versions for"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of versions to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_start_deployment":{"id":"appconfig_start_deployment","name":"AppConfig Start Deployment","description":"Start deploying a configuration version to an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to deploy in"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to deploy to"},"deploymentStrategyId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The deployment strategy ID to use"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to deploy"},"configurationVersion":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration version to deploy"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the deployment"}}},"appconfig_stop_deployment":{"id":"appconfig_stop_deployment","name":"AppConfig Stop Deployment","description":"Stop an in-progress AWS AppConfig deployment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployment"},"deploymentNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The sequence number of the deployment to stop"}}},"appconfig_update_application":{"id":"appconfig_update_application","name":"AppConfig Update Application","description":"Update the name or description of an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the application"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the application"}}},"appconfig_update_configuration_profile":{"id":"appconfig_update_configuration_profile","name":"AppConfig Update Configuration Profile","description":"Update the name, description, or retrieval role of an AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the configuration profile"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the configuration profile"},"retrievalRoleArn":{"type":"string","required":false,"visibility":"user-or-llm","description":"New ARN of the IAM role used to retrieve the configuration"}}},"appconfig_update_environment":{"id":"appconfig_update_environment","name":"AppConfig Update Environment","description":"Update the name or description of an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the environment"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the environment"}}},"arxiv_get_author_papers":{"id":"arxiv_get_author_papers","name":"ArXiv Get Author Papers","description":"Search for papers by a specific author on ArXiv.","version":"1.0.0","params":{"authorName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Author name to search for"},"maxResults":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 10, max: 2000)"}}},"arxiv_get_paper":{"id":"arxiv_get_paper","name":"ArXiv Get Paper","description":"Get detailed information about a specific ArXiv paper by its ID.","version":"1.0.0","params":{"paperId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ArXiv paper ID (e.g., \\"1706.03762\\")"}}},"arxiv_search":{"id":"arxiv_search","name":"ArXiv Search","description":"Search for academic papers on ArXiv by keywords, authors, titles, or other fields.","version":"1.0.0","params":{"searchQuery":{"type":"string","required":true,"visibility":"user-or-llm","description":"The search query to execute"},"searchField":{"type":"string","required":false,"visibility":"user-only","description":"Field to search in: all, ti (title), au (author), abs (abstract), co (comment), jr (journal), cat (category), rn (report number)"},"maxResults":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 10, max: 2000)"},"sortBy":{"type":"string","required":false,"visibility":"user-only","description":"Sort by: relevance, lastUpdatedDate, submittedDate (default: relevance)"},"sortOrder":{"type":"string","required":false,"visibility":"user-only","description":"Sort order: ascending, descending (default: descending)"}}},"asana_add_comment":{"id":"asana_add_comment","name":"Asana Add Comment","description":"Add a comment (story) to an Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana task GID (numeric string)"},"text":{"type":"string","required":true,"visibility":"user-or-llm","description":"The text content of the comment"}},"oauth":{"required":true,"provider":"asana"}},"asana_add_followers":{"id":"asana_add_followers","name":"Asana Add Followers","description":"Add one or more followers to an Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana task (numeric string)"},"followers":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of user GIDs to add as followers to the task"}},"oauth":{"required":true,"provider":"asana"}},"asana_create_project":{"id":"asana_create_project","name":"Asana Create Project","description":"Create a new project in an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) where the project will be created"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the project"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the project"}},"oauth":{"required":true,"provider":"asana"}},"asana_create_section":{"id":"asana_create_section","name":"Asana Create Section","description":"Create a new section in an Asana project","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana project (numeric string) to add the section to"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the section"}},"oauth":{"required":true,"provider":"asana"}},"asana_create_subtask":{"id":"asana_create_subtask","name":"Asana Create Subtask","description":"Create a subtask under an existing Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the parent Asana task (numeric string)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the subtask"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the subtask"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"User GID to assign the subtask to"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"}},"asana_create_task":{"id":"asana_create_task","name":"Asana Create Task","description":"Create a new task in Asana","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) where the task will be created"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the task"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the task"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"User GID to assign the task to"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"}},"asana_delete_task":{"id":"asana_delete_task","name":"Asana Delete Task","description":"Delete an Asana task by its GID (moves it to the trash)","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana task to delete (numeric string)"}},"oauth":{"required":true,"provider":"asana"}},"asana_get_project":{"id":"asana_get_project","name":"Asana Get Project","description":"Retrieve a single Asana project by its GID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana project GID (numeric string) to retrieve"}},"oauth":{"required":true,"provider":"asana"}},"asana_get_projects":{"id":"asana_get_projects","name":"Asana Get Projects","description":"Retrieve all projects from an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to retrieve projects from"}},"oauth":{"required":true,"provider":"asana"}},"asana_get_task":{"id":"asana_get_task","name":"Asana Get Task","description":"Retrieve a single task by GID or get multiple tasks with filters","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":false,"visibility":"user-or-llm","description":"The globally unique identifier (GID) of the task. If not provided, will get multiple tasks."},"workspace":{"type":"string","required":false,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to filter tasks (required when not using taskGid)"},"project":{"type":"string","required":false,"visibility":"user-or-llm","description":"Asana project GID (numeric string) to filter tasks"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of tasks to return (default: 50)"}},"oauth":{"required":true,"provider":"asana"}},"asana_list_sections":{"id":"asana_list_sections","name":"Asana List Sections","description":"List all sections in an Asana project","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana project (numeric string) to list sections from"}},"oauth":{"required":true,"provider":"asana"}},"asana_list_workspaces":{"id":"asana_list_workspaces","name":"Asana List Workspaces","description":"List all Asana workspaces and organizations the authenticated user belongs to","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"}},"oauth":{"required":true,"provider":"asana"}},"asana_search_tasks":{"id":"asana_search_tasks","name":"Asana Search Tasks","description":"Search for tasks in an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to search tasks in"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Text to search for in task names"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter tasks by assignee user GID"},"projects":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of Asana project GIDs (numeric strings) to filter tasks by"},"completed":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Filter by completion status"}},"oauth":{"required":true,"provider":"asana"}},"asana_update_task":{"id":"asana_update_task","name":"Asana Update Task","description":"Update an existing task in Asana","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana task GID (numeric string) of the task to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated name for the task"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated notes or description for the task"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated assignee user GID"},"completed":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Mark task as completed or not completed"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"}},"ashby_add_candidate_tag":{"id":"ashby_add_candidate_tag","name":"Ashby Add Candidate Tag","description":"Adds a tag to a candidate in Ashby and returns the updated candidate.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to add the tag to"},"tagId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the tag to add"}}},"ashby_anonymize_candidate":{"id":"ashby_anonymize_candidate","name":"Ashby Anonymize Candidate","description":"Strips personally identifiable information from a candidate in Ashby. This does not delete the candidate - the record and its applications remain, with the PII removed. Ashby exposes no candidate deletion endpoint; true deletion is UI-only, restricted by role, and limited to a 10-day window. Requires the candidatesWrite permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the candidate to anonymize"}}},"ashby_change_application_source":{"id":"ashby_change_application_source","name":"Ashby Change Application Source","description":"Changes the source attributed to an existing application, so programmatically created applications report correctly on the recruiting side. Requires the candidatesWrite permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the application whose source should change"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to attribute the application to, as returned by List Sources. Omit only when unsetSource is true."},"unsetSource":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Set true to deliberately clear the application source. Required to unset, so that a missing or empty sourceId cannot wipe attribution by accident."}}},"ashby_change_application_stage":{"id":"ashby_change_application_stage","name":"Ashby Change Application Stage","description":"Moves an application to a different interview stage. Requires an archive reason when moving to an Archived stage.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the application to update the stage of"},"interviewStageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the interview stage to move the application to"},"archiveReasonId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Archive reason UUID. Required when moving to an Archived stage, ignored otherwise"}}},"ashby_create_application":{"id":"ashby_create_application","name":"Ashby Create Application","description":"Creates a new application for a candidate on a job. Optionally specify interview plan, stage, source, and credited user.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to consider for the job"},"jobId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the job to consider the candidate for"},"interviewPlanId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the interview plan to use (defaults to the job default plan)"},"interviewStageId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the interview stage to place the application in (defaults to first Lead stage)"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to set on the application"},"creditedToUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the user the application is credited to"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to set as the application creation date (defaults to now)"}}},"ashby_create_candidate":{"id":"ashby_create_candidate","name":"Ashby Create Candidate","description":"Creates a new candidate record in Ashby.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"The candidate full name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary email address for the candidate"},"phoneNumber":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number for the candidate"},"linkedInUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"LinkedIn profile URL"},"githubUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"GitHub profile URL"},"website":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal website URL"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to attribute the candidate to"},"creditedToUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the Ashby user to credit with sourcing this candidate"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"Backdated creation timestamp in ISO 8601 (e.g. 2024-01-01T00:00:00Z). Defaults to now."},"alternateEmailAddresses":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of additional email address strings to add to the candidate, e.g. [\\"a@x.com\\",\\"b@y.com\\"]"}}},"ashby_create_note":{"id":"ashby_create_note","name":"Ashby Create Note","description":"Creates a note on a candidate in Ashby. Supports plain text and HTML content (bold, italic, underline, links, lists, code).","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to add the note to"},"note":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note content. If noteType is text/html, supports: , , , ,