From 8c7a1afa539e3e77acea74cee668bbbb4d143df6 Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Mon, 5 Oct 2026 15:20:49 +0200 Subject: [PATCH 1/2] feat(tools): add custom inbound filter tools Add find, create, update and delete tools for Sentry's custom inbound filters so an agent that searches for "inbound filters", "ignore errors by message" or "drop logs" finds the API instead of concluding it does not exist. The tools wrap the experimental /projects/{org}/{project}/custom-inbound-filters/ endpoints, validate condition types against the data type before the request, and turn the "feature not enabled" 400 into a message that names the feature. Co-Authored-By: Claude Fable 5.1 --- packages/mcp-core/src/api-client/client.ts | 152 +++++ packages/mcp-core/src/api-client/schema.ts | 19 + packages/mcp-core/src/api-client/types.ts | 6 + packages/mcp-core/src/server.test.ts | 3 + packages/mcp-core/src/skillDefinitions.json | 31 +- packages/mcp-core/src/skills.ts | 2 +- packages/mcp-core/src/toolDefinitions.json | 530 ++++++++++++++++++ .../create-custom-inbound-filter.test.ts | 163 ++++++ .../catalog/create-custom-inbound-filter.ts | 117 ++++ .../delete-custom-inbound-filter.test.ts | 88 +++ .../catalog/delete-custom-inbound-filter.ts | 80 +++ .../find-custom-inbound-filters.test.ts | 161 ++++++ .../catalog/find-custom-inbound-filters.ts | 97 ++++ packages/mcp-core/src/tools/catalog/index.ts | 8 + .../catalog/support/custom-inbound-filters.ts | 163 ++++++ .../update-custom-inbound-filter.test.ts | 136 +++++ .../catalog/update-custom-inbound-filter.ts | 127 +++++ .../src/fixtures/custom-inbound-filter.json | 18 + packages/mcp-server-mocks/src/index.ts | 56 ++ 19 files changed, 1953 insertions(+), 4 deletions(-) create mode 100644 packages/mcp-core/src/tools/catalog/create-custom-inbound-filter.test.ts create mode 100644 packages/mcp-core/src/tools/catalog/create-custom-inbound-filter.ts create mode 100644 packages/mcp-core/src/tools/catalog/delete-custom-inbound-filter.test.ts create mode 100644 packages/mcp-core/src/tools/catalog/delete-custom-inbound-filter.ts create mode 100644 packages/mcp-core/src/tools/catalog/find-custom-inbound-filters.test.ts create mode 100644 packages/mcp-core/src/tools/catalog/find-custom-inbound-filters.ts create mode 100644 packages/mcp-core/src/tools/catalog/support/custom-inbound-filters.ts create mode 100644 packages/mcp-core/src/tools/catalog/update-custom-inbound-filter.test.ts create mode 100644 packages/mcp-core/src/tools/catalog/update-custom-inbound-filter.ts create mode 100644 packages/mcp-server-mocks/src/fixtures/custom-inbound-filter.json diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 95325c0be..473db0f71 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -51,6 +51,8 @@ import { AutofixRunStateSchema, ClientKeyListSchema, ClientKeySchema, + CustomInboundFilterListSchema, + CustomInboundFilterSchema, CommitListSchema, CommittersResponseSchema, DashboardListSchema, @@ -130,6 +132,8 @@ import type { SearchAgentState, ClientKey, ClientKeyList, + CustomInboundFilter, + CustomInboundFilterList, CommitList, CommitterList, Dashboard, @@ -346,6 +350,13 @@ type UpdateProjectRequest = { platform?: string; }; +type CustomInboundFilterWriteRequest = { + name?: string | null; + active?: boolean; + dataType: string; + conditions: Array<{ type: string; value: string[] }>; +}; + type UpdateClientKeyRequest = { name?: string; isActive?: boolean; @@ -2856,6 +2867,147 @@ export class SentryApiService { return ClientKeyListSchema.parse(body); } + /** + * Lists the custom inbound filters of a project. + * + * Source: src/sentry/api/endpoints/project_custom_inbound_filters.py + * + * @param params Query parameters + * @param params.organizationSlug Organization identifier + * @param params.projectSlug Project identifier + * @param params.limit Maximum number of filters per page + * @param params.cursor Pagination cursor from a previous call's nextCursor + * @param opts Request options + * @returns A page of custom inbound filters, plus a cursor for the next page (null once exhausted) + */ + async listCustomInboundFilters( + { + organizationSlug, + projectSlug, + limit, + cursor, + }: { + organizationSlug: string; + projectSlug: string; + limit?: number; + cursor?: string | null; + }, + opts?: RequestOptions, + ): Promise<{ filters: CustomInboundFilterList; nextCursor: string | null }> { + const queryParams = new URLSearchParams(); + queryParams.set("per_page", String(limit ?? 25)); + if (cursor) { + queryParams.set("cursor", cursor); + } + const basePath = apiPath`/projects/${organizationSlug}/${projectSlug}/custom-inbound-filters/`; + const response = await this.request( + `${basePath}?${queryParams.toString()}`, + undefined, + opts, + ); + const body = await this.parseJsonResponse(response); + return { + filters: CustomInboundFilterListSchema.parse(body), + nextCursor: getNextCursor(response.headers.get("link")), + }; + } + + /** + * Creates a custom inbound filter in a project. + * + * Source: src/sentry/api/endpoints/project_custom_inbound_filters.py + */ + async createCustomInboundFilter( + { + organizationSlug, + projectSlug, + ...filter + }: { + organizationSlug: string; + projectSlug: string; + } & CustomInboundFilterWriteRequest, + opts?: RequestOptions, + ): Promise { + const body = await this.requestJSON( + apiPath`/projects/${organizationSlug}/${projectSlug}/custom-inbound-filters/`, + { + method: "POST", + body: JSON.stringify(filter), + }, + opts, + ); + return CustomInboundFilterSchema.parse(body); + } + + /** + * Updates a custom inbound filter. Only the provided fields change. + * + * Source: src/sentry/api/endpoints/project_custom_inbound_filters.py + */ + async updateCustomInboundFilter( + { + organizationSlug, + projectSlug, + filterId, + ...filter + }: { + organizationSlug: string; + projectSlug: string; + filterId: string; + } & Partial, + opts?: RequestOptions, + ): Promise { + const updateData: Partial = {}; + if (filter.name !== undefined) { + updateData.name = filter.name; + } + if (filter.active !== undefined) { + updateData.active = filter.active; + } + if (filter.dataType !== undefined) { + updateData.dataType = filter.dataType; + } + if (filter.conditions !== undefined) { + updateData.conditions = filter.conditions; + } + const body = await this.requestJSON( + apiPath`/projects/${organizationSlug}/${projectSlug}/custom-inbound-filters/${filterId}/`, + { + method: "PUT", + body: JSON.stringify(updateData), + }, + opts, + ); + return CustomInboundFilterSchema.parse(body); + } + + /** + * Deletes a custom inbound filter. + * + * Source: src/sentry/api/endpoints/project_custom_inbound_filters.py + */ + async deleteCustomInboundFilter( + { + organizationSlug, + projectSlug, + filterId, + }: { + organizationSlug: string; + projectSlug: string; + filterId: string; + }, + opts?: RequestOptions, + ): Promise { + // Treat 404 as success so repeated deletes are idempotent. + await this.request( + apiPath`/projects/${organizationSlug}/${projectSlug}/custom-inbound-filters/${filterId}/`, + { + method: "DELETE", + }, + { ...opts, allowStatuses: [404] }, + ); + } + /** * Lists releases for an organization or specific project. * diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index cccc58224..005813360 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -557,6 +557,25 @@ export const ClientKeySchema = z export const ClientKeyListSchema = z.array(ClientKeySchema); +export const CustomInboundFilterConditionSchema = z.object({ + type: z.string(), + value: z.array(z.string()), +}); + +export const CustomInboundFilterSchema = z + .object({ + id: z.union([z.string(), z.number()]), + name: z.string().nullable(), + active: z.boolean(), + dataType: z.string(), + conditions: z.array(CustomInboundFilterConditionSchema), + dateCreated: z.string().datetime().nullable(), + dateUpdated: z.string().datetime().nullable(), + }) + .passthrough(); + +export const CustomInboundFilterListSchema = z.array(CustomInboundFilterSchema); + const ReleaseProjectSchema = z .object({ id: z.union([z.string(), z.number()]), diff --git a/packages/mcp-core/src/api-client/types.ts b/packages/mcp-core/src/api-client/types.ts index 1405480eb..2a5082d5e 100644 --- a/packages/mcp-core/src/api-client/types.ts +++ b/packages/mcp-core/src/api-client/types.ts @@ -58,6 +58,8 @@ import type { SearchAgentStateSchema, ClientKeyListSchema, ClientKeySchema, + CustomInboundFilterListSchema, + CustomInboundFilterSchema, CommitListSchema, CommitSchema, CommittersResponseSchema, @@ -278,6 +280,10 @@ export type OrganizationEnvironmentList = z.infer< >; export type TagList = z.infer; export type ClientKeyList = z.infer; +export type CustomInboundFilter = z.infer; +export type CustomInboundFilterList = z.infer< + typeof CustomInboundFilterListSchema +>; // Dashboard types export type Dashboard = z.infer; diff --git a/packages/mcp-core/src/server.test.ts b/packages/mcp-core/src/server.test.ts index 57c0cb4de..327aaf635 100644 --- a/packages/mcp-core/src/server.test.ts +++ b/packages/mcp-core/src/server.test.ts @@ -1265,6 +1265,9 @@ describe("buildServer", () => { ["remove_team_from_project", ["project-management"]], ["create_dsn", ["project-management"]], ["find_dsns", ["project-management"]], + ["create_custom_inbound_filter", ["project-management"]], + ["update_custom_inbound_filter", ["project-management"]], + ["delete_custom_inbound_filter", ["project-management"]], ] as const) { const grantedServer = buildServer({ context: { diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 44b41ac87..82e713629 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -5,13 +5,18 @@ "description": "Read-only access to core Sentry data: issues, events, traces, replays, releases, cron monitors, uptime monitors, metric monitors, profiles, documentation, and project metadata", "defaultEnabled": true, "order": 1, - "toolCount": 46, + "toolCount": 47, "tools": [ { "name": "find_alert_rules", "description": "Find Sentry alert rules.\n\nUse this tool when you need to:\n- List Alerts (workflows) for an organization or project, including Alerts without connected sources\n- List Metric Monitors using compatibility references; prefer find_metric_monitors for canonical monitor IDs\n- Find an alert rule ID by name before inspecting it\n- Check alert conditions, queries, triggers, actions, owner, or environment\n\n\nfind_alert_rules(organizationSlug='my-org')\nfind_alert_rules(organizationSlug='my-org', projectSlug='backend')\nfind_alert_rules(organizationSlug='my-org', kind='issue', projectSlug='backend', query='critical')\n\n\n\n- Omit `projectSlug` to search organization-wide. A project filter finds connected Alerts, which may also cover other projects.\n- Metric entries expose monitorId and projectId. id retains a legacy alert-rule ID when available, otherwise detector:. status describes enabled/disabled monitoring, not a legacy alert status.\n- Issue and metric alert rules have independent pagination state. Reuse a nextCursor only with its matching kind.\n", "requiredScopes": ["org:read", "project:read"] }, + { + "name": "find_custom_inbound_filters", + "description": "List the custom inbound filters of a Sentry project: rules that drop errors, logs, metrics or spans at ingest before they count against quota.\n\nUse this tool when you need to:\n- See which events a project ignores or filters out at ingest, and why\n- Check whether an error message, release, IP address, log message or metric name is already filtered\n- Find a filter ID before update_custom_inbound_filter or delete_custom_inbound_filter\n\nEach filter has a data type (`error`, `log`, `metric`, `span` or `all`) and conditions on `error_type`, `error_message`, `log_message`, `metric_name`, `release` or `ip_address`. Conditions are ANDed; the values inside one condition are ORed.\n\nThis covers the custom filters only. Built-in inbound filters such as legacy browsers, web crawlers, browser extensions, health checks and localhost are project settings and are not listed here.\n\nReturns up to 25 filters per page (a project holds at most 50). When hasMore is true, pass nextCursor as cursor with the same project.\n\n\nfind_custom_inbound_filters(organizationSlug='my-org', projectSlug='my-project')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it is likely in the format of /.\n- An inactive filter is kept but drops nothing.\n", + "requiredScopes": ["project:read"] + }, { "name": "find_dashboards", "description": "Find Sentry dashboards in an organization.\n\nUse this tool when you need to:\n- List dashboards in an organization\n- Find a dashboard ID before calling get_dashboard_details\n- Search dashboards by title\n\n\nfind_dashboards(organizationSlug='my-organization')\nfind_dashboards(organizationSlug='my-organization', titleQuery='errors')\n\n\n\n- Dashboard IDs are organization-scoped.\n- Use `get_dashboard_details` after finding the correct dashboard ID.\n", @@ -513,10 +518,10 @@ { "id": "project-management", "name": "Manage Projects & Teams", - "description": "Create and modify projects, teams, DSNs, uptime monitors, metric monitors, and alert rules", + "description": "Create and modify projects, teams, DSNs, inbound filters, uptime monitors, metric monitors, and alert rules", "defaultEnabled": false, "order": 5, - "toolCount": 22, + "toolCount": 26, "tools": [ { "name": "add_team_to_project", @@ -528,6 +533,11 @@ "description": "Create a Sentry Alert (workflow) with notification actions and explicit sources.\nUse get_alert_options for available actions, integrations, conditions, and sources. All notification providers use their native config and data fields.\nTo copy an Alert, read get_alert_rule(kind='issue') and pass its configuration here; component IDs are discarded while integration and destination IDs are retained. New trigger groups require logicType='any-short'.\nTo reuse an existing Alert across projects, use update_alert_rule instead.\nSupply projectSlugs for project issue streams and/or detectorIds for individual monitors. Pass detectorIds=[] explicitly to create an Alert without sources; it will not send notifications until connected.\nNew Alerts default to active with a 30-minute notification interval. Use status='disabled' to configure one before enabling it.\nProject-constrained sessions require sources exclusively within that project. All-project sources require Sentry's feature and org:write in addition to alerts:write.\nAfter a timeout or error, search for the Alert before retrying: creation may already have succeeded.\n\ncreate_alert_rule(organizationSlug='my-org', name='Backend notifications', status='disabled', detectorIds=[], actionFilters=[])\n", "requiredScopes": ["org:read", "project:read", "alerts:write"] }, + { + "name": "create_custom_inbound_filter", + "description": "Create a custom inbound filter that drops matching errors, logs, metrics or spans at ingest, before they are stored or count against quota.\n\nUse this tool when the user wants to:\n- Ignore or filter out errors by message or exception type ('stop ingesting *ConnectionError*')\n- Drop events from an IP address or CIDR range, or from a release ('block 10.0.0.0/8', 'ignore release 1.4.*')\n- Drop noisy logs by message, or metrics by name\n- Filter every data type at once with dataType='all' on release or IP address\n\nConditions are ANDed; the values inside a condition are ORed. To express 'A or B' put both patterns into one condition's value list. To express 'A and B' use two conditions.\n\nBe careful when using this tool! An active filter drops data irreversibly. Create it with active=false to review it first.\n\n\n### Ignore one exception type on errors\ncreate_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', name='Ignore flaky connection errors', dataType='error', conditions=[{type: 'error_type', value: ['ConnectionError', 'TimeoutError']}])\n\n### Drop everything from an IP range for one release\ncreate_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', name='Load test traffic', dataType='all', conditions=[{type: 'ip_address', value: ['10.0.0.0/8']}, {type: 'release', value: ['my-app@2.1.*']}])\n\n### Drop debug logs\ncreate_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', name='Drop debug logs', dataType='log', conditions=[{type: 'log_message', value: ['*DEBUG*']}])\n\n\n\n- Error message conditions match `{exception.type}: {exception.value}` on error events and the formatted message on message events. Prefer wildcards such as `*ConnectionError*` over a full message.\n- Release conditions match the full release name, e.g. `my-app@1.4.0`; use globs such as `my-app@1.*` for a range.\n- Use find_custom_inbound_filters() first to avoid creating a duplicate.\n", + "requiredScopes": ["project:write"] + }, { "name": "create_dsn", "description": "Create an additional DSN for an EXISTING project.\n\nUSE THIS TOOL WHEN:\n- Project already exists and needs additional DSN\n- 'Create another DSN for project X'\n- 'I need a production DSN for existing project'\n\nDO NOT USE for new projects (use create_project instead)\n\nBe careful when using this tool!\n\n\n### Create additional DSN for existing project\n```\ncreate_dsn(organizationSlug='my-organization', projectSlug='my-project', name='Production')\n```\n\n\n\n- If the user passes a parameter in the form of name/otherName, its likely in the format of /.\n- If any parameter is ambiguous, you should clarify with the user what they meant.\n", @@ -558,6 +568,11 @@ "description": "Permanently delete a Sentry Alert (workflow), preserving its connected monitors.\nUse get_alert_rule(kind='issue') to inspect the Alert and obtain its workflow ID before deletion. Metric Monitor IDs and legacy metric alert IDs are not workflow IDs.\nDeleting a shared Alert removes its notifications for every connected project and monitor. Use update_alert_rule to disconnect individual sources or disable it instead.\nA project-constrained session can only delete Alerts affecting that project exclusively.\nSentry removes the Alert from normal reads immediately and completes internal deletion in the background.\n\ndelete_alert_rule(organizationSlug='my-org', ruleId='12345')\n", "requiredScopes": ["org:read", "project:read", "alerts:write"] }, + { + "name": "delete_custom_inbound_filter", + "description": "Delete a custom inbound filter so the project ingests the matching errors, logs, metrics or spans again.\n\nUse this tool when the user wants to permanently remove an inbound filter. To stop a filter temporarily, use update_custom_inbound_filter with active=false instead.\n\nBe careful when using this tool! Deletion cannot be undone.\n\n\ndelete_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', filterId='12345')\n", + "requiredScopes": ["project:write"] + }, { "name": "delete_metric_monitor", "description": "Permanently delete a Sentry Metric Monitor, preserving its connected Alerts and their other monitors.\nUse get_metric_monitor_details to inspect the monitor and obtain its native monitorId. Legacy metric alert IDs are not monitor IDs.\nUse update_metric_monitor(status='disabled') to pause detection instead. Deletion also removes an associated legacy metric alert and its incident history when present.\nSentry hides the monitor from normal reads immediately and completes deletion in the background.\ndelete_metric_monitor(organizationSlug='my-org', monitorId='12345')", @@ -568,6 +583,11 @@ "description": "Delete a Sentry HTTP uptime monitor.\n\nUse this tool when you need to permanently remove an uptime monitor.\n\nBe careful when using this tool! Deletion cannot be undone.\n\n\ndelete_uptime_monitor(organizationSlug='my-organization', projectSlug='backend', uptimeMonitorId='12345')\n", "requiredScopes": ["project:write"] }, + { + "name": "find_custom_inbound_filters", + "description": "List the custom inbound filters of a Sentry project: rules that drop errors, logs, metrics or spans at ingest before they count against quota.\n\nUse this tool when you need to:\n- See which events a project ignores or filters out at ingest, and why\n- Check whether an error message, release, IP address, log message or metric name is already filtered\n- Find a filter ID before update_custom_inbound_filter or delete_custom_inbound_filter\n\nEach filter has a data type (`error`, `log`, `metric`, `span` or `all`) and conditions on `error_type`, `error_message`, `log_message`, `metric_name`, `release` or `ip_address`. Conditions are ANDed; the values inside one condition are ORed.\n\nThis covers the custom filters only. Built-in inbound filters such as legacy browsers, web crawlers, browser extensions, health checks and localhost are project settings and are not listed here.\n\nReturns up to 25 filters per page (a project holds at most 50). When hasMore is true, pass nextCursor as cursor with the same project.\n\n\nfind_custom_inbound_filters(organizationSlug='my-org', projectSlug='my-project')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it is likely in the format of /.\n- An inactive filter is kept but drops nothing.\n", + "requiredScopes": ["project:read"] + }, { "name": "find_dsns", "description": "List all Sentry DSNs for a specific project.\n\nUse this tool when you need to:\n- Retrieve a SENTRY_DSN for a specific project\n\n\n- If the user passes a parameter in the form of name/otherName, its likely in the format of /.\n- If only one parameter is provided, and it could be either `organizationSlug` or `projectSlug`, its probably `organizationSlug`, but if you're really uncertain you might want to call `find_organizations()` first.\n", @@ -603,6 +623,11 @@ "description": "Update a Sentry Alert (workflow), including notification actions and connections.\nUse get_alert_rule with kind='issue' first to inspect the complete triggers and actionFilters configuration.\nUse get_alert_options to discover notification actions, integrations, conditions, and available sources.\nOmit fields to leave them unchanged. Pass null to clear owner or environment.\ntriggers replaces the trigger conditions. actionFilters replaces ALL action groups: copy the complete configuration, retain existing IDs, and change only the intended values. Omitted groups, conditions, and actions are removed.\nFor Slack or Microsoft Teams, change config.targetDisplay to the channel name and use integrationId for the workspace or team. Sentry resolves the channel ID. Slack also accepts an explicit new targetIdentifier; a copied old ID is cleared when the name or workspace changes.\nOther actions use their provider's config and data. For Discord, PagerDuty, Opsgenie, and email, update targetIdentifier to the channel, service, team, or recipient ID; changing only its display name does not change the destination.\nUse addProjectSlugs/removeProjectSlugs to connect/disconnect a project's issue stream. Other monitors in that project remain connected. Use addDetectorIds/removeDetectorIds for individual monitors. Unmentioned connections are preserved.\nMetric Monitor detection queries and thresholds are separate operations.\nAll-project connections require Sentry's all-project feature and an API token with org:write in addition to alerts:write.\nA project-constrained session can only edit alerts affecting that project exclusively.\nRequires alerts:write; reconnect OAuth if the existing token lacks it.\n\nupdate_alert_rule(organizationSlug='my-org', ruleIdOrName='12345', status='disabled')\nupdate_alert_rule(organizationSlug='my-org', projectSlug='backend', ruleIdOrName='Notify backend team', frequencyMinutes=30)\n", "requiredScopes": ["org:read", "project:read", "alerts:write"] }, + { + "name": "update_custom_inbound_filter", + "description": "Update a custom inbound filter: rename it, pause or resume it, or change what it drops at ingest.\n\nUse this tool when the user wants to:\n- Pause a filter without deleting it (active=false) or turn it back on\n- Add or remove a pattern, e.g. another error message, release or IP range\n- Rename a filter or move it to another data type\n\nOnly the fields you pass change. `conditions` replaces the whole condition list, so send the complete list you want to keep.\n\nBe careful when using this tool! Widening an active filter drops more data irreversibly.\n\n\n### Pause a filter\nupdate_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', filterId='12345', active=false)\n\n### Replace the patterns of a filter\nupdate_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', filterId='12345', conditions=[{type: 'error_message', value: ['*ConnectionError*', '*TimeoutError*']}])\n\n\n\n- Use find_custom_inbound_filters() to get the filterId and the current conditions before you change them.\n- When you change dataType, make sure every condition type is one that data type accepts.\n", + "requiredScopes": ["project:write"] + }, { "name": "update_dsn", "description": "Update settings for an existing DSN (client key) in a project, such as name, active status, rate limit, and loader script options.\n\nUSE THIS TOOL WHEN:\n- Deactivating or activating a DSN/client key\n- Setting or removing DSN rate limits ('set rate limit of 1000 per hour on DSN X')\n- Renaming a DSN ('rename DSN X to Production')\n- Configuring Javascript SDK loader script options (session replay, performance, debug, feedback, etc.)\n\nBe careful when using this tool!\n\n\n### Rename DSN and set rate limit\n```\nupdate_dsn(organizationSlug='my-organization', projectSlug='my-project', keyId='d20df0a1ab5031c7f3c7edca9c02814d', name='Production Key', rateLimitWindow=3600, rateLimitCount=500)\n```\n\n### Deactivate a DSN\n```\nupdate_dsn(organizationSlug='my-organization', projectSlug='my-project', keyId='d20df0a1ab5031c7f3c7edca9c02814d', isActive=false)\n```\n\n### Disable rate limit entirely\n```\nupdate_dsn(organizationSlug='my-organization', projectSlug='my-project', keyId='d20df0a1ab5031c7f3c7edca9c02814d', disableRateLimit=true)\n```\n\n\n\n- Use `find_dsns()` first to find the `keyId` for the DSN you want to update.\n- Both `rateLimitWindow` (seconds) and `rateLimitCount` (error cap) must be provided together to set a rate limit.\n", diff --git a/packages/mcp-core/src/skills.ts b/packages/mcp-core/src/skills.ts index 817c1d428..d9da10d99 100644 --- a/packages/mcp-core/src/skills.ts +++ b/packages/mcp-core/src/skills.ts @@ -61,7 +61,7 @@ export const SKILLS: Record = { id: "project-management", name: "Manage Projects & Teams", description: - "Create and modify projects, teams, DSNs, uptime monitors, metric monitors, and alert rules", + "Create and modify projects, teams, DSNs, inbound filters, uptime monitors, metric monitors, and alert rules", defaultEnabled: false, order: 5, }, diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 9fa64538c..f1743f864 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -576,6 +576,174 @@ "skills": ["project-management"], "surface": "catalog" }, + { + "name": "create_custom_inbound_filter", + "description": "Create a custom inbound filter that drops matching errors, logs, metrics or spans at ingest, before they are stored or count against quota.\n\nUse this tool when the user wants to:\n- Ignore or filter out errors by message or exception type ('stop ingesting *ConnectionError*')\n- Drop events from an IP address or CIDR range, or from a release ('block 10.0.0.0/8', 'ignore release 1.4.*')\n- Drop noisy logs by message, or metrics by name\n- Filter every data type at once with dataType='all' on release or IP address\n\nConditions are ANDed; the values inside a condition are ORed. To express 'A or B' put both patterns into one condition's value list. To express 'A and B' use two conditions.\n\nBe careful when using this tool! An active filter drops data irreversibly. Create it with active=false to review it first.\n\n\n### Ignore one exception type on errors\ncreate_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', name='Ignore flaky connection errors', dataType='error', conditions=[{type: 'error_type', value: ['ConnectionError', 'TimeoutError']}])\n\n### Drop everything from an IP range for one release\ncreate_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', name='Load test traffic', dataType='all', conditions=[{type: 'ip_address', value: ['10.0.0.0/8']}, {type: 'release', value: ['my-app@2.1.*']}])\n\n### Drop debug logs\ncreate_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', name='Drop debug logs', dataType='log', conditions=[{type: 'log_message', value: ['*DEBUG*']}])\n\n\n\n- Error message conditions match `{exception.type}: {exception.value}` on error events and the formatted message on message events. Prefer wildcards such as `*ConnectionError*` over a full message.\n- Release conditions match the full release name, e.g. `my-app@1.4.0`; use globs such as `my-app@1.*` for a range.\n- Use find_custom_inbound_filters() first to avoid creating a duplicate.\n", + "inputSchema": { + "type": "object", + "properties": { + "organizationSlug": { + "type": "string", + "description": "The organization's slug. You can find a existing list of organizations you have access to using the `find_organizations()` tool." + }, + "regionUrl": { + "default": null, + "anyOf": [ + { + "type": "string", + "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool." + }, + { + "type": "null" + } + ] + }, + "projectSlug": { + "type": "string", + "description": "The project's slug. You can find a list of existing projects in an organization using the `find_projects()` tool." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "A short label that says what the filter drops and why, e.g. 'Ignore flaky connection errors'." + }, + "dataType": { + "type": "string", + "enum": ["all", "error", "log", "metric", "span"], + "description": "The data the filter drops: `error` (errors and messages), `log`, `metric`, `span`, or `all`. `all` matches every data type Sentry ingests, including ones added later, and accepts only `release` and `ip_address` conditions." + }, + "conditions": { + "minItems": 1, + "maxItems": 10, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "error_type", + "error_message", + "log_message", + "metric_name", + "release", + "ip_address" + ], + "description": "The field to match. Every data type accepts `release` and `ip_address`. `error` also accepts `error_type` and `error_message`, `log` accepts `log_message`, `metric` accepts `metric_name`." + }, + "value": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Patterns for the field. The condition matches when ANY pattern matches (OR). Patterns are case-insensitive globs where `*` matches any characters and `?` one character, e.g. `*ConnectionError*`, `my-app@2.1.*`, `checkout.*`. For `ip_address`, each value must be an IP address or CIDR range such as `203.0.113.7` or `10.0.0.0/8`." + } + }, + "required": ["type", "value"] + }, + "description": "Conditions are combined with AND: data is dropped only when every condition matches. Use several values inside one condition for OR, or create separate filters. At most 10 conditions per filter." + }, + "active": { + "default": true, + "type": "boolean", + "description": "Whether the filter drops data right away. An inactive filter is stored but ignored." + } + }, + "required": [ + "organizationSlug", + "projectSlug", + "name", + "dataType", + "conditions" + ] + }, + "outputSchema": { + "type": "object", + "properties": { + "filter": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "active": { + "type": "boolean" + }, + "dataType": { + "type": "string" + }, + "conditions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "value"], + "additionalProperties": false + } + }, + "dateCreated": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "dateUpdated": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "name", + "active", + "dataType", + "conditions", + "dateCreated", + "dateUpdated" + ], + "additionalProperties": false + } + }, + "required": ["filter"], + "additionalProperties": false + }, + "requiredScopes": ["project:write"], + "skills": ["project-management"], + "surface": "catalog" + }, { "name": "create_dsn", "description": "Create an additional DSN for an EXISTING project.\n\nUSE THIS TOOL WHEN:\n- Project already exists and needs additional DSN\n- 'Create another DSN for project X'\n- 'I need a production DSN for existing project'\n\nDO NOT USE for new projects (use create_project instead)\n\nBe careful when using this tool!\n\n\n### Create additional DSN for existing project\n```\ncreate_dsn(organizationSlug='my-organization', projectSlug='my-project', name='Production')\n```\n\n\n\n- If the user passes a parameter in the form of name/otherName, its likely in the format of /.\n- If any parameter is ambiguous, you should clarify with the user what they meant.\n", @@ -1522,6 +1690,61 @@ "skills": ["project-management"], "surface": "catalog" }, + { + "name": "delete_custom_inbound_filter", + "description": "Delete a custom inbound filter so the project ingests the matching errors, logs, metrics or spans again.\n\nUse this tool when the user wants to permanently remove an inbound filter. To stop a filter temporarily, use update_custom_inbound_filter with active=false instead.\n\nBe careful when using this tool! Deletion cannot be undone.\n\n\ndelete_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', filterId='12345')\n", + "inputSchema": { + "type": "object", + "properties": { + "organizationSlug": { + "type": "string", + "description": "The organization's slug. You can find a existing list of organizations you have access to using the `find_organizations()` tool." + }, + "regionUrl": { + "default": null, + "anyOf": [ + { + "type": "string", + "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool." + }, + { + "type": "null" + } + ] + }, + "projectSlug": { + "type": "string", + "description": "The project's slug. You can find a list of existing projects in an organization using the `find_projects()` tool." + }, + "filterId": { + "type": "string", + "minLength": 1, + "description": "The custom inbound filter ID. Use find_custom_inbound_filters() to look it up." + } + }, + "required": ["organizationSlug", "projectSlug", "filterId"] + }, + "outputSchema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "const": true + }, + "filterId": { + "type": "string" + }, + "projectSlug": { + "type": "string" + } + }, + "required": ["success", "filterId", "projectSlug"], + "additionalProperties": false + }, + "requiredScopes": ["project:write"], + "skills": ["project-management"], + "surface": "catalog" + }, { "name": "delete_metric_monitor", "description": "Permanently delete a Sentry Metric Monitor, preserving its connected Alerts and their other monitors.\nUse get_metric_monitor_details to inspect the monitor and obtain its native monitorId. Legacy metric alert IDs are not monitor IDs.\nUse update_metric_monitor(status='disabled') to pause detection instead. Deletion also removes an associated legacy metric alert and its incident history when present.\nSentry hides the monitor from normal reads immediately and completes deletion in the background.\ndelete_metric_monitor(organizationSlug='my-org', monitorId='12345')", @@ -2046,6 +2269,147 @@ "skills": ["inspect"], "surface": "catalog" }, + { + "name": "find_custom_inbound_filters", + "description": "List the custom inbound filters of a Sentry project: rules that drop errors, logs, metrics or spans at ingest before they count against quota.\n\nUse this tool when you need to:\n- See which events a project ignores or filters out at ingest, and why\n- Check whether an error message, release, IP address, log message or metric name is already filtered\n- Find a filter ID before update_custom_inbound_filter or delete_custom_inbound_filter\n\nEach filter has a data type (`error`, `log`, `metric`, `span` or `all`) and conditions on `error_type`, `error_message`, `log_message`, `metric_name`, `release` or `ip_address`. Conditions are ANDed; the values inside one condition are ORed.\n\nThis covers the custom filters only. Built-in inbound filters such as legacy browsers, web crawlers, browser extensions, health checks and localhost are project settings and are not listed here.\n\nReturns up to 25 filters per page (a project holds at most 50). When hasMore is true, pass nextCursor as cursor with the same project.\n\n\nfind_custom_inbound_filters(organizationSlug='my-org', projectSlug='my-project')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it is likely in the format of /.\n- An inactive filter is kept but drops nothing.\n", + "inputSchema": { + "type": "object", + "properties": { + "organizationSlug": { + "type": "string", + "description": "The organization's slug. You can find a existing list of organizations you have access to using the `find_organizations()` tool." + }, + "regionUrl": { + "default": null, + "anyOf": [ + { + "type": "string", + "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool." + }, + { + "type": "null" + } + ] + }, + "projectSlug": { + "type": "string", + "description": "The project's slug. You can find a list of existing projects in an organization using the `find_projects()` tool." + }, + "cursor": { + "default": null, + "anyOf": [ + { + "type": "string", + "description": "Pagination cursor from a previous call's nextCursor. Reuse it with the same filters and scope to fetch the next page." + }, + { + "type": "null" + } + ] + } + }, + "required": ["organizationSlug", "projectSlug"] + }, + "outputSchema": { + "type": "object", + "properties": { + "filters": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "active": { + "type": "boolean" + }, + "dataType": { + "type": "string" + }, + "conditions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "value"], + "additionalProperties": false + } + }, + "dateCreated": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "dateUpdated": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "name", + "active", + "dataType", + "conditions", + "dateCreated", + "dateUpdated" + ], + "additionalProperties": false + } + }, + "hasMore": { + "type": "boolean" + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["filters", "hasMore", "nextCursor"], + "additionalProperties": false + }, + "requiredScopes": ["project:read"], + "skills": ["inspect", "project-management"], + "surface": "catalog" + }, { "name": "find_dashboards", "description": "Find Sentry dashboards in an organization.\n\nUse this tool when you need to:\n- List dashboards in an organization\n- Find a dashboard ID before calling get_dashboard_details\n- Search dashboards by title\n\n\nfind_dashboards(organizationSlug='my-organization')\nfind_dashboards(organizationSlug='my-organization', titleQuery='errors')\n\n\n\n- Dashboard IDs are organization-scoped.\n- Use `get_dashboard_details` after finding the correct dashboard ID.\n", @@ -8669,6 +9033,172 @@ "skills": ["project-management"], "surface": "catalog" }, + { + "name": "update_custom_inbound_filter", + "description": "Update a custom inbound filter: rename it, pause or resume it, or change what it drops at ingest.\n\nUse this tool when the user wants to:\n- Pause a filter without deleting it (active=false) or turn it back on\n- Add or remove a pattern, e.g. another error message, release or IP range\n- Rename a filter or move it to another data type\n\nOnly the fields you pass change. `conditions` replaces the whole condition list, so send the complete list you want to keep.\n\nBe careful when using this tool! Widening an active filter drops more data irreversibly.\n\n\n### Pause a filter\nupdate_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', filterId='12345', active=false)\n\n### Replace the patterns of a filter\nupdate_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', filterId='12345', conditions=[{type: 'error_message', value: ['*ConnectionError*', '*TimeoutError*']}])\n\n\n\n- Use find_custom_inbound_filters() to get the filterId and the current conditions before you change them.\n- When you change dataType, make sure every condition type is one that data type accepts.\n", + "inputSchema": { + "type": "object", + "properties": { + "organizationSlug": { + "type": "string", + "description": "The organization's slug. You can find a existing list of organizations you have access to using the `find_organizations()` tool." + }, + "regionUrl": { + "default": null, + "anyOf": [ + { + "type": "string", + "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool." + }, + { + "type": "null" + } + ] + }, + "projectSlug": { + "type": "string", + "description": "The project's slug. You can find a list of existing projects in an organization using the `find_projects()` tool." + }, + "filterId": { + "type": "string", + "minLength": 1, + "description": "The custom inbound filter ID. Use find_custom_inbound_filters() to look it up." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "The new label of the filter." + }, + "active": { + "type": "boolean", + "description": "Set false to pause the filter, true to resume it." + }, + "dataType": { + "type": "string", + "enum": ["all", "error", "log", "metric", "span"], + "description": "The data the filter drops: `error` (errors and messages), `log`, `metric`, `span`, or `all`. `all` matches every data type Sentry ingests, including ones added later, and accepts only `release` and `ip_address` conditions." + }, + "conditions": { + "minItems": 1, + "maxItems": 10, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "error_type", + "error_message", + "log_message", + "metric_name", + "release", + "ip_address" + ], + "description": "The field to match. Every data type accepts `release` and `ip_address`. `error` also accepts `error_type` and `error_message`, `log` accepts `log_message`, `metric` accepts `metric_name`." + }, + "value": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Patterns for the field. The condition matches when ANY pattern matches (OR). Patterns are case-insensitive globs where `*` matches any characters and `?` one character, e.g. `*ConnectionError*`, `my-app@2.1.*`, `checkout.*`. For `ip_address`, each value must be an IP address or CIDR range such as `203.0.113.7` or `10.0.0.0/8`." + } + }, + "required": ["type", "value"] + }, + "description": "Conditions are combined with AND: data is dropped only when every condition matches. Use several values inside one condition for OR, or create separate filters. At most 10 conditions per filter." + } + }, + "required": ["organizationSlug", "projectSlug", "filterId"] + }, + "outputSchema": { + "type": "object", + "properties": { + "filter": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "active": { + "type": "boolean" + }, + "dataType": { + "type": "string" + }, + "conditions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "value"], + "additionalProperties": false + } + }, + "dateCreated": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "dateUpdated": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "name", + "active", + "dataType", + "conditions", + "dateCreated", + "dateUpdated" + ], + "additionalProperties": false + } + }, + "required": ["filter"], + "additionalProperties": false + }, + "requiredScopes": ["project:write"], + "skills": ["project-management"], + "surface": "catalog" + }, { "name": "update_dsn", "description": "Update settings for an existing DSN (client key) in a project, such as name, active status, rate limit, and loader script options.\n\nUSE THIS TOOL WHEN:\n- Deactivating or activating a DSN/client key\n- Setting or removing DSN rate limits ('set rate limit of 1000 per hour on DSN X')\n- Renaming a DSN ('rename DSN X to Production')\n- Configuring Javascript SDK loader script options (session replay, performance, debug, feedback, etc.)\n\nBe careful when using this tool!\n\n\n### Rename DSN and set rate limit\n```\nupdate_dsn(organizationSlug='my-organization', projectSlug='my-project', keyId='d20df0a1ab5031c7f3c7edca9c02814d', name='Production Key', rateLimitWindow=3600, rateLimitCount=500)\n```\n\n### Deactivate a DSN\n```\nupdate_dsn(organizationSlug='my-organization', projectSlug='my-project', keyId='d20df0a1ab5031c7f3c7edca9c02814d', isActive=false)\n```\n\n### Disable rate limit entirely\n```\nupdate_dsn(organizationSlug='my-organization', projectSlug='my-project', keyId='d20df0a1ab5031c7f3c7edca9c02814d', disableRateLimit=true)\n```\n\n\n\n- Use `find_dsns()` first to find the `keyId` for the DSN you want to update.\n- Both `rateLimitWindow` (seconds) and `rateLimitCount` (error cap) must be provided together to set a rate limit.\n", diff --git a/packages/mcp-core/src/tools/catalog/create-custom-inbound-filter.test.ts b/packages/mcp-core/src/tools/catalog/create-custom-inbound-filter.test.ts new file mode 100644 index 000000000..c875b30be --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/create-custom-inbound-filter.test.ts @@ -0,0 +1,163 @@ +import { mswServer } from "@sentry/mcp-server-mocks"; +import { HttpResponse, http } from "msw"; +import { describe, expect, it } from "vitest"; +import { UserInputError } from "../../errors.js"; +import { getServerContext } from "../../test-setup.js"; +import { + assertStructuredOnlyResult, + getStructuredContent, +} from "../../test-utils/structured-content.js"; +import createCustomInboundFilter, { + createCustomInboundFilterOutputSchema, +} from "./create-custom-inbound-filter.js"; + +describe("create_custom_inbound_filter", () => { + it("creates a filter and returns it as structured content", async () => { + mswServer.use( + http.post( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/custom-inbound-filters/", + async ({ request }) => { + await expect(request.json()).resolves.toEqual({ + name: "Drop debug logs", + active: false, + dataType: "log", + conditions: [{ type: "log_message", value: ["*DEBUG*"] }], + }); + return HttpResponse.json( + { + id: 4509100000002002, + name: "Drop debug logs", + active: false, + dataType: "log", + conditions: [{ type: "log_message", value: ["*DEBUG*"] }], + dateCreated: "2026-10-01T12:00:00.000000Z", + dateUpdated: "2026-10-01T12:00:00.000000Z", + legacyFilter: "must-not-leak", + }, + { status: 201 }, + ); + }, + { once: true }, + ), + ); + + const result = await createCustomInboundFilter.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + regionUrl: null, + name: "Drop debug logs", + dataType: "log", + conditions: [{ type: "log_message", value: ["*DEBUG*"] }], + active: false, + }, + getServerContext(), + ); + + assertStructuredOnlyResult(result); + const structuredContent = getStructuredContent(result); + expect( + createCustomInboundFilterOutputSchema.parse(structuredContent), + ).toEqual(structuredContent); + expect(structuredContent).toMatchInlineSnapshot(` + { + "filter": { + "active": false, + "conditions": [ + { + "type": "log_message", + "value": [ + "*DEBUG*", + ], + }, + ], + "dataType": "log", + "dateCreated": "2026-10-01T12:00:00.000000Z", + "dateUpdated": "2026-10-01T12:00:00.000000Z", + "id": "4509100000002002", + "name": "Drop debug logs", + }, + } + `); + }); + + it("rejects a condition the data type does not carry before calling Sentry", async () => { + await expect( + createCustomInboundFilter.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + regionUrl: null, + name: "Bad filter", + dataType: "all", + conditions: [{ type: "error_message", value: ["*boom*"] }], + active: true, + }, + getServerContext(), + ), + ).rejects.toThrow( + "A filter on all data cannot use the error_message condition. It accepts release, ip_address.", + ); + }); + + it("explains a missing feature instead of a bare 400", async () => { + mswServer.use( + http.post( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/custom-inbound-filters/", + () => + HttpResponse.json( + { detail: "You do not have that feature enabled" }, + { status: 400 }, + ), + { once: true }, + ), + ); + + await expect( + createCustomInboundFilter.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + regionUrl: null, + name: "Ignore timeouts", + dataType: "error", + conditions: [{ type: "error_type", value: ["TimeoutError"] }], + active: true, + }, + getServerContext(), + ), + ).rejects.toThrow(UserInputError); + }); + + it("passes other validation errors through unchanged", async () => { + mswServer.use( + http.post( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/custom-inbound-filters/", + () => + HttpResponse.json( + { + detail: + "A filter's condition values can have at most 4000 characters in total.", + }, + { status: 400 }, + ), + { once: true }, + ), + ); + + await expect( + createCustomInboundFilter.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + regionUrl: null, + name: "Too big", + dataType: "error", + conditions: [{ type: "error_message", value: ["*"] }], + active: true, + }, + getServerContext(), + ), + ).rejects.toThrow(/at most 4000 characters/); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/create-custom-inbound-filter.ts b/packages/mcp-core/src/tools/catalog/create-custom-inbound-filter.ts new file mode 100644 index 000000000..a8f42da52 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/create-custom-inbound-filter.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; +import { apiServiceFromContext } from "../../internal/tool-helpers/api"; +import { defineTool } from "../../internal/tool-helpers/define"; +import { structuredResult } from "../../internal/tool-helpers/results"; +import { + ParamOrganizationSlug, + ParamProjectSlug, + ParamRegionUrl, +} from "../../schema"; +import { setTargetTagsAndAttributes } from "../../telem/scope"; +import type { ServerContext } from "../../types"; +import { + assertConditionsMatchDataType, + customInboundFilterItemSchema, + ParamCustomInboundFilterConditions, + ParamCustomInboundFilterDataType, + rethrowCustomInboundFilterError, + toCustomInboundFilterItem, +} from "./support/custom-inbound-filters"; +import { assertProjectRefWithinConstraint } from "./support/project-constraints"; + +export const createCustomInboundFilterOutputSchema = z.object({ + filter: customInboundFilterItemSchema, +}); + +export default defineTool({ + name: "create_custom_inbound_filter", + skills: ["project-management"], + requiredScopes: ["project:write"], + description: [ + "Create a custom inbound filter that drops matching errors, logs, metrics or spans at ingest, before they are stored or count against quota.", + "", + "Use this tool when the user wants to:", + "- Ignore or filter out errors by message or exception type ('stop ingesting *ConnectionError*')", + "- Drop events from an IP address or CIDR range, or from a release ('block 10.0.0.0/8', 'ignore release 1.4.*')", + "- Drop noisy logs by message, or metrics by name", + "- Filter every data type at once with dataType='all' on release or IP address", + "", + "Conditions are ANDed; the values inside a condition are ORed. To express 'A or B' put both patterns into one condition's value list. To express 'A and B' use two conditions.", + "", + "Be careful when using this tool! An active filter drops data irreversibly. Create it with active=false to review it first.", + "", + "", + "### Ignore one exception type on errors", + "create_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', name='Ignore flaky connection errors', dataType='error', conditions=[{type: 'error_type', value: ['ConnectionError', 'TimeoutError']}])", + "", + "### Drop everything from an IP range for one release", + "create_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', name='Load test traffic', dataType='all', conditions=[{type: 'ip_address', value: ['10.0.0.0/8']}, {type: 'release', value: ['my-app@2.1.*']}])", + "", + "### Drop debug logs", + "create_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', name='Drop debug logs', dataType='log', conditions=[{type: 'log_message', value: ['*DEBUG*']}])", + "", + "", + "", + "- Error message conditions match `{exception.type}: {exception.value}` on error events and the formatted message on message events. Prefer wildcards such as `*ConnectionError*` over a full message.", + "- Release conditions match the full release name, e.g. `my-app@1.4.0`; use globs such as `my-app@1.*` for a range.", + "- Use find_custom_inbound_filters() first to avoid creating a duplicate.", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.nullable().default(null), + projectSlug: ParamProjectSlug, + name: z + .string() + .trim() + .min(1) + .max(256) + .describe( + "A short label that says what the filter drops and why, e.g. 'Ignore flaky connection errors'.", + ), + dataType: ParamCustomInboundFilterDataType, + conditions: ParamCustomInboundFilterConditions, + active: z + .boolean() + .describe( + "Whether the filter drops data right away. An inactive filter is stored but ignored.", + ) + .default(true), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + openWorldHint: true, + }, + outputSchema: createCustomInboundFilterOutputSchema, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl ?? undefined, + }); + const organizationSlug = params.organizationSlug; + + assertProjectRefWithinConstraint({ + resourceLabel: "Custom inbound filter", + scopedProjectSlug: context.constraints.projectSlug, + project: { slug: params.projectSlug }, + }); + assertConditionsMatchDataType(params.dataType, params.conditions); + + setTargetTagsAndAttributes(params); + + const filter = await apiService + .createCustomInboundFilter({ + organizationSlug, + projectSlug: params.projectSlug, + name: params.name, + active: params.active, + dataType: params.dataType, + conditions: params.conditions, + }) + .catch(rethrowCustomInboundFilterError); + + return structuredResult({ + filter: toCustomInboundFilterItem(filter), + }); + }, +}); diff --git a/packages/mcp-core/src/tools/catalog/delete-custom-inbound-filter.test.ts b/packages/mcp-core/src/tools/catalog/delete-custom-inbound-filter.test.ts new file mode 100644 index 000000000..4e6ae0ead --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/delete-custom-inbound-filter.test.ts @@ -0,0 +1,88 @@ +import { mswServer } from "@sentry/mcp-server-mocks"; +import { HttpResponse, http } from "msw"; +import { describe, expect, it } from "vitest"; +import { UserInputError } from "../../errors.js"; +import { getServerContext } from "../../test-setup.js"; +import { + assertStructuredOnlyResult, + getStructuredContent, +} from "../../test-utils/structured-content.js"; +import deleteCustomInboundFilter, { + deleteCustomInboundFilterOutputSchema, +} from "./delete-custom-inbound-filter.js"; + +describe("delete_custom_inbound_filter", () => { + it("deletes a custom inbound filter", async () => { + const result = await deleteCustomInboundFilter.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + regionUrl: null, + filterId: "4509100000002001", + }, + getServerContext(), + ); + + assertStructuredOnlyResult(result); + const structuredContent = getStructuredContent(result); + expect( + deleteCustomInboundFilterOutputSchema.parse(structuredContent), + ).toEqual(structuredContent); + expect(structuredContent).toMatchInlineSnapshot(` + { + "filterId": "4509100000002001", + "projectSlug": "cloudflare-mcp", + "success": true, + } + `); + }); + + it("treats a second delete 404 as success", async () => { + mswServer.use( + http.delete( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/custom-inbound-filters/4509100000002001/", + () => new HttpResponse(null, { status: 404 }), + { once: true }, + ), + ); + + const result = await deleteCustomInboundFilter.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + regionUrl: null, + filterId: "4509100000002001", + }, + getServerContext(), + ); + + expect(getStructuredContent(result)).toEqual({ + success: true, + filterId: "4509100000002001", + projectSlug: "cloudflare-mcp", + }); + }); + + it("rejects a project outside the active project constraint", async () => { + await expect( + deleteCustomInboundFilter.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "other-project", + regionUrl: null, + filterId: "4509100000002001", + }, + getServerContext({ + constraints: { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + }, + }), + ), + ).rejects.toThrow(UserInputError); + }); + + it("claims idempotency", () => { + expect(deleteCustomInboundFilter.annotations.idempotentHint).toBe(true); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/delete-custom-inbound-filter.ts b/packages/mcp-core/src/tools/catalog/delete-custom-inbound-filter.ts new file mode 100644 index 000000000..21c4bf0cb --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/delete-custom-inbound-filter.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; +import { apiServiceFromContext } from "../../internal/tool-helpers/api"; +import { defineTool } from "../../internal/tool-helpers/define"; +import { structuredResult } from "../../internal/tool-helpers/results"; +import { + ParamOrganizationSlug, + ParamProjectSlug, + ParamRegionUrl, +} from "../../schema"; +import { setTargetTagsAndAttributes } from "../../telem/scope"; +import type { ServerContext } from "../../types"; +import { + ParamCustomInboundFilterId, + rethrowCustomInboundFilterError, +} from "./support/custom-inbound-filters"; +import { assertProjectRefWithinConstraint } from "./support/project-constraints"; + +export const deleteCustomInboundFilterOutputSchema = z.object({ + success: z.literal(true), + filterId: z.string(), + projectSlug: z.string(), +}); + +export default defineTool({ + name: "delete_custom_inbound_filter", + skills: ["project-management"], + requiredScopes: ["project:write"], + description: [ + "Delete a custom inbound filter so the project ingests the matching errors, logs, metrics or spans again.", + "", + "Use this tool when the user wants to permanently remove an inbound filter. To stop a filter temporarily, use update_custom_inbound_filter with active=false instead.", + "", + "Be careful when using this tool! Deletion cannot be undone.", + "", + "", + "delete_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', filterId='12345')", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.nullable().default(null), + projectSlug: ParamProjectSlug, + filterId: ParamCustomInboundFilterId, + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + outputSchema: deleteCustomInboundFilterOutputSchema, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl ?? undefined, + }); + const organizationSlug = params.organizationSlug; + + assertProjectRefWithinConstraint({ + resourceLabel: "Custom inbound filter", + scopedProjectSlug: context.constraints.projectSlug, + project: { slug: params.projectSlug }, + }); + + setTargetTagsAndAttributes(params); + + await apiService + .deleteCustomInboundFilter({ + organizationSlug, + projectSlug: params.projectSlug, + filterId: params.filterId, + }) + .catch(rethrowCustomInboundFilterError); + + return structuredResult({ + success: true as const, + filterId: params.filterId, + projectSlug: params.projectSlug, + }); + }, +}); diff --git a/packages/mcp-core/src/tools/catalog/find-custom-inbound-filters.test.ts b/packages/mcp-core/src/tools/catalog/find-custom-inbound-filters.test.ts new file mode 100644 index 000000000..25c9ac177 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/find-custom-inbound-filters.test.ts @@ -0,0 +1,161 @@ +import { mswServer } from "@sentry/mcp-server-mocks"; +import { HttpResponse, http } from "msw"; +import { describe, expect, it } from "vitest"; +import { UserInputError } from "../../errors.js"; +import { getServerContext } from "../../test-setup.js"; +import { + assertStructuredOnlyResult, + getStructuredContent, +} from "../../test-utils/structured-content.js"; +import findCustomInboundFilters, { + findCustomInboundFiltersOutputSchema, +} from "./find-custom-inbound-filters.js"; + +describe("find_custom_inbound_filters", () => { + it("returns the project's custom inbound filters as structured content", async () => { + const result = await findCustomInboundFilters.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + regionUrl: null, + cursor: null, + }, + getServerContext(), + ); + + assertStructuredOnlyResult(result); + const structuredContent = getStructuredContent(result); + expect( + findCustomInboundFiltersOutputSchema.parse(structuredContent), + ).toEqual(structuredContent); + expect(structuredContent).toMatchInlineSnapshot(` + { + "filters": [ + { + "active": true, + "conditions": [ + { + "type": "error_type", + "value": [ + "ConnectionError", + "TimeoutError", + ], + }, + { + "type": "release", + "value": [ + "my-app@2.1.*", + ], + }, + ], + "dataType": "error", + "dateCreated": "2026-09-25T10:00:00.000000Z", + "dateUpdated": "2026-09-26T08:30:00.000000Z", + "id": "4509100000002001", + "name": "Ignore flaky connection errors", + }, + ], + "hasMore": false, + "nextCursor": null, + } + `); + }); + + it("follows the Link header for the next page and drops backend-only fields", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/custom-inbound-filters/", + ({ request }) => { + expect(new URL(request.url).searchParams.get("per_page")).toBe("25"); + return HttpResponse.json( + [ + { + id: 7, + name: null, + active: false, + dataType: "all", + conditions: [{ type: "ip_address", value: ["10.0.0.0/8"] }], + dateCreated: "2026-09-25T10:00:00.000000Z", + dateUpdated: "2026-09-25T10:00:00.000000Z", + legacyFilter: "must-not-leak", + }, + ], + { + headers: { + link: '; rel="next"; results="true"; cursor="0:25:0"', + }, + }, + ); + }, + { once: true }, + ), + ); + + const result = await findCustomInboundFilters.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + regionUrl: null, + cursor: null, + }, + getServerContext(), + ); + + const structuredContent = getStructuredContent<{ + filters: Array>; + hasMore: boolean; + nextCursor: string | null; + }>(result); + expect(structuredContent.hasMore).toBe(true); + expect(structuredContent.nextCursor).toBe("0:25:0"); + expect(structuredContent.filters[0]?.id).toBe("7"); + expect(structuredContent.filters[0]).not.toHaveProperty("legacyFilter"); + }); + + it("explains a missing feature instead of a bare 400", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/custom-inbound-filters/", + () => + HttpResponse.json( + { detail: "You do not have that feature enabled" }, + { status: 400 }, + ), + { once: true }, + ), + ); + + await expect( + findCustomInboundFilters.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + regionUrl: null, + cursor: null, + }, + getServerContext(), + ), + ).rejects.toThrow( + /Custom inbound filters are not enabled for this organization/, + ); + }); + + it("rejects a project outside the active project constraint", async () => { + await expect( + findCustomInboundFilters.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "other-project", + regionUrl: null, + cursor: null, + }, + getServerContext({ + constraints: { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + }, + }), + ), + ).rejects.toThrow(UserInputError); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/find-custom-inbound-filters.ts b/packages/mcp-core/src/tools/catalog/find-custom-inbound-filters.ts new file mode 100644 index 000000000..4d4066d2d --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/find-custom-inbound-filters.ts @@ -0,0 +1,97 @@ +import { z } from "zod"; +import { apiServiceFromContext } from "../../internal/tool-helpers/api"; +import { defineTool } from "../../internal/tool-helpers/define"; +import { structuredResult } from "../../internal/tool-helpers/results"; +import { + ParamCursor, + ParamOrganizationSlug, + ParamProjectSlug, + ParamRegionUrl, +} from "../../schema"; +import { setTargetTagsAndAttributes } from "../../telem/scope"; +import type { ServerContext } from "../../types"; +import { + customInboundFilterItemSchema, + MAX_FILTERS_PER_PROJECT, + rethrowCustomInboundFilterError, + toCustomInboundFilterItem, +} from "./support/custom-inbound-filters"; +import { assertProjectRefWithinConstraint } from "./support/project-constraints"; + +const RESULT_LIMIT = 25; + +export const findCustomInboundFiltersOutputSchema = z.object({ + filters: z.array(customInboundFilterItemSchema), + hasMore: z.boolean(), + nextCursor: z.string().nullable(), +}); + +export default defineTool({ + name: "find_custom_inbound_filters", + skills: ["inspect", "project-management"], + requiredScopes: ["project:read"], + description: [ + "List the custom inbound filters of a Sentry project: rules that drop errors, logs, metrics or spans at ingest before they count against quota.", + "", + "Use this tool when you need to:", + "- See which events a project ignores or filters out at ingest, and why", + "- Check whether an error message, release, IP address, log message or metric name is already filtered", + "- Find a filter ID before update_custom_inbound_filter or delete_custom_inbound_filter", + "", + "Each filter has a data type (`error`, `log`, `metric`, `span` or `all`) and conditions on `error_type`, `error_message`, `log_message`, `metric_name`, `release` or `ip_address`. Conditions are ANDed; the values inside one condition are ORed.", + "", + "This covers the custom filters only. Built-in inbound filters such as legacy browsers, web crawlers, browser extensions, health checks and localhost are project settings and are not listed here.", + "", + `Returns up to ${RESULT_LIMIT} filters per page (a project holds at most ${MAX_FILTERS_PER_PROJECT}). When hasMore is true, pass nextCursor as cursor with the same project.`, + "", + "", + "find_custom_inbound_filters(organizationSlug='my-org', projectSlug='my-project')", + "", + "", + "", + "- If the user passes a parameter in the form of name/otherName, it is likely in the format of /.", + "- An inactive filter is kept but drops nothing.", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.nullable().default(null), + projectSlug: ParamProjectSlug, + cursor: ParamCursor.nullable().default(null), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: true, + }, + outputSchema: findCustomInboundFiltersOutputSchema, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl ?? undefined, + }); + const organizationSlug = params.organizationSlug; + + assertProjectRefWithinConstraint({ + resourceLabel: "Custom inbound filter list", + scopedProjectSlug: context.constraints.projectSlug, + project: { slug: params.projectSlug }, + }); + + setTargetTagsAndAttributes(params); + + const { filters, nextCursor } = await apiService + .listCustomInboundFilters({ + organizationSlug, + projectSlug: params.projectSlug, + limit: RESULT_LIMIT, + cursor: params.cursor, + }) + .catch(rethrowCustomInboundFilterError); + + return structuredResult({ + filters: filters.map(toCustomInboundFilterItem), + hasMore: nextCursor !== null, + nextCursor, + }); + }, +}); diff --git a/packages/mcp-core/src/tools/catalog/index.ts b/packages/mcp-core/src/tools/catalog/index.ts index dbe714170..62ee08fe5 100644 --- a/packages/mcp-core/src/tools/catalog/index.ts +++ b/packages/mcp-core/src/tools/catalog/index.ts @@ -53,6 +53,10 @@ import removeTeamFromProject from "./remove-team-from-project"; import createDsn from "./create-dsn"; import findDsns from "./find-dsns"; import updateDsn from "./update-dsn"; +import findCustomInboundFilters from "./find-custom-inbound-filters"; +import createCustomInboundFilter from "./create-custom-inbound-filter"; +import updateCustomInboundFilter from "./update-custom-inbound-filter"; +import deleteCustomInboundFilter from "./delete-custom-inbound-filter"; import analyzeIssueWithSeer from "./analyze-issue-with-seer"; import searchDocs from "./search-docs"; import getDoc from "./get-doc"; @@ -156,6 +160,10 @@ const catalogTools = { create_dsn: createDsn, find_dsns: findDsns, update_dsn: updateDsn, + find_custom_inbound_filters: findCustomInboundFilters, + create_custom_inbound_filter: createCustomInboundFilter, + update_custom_inbound_filter: updateCustomInboundFilter, + delete_custom_inbound_filter: deleteCustomInboundFilter, analyze_issue_with_seer: analyzeIssueWithSeer, search_docs: searchDocs, get_doc: getDoc, diff --git a/packages/mcp-core/src/tools/catalog/support/custom-inbound-filters.ts b/packages/mcp-core/src/tools/catalog/support/custom-inbound-filters.ts new file mode 100644 index 000000000..c818cee05 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/support/custom-inbound-filters.ts @@ -0,0 +1,163 @@ +import { z } from "zod"; +import { ApiClientError } from "../../../api-client"; +import type { CustomInboundFilter } from "../../../api-client"; +import { UserInputError } from "../../../errors"; + +/** + * Shared schema, validation and formatting for the custom inbound filter tools. + * + * Mirrors src/sentry/api/endpoints/project_custom_inbound_filters.py and + * src/sentry/ingest/inbound_filters.py in the Sentry monolith. + */ + +export const CUSTOM_INBOUND_FILTER_DATA_TYPES = [ + "all", + "error", + "log", + "metric", + "span", +] as const; + +export const CUSTOM_INBOUND_FILTER_CONDITION_TYPES = [ + "error_type", + "error_message", + "log_message", + "metric_name", + "release", + "ip_address", +] as const; + +export type CustomInboundFilterDataType = + (typeof CUSTOM_INBOUND_FILTER_DATA_TYPES)[number]; +export type CustomInboundFilterConditionType = + (typeof CUSTOM_INBOUND_FILTER_CONDITION_TYPES)[number]; + +export const MAX_CONDITIONS_PER_FILTER = 10; +export const MAX_FILTERS_PER_PROJECT = 50; + +/** Condition types every data type accepts, plus the ones only some carry a field for. */ +const CONDITION_TYPES_BY_DATA_TYPE: Record< + CustomInboundFilterDataType, + readonly CustomInboundFilterConditionType[] +> = { + all: ["release", "ip_address"], + error: ["error_type", "error_message", "release", "ip_address"], + log: ["log_message", "release", "ip_address"], + metric: ["metric_name", "release", "ip_address"], + span: ["release", "ip_address"], +}; + +export const ParamCustomInboundFilterDataType = z + .enum(CUSTOM_INBOUND_FILTER_DATA_TYPES) + .describe( + "The data the filter drops: `error` (errors and messages), `log`, `metric`, `span`, or `all`. `all` matches every data type Sentry ingests, including ones added later, and accepts only `release` and `ip_address` conditions.", + ); + +export const ParamCustomInboundFilterConditions = z + .array( + z.object({ + type: z + .enum(CUSTOM_INBOUND_FILTER_CONDITION_TYPES) + .describe( + "The field to match. Every data type accepts `release` and `ip_address`. `error` also accepts `error_type` and `error_message`, `log` accepts `log_message`, `metric` accepts `metric_name`.", + ), + value: z + .array(z.string().trim().min(1)) + .min(1) + .describe( + "Patterns for the field. The condition matches when ANY pattern matches (OR). Patterns are case-insensitive globs where `*` matches any characters and `?` one character, e.g. `*ConnectionError*`, `my-app@2.1.*`, `checkout.*`. For `ip_address`, each value must be an IP address or CIDR range such as `203.0.113.7` or `10.0.0.0/8`.", + ), + }), + ) + .min(1) + .max(MAX_CONDITIONS_PER_FILTER) + .describe( + `Conditions are combined with AND: data is dropped only when every condition matches. Use several values inside one condition for OR, or create separate filters. At most ${MAX_CONDITIONS_PER_FILTER} conditions per filter.`, + ); + +export const ParamCustomInboundFilterId = z + .string() + .trim() + .min(1) + .describe( + "The custom inbound filter ID. Use find_custom_inbound_filters() to look it up.", + ); + +export const customInboundFilterItemSchema = z.object({ + id: z.string(), + name: z.string().nullable(), + active: z.boolean(), + dataType: z.string(), + conditions: z.array( + z.object({ + type: z.string(), + value: z.array(z.string()), + }), + ), + dateCreated: z.string().nullable(), + dateUpdated: z.string().nullable(), +}); + +export type CustomInboundFilterItem = z.infer< + typeof customInboundFilterItemSchema +>; + +export function toCustomInboundFilterItem( + filter: CustomInboundFilter, +): CustomInboundFilterItem { + return { + id: String(filter.id), + name: filter.name, + active: filter.active, + dataType: filter.dataType, + conditions: filter.conditions.map((condition) => ({ + type: condition.type, + value: [...condition.value], + })), + dateCreated: filter.dateCreated, + dateUpdated: filter.dateUpdated, + }; +} + +/** + * Rejects a condition the data type has no field for before the request is sent, + * so the agent gets the allowed list instead of a generic 400. + */ +export function assertConditionsMatchDataType( + dataType: CustomInboundFilterDataType, + conditions: Array<{ type: CustomInboundFilterConditionType }>, +): void { + const allowed = CONDITION_TYPES_BY_DATA_TYPE[dataType]; + const unsupported = [ + ...new Set( + conditions + .map((condition) => condition.type) + .filter((type) => !allowed.includes(type)), + ), + ]; + if (unsupported.length === 0) { + return; + } + throw new UserInputError( + `A filter on ${dataType} data cannot use the ${unsupported.join(", ")} condition. It accepts ${allowed.join(", ")}.`, + ); +} + +/** + * Sentry answers 400 "You do not have that feature enabled" when the organization + * is not on custom inbound filters yet. Rewrite that into a message that names the + * feature; pass every other API error through unchanged. + */ +export function rethrowCustomInboundFilterError(error: unknown): never { + if ( + error instanceof ApiClientError && + error.status === 400 && + /feature enabled/i.test(error.message) + ) { + throw new UserInputError( + "Custom inbound filters are not enabled for this organization. The built-in inbound filters in Project Settings > Inbound Filters still apply.", + { cause: error }, + ); + } + throw error; +} diff --git a/packages/mcp-core/src/tools/catalog/update-custom-inbound-filter.test.ts b/packages/mcp-core/src/tools/catalog/update-custom-inbound-filter.test.ts new file mode 100644 index 000000000..63568aa35 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/update-custom-inbound-filter.test.ts @@ -0,0 +1,136 @@ +import { mswServer } from "@sentry/mcp-server-mocks"; +import { HttpResponse, http } from "msw"; +import { describe, expect, it } from "vitest"; +import { UserInputError } from "../../errors.js"; +import { getServerContext } from "../../test-setup.js"; +import { + assertStructuredOnlyResult, + getStructuredContent, +} from "../../test-utils/structured-content.js"; +import updateCustomInboundFilter, { + updateCustomInboundFilterOutputSchema, +} from "./update-custom-inbound-filter.js"; + +describe("update_custom_inbound_filter", () => { + it("sends only the provided fields and returns the updated filter", async () => { + mswServer.use( + http.put( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/custom-inbound-filters/4509100000002001/", + async ({ request }) => { + await expect(request.json()).resolves.toEqual({ active: false }); + return HttpResponse.json({ + id: "4509100000002001", + name: "Ignore flaky connection errors", + active: false, + dataType: "error", + conditions: [ + { + type: "error_type", + value: ["ConnectionError", "TimeoutError"], + }, + ], + dateCreated: "2026-09-25T10:00:00.000000Z", + dateUpdated: "2026-10-02T09:00:00.000000Z", + }); + }, + { once: true }, + ), + ); + + const result = await updateCustomInboundFilter.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + regionUrl: null, + filterId: "4509100000002001", + active: false, + }, + getServerContext(), + ); + + assertStructuredOnlyResult(result); + const structuredContent = getStructuredContent(result); + expect( + updateCustomInboundFilterOutputSchema.parse(structuredContent), + ).toEqual(structuredContent); + expect(structuredContent).toMatchInlineSnapshot(` + { + "filter": { + "active": false, + "conditions": [ + { + "type": "error_type", + "value": [ + "ConnectionError", + "TimeoutError", + ], + }, + ], + "dataType": "error", + "dateCreated": "2026-09-25T10:00:00.000000Z", + "dateUpdated": "2026-10-02T09:00:00.000000Z", + "id": "4509100000002001", + "name": "Ignore flaky connection errors", + }, + } + `); + }); + + it("replaces the conditions through the default mock", async () => { + const result = await updateCustomInboundFilter.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + regionUrl: null, + filterId: "4509100000002001", + conditions: [ + { type: "error_message", value: ["*ConnectionError*", "*Timeout*"] }, + ], + }, + getServerContext(), + ); + + const structuredContent = getStructuredContent<{ + filter: { conditions: Array<{ type: string; value: string[] }> }; + }>(result); + expect(structuredContent.filter.conditions).toEqual([ + { type: "error_message", value: ["*ConnectionError*", "*Timeout*"] }, + ]); + }); + + it("throws when no field is provided", async () => { + await expect( + updateCustomInboundFilter.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + regionUrl: null, + filterId: "4509100000002001", + }, + getServerContext(), + ), + ).rejects.toThrow(UserInputError); + }); + + it("rejects conditions that the new data type does not accept", async () => { + await expect( + updateCustomInboundFilter.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + regionUrl: null, + filterId: "4509100000002001", + dataType: "metric", + conditions: [{ type: "log_message", value: ["*DEBUG*"] }], + }, + getServerContext(), + ), + ).rejects.toThrow( + "A filter on metric data cannot use the log_message condition. It accepts metric_name, release, ip_address.", + ); + }); + + it("claims idempotency", () => { + expect(updateCustomInboundFilter.annotations.idempotentHint).toBe(true); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/update-custom-inbound-filter.ts b/packages/mcp-core/src/tools/catalog/update-custom-inbound-filter.ts new file mode 100644 index 000000000..15b9a940e --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/update-custom-inbound-filter.ts @@ -0,0 +1,127 @@ +import { z } from "zod"; +import { UserInputError } from "../../errors"; +import { apiServiceFromContext } from "../../internal/tool-helpers/api"; +import { defineTool } from "../../internal/tool-helpers/define"; +import { structuredResult } from "../../internal/tool-helpers/results"; +import { + ParamOrganizationSlug, + ParamProjectSlug, + ParamRegionUrl, +} from "../../schema"; +import { setTargetTagsAndAttributes } from "../../telem/scope"; +import type { ServerContext } from "../../types"; +import { + assertConditionsMatchDataType, + customInboundFilterItemSchema, + ParamCustomInboundFilterConditions, + ParamCustomInboundFilterDataType, + ParamCustomInboundFilterId, + rethrowCustomInboundFilterError, + toCustomInboundFilterItem, +} from "./support/custom-inbound-filters"; +import { assertProjectRefWithinConstraint } from "./support/project-constraints"; + +export const updateCustomInboundFilterOutputSchema = z.object({ + filter: customInboundFilterItemSchema, +}); + +export default defineTool({ + name: "update_custom_inbound_filter", + skills: ["project-management"], + requiredScopes: ["project:write"], + description: [ + "Update a custom inbound filter: rename it, pause or resume it, or change what it drops at ingest.", + "", + "Use this tool when the user wants to:", + "- Pause a filter without deleting it (active=false) or turn it back on", + "- Add or remove a pattern, e.g. another error message, release or IP range", + "- Rename a filter or move it to another data type", + "", + "Only the fields you pass change. `conditions` replaces the whole condition list, so send the complete list you want to keep.", + "", + "Be careful when using this tool! Widening an active filter drops more data irreversibly.", + "", + "", + "### Pause a filter", + "update_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', filterId='12345', active=false)", + "", + "### Replace the patterns of a filter", + "update_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', filterId='12345', conditions=[{type: 'error_message', value: ['*ConnectionError*', '*TimeoutError*']}])", + "", + "", + "", + "- Use find_custom_inbound_filters() to get the filterId and the current conditions before you change them.", + "- When you change dataType, make sure every condition type is one that data type accepts.", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.nullable().default(null), + projectSlug: ParamProjectSlug, + filterId: ParamCustomInboundFilterId, + name: z + .string() + .trim() + .min(1) + .max(256) + .describe("The new label of the filter.") + .optional(), + active: z + .boolean() + .describe("Set false to pause the filter, true to resume it.") + .optional(), + dataType: ParamCustomInboundFilterDataType.optional(), + conditions: ParamCustomInboundFilterConditions.optional(), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + outputSchema: updateCustomInboundFilterOutputSchema, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl ?? undefined, + }); + const organizationSlug = params.organizationSlug; + + assertProjectRefWithinConstraint({ + resourceLabel: "Custom inbound filter", + scopedProjectSlug: context.constraints.projectSlug, + project: { slug: params.projectSlug }, + }); + + if ( + params.name === undefined && + params.active === undefined && + params.dataType === undefined && + params.conditions === undefined + ) { + throw new UserInputError( + "Provide at least one of name, active, dataType or conditions to update.", + ); + } + if (params.dataType !== undefined && params.conditions !== undefined) { + assertConditionsMatchDataType(params.dataType, params.conditions); + } + + setTargetTagsAndAttributes(params); + + const filter = await apiService + .updateCustomInboundFilter({ + organizationSlug, + projectSlug: params.projectSlug, + filterId: params.filterId, + name: params.name, + active: params.active, + dataType: params.dataType, + conditions: params.conditions, + }) + .catch(rethrowCustomInboundFilterError); + + return structuredResult({ + filter: toCustomInboundFilterItem(filter), + }); + }, +}); diff --git a/packages/mcp-server-mocks/src/fixtures/custom-inbound-filter.json b/packages/mcp-server-mocks/src/fixtures/custom-inbound-filter.json new file mode 100644 index 000000000..4dabab087 --- /dev/null +++ b/packages/mcp-server-mocks/src/fixtures/custom-inbound-filter.json @@ -0,0 +1,18 @@ +{ + "id": "4509100000002001", + "name": "Ignore flaky connection errors", + "active": true, + "dataType": "error", + "conditions": [ + { + "type": "error_type", + "value": ["ConnectionError", "TimeoutError"] + }, + { + "type": "release", + "value": ["my-app@2.1.*"] + } + ], + "dateCreated": "2026-09-25T10:00:00.000000Z", + "dateUpdated": "2026-09-26T08:30:00.000000Z" +} diff --git a/packages/mcp-server-mocks/src/index.ts b/packages/mcp-server-mocks/src/index.ts index 38e4929d4..d1d725820 100644 --- a/packages/mcp-server-mocks/src/index.ts +++ b/packages/mcp-server-mocks/src/index.ts @@ -31,6 +31,9 @@ import autofixStateExplorerFixture from "./fixtures/autofix-state-explorer.json" type: "json", }; import clientKeyFixture from "./fixtures/client-key.json" with { type: "json" }; +import customInboundFilterFixture from "./fixtures/custom-inbound-filter.json" with { + type: "json", +}; import dashboardDetailsFixture from "./fixtures/dashboard-details.json" with { type: "json", }; @@ -237,6 +240,13 @@ type IssueUpdateBody = { substatus?: string; }; +type CustomInboundFilterWriteBody = { + name?: string | null; + active?: boolean; + dataType: string; + conditions: Array<{ type: string; value: string[] }>; +}; + type ClientKeyUpdateBody = { name?: string; isActive?: boolean; @@ -572,6 +582,51 @@ export const restHandlers = buildHandlers([ return HttpResponse.json([clientKeyFixture]); }, }, + { + method: "get", + path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/custom-inbound-filters/", + fetch: () => HttpResponse.json([customInboundFilterFixture]), + }, + { + method: "post", + path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/custom-inbound-filters/", + fetch: async ({ request }) => { + const body = (await request.json()) as CustomInboundFilterWriteBody; + return HttpResponse.json( + { + ...customInboundFilterFixture, + id: "4509100000002002", + name: body.name ?? null, + active: body.active ?? true, + dataType: body.dataType, + conditions: body.conditions, + }, + { status: 201 }, + ); + }, + }, + { + method: "put", + path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/custom-inbound-filters/:filterId/", + fetch: async ({ request, params }) => { + const body = + (await request.json()) as Partial; + return HttpResponse.json({ + ...customInboundFilterFixture, + id: String(params.filterId), + name: + body.name !== undefined ? body.name : customInboundFilterFixture.name, + active: body.active ?? customInboundFilterFixture.active, + dataType: body.dataType ?? customInboundFilterFixture.dataType, + conditions: body.conditions ?? customInboundFilterFixture.conditions, + }); + }, + }, + { + method: "delete", + path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/custom-inbound-filters/:filterId/", + fetch: () => new HttpResponse(null, { status: 204 }), + }, { method: "get", path: "/api/0/projects/:organizationSlug/:projectSlug/teams/", @@ -2101,6 +2156,7 @@ export { autofixStateExplorerFixture, autofixStateFixture, clientKeyFixture, + customInboundFilterFixture, dashboardDetailsFixture, dashboardListFixture, eventAttachmentsFixture, From 99cd3f826914731819ea2243472f6302891c1838 Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Mon, 5 Oct 2026 15:22:17 +0200 Subject: [PATCH 2/2] fix(tools): describe how error message conditions match --- packages/mcp-core/src/skillDefinitions.json | 2 +- packages/mcp-core/src/toolDefinitions.json | 2 +- .../mcp-core/src/tools/catalog/create-custom-inbound-filter.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 82e713629..c08746f0b 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -535,7 +535,7 @@ }, { "name": "create_custom_inbound_filter", - "description": "Create a custom inbound filter that drops matching errors, logs, metrics or spans at ingest, before they are stored or count against quota.\n\nUse this tool when the user wants to:\n- Ignore or filter out errors by message or exception type ('stop ingesting *ConnectionError*')\n- Drop events from an IP address or CIDR range, or from a release ('block 10.0.0.0/8', 'ignore release 1.4.*')\n- Drop noisy logs by message, or metrics by name\n- Filter every data type at once with dataType='all' on release or IP address\n\nConditions are ANDed; the values inside a condition are ORed. To express 'A or B' put both patterns into one condition's value list. To express 'A and B' use two conditions.\n\nBe careful when using this tool! An active filter drops data irreversibly. Create it with active=false to review it first.\n\n\n### Ignore one exception type on errors\ncreate_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', name='Ignore flaky connection errors', dataType='error', conditions=[{type: 'error_type', value: ['ConnectionError', 'TimeoutError']}])\n\n### Drop everything from an IP range for one release\ncreate_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', name='Load test traffic', dataType='all', conditions=[{type: 'ip_address', value: ['10.0.0.0/8']}, {type: 'release', value: ['my-app@2.1.*']}])\n\n### Drop debug logs\ncreate_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', name='Drop debug logs', dataType='log', conditions=[{type: 'log_message', value: ['*DEBUG*']}])\n\n\n\n- Error message conditions match `{exception.type}: {exception.value}` on error events and the formatted message on message events. Prefer wildcards such as `*ConnectionError*` over a full message.\n- Release conditions match the full release name, e.g. `my-app@1.4.0`; use globs such as `my-app@1.*` for a range.\n- Use find_custom_inbound_filters() first to avoid creating a duplicate.\n", + "description": "Create a custom inbound filter that drops matching errors, logs, metrics or spans at ingest, before they are stored or count against quota.\n\nUse this tool when the user wants to:\n- Ignore or filter out errors by message or exception type ('stop ingesting *ConnectionError*')\n- Drop events from an IP address or CIDR range, or from a release ('block 10.0.0.0/8', 'ignore release 1.4.*')\n- Drop noisy logs by message, or metrics by name\n- Filter every data type at once with dataType='all' on release or IP address\n\nConditions are ANDed; the values inside a condition are ORed. To express 'A or B' put both patterns into one condition's value list. To express 'A and B' use two conditions.\n\nBe careful when using this tool! An active filter drops data irreversibly. Create it with active=false to review it first.\n\n\n### Ignore one exception type on errors\ncreate_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', name='Ignore flaky connection errors', dataType='error', conditions=[{type: 'error_type', value: ['ConnectionError', 'TimeoutError']}])\n\n### Drop everything from an IP range for one release\ncreate_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', name='Load test traffic', dataType='all', conditions=[{type: 'ip_address', value: ['10.0.0.0/8']}, {type: 'release', value: ['my-app@2.1.*']}])\n\n### Drop debug logs\ncreate_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', name='Drop debug logs', dataType='log', conditions=[{type: 'log_message', value: ['*DEBUG*']}])\n\n\n\n- Error message conditions match the exception type, the exception value and the formatted message of an event, each on its own. Prefer wildcards such as `*ConnectionError*` over a full message.\n- Release conditions match the full release name, e.g. `my-app@1.4.0`; use globs such as `my-app@1.*` for a range.\n- Use find_custom_inbound_filters() first to avoid creating a duplicate.\n", "requiredScopes": ["project:write"] }, { diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index f1743f864..84c2a39fc 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -578,7 +578,7 @@ }, { "name": "create_custom_inbound_filter", - "description": "Create a custom inbound filter that drops matching errors, logs, metrics or spans at ingest, before they are stored or count against quota.\n\nUse this tool when the user wants to:\n- Ignore or filter out errors by message or exception type ('stop ingesting *ConnectionError*')\n- Drop events from an IP address or CIDR range, or from a release ('block 10.0.0.0/8', 'ignore release 1.4.*')\n- Drop noisy logs by message, or metrics by name\n- Filter every data type at once with dataType='all' on release or IP address\n\nConditions are ANDed; the values inside a condition are ORed. To express 'A or B' put both patterns into one condition's value list. To express 'A and B' use two conditions.\n\nBe careful when using this tool! An active filter drops data irreversibly. Create it with active=false to review it first.\n\n\n### Ignore one exception type on errors\ncreate_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', name='Ignore flaky connection errors', dataType='error', conditions=[{type: 'error_type', value: ['ConnectionError', 'TimeoutError']}])\n\n### Drop everything from an IP range for one release\ncreate_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', name='Load test traffic', dataType='all', conditions=[{type: 'ip_address', value: ['10.0.0.0/8']}, {type: 'release', value: ['my-app@2.1.*']}])\n\n### Drop debug logs\ncreate_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', name='Drop debug logs', dataType='log', conditions=[{type: 'log_message', value: ['*DEBUG*']}])\n\n\n\n- Error message conditions match `{exception.type}: {exception.value}` on error events and the formatted message on message events. Prefer wildcards such as `*ConnectionError*` over a full message.\n- Release conditions match the full release name, e.g. `my-app@1.4.0`; use globs such as `my-app@1.*` for a range.\n- Use find_custom_inbound_filters() first to avoid creating a duplicate.\n", + "description": "Create a custom inbound filter that drops matching errors, logs, metrics or spans at ingest, before they are stored or count against quota.\n\nUse this tool when the user wants to:\n- Ignore or filter out errors by message or exception type ('stop ingesting *ConnectionError*')\n- Drop events from an IP address or CIDR range, or from a release ('block 10.0.0.0/8', 'ignore release 1.4.*')\n- Drop noisy logs by message, or metrics by name\n- Filter every data type at once with dataType='all' on release or IP address\n\nConditions are ANDed; the values inside a condition are ORed. To express 'A or B' put both patterns into one condition's value list. To express 'A and B' use two conditions.\n\nBe careful when using this tool! An active filter drops data irreversibly. Create it with active=false to review it first.\n\n\n### Ignore one exception type on errors\ncreate_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', name='Ignore flaky connection errors', dataType='error', conditions=[{type: 'error_type', value: ['ConnectionError', 'TimeoutError']}])\n\n### Drop everything from an IP range for one release\ncreate_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', name='Load test traffic', dataType='all', conditions=[{type: 'ip_address', value: ['10.0.0.0/8']}, {type: 'release', value: ['my-app@2.1.*']}])\n\n### Drop debug logs\ncreate_custom_inbound_filter(organizationSlug='my-org', projectSlug='my-project', name='Drop debug logs', dataType='log', conditions=[{type: 'log_message', value: ['*DEBUG*']}])\n\n\n\n- Error message conditions match the exception type, the exception value and the formatted message of an event, each on its own. Prefer wildcards such as `*ConnectionError*` over a full message.\n- Release conditions match the full release name, e.g. `my-app@1.4.0`; use globs such as `my-app@1.*` for a range.\n- Use find_custom_inbound_filters() first to avoid creating a duplicate.\n", "inputSchema": { "type": "object", "properties": { diff --git a/packages/mcp-core/src/tools/catalog/create-custom-inbound-filter.ts b/packages/mcp-core/src/tools/catalog/create-custom-inbound-filter.ts index a8f42da52..ccf943f17 100644 --- a/packages/mcp-core/src/tools/catalog/create-custom-inbound-filter.ts +++ b/packages/mcp-core/src/tools/catalog/create-custom-inbound-filter.ts @@ -52,7 +52,7 @@ export default defineTool({ "", "", "", - "- Error message conditions match `{exception.type}: {exception.value}` on error events and the formatted message on message events. Prefer wildcards such as `*ConnectionError*` over a full message.", + "- Error message conditions match the exception type, the exception value and the formatted message of an event, each on its own. Prefer wildcards such as `*ConnectionError*` over a full message.", "- Release conditions match the full release name, e.g. `my-app@1.4.0`; use globs such as `my-app@1.*` for a range.", "- Use find_custom_inbound_filters() first to avoid creating a duplicate.", "",