Skip to content

Commit eec2657

Browse files
committed
feat(zendesk): implement area gating for MCP tools
Introduced area gating in Zendesk MCP server to control tool registration based on specified areas, enhancing security and configurability. This change allows for restricted tool exposure, ensuring only relevant tools are registered.
1 parent 6e066e2 commit eec2657

50 files changed

Lines changed: 1593 additions & 583 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/libraries/zendesk-mcp-server.md

Lines changed: 334 additions & 171 deletions
Large diffs are not rendered by default.

src/ES.FX.Zendesk.MCP.Host/Configuration/McpOptions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,9 @@ public class McpOptions
3333
/// Execution-mode options (read-only / dry-run) for write operations.
3434
/// </summary>
3535
public McpExecutionOptions Execution { get; set; } = new();
36+
37+
/// <summary>
38+
/// Tool-surface options (area gating) controlling which tool areas are registered.
39+
/// </summary>
40+
public McpToolsOptions Tools { get; set; } = new();
3641
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
namespace ES.FX.Zendesk.MCP.Host.Configuration;
2+
3+
/// <summary>
4+
/// Options controlling which Zendesk tool <em>areas</em> (top-level resource groupings such as
5+
/// <c>tickets</c>, <c>users</c>, <c>organizations</c>) are exposed by this MCP server. This is a
6+
/// server-side filtering lever: because MCP clients (Hermes, Claude) filter by exact tool name only,
7+
/// narrowing the surface here — rather than enumerating names in a client include-list — is the primary
8+
/// way to expose "only ticket operations" and, combined with a read-only execution baseline, "only ticket
9+
/// reads".
10+
/// </summary>
11+
public class McpToolsOptions
12+
{
13+
/// <summary>
14+
/// The set of tool areas to register. An <em>empty</em> (or absent) list registers <em>all</em> areas
15+
/// (backward compatible). When non-empty, only tool classes whose area is in this set are registered; an
16+
/// unknown/misspelled area — or a list that is present but entirely blank — fails startup (fail-closed)
17+
/// rather than silently exposing nothing (or everything). Blank entries mixed with valid ones are ignored.
18+
/// Matching is case-insensitive; areas are the plural snake_case resource names (e.g. <c>tickets</c>,
19+
/// <c>ticket_fields</c>).
20+
/// </summary>
21+
public string[] Areas { get; set; } = [];
22+
}

src/ES.FX.Zendesk.MCP.Host/Hosting/McpServerHostingExtensions.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
using System.Diagnostics.CodeAnalysis;
12
using ES.FX.Zendesk.MCP.Host.Configuration;
23
using ES.FX.Zendesk.MCP.Host.Diagnostics;
34
using ES.FX.Zendesk.MCP.Host.Execution;
5+
using ES.FX.Zendesk.MCP.Host.Tools;
46
using JetBrains.Annotations;
57
using Microsoft.Extensions.Options;
68

@@ -53,6 +55,21 @@ public static McpOptions GetMcpOptions(this IHostApplicationBuilder builder)
5355
return options;
5456
}
5557

58+
/// <summary>
59+
/// Registers a tool class only when the area gate admits its area, otherwise leaves the builder unchanged.
60+
/// This composes with the read-only registration gate in <c>Program.cs</c> (which already omits the
61+
/// <c>*WriteTools</c> classes under a read-only baseline) via AND: a class is registered only when it
62+
/// passes both gates.
63+
/// </summary>
64+
/// <typeparam name="TTool">The <see cref="ModelContextProtocol.Server.McpServerToolTypeAttribute" /> tool class.</typeparam>
65+
/// <param name="builder">The MCP server builder.</param>
66+
/// <param name="gate">The area gate built from <c>Mcp:Tools:Areas</c>.</param>
67+
/// <returns>The builder, for chaining.</returns>
68+
public static IMcpServerBuilder WithToolsInArea<[DynamicallyAccessedMembers(
69+
DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods)]
70+
TTool>(this IMcpServerBuilder builder, ZendeskToolAreaGate gate)
71+
=> gate.Allows<TTool>() ? builder.WithTools<TTool>() : builder;
72+
5673
/// <summary>
5774
/// Maps the MCP endpoints onto the application pipeline. Call after <c>app.Ignite()</c>.
5875
/// </summary>

src/ES.FX.Zendesk.MCP.Host/Program.cs

Lines changed: 40 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -21,42 +21,52 @@
2121
// Zendesk API client (Spark): config binding, authentication, typed HttpClient, live health check, tracing.
2222
builder.IgniteZendeskClient();
2323

24+
var mcpOptions = builder.GetMcpOptions();
25+
26+
// Area gate (Mcp:Tools:Areas). Empty = all areas (backward compatible). An unknown/misspelled area throws
27+
// here with the list of valid areas — fail-closed, mirroring the execution-mode resolver. This composes with
28+
// the read-only baseline gate below via AND: read-only drops the write classes, the area gate drops classes
29+
// outside the configured areas.
30+
var areaGate = ZendeskToolAreaGate.FromConfiguration(mcpOptions.Tools.Areas, typeof(Program).Assembly);
31+
2432
// MCP server (wired directly — MCP is a building block of this app, not a standalone package) + tools.
2533
var mcpServer = builder.AddZendeskMcpServer()
26-
.WithTools<ZendeskUserTools>()
27-
.WithTools<ZendeskTicketTools>()
28-
.WithTools<ZendeskFormTools>()
29-
.WithTools<ZendeskOrganizationTools>()
30-
.WithTools<ZendeskGroupTools>()
31-
.WithTools<ZendeskArticleTools>()
32-
.WithTools<ZendeskTicketFieldTools>()
33-
.WithTools<ZendeskMacroTools>()
34-
.WithTools<ZendeskAttachmentTools>()
35-
.WithTools<ZendeskSearchTools>()
36-
.WithTools<ZendeskViewTools>()
37-
.WithTools<ZendeskBrandTools>()
38-
.WithTools<ZendeskCustomStatusTools>()
39-
.WithTools<ZendeskJobStatusTools>()
40-
.WithTools<ZendeskTagTools>()
41-
.WithTools<ZendeskSuspendedTicketTools>();
34+
.WithToolsInArea<ZendeskUserTools>(areaGate)
35+
.WithToolsInArea<ZendeskTicketTools>(areaGate)
36+
.WithToolsInArea<ZendeskFormTools>(areaGate)
37+
.WithToolsInArea<ZendeskOrganizationTools>(areaGate)
38+
.WithToolsInArea<ZendeskGroupTools>(areaGate)
39+
.WithToolsInArea<ZendeskArticleTools>(areaGate)
40+
.WithToolsInArea<ZendeskTicketFieldTools>(areaGate)
41+
.WithToolsInArea<ZendeskMacroTools>(areaGate)
42+
.WithToolsInArea<ZendeskAttachmentTools>(areaGate)
43+
.WithToolsInArea<ZendeskSearchTools>(areaGate)
44+
.WithToolsInArea<ZendeskViewTools>(areaGate)
45+
.WithToolsInArea<ZendeskBrandTools>(areaGate)
46+
.WithToolsInArea<ZendeskCustomStatusTools>(areaGate)
47+
.WithToolsInArea<ZendeskJobStatusTools>(areaGate)
48+
.WithToolsInArea<ZendeskTagTools>(areaGate)
49+
.WithToolsInArea<ZendeskSuspendedTicketTools>(areaGate);
4250

4351
// Write tools are registered only when the configured baseline allows them: with a ReadOnly baseline the
4452
// per-request header can only tighten, so write tools could never execute — omitting them keeps the
45-
// agent's tool list truthful. ZendeskToolInvoker still enforces the effective mode on every call.
46-
if (!builder.GetMcpOptions().Execution.Mode.IsReadOnly())
53+
// agent's tool list truthful. ZendeskToolInvoker still enforces the effective mode on every call. Each write
54+
// class is additionally subject to the area gate (AND), so Areas=tickets + ReadOnly=false registers only the
55+
// ticket write tools.
56+
if (!mcpOptions.Execution.Mode.IsReadOnly())
4757
mcpServer
48-
.WithTools<ZendeskTicketWriteTools>()
49-
.WithTools<ZendeskUserWriteTools>()
50-
.WithTools<ZendeskOrganizationWriteTools>()
51-
.WithTools<ZendeskGroupWriteTools>()
52-
.WithTools<ZendeskFormWriteTools>()
53-
.WithTools<ZendeskTicketFieldWriteTools>()
54-
.WithTools<ZendeskMacroWriteTools>()
55-
.WithTools<ZendeskViewWriteTools>()
56-
.WithTools<ZendeskBrandWriteTools>()
57-
.WithTools<ZendeskCustomStatusWriteTools>()
58-
.WithTools<ZendeskSuspendedTicketWriteTools>()
59-
.WithTools<ZendeskUploadWriteTools>();
58+
.WithToolsInArea<ZendeskTicketWriteTools>(areaGate)
59+
.WithToolsInArea<ZendeskUserWriteTools>(areaGate)
60+
.WithToolsInArea<ZendeskOrganizationWriteTools>(areaGate)
61+
.WithToolsInArea<ZendeskGroupWriteTools>(areaGate)
62+
.WithToolsInArea<ZendeskFormWriteTools>(areaGate)
63+
.WithToolsInArea<ZendeskTicketFieldWriteTools>(areaGate)
64+
.WithToolsInArea<ZendeskMacroWriteTools>(areaGate)
65+
.WithToolsInArea<ZendeskViewWriteTools>(areaGate)
66+
.WithToolsInArea<ZendeskBrandWriteTools>(areaGate)
67+
.WithToolsInArea<ZendeskCustomStatusWriteTools>(areaGate)
68+
.WithToolsInArea<ZendeskSuspendedTicketWriteTools>(areaGate)
69+
.WithToolsInArea<ZendeskUploadWriteTools>(areaGate);
6070

6171
var app = builder.Build();
6272

src/ES.FX.Zendesk.MCP.Host/README.md

Lines changed: 31 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,56 +15,60 @@ over the Streamable HTTP transport.
1515
1616
## Tools
1717

18-
**168 tools** namespaced `zendesk_{resource}_{verb}`, mirroring the Zendesk API: **81 read tools**
19-
(`ReadOnly = true`) and **87 write tools** (`ReadOnly = false`, with truthful `Destructive`/`Idempotent`
20-
annotations). Write tools are gated by the [execution mode](#execution-modes-read-only--dry-run) and are **not
21-
registered at all** when the configured baseline is `ReadOnly`, so a read-only deployment advertises a purely
22-
read-only tool list.
18+
**168 tools** named resource-first as `{area}[_{subresource}]_{verb}[_{qualifier}]` (snake_case, no product
19+
prefix — MCP clients namespace by server): **81 read tools** (`ReadOnly = true`) and **87 write tools**
20+
(`ReadOnly = false`, with truthful `Destructive`/`Idempotent` annotations). Every read tool ends in a
21+
controlled read verb (`get`/`list`/`search`/`count`/`export`/`autocomplete`); any other verb denotes a write,
22+
so a tool's risk class is legible from its name (and enforced by a test). Write tools are gated by the
23+
[execution mode](#execution-modes-read-only--dry-run) and are **not registered at all** when the configured
24+
baseline is `ReadOnly`, so a read-only deployment advertises a purely read-only tool list. The exposed surface
25+
can be narrowed further by resource area via `Mcp:Tools:Areas` — see the
26+
[filtering guide](../../docs/libraries/zendesk-mcp-server.md#filtering-the-tool-surface).
2327

2428
Conventions shared by all tools:
2529

2630
- **Pagination** — offset-paginated tools take `page`/`perPage` and return `count` + `next_page` (search-style
2731
tools default `perPage` to 25); cursor-paginated tools take `pageSize`/`afterCursor` and return
28-
`meta.has_more` + `meta.after_cursor`. `zendesk_ticket_fields_list` takes no paging parameters and returns
32+
`meta.has_more` + `meta.after_cursor`. `ticket_fields_list` takes no paging parameters and returns
2933
the full set in one call (a documented Zendesk exception).
3034
- **Sideloads** — list/read tools accept `include` (e.g. `users`, `groups`, `organizations`) where the Zendesk
3135
endpoint supports it, returning sibling arrays that remove per-id follow-up lookups.
3236
- **Errors** — Zendesk failures surface to the agent with the real HTTP status and response body (never the
3337
SDK's opaque generic error), including `Retry-After` semantics on 429s.
3438
- **Bulk writes** — bulk operations (≤100 items unless noted) return a `job_status`; poll
35-
`zendesk_job_statuses_read` until `completed`/`failed`.
39+
`job_statuses_get` until `completed`/`failed`.
3640

3741
### Read tools (81)
3842

39-
| Area | Tools (`zendesk_…`) |
43+
| Area | Tools |
4044
| --- | --- |
41-
| Users (15) | `users_whoami`, `users_read`, `users_read_many`, `users_search`, `users_requested_tickets`, `users_list`, `users_count`, `users_autocomplete`, `users_related`, `users_identities`, `users_groups`, `users_organizations`, `users_assigned_tickets`, `users_ccd_tickets`, `users_tags` |
42-
| Tickets (15) | `tickets_read`, `tickets_search`, `tickets_comments`, `tickets_audits`, `tickets_metrics`, `tickets_metric_events`, `tickets_incidents`, `tickets_side_conversations`, `tickets_list`, `tickets_read_many`, `tickets_count`, `tickets_read_by_external_id`, `tickets_collaborators`, `tickets_comments_count`, `tickets_incremental` |
43-
| Organizations (11) | `organizations_read`, `organizations_tickets`, `organizations_list`, `organizations_count`, `organizations_read_many`, `organizations_search`, `organizations_autocomplete`, `organizations_users`, `organizations_memberships`, `organizations_merge_status`, `organizations_tags` |
44-
| Groups (6) | `groups_list`, `groups_read`, `groups_memberships`, `groups_assignable`, `groups_count`, `groups_users` |
45-
| Help Center (7) | `articles_search`, `articles_read`, `articles_list`, `articles_sections`, `articles_section_read`, `articles_categories`, `articles_category_read` |
46-
| Ticket fields (3) | `ticket_fields_list`, `ticket_fields_read`, `ticket_fields_options` |
47-
| Macros (3) | `macros_list`, `macros_read`, `macros_list_active` |
48-
| Forms (2) | `forms_search`, `forms_read` |
49-
| Views (4) | `views_list`, `views_read`, `views_tickets`, `views_count` |
50-
| Search (2) | `search_count`, `search_export_tickets` (cursor-only deep export, no 1k cap) |
51-
| Brands (2) | `brands_list`, `brands_read` |
52-
| Custom statuses (2) | `custom_statuses_list`, `custom_statuses_read` |
53-
| Job statuses (3) | `job_statuses_list`, `job_statuses_read`, `job_statuses_read_many` |
45+
| Users (15) | `users_me_get`, `users_get`, `users_get_many`, `users_search`, `users_tickets_requested_list`, `users_list`, `users_count`, `users_autocomplete`, `users_related_get`, `users_identities_list`, `users_groups_list`, `users_organizations_list`, `users_tickets_assigned_list`, `users_tickets_ccd_list`, `users_tags_list` |
46+
| Tickets (16) | `tickets_get`, `tickets_search`, `tickets_comments_list`, `tickets_audits_list`, `tickets_metrics_get`, `tickets_metric_events_export`, `tickets_incidents_list`, `tickets_side_conversations_list`, `tickets_list`, `tickets_get_many`, `tickets_count`, `tickets_get_by_external_id`, `tickets_collaborators_list`, `tickets_comments_count`, `tickets_export_incremental`, `tickets_search_export` (cursor-only deep export, no 1k cap) |
47+
| Organizations (11) | `organizations_get`, `organizations_tickets_list`, `organizations_list`, `organizations_count`, `organizations_get_many`, `organizations_get_by_name_or_external_id`, `organizations_autocomplete`, `organizations_users_list`, `organizations_memberships_list`, `organizations_merges_get`, `organizations_tags_list` |
48+
| Groups (6) | `groups_list`, `groups_get`, `groups_memberships_list`, `groups_assignable_list`, `groups_count`, `groups_users_list` |
49+
| Help Center (7) | `articles_search`, `articles_get`, `articles_list`, `articles_sections_list`, `articles_sections_get`, `articles_categories_list`, `articles_categories_get` |
50+
| Ticket fields (3) | `ticket_fields_list`, `ticket_fields_get`, `ticket_fields_options_list` |
51+
| Macros (3) | `macros_list`, `macros_get`, `macros_list_active` |
52+
| Forms (2) | `forms_list`, `forms_get` |
53+
| Views (4) | `views_list`, `views_get`, `views_tickets_list`, `views_count` |
54+
| Search (1) | `search_count` |
55+
| Brands (2) | `brands_list`, `brands_get` |
56+
| Custom statuses (2) | `custom_statuses_list`, `custom_statuses_get` |
57+
| Job statuses (3) | `job_statuses_list`, `job_statuses_get`, `job_statuses_get_many` |
5458
| Tags (3) | `tags_list`, `tags_count`, `tags_autocomplete` |
55-
| Suspended tickets (2) | `suspended_tickets_list`, `suspended_tickets_read` |
56-
| Attachments (1) | `attachments_read` (authenticated content download; text or size-capped base64) |
59+
| Suspended tickets (2) | `suspended_tickets_list`, `suspended_tickets_get` |
60+
| Attachments (1) | `attachments_get` (authenticated content download; text or size-capped base64) |
5761

5862
### Write tools (87)
5963

60-
| Area | Tools (`zendesk_…`) |
64+
| Area | Tools |
6165
| --- | --- |
62-
| Tickets (21) | `tickets_create`, `tickets_create_many`, `tickets_update` (public reply / internal note via `comment.public`; 409 optimistic locking via `SafeUpdate`/`UpdatedStamp`), `tickets_update_many`, `tickets_update_many_batch`, `tickets_delete`, `tickets_delete_many`, `tickets_merge`, `tickets_mark_spam`, `tickets_mark_spam_many`, `tickets_restore`, `tickets_restore_many`, `tickets_delete_permanently`, `tickets_delete_permanently_many`, `tickets_tags_set`, `tickets_tags_add`, `tickets_tags_remove`, `tickets_comment_make_private`, `tickets_comment_attachment_redact`, `tickets_import`, `tickets_import_many` |
66+
| Tickets (21) | `tickets_create`, `tickets_create_many`, `tickets_update` (public reply / internal note via `comment.public`; 409 optimistic locking via `SafeUpdate`/`UpdatedStamp`), `tickets_update_many`, `tickets_update_many_batch`, `tickets_delete`, `tickets_delete_many`, `tickets_merge`, `tickets_mark_spam`, `tickets_mark_spam_many`, `tickets_restore`, `tickets_restore_many`, `tickets_delete_permanently`, `tickets_delete_permanently_many`, `tickets_tags_set`, `tickets_tags_add`, `tickets_tags_remove`, `tickets_comments_make_private`, `tickets_comments_attachment_redact`, `tickets_import`, `tickets_import_many` |
6367
| Users (17) | `users_create`, `users_create_or_update`, `users_create_many`, `users_create_or_update_many`, `users_update`, `users_update_many`, `users_update_many_batch`, `users_merge`, `users_delete`, `users_delete_many`, `users_delete_permanently`, `users_identities_create`, `users_identities_update`, `users_identities_make_primary`, `users_identities_verify`, `users_identities_request_verification`, `users_identities_delete` |
64-
| Organizations (14) | `organizations_create`, `organizations_create_many`, `organizations_create_or_update`, `organizations_update`, `organizations_update_many`, `organizations_update_many_batch`, `organizations_delete`, `organizations_delete_many`, `organizations_merge` (poll `organizations_merge_status`), `organizations_memberships_create`, `organizations_memberships_create_many`, `organizations_memberships_delete`, `organizations_memberships_delete_many`, `organizations_memberships_make_default` |
68+
| Organizations (14) | `organizations_create`, `organizations_create_many`, `organizations_create_or_update`, `organizations_update`, `organizations_update_many`, `organizations_update_many_batch`, `organizations_delete`, `organizations_delete_many`, `organizations_merge` (poll `organizations_merges_get`), `organizations_memberships_create`, `organizations_memberships_create_many`, `organizations_memberships_delete`, `organizations_memberships_delete_many`, `organizations_memberships_make_default` |
6569
| Groups (8) | `groups_create`, `groups_update`, `groups_delete`, `groups_memberships_create`, `groups_memberships_create_many`, `groups_memberships_delete`, `groups_memberships_delete_many`, `groups_memberships_make_default` |
6670
| Forms (4) | `forms_create`, `forms_update`, `forms_delete`, `forms_clone` |
67-
| Ticket fields (5) | `ticket_fields_create`, `ticket_fields_update`, `ticket_fields_delete`, `ticket_fields_options_set`, `ticket_fields_options_delete` |
71+
| Ticket fields (5) | `ticket_fields_create`, `ticket_fields_update`, `ticket_fields_delete`, `ticket_fields_options_create_or_update`, `ticket_fields_options_delete` |
6872
| Macros (3) | `macros_create`, `macros_update`, `macros_delete` |
6973
| Views (3) | `views_create`, `views_update`, `views_delete` |
7074
| Brands (3) | `brands_create`, `brands_update`, `brands_delete` |

0 commit comments

Comments
 (0)