From fdb6ccc014e2ec228ae7dee5f01d5548b63bce9a Mon Sep 17 00:00:00 2001 From: Rui Fu Date: Tue, 19 May 2026 13:48:04 +0800 Subject: [PATCH 01/10] feat: split MCP tools for Claude connector --- PLAN.md | 503 ++++++++++++++++++ README.md | 2 + docs/tools/functions_as_tools.md | 4 +- docs/tools/kafka_admin_connect.md | 5 +- docs/tools/kafka_admin_groups.md | 5 +- docs/tools/kafka_admin_partitions.md | 5 +- docs/tools/kafka_admin_schema_registry.md | 5 +- docs/tools/kafka_admin_topics.md | 5 +- docs/tools/kafka_client_consume.md | 5 +- docs/tools/kafka_client_produce.md | 2 +- docs/tools/pulsar_admin_broker_stats.md | 10 +- docs/tools/pulsar_admin_brokers.md | 11 +- docs/tools/pulsar_admin_clusters.md | 9 +- docs/tools/pulsar_admin_functions.md | 3 + docs/tools/pulsar_admin_namespaces.md | 19 +- docs/tools/pulsar_admin_nsisolationpolicy.md | 5 +- docs/tools/pulsar_admin_packages.md | 5 +- docs/tools/pulsar_admin_resource_quotas.md | 5 +- docs/tools/pulsar_admin_schemas.md | 5 +- docs/tools/pulsar_admin_sinks.md | 5 +- docs/tools/pulsar_admin_sources.md | 5 +- docs/tools/pulsar_admin_subscriptions.md | 3 + docs/tools/pulsar_admin_tenants.md | 5 +- docs/tools/pulsar_admin_topic_policy.md | 3 + docs/tools/pulsar_admin_topics.md | 5 +- docs/tools/pulsar_client_consume.md | 5 +- docs/tools/pulsar_client_produce.md | 2 +- docs/tools/streamnative_cloud.md | 8 +- pkg/cmd/mcp/server.go | 4 +- .../kafka/annotation_compliance_test.go | 113 ++++ pkg/mcp/builders/kafka/connect.go | 68 ++- pkg/mcp/builders/kafka/connect_test.go | 17 +- pkg/mcp/builders/kafka/consume.go | 8 +- pkg/mcp/builders/kafka/groups.go | 59 +- pkg/mcp/builders/kafka/partitions.go | 20 +- pkg/mcp/builders/kafka/produce.go | 4 +- pkg/mcp/builders/kafka/schema_registry.go | 59 +- pkg/mcp/builders/kafka/tool_mode.go | 33 ++ pkg/mcp/builders/kafka/topics.go | 54 +- .../pulsar/annotation_compliance_test.go | 149 ++++++ pkg/mcp/builders/pulsar/brokers.go | 54 +- pkg/mcp/builders/pulsar/brokers_stats.go | 4 +- pkg/mcp/builders/pulsar/cluster.go | 52 +- pkg/mcp/builders/pulsar/consume.go | 8 +- pkg/mcp/builders/pulsar/feature_gates_test.go | 12 +- pkg/mcp/builders/pulsar/functions.go | 48 +- .../builders/pulsar/functions_parity_test.go | 4 +- pkg/mcp/builders/pulsar/functions_test.go | 7 +- pkg/mcp/builders/pulsar/functions_worker.go | 4 +- pkg/mcp/builders/pulsar/namespace.go | 58 +- pkg/mcp/builders/pulsar/namespace_policy.go | 7 +- pkg/mcp/builders/pulsar/nsisolationpolicy.go | 50 +- pkg/mcp/builders/pulsar/packages.go | 51 +- pkg/mcp/builders/pulsar/produce.go | 4 +- pkg/mcp/builders/pulsar/resourcequotas.go | 50 +- pkg/mcp/builders/pulsar/schema.go | 50 +- pkg/mcp/builders/pulsar/sinks.go | 61 ++- pkg/mcp/builders/pulsar/sources.go | 61 ++- pkg/mcp/builders/pulsar/status.go | 4 +- pkg/mcp/builders/pulsar/subscription.go | 45 +- pkg/mcp/builders/pulsar/tenant.go | 51 +- pkg/mcp/builders/pulsar/tool_mode.go | 37 ++ pkg/mcp/builders/pulsar/topic.go | 45 +- pkg/mcp/builders/pulsar/topic_policy.go | 94 +++- pkg/mcp/builders/pulsar/topic_policy_test.go | 10 +- pkg/mcp/builders/pulsar/topic_test.go | 8 +- pkg/mcp/pftools/manager.go | 12 +- pkg/mcp/sncontext_tools.go | 13 +- pkg/mcp/static_tool_annotations_test.go | 86 +++ pkg/mcp/streamnative_resources_log_tools.go | 4 +- pkg/mcp/streamnative_resources_tools.go | 12 +- pkg/mcp/toolannotations/annotations.go | 54 ++ 72 files changed, 1898 insertions(+), 404 deletions(-) create mode 100644 PLAN.md create mode 100644 pkg/mcp/builders/kafka/annotation_compliance_test.go create mode 100644 pkg/mcp/builders/kafka/tool_mode.go create mode 100644 pkg/mcp/builders/pulsar/annotation_compliance_test.go create mode 100644 pkg/mcp/builders/pulsar/tool_mode.go create mode 100644 pkg/mcp/static_tool_annotations_test.go create mode 100644 pkg/mcp/toolannotations/annotations.go diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..9b52f4c7 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,503 @@ +# Plan: Claude connector tool split + annotations + +## Goal + +Prepare StreamNative MCP Server for Claude connector submission and review. + +Hard requirements from Claude docs: + +- every MCP tool has non-empty `annotations.title` +- every MCP tool has explicit applicable `annotations.readOnlyHint` or `annotations.destructiveHint` +- read and write operations must be separate tools; no mixed `operation` catch-all that contains both safe and unsafe operations + +Source docs checked: + +- https://claude.com/docs/connectors/building/submission +- https://claude.com/docs/connectors/building/review-criteria + +Reference implementation checked: + +- `/Users/rui/playground/sn/mcp-auth0-proxy/internal/hooks/org_session_tools.go` +- Existing pattern there: + - separate tool names: e.g. `sncloud_byoc_read` and `sncloud_byoc_write` + - shared builder with mode enum: `controlPlaneToolModeRead` / `controlPlaneToolModeWrite` + - read tool operation enum: `list`, `get` + - write tool operation enum: `apply`, `delete` + - annotation set from mode: + - read: `readOnlyHint=true`, `destructiveHint=false` + - write: `readOnlyHint=false`, `destructiveHint=true` + - shared handler still validates operation against mode + +## Current findings + +Static `mcp.NewTool(...)` definitions found under `pkg/`: 36 tool definitions plus dynamic Pulsar Functions-as-Tools. + +Current gaps: + +- Most static tools have no explicit title. +- Most static tools rely on `mcp-go` defaults (`readOnlyHint=false`, `destructiveHint=true`, `openWorldHint=true`), which marks read tools as destructive. +- Only `sncloud_resources_apply` and `sncloud_resources_delete` currently set `WithToolAnnotation`; `apply` sets title only. +- Dynamic Pulsar Functions-as-Tools in `pkg/mcp/pftools/manager.go` create tools without title/read-only/destructive annotations. +- Many admin tools multiplex read and write operations through one `operation` parameter. Claude review criteria says mixed read/write catch-all tools can be rejected even if description documents safe/unsafe operations. + +## Proposed design + +### Design principle + +Follow `mcp-auth0-proxy` pattern: + +- split mixed tools into separate read and write tool names +- keep shared internal implementation where practical +- make operation enum mode-specific, so tool schema itself prevents mixed use +- keep read-only runtime mode simple: register only read tools +- in read-write runtime mode: register read tools and write tools as separate entries +- do not expose legacy mixed tools in Claude-submitted surface + +### Naming convention + +For mixed tools, replace one tool with two tools: + +- `_read` +- `_write` + +Examples: + +- `kafka_admin_topics` -> `kafka_admin_topics_read`, `kafka_admin_topics_write` +- `pulsar_admin_topic` -> `pulsar_admin_topic_read`, `pulsar_admin_topic_write` +- `pulsar_admin_namespace_policy` already has partial split; align names and annotations instead of forcing one exact pattern when current names are already narrow. + +Pure read tools can keep current names if no write side effects exist. +Pure write/side-effect tools can keep current names if description and annotation are clear. + +Compatibility policy: + +- Recommended for Claude readiness: remove mixed legacy tool registration from default surface. +- If backward compatibility is required, add opt-in legacy registration behind a feature/config flag and keep it disabled for connector submission. +- Do not keep mixed legacy tools visible in submitted connector, even with destructive annotation. + +### Shared helper APIs + +Add a small helper package, likely `pkg/mcp/toolannotations`, to avoid duplicated pointer boilerplate and import cycles: + +- `ReadOnly(title string) mcp.ToolOption` -> title, `readOnlyHint=true`, `destructiveHint=false` +- `Destructive(title string) mcp.ToolOption` -> title, `readOnlyHint=false`, `destructiveHint=true` +- optional `NonDestructiveWrite(title string)` only if a tool changes local session state without modifying external service; use sparingly because Claude requirement names `destructiveHint` for modifying/deleting tools. + +Add builder-local mode types where useful: + +```go +type toolMode string + +const ( + toolModeRead toolMode = "read" + toolModeWrite toolMode = "write" +) +``` + +Build functions should accept mode: + +- `buildTool(mode toolMode)` +- `buildHandler(mode toolMode, readOnly bool)` +- `validateOperation(mode, operation)` +- `isWriteOperation(operation)` + +## Split inventory + +### Kafka builders + +#### `kafka_admin_topics` + +Split: + +- `kafka_admin_topics_read` + - operations: `list`, `get`, `metadata` + - annotation: read-only +- `kafka_admin_topics_write` + - operations: `create`, `delete` + - annotation: destructive + +Read-only runtime: register read only. +Read-write runtime: register both. + +#### `kafka_admin_groups` + +Split: + +- `kafka_admin_groups_read` + - operations: `list`, `describe`, `offsets` + - annotation: read-only +- `kafka_admin_groups_write` + - operations: `remove-members`, `delete-offset`, `set-offset` + - annotation: destructive + +#### `kafka_admin_sr` + +Split: + +- `kafka_admin_sr_read` + - operations: `list`, `get`, plus schema type/capability read operations + - annotation: read-only +- `kafka_admin_sr_write` + - operations: `set`, `create`, `delete` + - annotation: destructive + +#### `kafka_admin_connect` + +Split: + +- `kafka_admin_connect_read` + - read operations: cluster info, connector list/get/status/config, connector plugins, transforms + - annotation: read-only +- `kafka_admin_connect_write` + - write operations: `create`, `update`, `delete`, `restart`, `pause`, `resume` + - annotation: destructive + +#### `kafka_admin_partitions` + +Current tool appears write-only (`update`). Options: + +- keep `kafka_admin_partitions` as destructive write-only; or +- rename to `kafka_admin_partitions_write` for consistency. + +Recommendation: rename to `kafka_admin_partitions_write` if no read operations exist, and update docs. If preserving name matters, keep current name but annotate destructive. + +#### `kafka_client_produce` + +Write/side-effect tool. Keep current name, annotate destructive. + +#### `kafka_client_consume` + +Ambiguous: + +- description says no offset commit unless `group` parameter is explicitly specified +- with `group`, consumer group state may change + +Recommendation for review safety: + +- split into `kafka_client_consume_read` without `group` / no offset commit +- optional `kafka_client_consume_group` or `kafka_client_consume_write` for group-based consumption that may affect offsets/state, annotated destructive + +If implementation never commits offsets, keep single read tool after code verification and adjust description/schema to remove side-effect ambiguity. + +### Pulsar builders + +#### `pulsar_admin_topic` + +Split: + +- `pulsar_admin_topic_read` + - operations: `list`, `get`, `get-permissions`, `stats`, `lookup`, `internal-stats`, `internal-info`, `bundle-range`, `last-message-id`, `compact-status`, `offload-status` + - annotation: read-only +- `pulsar_admin_topic_write` + - operations: `grant-permissions`, `revoke-permissions`, `create`, `delete`, `unload`, `terminate`, `compact`, `update`, `offload` + - annotation: destructive + +#### `pulsar_admin_subscription` + +Split: + +- `pulsar_admin_subscription_read` + - operations: `list`, `peek`, `get-message-by-id` + - annotation: read-only +- `pulsar_admin_subscription_write` + - operations: `create`, `delete`, `skip`, `expire`, `reset-cursor` + - annotation: destructive + +#### `pulsar_admin_namespace` + +Split: + +- `pulsar_admin_namespace_read` + - operations: `list`, `get_topics` + - annotation: read-only +- `pulsar_admin_namespace_write` + - operations: `create`, `delete`, `clear_backlog`, `unsubscribe`, `unload`, `split_bundle` + - annotation: destructive + +#### `pulsar_admin_namespace_policy*` + +Already partly separated: + +- `pulsar_admin_namespace_policy_get` -> read-only +- `pulsar_admin_namespace_policy_get_anti_affinity_namespaces` -> read-only +- `pulsar_admin_namespace_policy_set` -> destructive +- `pulsar_admin_namespace_policy_remove` -> destructive + +Keep split; add titles/annotations and ensure no tool mixes set/remove/get. + +#### `pulsar_admin_topic_policy` + +Likely mixed get/set/remove operations. Split: + +- `pulsar_admin_topic_policy_read` +- `pulsar_admin_topic_policy_write` + +Use same operation partitioning as handler supports. + +#### `pulsar_admin_brokers` + +Split: + +- `pulsar_admin_brokers_read` + - list/get/health/config/namespaces/runtime/internal/all_dynamic reads + - annotation: read-only +- `pulsar_admin_brokers_write` + - dynamic config update/delete or any mutable broker operation + - annotation: destructive + +#### `pulsar_admin_cluster` + +Split: + +- `pulsar_admin_cluster_read` + - `list`, `get`, read peer/failure-domain operations + - annotation: read-only +- `pulsar_admin_cluster_write` + - `create`, `update`, `delete`, write peer/failure-domain operations + - annotation: destructive + +#### `pulsar_admin_functions` + +Split: + +- `pulsar_admin_functions_read` + - `list`, `get`, `status`, `stats`, `querystate`, `download` + - annotation: read-only +- `pulsar_admin_functions_write` + - `create`, `update`, `delete`, `start`, `stop`, `restart`, `putstate`, `trigger`, `upload` + - annotation: destructive + +#### `pulsar_admin_sinks` / `pulsar_admin_sources` + +Split each: + +- `*_read` + - `list`, `get`, `status`, `list-built-in` + - annotation: read-only +- `*_write` + - `create`, `update`, `delete`, `start`, `stop`, `restart` + - annotation: destructive + +#### `pulsar_admin_packages` + +Split: + +- `pulsar_admin_package_read` + - `list`, `get`, `download` + - annotation: read-only +- `pulsar_admin_package_write` + - `update`, `delete`, `upload` + - annotation: destructive + +#### `pulsar_admin_schema` + +Split: + +- `pulsar_admin_schema_read` + - `get` + - annotation: read-only +- `pulsar_admin_schema_write` + - `upload`, `delete` + - annotation: destructive + +#### `pulsar_admin_tenant` + +Split: + +- `pulsar_admin_tenant_read` + - `list`, `get` + - annotation: read-only +- `pulsar_admin_tenant_write` + - `create`, `update`, `delete` + - annotation: destructive + +#### `pulsar_admin_nsisolationpolicy` + +Split: + +- `pulsar_admin_nsisolationpolicy_read` + - `get`, `list`, broker read operations + - annotation: read-only +- `pulsar_admin_nsisolationpolicy_write` + - `set`, `delete` + - annotation: destructive + +#### `pulsar_admin_resourcequota` + +Split: + +- `pulsar_admin_resourcequota_read` + - `get` + - annotation: read-only +- `pulsar_admin_resourcequota_write` + - `set`, `reset` + - annotation: destructive + +#### Pure read tools + +Keep current names, add read-only annotation: + +- `pulsar_admin_status` +- `pulsar_admin_broker_stats` +- `pulsar_admin_functions_worker` +- any MCP resources/templates that are not tools stay out of tool annotation scope + +#### Pulsar client tools + +- `pulsar_client_produce`: keep current name, destructive annotation. +- `pulsar_client_consume`: likely side-effectful because subscriptions/cursors can be created/advanced. Recommendation: annotate destructive unless implementation is changed to provide a non-mutating peek/read variant. + +Possible future split: + +- `pulsar_client_peek_read` for non-destructive peeking if supported by admin APIs +- `pulsar_client_consume` remains destructive + +### StreamNative Cloud tools + +#### Existing resource tools + +Already split by action: + +- `sncloud_resources_apply`: destructive; include title and `destructiveHint=true` +- `sncloud_resources_delete`: destructive; title already present, ensure readOnlyHint false too + +No read counterpart currently. If resource list/get is added, use `sncloud_resources_read` rather than adding list/get to apply/delete tools. + +#### Context tools + +- `sncloud_context_whoami`: read-only +- `sncloud_context_available_clusters`: read-only +- `sncloud_context_use_cluster`: session/context mutation; annotate destructive or non-read-only. For Claude safety, use destructive unless we explicitly add `NonDestructiveWrite` and verify review accepts it. +- `sncloud_context_reset`: session/context mutation; annotate destructive or non-read-only. For Claude safety, use destructive. + +#### Logs + +- `sncloud_logs`: read-only + +### Dynamic Functions-as-Tools + +`pkg/mcp/pftools/manager.go` dynamic tools invoke deployed Pulsar Functions and can produce messages / trigger external effects. + +Plan: + +- keep dynamic tool name from function metadata +- add human-readable title from function metadata/tool name +- annotate `destructiveHint=true` +- if read-only mode should not expose dynamic invocation tools, verify registration path and add test +- do not mark dynamic function tools read-only unless function metadata explicitly supports safe read-only classification in future + +## Implementation phases + +### Phase 1: shared annotation + mode helpers + +- Add `pkg/mcp/toolannotations` helper. +- Add local read/write mode helpers in builders with mixed operations. +- Add reusable operation validation helpers where a builder already has operation maps. + +### Phase 2: split Kafka tools + +- Update Kafka builders to build mode-specific tools. +- Read-only config returns only read tools. +- Read-write config returns read + write tools, except pure write tools remain write-only. +- Update wrapper tests/docs. + +### Phase 3: split Pulsar tools + +- Update Pulsar builders to build mode-specific tools. +- Preserve existing read-only behavior by not registering write tools in read-only config. +- Ensure mode-specific operation enums and validation errors. +- Add/extend parity tests for operation coverage. + +### Phase 4: StreamNative Cloud/static tool annotations + +- Add annotations to context/log/resource tools. +- Keep already split apply/delete tools. +- Ensure no new mixed resource tool appears. + +### Phase 5: dynamic tools + +- Add annotations to Functions-as-Tools. +- Validate read-only exposure behavior. + +### Phase 6: docs and compatibility + +Update runtime-visible docs: + +- `README.md` feature/tool examples if names change. +- `docs/tools/*.md` matching renamed/split tools. +- Any design notes under `agents/` if tool surface changes are architectural. + +Compatibility decision needed before implementation: + +- Preferred: breaking but review-safe tool surface; remove mixed tool names. +- Alternative: temporary alias support hidden behind explicit opt-in flag, disabled by default and not used for Claude submission. + +## Tests / compliance guard + +Add focused tests: + +- For every builder under `pkg/mcp/builders/kafka` and `pkg/mcp/builders/pulsar`: + - no returned tool mixes read and write operations + - tool name length <= 64 + - title non-empty + - read-only or destructive hint explicit + - read tools: `ReadOnlyHint=true`, `DestructiveHint=false` + - write tools: `DestructiveHint=true`, `ReadOnlyHint=false` + - read-only config returns no write tools +- StreamNative Cloud/context/log/resource tools have valid annotations. +- PFTools dynamic tool creation has valid annotation. +- Operation validation rejects read operations on write tools and write operations on read tools. + +Optional static test: + +- Build all feature sets and assert no `operation` enum contains both read and write verbs in one tool. + +## Risks + +- Tool split is runtime-visible and likely breaking for clients/prompts that call old names. +- Docs under `docs/tools/` can drift if not updated with split names. +- Some operations are ambiguous (`consume`, `trigger`, cursor operations, context reset). Conservative destructive annotation may add confirmations but avoids unsafe auto-run. +- Some current tools may have read-only-mode logic embedded in handlers; after split, registration and handler validation must both enforce mode to prevent write leakage. +- `mcp-go` default annotations are unsafe for compliance because title empty and destructive default true. + +## Questions to confirm + +1. Can we remove legacy mixed tool names from default registration, accepting breaking tool-name changes for Claude readiness? +2. Should we add an opt-in legacy compatibility flag, disabled by default, or avoid compatibility layer entirely? +3. For consume tools, should we conservatively classify as destructive, or implement a true non-mutating read variant first? +4. Should session-only context changes (`sncloud_context_use_cluster/reset`) be marked destructive for Claude safety? + +## Recommended validation + +Fast local: + +```bash +go test ./pkg/mcp/... ./pkg/schema/... +go test -race ./pkg/mcp/builders/... +go fmt ./... +go mod tidy +``` + +Full repo before PR: + +```bash +go mod verify +go mod download +golangci-lint run --timeout=3m +go test -race ./... +make build +make license-check +``` + +Connector-specific manual check: + +```bash +bin/snmcp stdio --use-external-pulsar --pulsar-web-service-url http://localhost:8080 +# then inspect tools/list with MCP Inspector and verify every tool annotation and no mixed read/write operation enum +``` + +Chart/E2E only needed if chart, SSE auth, or e2e harness touched: + +```bash +./scripts/e2e-test.sh all +``` diff --git a/README.md b/README.md index ee1e0579..6f9b107f 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,8 @@ The StreamNative MCP Server allows you to enable or disable specific groups of f --- +Claude connector compatibility: admin tools that previously mixed read and write operations behind one `operation` parameter are exposed as separate read/write MCP tools, for example `kafka_admin_topics_read` and `kafka_admin_topics_write`. Read tools include `annotations.readOnlyHint=true`; write or side-effectful tools include `annotations.destructiveHint=true`. In `--read-only` mode, write/destructive tools are not registered. + #### Kafka Features | Feature | Description | Docs | diff --git a/docs/tools/functions_as_tools.md b/docs/tools/functions_as_tools.md index 81a3e91f..557196ba 100644 --- a/docs/tools/functions_as_tools.md +++ b/docs/tools/functions_as_tools.md @@ -1,3 +1,5 @@ +**Claude connector safety:** Safety annotation: dynamically generated Pulsar Functions-as-Tools are destructive and are not registered in read-only mode. + # Functions as Tools The "Functions as Tools" feature allows the StreamNative MCP Server to dynamically discover Apache Pulsar Functions deployed in your cluster and expose them as invokable MCP tools for AI agents. This significantly enhances the capabilities of AI agents by allowing them to interact with custom business logic encapsulated in Pulsar Functions without manual tool registration for each function. @@ -93,7 +95,7 @@ Beyond customizing individual tool properties at the function deployment level, * **Example**: `public/default,my-tenant/app-functions` * **Default**: Empty (meaning discover from all accessible namespaces (only on StreamNative Cloud)). * `FUNCTIONS_AS_TOOLS_STRICT_EXPORT` - * **Description**: Only export functions with `MCP_TOOL_NAME` and `MCP_TOOL_DESCRIPTION` defined. + * **Description**: Only export functions with `MCP_TOOL_NAME` and `MCP_TOOL_DESCRIPTION` defined. * **Format**: `true` or `false` * **Example**: `false` * **Default**: `true` diff --git a/docs/tools/kafka_admin_connect.md b/docs/tools/kafka_admin_connect.md index 2edcb39a..afb6d066 100644 --- a/docs/tools/kafka_admin_connect.md +++ b/docs/tools/kafka_admin_connect.md @@ -1,5 +1,8 @@ #### kafka-admin-connect +**Claude connector safety:** Actual MCP tools: `kafka_admin_connect_read` (`list`, `get`) and `kafka_admin_connect_write` (`create`, `update`, `delete`, `restart`, `pause`, `resume`). + + Kafka Connect is a framework for integrating Kafka with external systems. The following resources and operations are supported: - **kafka-connect-cluster** @@ -31,4 +34,4 @@ Kafka Connect is a framework for integrating Kafka with external systems. The fo - **connector-plugins** - **list**: List all available connector plugins - - _Parameters_: None \ No newline at end of file + - _Parameters_: None diff --git a/docs/tools/kafka_admin_groups.md b/docs/tools/kafka_admin_groups.md index fd78e0bc..3889762c 100644 --- a/docs/tools/kafka_admin_groups.md +++ b/docs/tools/kafka_admin_groups.md @@ -1,5 +1,8 @@ #### kafka-admin-groups +**Claude connector safety:** Actual MCP tools: `kafka_admin_groups_read` (`list`, `describe`, `offsets`) and `kafka_admin_groups_write` (`remove-members`, `delete-offset`, `set-offset`). + + This tool provides access to Kafka consumer group operations including listing, describing, and managing group membership. - **groups** @@ -21,4 +24,4 @@ This tool provides access to Kafka consumer group operations including listing, - `group` (string, required): The name of the Kafka Consumer Group - `topic` (string, required): The name of the Kafka topic - `partition` (number, required): The partition number - - `offset` (number, required): The offset value to set (use -1 for earliest, -2 for latest, or a specific value) \ No newline at end of file + - `offset` (number, required): The offset value to set (use -1 for earliest, -2 for latest, or a specific value) diff --git a/docs/tools/kafka_admin_partitions.md b/docs/tools/kafka_admin_partitions.md index 11689b24..38e98bb8 100644 --- a/docs/tools/kafka_admin_partitions.md +++ b/docs/tools/kafka_admin_partitions.md @@ -1,8 +1,11 @@ #### kafka-admin-partitions +**Claude connector safety:** Actual MCP tool: `kafka_admin_partitions_write` (`update`). This write-only tool is not registered in read-only mode. + + This tool provides access to Kafka partition operations, particularly adding partitions to existing topics. - **partition** - **update**: Update the number of partitions for an existing Kafka topic (can only increase, not decrease) - `topic` (string, required): The name of the Kafka topic - - `new-total` (number, required): The new total number of partitions (must be greater than current) \ No newline at end of file + - `new-total` (number, required): The new total number of partitions (must be greater than current) diff --git a/docs/tools/kafka_admin_schema_registry.md b/docs/tools/kafka_admin_schema_registry.md index 033d6c66..74e35f1d 100644 --- a/docs/tools/kafka_admin_schema_registry.md +++ b/docs/tools/kafka_admin_schema_registry.md @@ -1,5 +1,8 @@ #### kafka-admin-schema-registry +**Claude connector safety:** Actual MCP tools: `kafka_admin_sr_read` (`list`, `get`) and `kafka_admin_sr_write` (`set`, `create`, `delete`). + + This tool provides access to Kafka Schema Registry operations, including managing subjects, versions, and compatibility settings. - **subjects** @@ -35,4 +38,4 @@ This tool provides access to Kafka Schema Registry operations, including managin - **types** - **list**: List supported schema types (e.g. AVRO, JSON, PROTOBUF) - - _Parameters_: None \ No newline at end of file + - _Parameters_: None diff --git a/docs/tools/kafka_admin_topics.md b/docs/tools/kafka_admin_topics.md index a06187e4..3616ed91 100644 --- a/docs/tools/kafka_admin_topics.md +++ b/docs/tools/kafka_admin_topics.md @@ -1,5 +1,8 @@ #### kafka-admin-topics +**Claude connector safety:** Actual MCP tools: `kafka_admin_topics_read` (`list`, `get`, `metadata`) and `kafka_admin_topics_write` (`create`, `delete`). + + This tool provides access to various Kafka topic operations, including creation, deletion, listing, and configuration retrieval. - **topics** @@ -15,4 +18,4 @@ This tool provides access to various Kafka topic operations, including creation, - `replication-factor` (number, optional): Replication factor. Default: 1 - `configs` (array of string, optional): Topic configuration overrides as key-value strings, e.g. ["cleanup.policy=compact", "retention.ms=604800000"] - **delete**: Delete an existing topic - - `name` (string, required): The name of the Kafka topic \ No newline at end of file + - `name` (string, required): The name of the Kafka topic diff --git a/docs/tools/kafka_client_consume.md b/docs/tools/kafka_client_consume.md index f5e4c55a..82cf873f 100644 --- a/docs/tools/kafka_client_consume.md +++ b/docs/tools/kafka_client_consume.md @@ -1,5 +1,8 @@ #### kafka-client-consume +**Claude connector safety:** Safety annotation: `kafka_client_consume` is destructive and is not registered in read-only mode because it can commit consumer offsets. + + Consume messages from a Kafka topic. This tool allows you to read messages from Kafka topics with various consumption options. - **kafka_client_consume** @@ -12,4 +15,4 @@ Consume messages from a Kafka topic. This tool allows you to read messages from - 'atend': Begin from the next message after the consumer starts - 'atcommitted': Begin from the last committed offset (only works with specified 'group') - `max-messages` (number, optional): Maximum number of messages to consume in this request. Default: 10 - - `timeout` (number, optional): Maximum time in seconds to wait for messages. Default: 10 \ No newline at end of file + - `timeout` (number, optional): Maximum time in seconds to wait for messages. Default: 10 diff --git a/docs/tools/kafka_client_produce.md b/docs/tools/kafka_client_produce.md index 768b66fa..7da3a367 100644 --- a/docs/tools/kafka_client_produce.md +++ b/docs/tools/kafka_client_produce.md @@ -9,4 +9,4 @@ Produce messages to a Kafka topic. This tool allows you to send single or multip - `key` (string, optional): The key for the message. Used for partition assignment and ordering. - `value` (string, required if 'messages' is not provided): The value/content of the message to send. - `headers` (array, optional): Message headers in the format of [{"key": "header-key", "value": "header-value"}]. - - `sync` (boolean, optional): Whether to wait for server acknowledgment before returning. Default: true. \ No newline at end of file + - `sync` (boolean, optional): Whether to wait for server acknowledgment before returning. Default: true. diff --git a/docs/tools/pulsar_admin_broker_stats.md b/docs/tools/pulsar_admin_broker_stats.md index 8837cf86..0086001f 100644 --- a/docs/tools/pulsar_admin_broker_stats.md +++ b/docs/tools/pulsar_admin_broker_stats.md @@ -4,16 +4,16 @@ Unified tool for retrieving Apache Pulsar broker statistics. - **monitoring_metrics** - **get**: Get broker monitoring metrics - + - **mbeans** - **get**: Get JVM MBeans statistics from broker - + - **topics** - **get**: Get statistics for all topics managed by the broker - + - **allocator_stats** - **get**: Get memory allocator statistics - `allocator_name` (string, required): Name of the allocator - + - **load_report** - - **get**: Get broker load report \ No newline at end of file + - **get**: Get broker load report diff --git a/docs/tools/pulsar_admin_brokers.md b/docs/tools/pulsar_admin_brokers.md index e475d3c0..36e2d96c 100644 --- a/docs/tools/pulsar_admin_brokers.md +++ b/docs/tools/pulsar_admin_brokers.md @@ -1,14 +1,17 @@ #### pulsar_admin_brokers +**Claude connector safety:** Actual MCP tools: `pulsar_admin_brokers_read` (`list`, `get`) and `pulsar_admin_brokers_write` (`update`, `delete` broker dynamic configuration). + + Unified tool for managing Apache Pulsar broker resources. - **brokers** - **list**: List all active brokers in a cluster - `clusterName` (string, required): The cluster name - + - **health** - **get**: Check the health status of a broker - + - **config** - **get**: Get broker configuration - `configType` (string, required): Configuration type, available options: @@ -21,7 +24,7 @@ Unified tool for managing Apache Pulsar broker resources. - `configValue` (string, required): Configuration parameter value - **delete**: Delete broker configuration - `configName` (string, required): Configuration parameter name - + - **namespaces** - **get**: Get namespaces managed by a broker - - `brokerUrl` (string, required): Broker URL, e.g., '127.0.0.1:8080' \ No newline at end of file + - `brokerUrl` (string, required): Broker URL, e.g., '127.0.0.1:8080' diff --git a/docs/tools/pulsar_admin_clusters.md b/docs/tools/pulsar_admin_clusters.md index fe90279f..6ee652c2 100644 --- a/docs/tools/pulsar_admin_clusters.md +++ b/docs/tools/pulsar_admin_clusters.md @@ -1,5 +1,8 @@ #### pulsar_admin_cluster +**Claude connector safety:** Actual MCP tools: `pulsar_admin_cluster_read` (`list`, `get`) and `pulsar_admin_cluster_write` (`create`, `update`, `delete`). + + Unified tool for managing Apache Pulsar clusters. - **cluster** @@ -18,14 +21,14 @@ Unified tool for managing Apache Pulsar clusters. - Same optional parameters as create - **delete**: Delete a cluster - `cluster_name` (string, required): The cluster name - + - **peer_clusters** - **get**: Get list of peer clusters - `cluster_name` (string, required): The cluster name - **update**: Update peer clusters list - `cluster_name` (string, required): The cluster name - `peer_cluster_names` (array, required): List of peer cluster names - + - **failure_domain** - **list**: List all failure domains in a cluster - `cluster_name` (string, required): The cluster name @@ -42,4 +45,4 @@ Unified tool for managing Apache Pulsar clusters. - `brokers` (array, required): List of brokers in the domain - **delete**: Delete a failure domain - `cluster_name` (string, required): The cluster name - - `domain_name` (string, required): The failure domain name \ No newline at end of file + - `domain_name` (string, required): The failure domain name diff --git a/docs/tools/pulsar_admin_functions.md b/docs/tools/pulsar_admin_functions.md index 5e97b3fc..85a7b85f 100644 --- a/docs/tools/pulsar_admin_functions.md +++ b/docs/tools/pulsar_admin_functions.md @@ -1,5 +1,8 @@ #### pulsar_admin_functions +**Claude connector safety:** Actual MCP tools: `pulsar_admin_functions_read` (`list`, `get`, `status`, `stats`, `querystate`) and `pulsar_admin_functions_write` (`create`, `update`, `delete`, `download`, `start`, `stop`, `restart`, `putstate`, `trigger`, `upload`). + + Manage Apache Pulsar Functions for stream processing. Pulsar Functions are lightweight compute processes that can consume messages from one or more Pulsar topics, apply user-defined processing logic, and produce results to another topic. Functions support Java, Python, and Go runtimes, enabling complex event processing, data transformations, filtering, and integration with external systems. This tool provides a comprehensive set of operations to manage the entire function lifecycle: diff --git a/docs/tools/pulsar_admin_namespaces.md b/docs/tools/pulsar_admin_namespaces.md index c1c28bdf..938fd474 100644 --- a/docs/tools/pulsar_admin_namespaces.md +++ b/docs/tools/pulsar_admin_namespaces.md @@ -1,37 +1,40 @@ #### pulsar_admin_namespace +**Claude connector safety:** Actual MCP tools: `pulsar_admin_namespace_read` (`list`, `get_topics`) and `pulsar_admin_namespace_write` (`create`, `delete`, `clear_backlog`, `unsubscribe`, `unload`, `split_bundle`). + + Manage Pulsar namespaces with various operations. - **list**: List all namespaces for a tenant - `tenant` (string, required): The tenant name - + - **get_topics**: Get all topics within a namespace - `namespace` (string, required): The namespace name (format: tenant/namespace) - + - **create**: Create a new namespace - `namespace` (string, required): The namespace name (format: tenant/namespace) - `bundles` (string, optional): Number of bundles to activate - `clusters` (array, optional): List of clusters to assign - + - **delete**: Delete a namespace - `namespace` (string, required): The namespace name (format: tenant/namespace) - + - **clear_backlog**: Clear backlog for all topics in a namespace - `namespace` (string, required): The namespace name (format: tenant/namespace) - `subscription` (string, optional): Subscription name - `bundle` (string, optional): Bundle name or range - `force` (string, optional): Force clear backlog (true/false) - + - **unsubscribe**: Unsubscribe from a subscription for all topics in a namespace - `namespace` (string, required): The namespace name (format: tenant/namespace) - `subscription` (string, required): Subscription name - `bundle` (string, optional): Bundle name or range - + - **unload**: Unload a namespace from the current serving broker - `namespace` (string, required): The namespace name (format: tenant/namespace) - `bundle` (string, optional): Bundle name or range - + - **split_bundle**: Split a namespace bundle - `namespace` (string, required): The namespace name (format: tenant/namespace) - `bundle` (string, required): Bundle name or range - - `unload` (string, optional): Unload newly split bundles (true/false) \ No newline at end of file + - `unload` (string, optional): Unload newly split bundles (true/false) diff --git a/docs/tools/pulsar_admin_nsisolationpolicy.md b/docs/tools/pulsar_admin_nsisolationpolicy.md index 41a0e6af..0661dea0 100644 --- a/docs/tools/pulsar_admin_nsisolationpolicy.md +++ b/docs/tools/pulsar_admin_nsisolationpolicy.md @@ -1,5 +1,8 @@ #### pulsar_admin_nsisolationpolicy +**Claude connector safety:** Actual MCP tools: `pulsar_admin_nsisolationpolicy_read` (`get`, `list`) and `pulsar_admin_nsisolationpolicy_write` (`set`, `delete`). + + Manage namespace isolation policies in a Pulsar cluster. Namespace isolation policies enable physical isolation of namespaces by controlling which brokers specific namespaces can use. This helps provide predictable performance and resource isolation, especially in multi-tenant environments. This tool provides operations across three resource types: @@ -29,4 +32,4 @@ This tool provides operations across three resource types: - **brokers** (All brokers with isolation policies): - **list**: List all brokers with their isolation policies - - `cluster` (string, required): The cluster name \ No newline at end of file + - `cluster` (string, required): The cluster name diff --git a/docs/tools/pulsar_admin_packages.md b/docs/tools/pulsar_admin_packages.md index 5c0fe863..45d7c046 100644 --- a/docs/tools/pulsar_admin_packages.md +++ b/docs/tools/pulsar_admin_packages.md @@ -1,5 +1,8 @@ #### pulsar_admin_package +**Claude connector safety:** Actual MCP tools: `pulsar_admin_package_read` (`list`, `get`, `download`) and `pulsar_admin_package_write` (`update`, `delete`, `upload`). + + Manage packages in Apache Pulsar. Packages are reusable components that can be shared across functions, sources, and sinks. The system supports package schemes including `function://`, `source://`, and `sink://` for different component types. This tool provides operations across two resource types: @@ -29,4 +32,4 @@ This tool provides operations across two resource types: - **packages** (Packages of a specific type): - **list**: List all packages of a specific type in a namespace - `type` (string, required): Package type (function, source, sink) - - `namespace` (string, required): The namespace name \ No newline at end of file + - `namespace` (string, required): The namespace name diff --git a/docs/tools/pulsar_admin_resource_quotas.md b/docs/tools/pulsar_admin_resource_quotas.md index e5b349b4..362b8db9 100644 --- a/docs/tools/pulsar_admin_resource_quotas.md +++ b/docs/tools/pulsar_admin_resource_quotas.md @@ -1,5 +1,8 @@ #### pulsar_admin_resourcequota +**Claude connector safety:** Actual MCP tools: `pulsar_admin_resourcequota_read` (`get`) and `pulsar_admin_resourcequota_write` (`set`, `reset`). + + Manage Apache Pulsar resource quotas for brokers, namespaces and bundles. Resource quotas define limits for resource usage such as message rates, bandwidth, and memory. These quotas help prevent resource abuse and ensure fair resource allocation across the Pulsar cluster. This tool provides operations on the following resource: @@ -21,4 +24,4 @@ This tool provides operations on the following resource: - `dynamic` (boolean, optional): Whether to allow quota to be dynamically re-calculated - **reset**: Reset a namespace bundle's resource quota to default value - `namespace` (string, required): The namespace name in format 'tenant/namespace' - - `bundle` (string, required): The bundle range in format '{start-boundary}_{end-boundary}' \ No newline at end of file + - `bundle` (string, required): The bundle range in format '{start-boundary}_{end-boundary}' diff --git a/docs/tools/pulsar_admin_schemas.md b/docs/tools/pulsar_admin_schemas.md index f6ec8212..cbf67e05 100644 --- a/docs/tools/pulsar_admin_schemas.md +++ b/docs/tools/pulsar_admin_schemas.md @@ -1,5 +1,8 @@ #### pulsar_admin_schema +**Claude connector safety:** Actual MCP tools: `pulsar_admin_schema_read` (`get`) and `pulsar_admin_schema_write` (`upload`, `delete`). + + Manage Apache Pulsar schemas for topics. - **schema** @@ -10,4 +13,4 @@ Manage Apache Pulsar schemas for topics. - `topic` (string, required): The fully qualified topic name - `filename` (string, required): Path to the schema definition file - **delete**: Delete the schema for a topic - - `topic` (string, required): The fully qualified topic name \ No newline at end of file + - `topic` (string, required): The fully qualified topic name diff --git a/docs/tools/pulsar_admin_sinks.md b/docs/tools/pulsar_admin_sinks.md index be63ef86..9d6ed21d 100644 --- a/docs/tools/pulsar_admin_sinks.md +++ b/docs/tools/pulsar_admin_sinks.md @@ -1,5 +1,8 @@ #### pulsar_admin_sinks +**Claude connector safety:** Actual MCP tools: `pulsar_admin_sinks_read` (`list`, `get`, `status`, `list-built-in`) and `pulsar_admin_sinks_write` (`create`, `update`, `delete`, `start`, `stop`, `restart`). + + Manage Apache Pulsar Sinks for data movement and integration. Pulsar Sinks are connectors that export data from Pulsar topics to external systems such as databases, storage services, messaging systems, and third-party applications. Sinks consume messages from one or more Pulsar topics, transform the data if needed, and write it to external systems in a format compatible with the target destination. This tool provides complete lifecycle management for sink connectors: @@ -83,4 +86,4 @@ This tool provides complete lifecycle management for sink connectors: - **list-built-in**: List all built-in sink connectors available in the system - No parameters required -Built-in sink connectors are available for common systems like Kafka, JDBC, Elasticsearch, and cloud storage. Sinks follow the tenant/namespace/name hierarchy for organization and access control, can scale through parallelism configuration, and support configurable subscription types. Sinks require proper permissions to access their input topics. +Built-in sink connectors are available for common systems like Kafka, JDBC, Elasticsearch, and cloud storage. Sinks follow the tenant/namespace/name hierarchy for organization and access control, can scale through parallelism configuration, and support configurable subscription types. Sinks require proper permissions to access their input topics. diff --git a/docs/tools/pulsar_admin_sources.md b/docs/tools/pulsar_admin_sources.md index 53141ad1..80adcd20 100644 --- a/docs/tools/pulsar_admin_sources.md +++ b/docs/tools/pulsar_admin_sources.md @@ -1,5 +1,8 @@ #### pulsar_admin_sources +**Claude connector safety:** Actual MCP tools: `pulsar_admin_sources_read` (`list`, `get`, `status`, `list-built-in`) and `pulsar_admin_sources_write` (`create`, `update`, `delete`, `start`, `stop`, `restart`). + + Manage Apache Pulsar Sources for data ingestion and integration. Pulsar Sources are connectors that import data from external systems into Pulsar topics. Sources connect to external systems such as databases, messaging platforms, storage services, and real-time data streams to pull data and publish it to Pulsar topics. This tool provides complete lifecycle management for source connectors: @@ -72,4 +75,4 @@ This tool provides complete lifecycle management for source connectors: - **list-built-in**: List all built-in source connectors available in the system - No parameters required -Built-in source connectors are available for common systems like Kafka, JDBC, AWS services, and more. Sources follow the tenant/namespace/name hierarchy for organization and access control, can scale through parallelism configuration, and support various processing guarantees. +Built-in source connectors are available for common systems like Kafka, JDBC, AWS services, and more. Sources follow the tenant/namespace/name hierarchy for organization and access control, can scale through parallelism configuration, and support various processing guarantees. diff --git a/docs/tools/pulsar_admin_subscriptions.md b/docs/tools/pulsar_admin_subscriptions.md index c75f4f11..79497081 100644 --- a/docs/tools/pulsar_admin_subscriptions.md +++ b/docs/tools/pulsar_admin_subscriptions.md @@ -1,5 +1,8 @@ #### pulsar_admin_subscription +**Claude connector safety:** Actual MCP tools: `pulsar_admin_subscription_read` (`list`, `peek`, `get-message-by-id`) and `pulsar_admin_subscription_write` (`create`, `delete`, `skip`, `expire`, `reset-cursor`). + + Manage Pulsar topic subscriptions, which represent consumer groups reading from topics. - **list**: List all subscriptions for a topic diff --git a/docs/tools/pulsar_admin_tenants.md b/docs/tools/pulsar_admin_tenants.md index 9a9f5e72..a183ecc8 100644 --- a/docs/tools/pulsar_admin_tenants.md +++ b/docs/tools/pulsar_admin_tenants.md @@ -1,5 +1,8 @@ #### pulsar_admin_tenant +**Claude connector safety:** Actual MCP tools: `pulsar_admin_tenant_read` (`list`, `get`) and `pulsar_admin_tenant_write` (`create`, `update`, `delete`). + + Manage Pulsar tenants, which are the highest level administrative units. - **list**: List all tenants in the Pulsar instance @@ -14,4 +17,4 @@ Manage Pulsar tenants, which are the highest level administrative units. - `admin_roles` (array, optional): List of roles with admin permissions - `allowed_clusters` (array, required): List of clusters tenant can access - **delete**: Delete a tenant - - `tenant` (string, required): The tenant name \ No newline at end of file + - `tenant` (string, required): The tenant name diff --git a/docs/tools/pulsar_admin_topic_policy.md b/docs/tools/pulsar_admin_topic_policy.md index 93a5b19e..25f03390 100644 --- a/docs/tools/pulsar_admin_topic_policy.md +++ b/docs/tools/pulsar_admin_topic_policy.md @@ -1,5 +1,8 @@ #### pulsar_admin_topic_policy +**Claude connector safety:** Actual MCP tools: `pulsar_admin_topic_policy_read` (get policy operations) and `pulsar_admin_topic_policy_write` (set/remove policy operations). + + Manage Pulsar topic-level policies with operation names aligned to `pulsarctl topics`. - **Core operations** diff --git a/docs/tools/pulsar_admin_topics.md b/docs/tools/pulsar_admin_topics.md index 491e9b28..8f003ecf 100644 --- a/docs/tools/pulsar_admin_topics.md +++ b/docs/tools/pulsar_admin_topics.md @@ -1,5 +1,8 @@ #### pulsar_admin_topic +**Claude connector safety:** Actual MCP tools: `pulsar_admin_topic_read` (`list`, `get`, `get-permissions`, stats/lookup/status operations) and `pulsar_admin_topic_write` (permission grants/revokes and mutating topic lifecycle operations). + + Manage Apache Pulsar topics. Topics are the core messaging entities in Pulsar that store and transmit messages. Pulsar supports two types of topics: persistent (durable storage with guaranteed delivery) and non-persistent (in-memory with at-most-once delivery). Topics can be partitioned for parallel processing and higher throughput. - **topic** @@ -56,4 +59,4 @@ Manage Apache Pulsar topics. Topics are the core messaging entities in Pulsar th - **topics** - **list**: List all topics in a namespace - - `namespace` (string, required): The namespace name (format: tenant/namespace) + - `namespace` (string, required): The namespace name (format: tenant/namespace) diff --git a/docs/tools/pulsar_client_consume.md b/docs/tools/pulsar_client_consume.md index 1cc9f881..9c791c05 100644 --- a/docs/tools/pulsar_client_consume.md +++ b/docs/tools/pulsar_client_consume.md @@ -1,5 +1,8 @@ #### pulsar_client_consume +**Claude connector safety:** Safety annotation: `pulsar_client_consume` is destructive and is not registered in read-only mode because consuming can create or advance subscription cursors. + + Consume messages from a Pulsar topic. This tool allows you to consume messages from a specified Pulsar topic with various options to control the subscription behavior, message processing, and display format. - **pulsar_client_consume** @@ -14,4 +17,4 @@ Consume messages from a Pulsar topic. This tool allows you to consume messages f - `num-messages` (number, optional): Number of messages to consume (0 for unlimited, default: 0) - `timeout` (number, optional): Timeout for consuming messages in seconds (default: 30) - `show-properties` (boolean, optional): Show message properties (default: false) - - `hide-payload` (boolean, optional): Hide message payload (default: false) \ No newline at end of file + - `hide-payload` (boolean, optional): Hide message payload (default: false) diff --git a/docs/tools/pulsar_client_produce.md b/docs/tools/pulsar_client_produce.md index 5601d005..6c97b6c2 100644 --- a/docs/tools/pulsar_client_produce.md +++ b/docs/tools/pulsar_client_produce.md @@ -11,4 +11,4 @@ Produce messages to a Pulsar topic. This tool allows you to send messages to a s - `chunking` (boolean, optional): Should split the message and publish in chunks if message size is larger than allowed max size (default: false) - `separator` (string, optional): Character to split messages string on (default: none) - `properties` (array, optional): Properties to add, key=value format. Specify multiple times for multiple properties - - `key` (string, optional): Partitioning key to add to each message \ No newline at end of file + - `key` (string, optional): Partitioning key to add to each message diff --git a/docs/tools/streamnative_cloud.md b/docs/tools/streamnative_cloud.md index 718f365f..dc050467 100644 --- a/docs/tools/streamnative_cloud.md +++ b/docs/tools/streamnative_cloud.md @@ -5,7 +5,7 @@ Display the context-bindable Pulsar clusters available in the current StreamNati - **sncloud_context_available_clusters** - No parameters required -You can use `sncloud_context_use_cluster` to bind the current session to a specific Pulsar cluster. You will need to ask for user confirmation of the target cluster if there are multiple clusters available. +You can use `sncloud_context_use_cluster` to bind the current session to a specific Pulsar cluster. You will need to ask for user confirmation of the target cluster if there are multiple clusters available. --- @@ -17,7 +17,7 @@ Bind the current session to a specific StreamNative Cloud cluster. Once the sess - `instanceName` (string, required): The name of the Pulsar instance to use - `clusterName` (string, required): The name of the Pulsar cluster to use -If you encounter `ContextNotSetErr`, use `sncloud_context_available_clusters` to list the available clusters and bind the session to a specific cluster. +If you encounter `ContextNotSetErr`, use `sncloud_context_available_clusters` to list the available clusters and bind the session to a specific cluster. --- @@ -37,7 +37,7 @@ Display the currently logged-in service account. Returns the name of the authent - **sncloud_context_whoami** - No parameters required -This tool returns a JSON object containing the service account name and organization. +This tool returns a JSON object containing the service account name and organization. --- @@ -88,4 +88,4 @@ Delete StreamNative Cloud resources. This tool removes resources from the organi - `type` (string, required): The type of the resource to delete - Options: Instance, PulsarInstance, PulsarCluster, KafkaCluster -This is a destructive operation that cannot be undone. Use with caution. +This is a destructive operation that cannot be undone. Use with caution. diff --git a/pkg/cmd/mcp/server.go b/pkg/cmd/mcp/server.go index 3a9e6c9c..e9966e3e 100644 --- a/pkg/cmd/mcp/server.go +++ b/pkg/cmd/mcp/server.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -53,7 +53,7 @@ func newMcpServer(_ context.Context, configOpts *ServerOptions, logrusLogger *lo mcp.RegisterPrompts(s) // Skip context tools if pulsar instance and cluster are provided via CLI skipContextTools := snConfig.Context.PulsarInstance != "" && snConfig.Context.PulsarCluster != "" - mcp.RegisterContextTools(s, configOpts.Features, skipContextTools) + mcp.RegisterContextTools(s, configOpts.Features, configOpts.ReadOnly, skipContextTools) mcp.StreamNativeAddLogTools(s, configOpts.ReadOnly, configOpts.Features) mcp.StreamNativeAddResourceTools(s, configOpts.ReadOnly, configOpts.Features) } diff --git a/pkg/mcp/builders/kafka/annotation_compliance_test.go b/pkg/mcp/builders/kafka/annotation_compliance_test.go new file mode 100644 index 00000000..ba9cb08c --- /dev/null +++ b/pkg/mcp/builders/kafka/annotation_compliance_test.go @@ -0,0 +1,113 @@ +// Copyright 2026 StreamNative +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package kafka + +import ( + "context" + "strings" + "testing" + + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" + "github.com/stretchr/testify/require" +) + +func TestKafkaToolAnnotationCompliance(t *testing.T) { + builderList := []builders.ToolBuilder{ + NewKafkaTopicsToolBuilder(), + NewKafkaGroupsToolBuilder(), + NewKafkaSchemaRegistryToolBuilder(), + NewKafkaConnectToolBuilder(), + NewKafkaPartitionsToolBuilder(), + NewKafkaProduceToolBuilder(), + NewKafkaConsumeToolBuilder(), + } + + for _, builder := range builderList { + tools, err := builder.BuildTools(context.Background(), builders.ToolBuildConfig{ + Features: []string{"all", "all-kafka", "kafka-admin", "kafka-admin-kafka-connect", "kafka-admin-schema-registry", "kafka-client"}, + }) + require.NoError(t, err) + for _, serverTool := range tools { + tool := serverTool.Tool + require.NotEmpty(t, tool.Annotations.Title, tool.Name) + require.NotNil(t, tool.Annotations.ReadOnlyHint, tool.Name) + require.NotNil(t, tool.Annotations.DestructiveHint, tool.Name) + require.LessOrEqual(t, len(tool.Name), 64, tool.Name) + + isRead := strings.HasSuffix(tool.Name, "_read") + isWrite := strings.HasSuffix(tool.Name, "_write") || strings.Contains(tool.Name, "produce") || strings.Contains(tool.Name, "consume") + if isRead { + require.True(t, *tool.Annotations.ReadOnlyHint, tool.Name) + require.False(t, *tool.Annotations.DestructiveHint, tool.Name) + } + if isWrite { + require.False(t, *tool.Annotations.ReadOnlyHint, tool.Name) + require.True(t, *tool.Annotations.DestructiveHint, tool.Name) + } + assertOperationEnumMode(t, tool.Name, tool.InputSchema.Properties["operation"]) + } + } +} + +func assertOperationEnumMode(t *testing.T, toolName string, operationSchema any) { + t.Helper() + schema, ok := operationSchema.(map[string]any) + if !ok { + return + } + rawEnum, ok := schema["enum"].([]string) + if !ok { + return + } + writeOperations := map[string]struct{}{ + "create": {}, "delete": {}, "set": {}, "update": {}, "restart": {}, "pause": {}, "resume": {}, + "remove-members": {}, "delete-offset": {}, "set-offset": {}, + } + seenRead, seenWrite := false, false + for _, op := range rawEnum { + if _, ok := writeOperations[op]; ok { + seenWrite = true + } else { + seenRead = true + } + } + require.False(t, seenRead && seenWrite, toolName) +} + +func TestKafkaReadOnlyBuildsNoWriteTools(t *testing.T) { + builderList := []builders.ToolBuilder{ + NewKafkaTopicsToolBuilder(), + NewKafkaGroupsToolBuilder(), + NewKafkaSchemaRegistryToolBuilder(), + NewKafkaConnectToolBuilder(), + NewKafkaPartitionsToolBuilder(), + NewKafkaProduceToolBuilder(), + NewKafkaConsumeToolBuilder(), + } + + for _, builder := range builderList { + tools, err := builder.BuildTools(context.Background(), builders.ToolBuildConfig{ + ReadOnly: true, + Features: []string{"all", "all-kafka", "kafka-admin", "kafka-admin-kafka-connect", "kafka-admin-schema-registry", "kafka-client"}, + }) + require.NoError(t, err) + for _, serverTool := range tools { + name := serverTool.Tool.Name + require.NotContains(t, name, "_write") + require.NotEqual(t, "kafka_admin_partitions_write", name) + require.NotEqual(t, "kafka_client_produce", name) + } + } +} diff --git a/pkg/mcp/builders/kafka/connect.go b/pkg/mcp/builders/kafka/connect.go index 8ba16b87..a476096b 100644 --- a/pkg/mcp/builders/kafka/connect.go +++ b/pkg/mcp/builders/kafka/connect.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -27,8 +27,18 @@ import ( "github.com/streamnative/streamnative-mcp-server/pkg/kafka" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) +var kafkaConnectWriteOperations = map[string]struct{}{ + "create": {}, + "update": {}, + "delete": {}, + "restart": {}, + "pause": {}, + "resume": {}, +} + // KafkaConnectToolBuilder implements the ToolBuilder interface for Kafka Connect // It provides functionality to build Kafka Connect administration tools // /nolint:revive @@ -71,21 +81,25 @@ func (b *KafkaConnectToolBuilder) BuildTools(_ context.Context, config builders. return nil, err } - // Build tools - tool := b.buildKafkaConnectTool() - handler := b.buildKafkaConnectHandler(config.ReadOnly) - - return []server.ServerTool{ + tools := []server.ServerTool{ { - Tool: tool, - Handler: handler, + Tool: b.buildKafkaConnectTool(toolModeRead), + Handler: b.buildKafkaConnectHandler(toolModeRead), }, - }, nil + } + if !config.ReadOnly { + tools = append(tools, server.ServerTool{ + Tool: b.buildKafkaConnectTool(toolModeWrite), + Handler: b.buildKafkaConnectHandler(toolModeWrite), + }) + } + + return tools, nil } // buildKafkaConnectTool builds the Kafka Connect MCP tool definition // Migrated from the original tool definition logic -func (b *KafkaConnectToolBuilder) buildKafkaConnectTool() mcp.Tool { +func (b *KafkaConnectToolBuilder) buildKafkaConnectTool(mode toolMode) mcp.Tool { resourceDesc := "Resource to operate on. Available resources:\n" + "- kafka-connect-cluster: A single Kafka Connect cluster that manages connectors and tasks.\n" + "- connector: A single Kafka Connect connector instance that moves data between Kafka and external systems.\n" + @@ -94,13 +108,22 @@ func (b *KafkaConnectToolBuilder) buildKafkaConnectTool() mcp.Tool { operationDesc := "Operation to perform. Available operations:\n" + "- list: List all connectors or connector plugins in a cluster.\n" + - "- get: Retrieve detailed information about a Kafka Connect cluster or specific connector.\n" + - "- create: Create a new connector with specified configuration.\n" + - "- update: Modify an existing connector's configuration.\n" + - "- delete: Remove a connector from the Kafka Connect cluster.\n" + - "- restart: Restart a running connector (useful after failures or configuration changes).\n" + - "- pause: Temporarily stop a connector from processing data.\n" + - "- resume: Continue processing with a previously paused connector." + "- get: Retrieve detailed information about a Kafka Connect cluster or specific connector." + operationEnum := []string{"list", "get"} + toolName := "kafka_admin_connect_read" + annotation := toolannotations.ReadOnly("Read Kafka Connect") + if isToolModeWrite(mode) { + operationDesc = "Operation to perform. Available operations:\n" + + "- create: Create a new connector with specified configuration.\n" + + "- update: Modify an existing connector's configuration.\n" + + "- delete: Remove a connector from the Kafka Connect cluster.\n" + + "- restart: Restart a running connector (useful after failures or configuration changes).\n" + + "- pause: Temporarily stop a connector from processing data.\n" + + "- resume: Continue processing with a previously paused connector." + operationEnum = []string{"create", "update", "delete", "restart", "pause", "resume"} + toolName = "kafka_admin_connect_write" + annotation = toolannotations.Destructive("Manage Kafka Connect") + } toolDesc := "Unified tool for managing Apache Kafka Connect.\n" + "Kafka Connect is a framework for connecting Kafka with external systems such as databases, key-value stores, search indexes, and file systems. " + @@ -170,13 +193,14 @@ func (b *KafkaConnectToolBuilder) buildKafkaConnectTool() mcp.Tool { " operation: \"get\"\n\n" + "This tool requires appropriate Kafka Connect permissions." - return mcp.NewTool("kafka_admin_connect", + return mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), ), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), + mcp.Enum(operationEnum...), ), mcp.WithString("name", mcp.Description("The name of the Kafka Connect connector to operate on. "+ @@ -194,12 +218,13 @@ func (b *KafkaConnectToolBuilder) buildKafkaConnectTool() mcp.Tool { "- key.converter/value.converter: Data format converters\n"+ "- transforms: Optional transformations to apply to data\n"+ "Additional fields depend on the specific connector type being used.")), + annotation, ) } // buildKafkaConnectHandler builds the Kafka Connect handler function // Migrated from the original handler logic -func (b *KafkaConnectToolBuilder) buildKafkaConnectHandler(readOnly bool) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (b *KafkaConnectToolBuilder) buildKafkaConnectHandler(mode toolMode) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Get required parameters resource, err := request.RequireString("resource") @@ -216,9 +241,8 @@ func (b *KafkaConnectToolBuilder) buildKafkaConnectHandler(readOnly bool) func(c resource = strings.ToLower(resource) operation = strings.ToLower(operation) - // Validate write operations in read-only mode - if readOnly && (operation == "create" || operation == "update" || operation == "delete" || operation == "restart" || operation == "pause" || operation == "resume") { - return mcp.NewToolResultError("Write operations are not allowed in read-only mode"), nil + if !validateModeOperation(mode, operation, kafkaConnectWriteOperations) { + return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil } // Get Kafka Connect client diff --git a/pkg/mcp/builders/kafka/connect_test.go b/pkg/mcp/builders/kafka/connect_test.go index 4c8252c8..13cc0541 100644 --- a/pkg/mcp/builders/kafka/connect_test.go +++ b/pkg/mcp/builders/kafka/connect_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -56,12 +56,13 @@ func TestKafkaConnectToolBuilder_BuildTools_Success(t *testing.T) { tools, err := builder.BuildTools(context.Background(), config) require.NoError(t, err) - require.Len(t, tools, 1) + require.Len(t, tools, 2) tool := tools[0] assert.NotNil(t, tool.Tool) assert.NotNil(t, tool.Handler) - assert.Equal(t, "kafka_admin_connect", tool.Tool.Name) + assert.Equal(t, "kafka_admin_connect_read", tool.Tool.Name) + assert.Equal(t, "kafka_admin_connect_write", tools[1].Tool.Name) } func TestKafkaConnectToolBuilder_BuildTools_WithAllFeature(t *testing.T) { @@ -78,7 +79,7 @@ func TestKafkaConnectToolBuilder_BuildTools_WithAllFeature(t *testing.T) { require.Len(t, tools, 1) tool := tools[0] - assert.Equal(t, "kafka_admin_connect", tool.Tool.Name) + assert.Equal(t, "kafka_admin_connect_read", tool.Tool.Name) } func TestKafkaConnectToolBuilder_BuildTools_WithAllKafkaFeature(t *testing.T) { @@ -92,7 +93,7 @@ func TestKafkaConnectToolBuilder_BuildTools_WithAllKafkaFeature(t *testing.T) { tools, err := builder.BuildTools(context.Background(), config) require.NoError(t, err) - require.Len(t, tools, 1) + require.Len(t, tools, 2) } func TestKafkaConnectToolBuilder_BuildTools_WithKafkaAdminFeature(t *testing.T) { @@ -106,7 +107,7 @@ func TestKafkaConnectToolBuilder_BuildTools_WithKafkaAdminFeature(t *testing.T) tools, err := builder.BuildTools(context.Background(), config) require.NoError(t, err) - require.Len(t, tools, 1) + require.Len(t, tools, 2) } func TestKafkaConnectToolBuilder_BuildTools_NoMatchingFeatures(t *testing.T) { @@ -165,9 +166,9 @@ func TestKafkaConnectToolBuilder_Validate_NoRequiredFeatures(t *testing.T) { func TestKafkaConnectToolBuilder_ToolDefinition(t *testing.T) { builder := NewKafkaConnectToolBuilder() - tool := builder.buildKafkaConnectTool() + tool := builder.buildKafkaConnectTool(toolModeRead) - assert.Equal(t, "kafka_admin_connect", tool.Name) + assert.Equal(t, "kafka_admin_connect_read", tool.Name) assert.NotEmpty(t, tool.Description) // Verify required parameters diff --git a/pkg/mcp/builders/kafka/consume.go b/pkg/mcp/builders/kafka/consume.go index 9b8fae2c..9f1acd26 100644 --- a/pkg/mcp/builders/kafka/consume.go +++ b/pkg/mcp/builders/kafka/consume.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -26,6 +26,7 @@ import ( "github.com/sirupsen/logrus" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" "github.com/twmb/franz-go/pkg/kgo" "github.com/twmb/franz-go/pkg/sr" ) @@ -62,6 +63,10 @@ func NewKafkaConsumeToolBuilder() *KafkaConsumeToolBuilder { // BuildTools builds the Kafka consume tool list // This is the core method implementing the ToolBuilder interface func (b *KafkaConsumeToolBuilder) BuildTools(_ context.Context, config builders.ToolBuildConfig) ([]server.ServerTool, error) { + if config.ReadOnly { + return nil, nil + } + // Check features - return empty list if no required features are present if !b.HasAnyRequiredFeature(config.Features) { return nil, nil @@ -123,6 +128,7 @@ func (b *KafkaConsumeToolBuilder) buildKafkaConsumeTool() mcp.Tool { return mcp.NewTool("kafka_client_consume", mcp.WithDescription(toolDesc), + toolannotations.Destructive("Consume Kafka Messages"), mcp.WithString("topic", mcp.Required(), mcp.Description("The name of the Kafka topic to consume messages from. "+ "Must be an existing topic that the user has read permissions for. "+ diff --git a/pkg/mcp/builders/kafka/groups.go b/pkg/mcp/builders/kafka/groups.go index ba4c80c3..155ac983 100644 --- a/pkg/mcp/builders/kafka/groups.go +++ b/pkg/mcp/builders/kafka/groups.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -24,9 +24,16 @@ import ( "github.com/mark3labs/mcp-go/server" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" "github.com/twmb/franz-go/pkg/kadm" ) +var kafkaGroupWriteOperations = map[string]struct{}{ + "remove-members": {}, + "delete-offset": {}, + "set-offset": {}, +} + // KafkaGroupsToolBuilder implements the ToolBuilder interface for Kafka Consumer Groups // /nolint:revive type KafkaGroupsToolBuilder struct { @@ -66,20 +73,24 @@ func (b *KafkaGroupsToolBuilder) BuildTools(_ context.Context, config builders.T return nil, err } - // Build tools - tool := b.buildKafkaGroupsTool() - handler := b.buildKafkaGroupsHandler(config.ReadOnly) - - return []server.ServerTool{ + tools := []server.ServerTool{ { - Tool: tool, - Handler: handler, + Tool: b.buildKafkaGroupsTool(toolModeRead), + Handler: b.buildKafkaGroupsHandler(toolModeRead), }, - }, nil + } + if !config.ReadOnly { + tools = append(tools, server.ServerTool{ + Tool: b.buildKafkaGroupsTool(toolModeWrite), + Handler: b.buildKafkaGroupsHandler(toolModeWrite), + }) + } + + return tools, nil } // buildKafkaGroupsTool builds the Kafka Groups MCP tool definition -func (b *KafkaGroupsToolBuilder) buildKafkaGroupsTool() mcp.Tool { +func (b *KafkaGroupsToolBuilder) buildKafkaGroupsTool(mode toolMode) mcp.Tool { resourceDesc := "Resource to operate on. Available resources:\n" + "- group: A single Kafka Consumer Group for operations on individual groups (describe, remove-members, set-offset, delete-offset)\n" + "- groups: Collection of Kafka Consumer Groups for bulk operations (list)" @@ -87,10 +98,19 @@ func (b *KafkaGroupsToolBuilder) buildKafkaGroupsTool() mcp.Tool { operationDesc := "Operation to perform. Available operations:\n" + "- list: List all Kafka Consumer Groups in the cluster\n" + "- describe: Get detailed information about a specific Consumer Group, including members, offsets, and lag\n" + - "- remove-members: Remove specific members from a Consumer Group to force rebalancing or troubleshoot issues\n" + - "- offsets: Get offsets for a specific consumer group\n" + - "- delete-offset: Delete a specific offset for a consumer group of a topic\n" + - "- set-offset: Set a specific offset for a consumer group's topic-partition" + "- offsets: Get offsets for a specific consumer group" + operationEnum := []string{"list", "describe", "offsets"} + toolName := "kafka_admin_groups_read" + annotation := toolannotations.ReadOnly("Read Kafka Consumer Groups") + if isToolModeWrite(mode) { + operationDesc = "Operation to perform. Available operations:\n" + + "- remove-members: Remove specific members from a Consumer Group to force rebalancing or troubleshoot issues\n" + + "- delete-offset: Delete a specific offset for a consumer group of a topic\n" + + "- set-offset: Set a specific offset for a consumer group's topic-partition" + operationEnum = []string{"remove-members", "delete-offset", "set-offset"} + toolName = "kafka_admin_groups_write" + annotation = toolannotations.Destructive("Manage Kafka Consumer Groups") + } toolDesc := "Unified tool for managing Apache Kafka Consumer Groups.\n" + "This tool provides access to Kafka consumer group operations including listing, describing, and managing group membership.\n" + @@ -130,13 +150,14 @@ func (b *KafkaGroupsToolBuilder) buildKafkaGroupsTool() mcp.Tool { " offset: 1000\n\n" + "This tool requires Kafka super-user permissions." - return mcp.NewTool("kafka_admin_groups", + return mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), ), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), + mcp.Enum(operationEnum...), ), mcp.WithString("group", mcp.Description("The name of the Kafka Consumer Group to operate on. "+ @@ -153,11 +174,12 @@ func (b *KafkaGroupsToolBuilder) buildKafkaGroupsTool() mcp.Tool { mcp.Description("The partition number. Required for 'set-offset' operation.")), mcp.WithNumber("offset", mcp.Description("The offset value to set. Required for 'set-offset' operation.")), + annotation, ) } // buildKafkaGroupsHandler builds the Kafka Groups handler function -func (b *KafkaGroupsToolBuilder) buildKafkaGroupsHandler(readOnly bool) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (b *KafkaGroupsToolBuilder) buildKafkaGroupsHandler(mode toolMode) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Get required parameters resource, err := request.RequireString("resource") @@ -174,9 +196,8 @@ func (b *KafkaGroupsToolBuilder) buildKafkaGroupsHandler(readOnly bool) func(con resource = strings.ToLower(resource) operation = strings.ToLower(operation) - // Validate write operations in read-only mode - if readOnly && (operation == "remove-members" || operation == "delete-offset" || operation == "set-offset") { - return mcp.NewToolResultError("Write operations are not allowed in read-only mode"), nil + if !validateModeOperation(mode, operation, kafkaGroupWriteOperations) { + return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil } // Get Kafka admin client diff --git a/pkg/mcp/builders/kafka/partitions.go b/pkg/mcp/builders/kafka/partitions.go index c189e176..b4ff90db 100644 --- a/pkg/mcp/builders/kafka/partitions.go +++ b/pkg/mcp/builders/kafka/partitions.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -24,6 +24,7 @@ import ( "github.com/mark3labs/mcp-go/server" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" "github.com/twmb/franz-go/pkg/kadm" ) @@ -56,6 +57,10 @@ func NewKafkaPartitionsToolBuilder() *KafkaPartitionsToolBuilder { // BuildTools builds the Kafka Partitions tool list func (b *KafkaPartitionsToolBuilder) BuildTools(_ context.Context, config builders.ToolBuildConfig) ([]server.ServerTool, error) { + if config.ReadOnly { + return nil, nil + } + // Check features - return empty list if no required features are present if !b.HasAnyRequiredFeature(config.Features) { return nil, nil @@ -68,7 +73,7 @@ func (b *KafkaPartitionsToolBuilder) BuildTools(_ context.Context, config builde // Build tools tool := b.buildKafkaPartitionsTool() - handler := b.buildKafkaPartitionsHandler(config.ReadOnly) + handler := b.buildKafkaPartitionsHandler() return []server.ServerTool{ { @@ -109,13 +114,14 @@ func (b *KafkaPartitionsToolBuilder) buildKafkaPartitionsTool() mcp.Tool { " partitions: 12\n\n" + "This tool requires appropriate Kafka permissions for partition management." - return mcp.NewTool("kafka_admin_partitions", + return mcp.NewTool("kafka_admin_partitions_write", mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), ), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), + mcp.Enum("update"), ), mcp.WithString("topic", mcp.Description("The name of the Kafka topic to operate on. "+ @@ -129,11 +135,12 @@ func (b *KafkaPartitionsToolBuilder) buildKafkaPartitionsTool() mcp.Tool { "A larger number of partitions can help increase parallelism and throughput, but may also "+ "increase resource utilization on the brokers. "+ "Consider Kafka cluster capacity when setting this value.")), + toolannotations.Destructive("Update Kafka Partitions"), ) } // buildKafkaPartitionsHandler builds the Kafka Partitions handler function -func (b *KafkaPartitionsToolBuilder) buildKafkaPartitionsHandler(readOnly bool) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (b *KafkaPartitionsToolBuilder) buildKafkaPartitionsHandler() func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Get required parameters resource, err := request.RequireString("resource") @@ -150,11 +157,6 @@ func (b *KafkaPartitionsToolBuilder) buildKafkaPartitionsHandler(readOnly bool) resource = strings.ToLower(resource) operation = strings.ToLower(operation) - // Validate write operations in read-only mode - if readOnly && operation == "update" { - return mcp.NewToolResultError("Write operations are not allowed in read-only mode"), nil - } - // Get Kafka admin client session := mcpCtx.GetKafkaSession(ctx) if session == nil { diff --git a/pkg/mcp/builders/kafka/produce.go b/pkg/mcp/builders/kafka/produce.go index 0c7596c7..2b27ebcc 100644 --- a/pkg/mcp/builders/kafka/produce.go +++ b/pkg/mcp/builders/kafka/produce.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -26,6 +26,7 @@ import ( "github.com/mark3labs/mcp-go/server" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" "github.com/twmb/franz-go/pkg/kgo" "github.com/twmb/franz-go/pkg/sr" ) @@ -145,6 +146,7 @@ func (b *KafkaProduceToolBuilder) buildKafkaProduceTool() mcp.Tool { "description": "key value pair in the format of \"key=value\"", }), ), + toolannotations.Destructive("Produce Kafka Messages"), mcp.WithNumber("partition", mcp.Description("The specific partition to send the message to. "+ "Optional. If not specified, Kafka will automatically assign a partition based on the message key (if provided) or round-robin assignment. "+ diff --git a/pkg/mcp/builders/kafka/schema_registry.go b/pkg/mcp/builders/kafka/schema_registry.go index a4a0eae5..cc62f1b2 100644 --- a/pkg/mcp/builders/kafka/schema_registry.go +++ b/pkg/mcp/builders/kafka/schema_registry.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -25,9 +25,16 @@ import ( "github.com/mark3labs/mcp-go/server" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" "github.com/twmb/franz-go/pkg/sr" ) +var kafkaSchemaRegistryWriteOperations = map[string]struct{}{ + "set": {}, + "create": {}, + "delete": {}, +} + // KafkaSchemaRegistryToolBuilder implements the ToolBuilder interface for Kafka Schema Registry // /nolint:revive type KafkaSchemaRegistryToolBuilder struct { @@ -68,20 +75,24 @@ func (b *KafkaSchemaRegistryToolBuilder) BuildTools(_ context.Context, config bu return nil, err } - // Build tools - tool := b.buildKafkaSchemaRegistryTool() - handler := b.buildKafkaSchemaRegistryHandler(config.ReadOnly) - - return []server.ServerTool{ + tools := []server.ServerTool{ { - Tool: tool, - Handler: handler, + Tool: b.buildKafkaSchemaRegistryTool(toolModeRead), + Handler: b.buildKafkaSchemaRegistryHandler(toolModeRead), }, - }, nil + } + if !config.ReadOnly { + tools = append(tools, server.ServerTool{ + Tool: b.buildKafkaSchemaRegistryTool(toolModeWrite), + Handler: b.buildKafkaSchemaRegistryHandler(toolModeWrite), + }) + } + + return tools, nil } // buildKafkaSchemaRegistryTool builds the Kafka Schema Registry MCP tool definition -func (b *KafkaSchemaRegistryToolBuilder) buildKafkaSchemaRegistryTool() mcp.Tool { +func (b *KafkaSchemaRegistryToolBuilder) buildKafkaSchemaRegistryTool(mode toolMode) mcp.Tool { resourceDesc := "Resource to operate on. Available resources:\n" + "- subjects: Collection of all schema subjects in the Schema Registry\n" + "- subject: A specific schema subject (a named schema that can have multiple versions)\n" + @@ -92,10 +103,19 @@ func (b *KafkaSchemaRegistryToolBuilder) buildKafkaSchemaRegistryTool() mcp.Tool operationDesc := "Operation to perform. Available operations:\n" + "- list: List all subjects, versions for a subject, or supported schema types\n" + - "- get: Get a subject's latest schema, a specific version, or compatibility setting\n" + - "- set: Set compatibility level for global or subject-specific schema evolution\n" + - "- create: Register a new schema for a subject\n" + - "- delete: Delete a schema subject or a specific version" + "- get: Get a subject's latest schema, a specific version, or compatibility setting" + operationEnum := []string{"list", "get"} + toolName := "kafka_admin_sr_read" + annotation := toolannotations.ReadOnly("Read Kafka Schema Registry") + if isToolModeWrite(mode) { + operationDesc = "Operation to perform. Available operations:\n" + + "- set: Set compatibility level for global or subject-specific schema evolution\n" + + "- create: Register a new schema for a subject\n" + + "- delete: Delete a schema subject or a specific version" + operationEnum = []string{"set", "create", "delete"} + toolName = "kafka_admin_sr_write" + annotation = toolannotations.Destructive("Manage Kafka Schema Registry") + } toolDesc := "Unified tool for managing Apache Kafka Schema Registry.\n" + "Schema Registry provides a centralized repository for managing and validating schemas for Kafka data.\n" + @@ -131,13 +151,14 @@ func (b *KafkaSchemaRegistryToolBuilder) buildKafkaSchemaRegistryTool() mcp.Tool " compatibility: \"BACKWARD\"\n\n" + "This tool requires appropriate Schema Registry permissions." - return mcp.NewTool("kafka_admin_sr", + return mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), ), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), + mcp.Enum(operationEnum...), ), mcp.WithString("subject", mcp.Description("The name of the schema subject. "+ @@ -158,11 +179,12 @@ func (b *KafkaSchemaRegistryToolBuilder) buildKafkaSchemaRegistryTool() mcp.Tool mcp.Description("The schema definition as a JSON object. "+ "Required for 'create' operation on 'subject' resource. "+ "The structure depends on the schema type (AVRO, JSON Schema, or Protocol Buffers).")), + annotation, ) } // buildKafkaSchemaRegistryHandler builds the Kafka Schema Registry handler function -func (b *KafkaSchemaRegistryToolBuilder) buildKafkaSchemaRegistryHandler(readOnly bool) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (b *KafkaSchemaRegistryToolBuilder) buildKafkaSchemaRegistryHandler(mode toolMode) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Get required parameters resource, err := request.RequireString("resource") @@ -179,9 +201,8 @@ func (b *KafkaSchemaRegistryToolBuilder) buildKafkaSchemaRegistryHandler(readOnl resource = strings.ToLower(resource) operation = strings.ToLower(operation) - // Validate write operations in read-only mode - if readOnly && (operation == "create" || operation == "delete" || operation == "set") { - return mcp.NewToolResultError("Write operations are not allowed in read-only mode"), nil + if !validateModeOperation(mode, operation, kafkaSchemaRegistryWriteOperations) { + return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil } // Get Schema Registry client diff --git a/pkg/mcp/builders/kafka/tool_mode.go b/pkg/mcp/builders/kafka/tool_mode.go new file mode 100644 index 00000000..740834ca --- /dev/null +++ b/pkg/mcp/builders/kafka/tool_mode.go @@ -0,0 +1,33 @@ +// Copyright 2026 StreamNative +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package kafka + +import "strings" + +type toolMode string + +const ( + toolModeRead toolMode = "read" + toolModeWrite toolMode = "write" +) + +func isToolModeWrite(mode toolMode) bool { + return mode == toolModeWrite +} + +func validateModeOperation(mode toolMode, operation string, writeOperations map[string]struct{}) bool { + _, isWrite := writeOperations[strings.ToLower(operation)] + return (mode == toolModeWrite) == isWrite +} diff --git a/pkg/mcp/builders/kafka/topics.go b/pkg/mcp/builders/kafka/topics.go index 3960d543..33b5decb 100644 --- a/pkg/mcp/builders/kafka/topics.go +++ b/pkg/mcp/builders/kafka/topics.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -24,9 +24,15 @@ import ( "github.com/mark3labs/mcp-go/server" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" "github.com/twmb/franz-go/pkg/kadm" ) +var kafkaTopicWriteOperations = map[string]struct{}{ + "create": {}, + "delete": {}, +} + // KafkaTopicsToolBuilder implements the ToolBuilder interface for Kafka Topics // /nolint:revive type KafkaTopicsToolBuilder struct { @@ -66,20 +72,24 @@ func (b *KafkaTopicsToolBuilder) BuildTools(_ context.Context, config builders.T return nil, err } - // Build tools - tool := b.buildKafkaTopicsTool() - handler := b.buildKafkaTopicsHandler(config.ReadOnly) - - return []server.ServerTool{ + tools := []server.ServerTool{ { - Tool: tool, - Handler: handler, + Tool: b.buildKafkaTopicsTool(toolModeRead), + Handler: b.buildKafkaTopicsHandler(toolModeRead), }, - }, nil + } + if !config.ReadOnly { + tools = append(tools, server.ServerTool{ + Tool: b.buildKafkaTopicsTool(toolModeWrite), + Handler: b.buildKafkaTopicsHandler(toolModeWrite), + }) + } + + return tools, nil } // buildKafkaTopicsTool builds the Kafka Topics MCP tool definition -func (b *KafkaTopicsToolBuilder) buildKafkaTopicsTool() mcp.Tool { +func (b *KafkaTopicsToolBuilder) buildKafkaTopicsTool(mode toolMode) mcp.Tool { resourceDesc := "Resource to operate on. Available resources:\n" + "- topic: A single Kafka topic for operations on individual topics (create, get, delete)\n" + "- topics: Collection of Kafka topics for bulk operations (list)" @@ -87,9 +97,18 @@ func (b *KafkaTopicsToolBuilder) buildKafkaTopicsTool() mcp.Tool { operationDesc := "Operation to perform. Available operations:\n" + "- list: List all topics in the Kafka cluster, optionally including internal topics\n" + "- get: Get detailed configuration for a specific topic\n" + - "- create: Create a new topic with specified partitions, replication factor, and optional configs\n" + - "- delete: Delete an existing topic\n" + "- metadata: Get metadata for a specific topic\n" + operationEnum := []string{"list", "get", "metadata"} + toolName := "kafka_admin_topics_read" + annotation := toolannotations.ReadOnly("Read Kafka Topics") + if isToolModeWrite(mode) { + operationDesc = "Operation to perform. Available operations:\n" + + "- create: Create a new topic with specified partitions, replication factor, and optional configs\n" + + "- delete: Delete an existing topic\n" + operationEnum = []string{"create", "delete"} + toolName = "kafka_admin_topics_write" + annotation = toolannotations.Destructive("Manage Kafka Topics") + } toolDesc := "Unified tool for managing Apache Kafka topics.\n" + "This tool provides access to various Kafka topic operations, including creation, deletion, listing, and configuration retrieval.\n" + @@ -137,13 +156,14 @@ func (b *KafkaTopicsToolBuilder) buildKafkaTopicsTool() mcp.Tool { " name: \"old-topic\"\n\n" + "This tool requires appropriate Kafka permissions for topic management." - return mcp.NewTool("kafka_admin_topics", + return mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), ), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), + mcp.Enum(operationEnum...), ), mcp.WithString("name", mcp.Description("The name of the Kafka topic to operate on. "+ @@ -169,11 +189,12 @@ func (b *KafkaTopicsToolBuilder) buildKafkaTopicsTool() mcp.Tool { mcp.Description("Whether to include internal Kafka topics in the 'list' operation. "+ "Internal topics are used by Kafka itself (e.g., __consumer_offsets, __transaction_state). "+ "Default: false")), + annotation, ) } // buildKafkaTopicsHandler builds the Kafka Topics handler function -func (b *KafkaTopicsToolBuilder) buildKafkaTopicsHandler(readOnly bool) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (b *KafkaTopicsToolBuilder) buildKafkaTopicsHandler(mode toolMode) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Get required parameters resource, err := request.RequireString("resource") @@ -190,9 +211,8 @@ func (b *KafkaTopicsToolBuilder) buildKafkaTopicsHandler(readOnly bool) func(con resource = strings.ToLower(resource) operation = strings.ToLower(operation) - // Validate write operations in read-only mode - if readOnly && (operation == "create" || operation == "delete") { - return mcp.NewToolResultError("Write operations are not allowed in read-only mode"), nil + if !validateModeOperation(mode, operation, kafkaTopicWriteOperations) { + return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil } // Get Kafka admin client diff --git a/pkg/mcp/builders/pulsar/annotation_compliance_test.go b/pkg/mcp/builders/pulsar/annotation_compliance_test.go new file mode 100644 index 00000000..8de220f7 --- /dev/null +++ b/pkg/mcp/builders/pulsar/annotation_compliance_test.go @@ -0,0 +1,149 @@ +// Copyright 2026 StreamNative +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pulsar + +import ( + "context" + "strings" + "testing" + + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" + "github.com/stretchr/testify/require" +) + +func TestPulsarToolAnnotationCompliance(t *testing.T) { + builderList := allPulsarComplianceBuilders() + + for _, builder := range builderList { + tools, err := builder.BuildTools(context.Background(), builders.ToolBuildConfig{ + Features: []string{"all", "all-pulsar", "pulsar-admin", "pulsar-client"}, + }) + require.NoError(t, err) + for _, serverTool := range tools { + tool := serverTool.Tool + require.NotEmpty(t, tool.Annotations.Title, tool.Name) + require.NotNil(t, tool.Annotations.ReadOnlyHint, tool.Name) + require.NotNil(t, tool.Annotations.DestructiveHint, tool.Name) + require.LessOrEqual(t, len(tool.Name), 64, tool.Name) + + isRead := strings.HasSuffix(tool.Name, "_read") || strings.HasPrefix(tool.Name, "pulsar_admin_namespace_policy_get") || tool.Name == "pulsar_admin_status" || tool.Name == "pulsar_admin_broker_stats" || tool.Name == "pulsar_admin_functions_worker" + isWrite := strings.HasSuffix(tool.Name, "_write") || strings.Contains(tool.Name, "_set") || strings.Contains(tool.Name, "_remove") || strings.Contains(tool.Name, "produce") || strings.Contains(tool.Name, "consume") + if isRead { + require.True(t, *tool.Annotations.ReadOnlyHint, tool.Name) + require.False(t, *tool.Annotations.DestructiveHint, tool.Name) + } + if isWrite { + require.False(t, *tool.Annotations.ReadOnlyHint, tool.Name) + require.True(t, *tool.Annotations.DestructiveHint, tool.Name) + } + assertOperationEnumMode(t, tool.Name, tool.InputSchema.Properties["operation"]) + } + } +} + +func assertOperationEnumMode(t *testing.T, toolName string, operationSchema any) { + t.Helper() + schema, ok := operationSchema.(map[string]any) + if !ok { + return + } + rawEnum, ok := schema["enum"].([]string) + if !ok { + return + } + writeOperations := pulsarComplianceWriteOperations() + seenRead, seenWrite := false, false + for _, op := range rawEnum { + if _, isWrite := writeOperations[op]; isWrite { + seenWrite = true + } else { + seenRead = true + } + } + require.False(t, seenRead && seenWrite, toolName) +} + +func pulsarComplianceWriteOperations() map[string]struct{} { + writeOperations := map[string]struct{}{ + "create": {}, + "update": {}, + "delete": {}, + "upload": {}, + } + for _, source := range []map[string]struct{}{ + readOnlyRestrictedTopicOperations, + readOnlyRestrictedSubscriptionOperations, + pulsarNamespaceWriteOperations, + readOnlyRestrictedTopicPolicyOperations, + pulsarBrokerWriteOperations, + pulsarClusterWriteOperations, + readOnlyRestrictedFunctionOperations, + pulsarSinkWriteOperations, + pulsarSourceWriteOperations, + pulsarPackageWriteOperations, + pulsarSchemaWriteOperations, + pulsarTenantWriteOperations, + pulsarNsIsolationPolicyWriteOperations, + pulsarResourceQuotaWriteOperations, + } { + for op := range source { + writeOperations[op] = struct{}{} + } + } + return writeOperations +} + +func TestPulsarReadOnlyBuildsNoWriteTools(t *testing.T) { + for _, builder := range allPulsarComplianceBuilders() { + tools, err := builder.BuildTools(context.Background(), builders.ToolBuildConfig{ + ReadOnly: true, + Features: []string{"all", "all-pulsar", "pulsar-admin", "pulsar-client"}, + }) + require.NoError(t, err) + for _, serverTool := range tools { + name := serverTool.Tool.Name + require.NotContains(t, name, "_write") + require.NotContains(t, name, "_set") + require.NotContains(t, name, "_remove") + require.NotEqual(t, "pulsar_client_produce", name) + require.NotEqual(t, "pulsar_client_consume", name) + } + } +} + +func allPulsarComplianceBuilders() []builders.ToolBuilder { + return []builders.ToolBuilder{ + NewPulsarAdminBrokerStatsToolBuilder(), + NewPulsarAdminBrokersToolBuilder(), + NewPulsarAdminClusterToolBuilder(), + NewPulsarAdminFunctionsToolBuilder(), + NewPulsarAdminFunctionsWorkerToolBuilder(), + NewPulsarAdminNamespaceToolBuilder(), + NewPulsarAdminNamespacePolicyToolBuilder(), + NewPulsarAdminNsIsolationPolicyToolBuilder(), + NewPulsarAdminPackagesToolBuilder(), + NewPulsarAdminResourceQuotasToolBuilder(), + NewPulsarAdminSchemaToolBuilder(), + NewPulsarAdminSinksToolBuilder(), + NewPulsarAdminSourcesToolBuilder(), + NewPulsarAdminStatusToolBuilder(), + NewPulsarAdminSubscriptionToolBuilder(), + NewPulsarAdminTenantToolBuilder(), + NewPulsarAdminTopicPolicyToolBuilder(), + NewPulsarAdminTopicToolBuilder(), + NewPulsarClientConsumeToolBuilder(), + NewPulsarClientProduceToolBuilder(), + } +} diff --git a/pkg/mcp/builders/pulsar/brokers.go b/pkg/mcp/builders/pulsar/brokers.go index 6a81fd30..305664d2 100644 --- a/pkg/mcp/builders/pulsar/brokers.go +++ b/pkg/mcp/builders/pulsar/brokers.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -25,8 +25,14 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) +var pulsarBrokerWriteOperations = map[string]struct{}{ + "update": {}, + "delete": {}, +} + // PulsarAdminBrokersToolBuilder implements the ToolBuilder interface for Pulsar admin brokers // /nolint:revive type PulsarAdminBrokersToolBuilder struct { @@ -67,21 +73,34 @@ func (b *PulsarAdminBrokersToolBuilder) BuildTools(_ context.Context, config bui return nil, err } - // Build tools - tool := b.buildPulsarAdminBrokersTool() - handler := b.buildPulsarAdminBrokersHandler(config.ReadOnly) - - return []server.ServerTool{ + tools := []server.ServerTool{ { - Tool: tool, - Handler: handler, + Tool: b.buildPulsarAdminBrokersTool(toolModeRead), + Handler: b.buildPulsarAdminBrokersHandler(toolModeRead), }, - }, nil + } + if !config.ReadOnly { + tools = append(tools, server.ServerTool{ + Tool: b.buildPulsarAdminBrokersTool(toolModeWrite), + Handler: b.buildPulsarAdminBrokersHandler(toolModeWrite), + }) + } + + return tools, nil } // buildPulsarAdminBrokersTool builds the Pulsar admin brokers MCP tool definition -func (b *PulsarAdminBrokersToolBuilder) buildPulsarAdminBrokersTool() mcp.Tool { - return mcp.NewTool("pulsar_admin_brokers", +func (b *PulsarAdminBrokersToolBuilder) buildPulsarAdminBrokersTool(mode toolMode) mcp.Tool { + operationEnum := []string{"list", "get"} + toolName := "pulsar_admin_brokers_read" + annotation := toolannotations.ReadOnly("Read Pulsar Brokers") + if isToolModeWrite(mode) { + operationEnum = []string{"update", "delete"} + toolName = "pulsar_admin_brokers_write" + annotation = toolannotations.Destructive("Manage Pulsar Brokers") + } + + return mcp.NewTool(toolName, mcp.WithDescription("Unified tool for managing Apache Pulsar broker resources. This tool integrates multiple broker management functions, including:\n"+ "1. List active brokers in a cluster (resource=brokers, operation=list)\n"+ "2. Check broker health status (resource=health, operation=get)\n"+ @@ -103,6 +122,7 @@ func (b *PulsarAdminBrokersToolBuilder) buildPulsarAdminBrokersTool() mcp.Tool { "- get: Retrieve resource information (used with health, config, namespaces)\n"+ "- update: Update a resource (used with config)\n"+ "- delete: Delete a resource (used with config)"), + mcp.Enum(operationEnum...), ), mcp.WithString("clusterName", mcp.Description("Pulsar cluster name, required for these operations:\n"+ @@ -129,11 +149,12 @@ func (b *PulsarAdminBrokersToolBuilder) buildPulsarAdminBrokersTool() mcp.Tool { mcp.Description("Configuration parameter value, required for these operations:\n"+ "- When resource=config, operation=update"), ), + annotation, ) } // buildPulsarAdminBrokersHandler builds the Pulsar admin brokers handler function -func (b *PulsarAdminBrokersToolBuilder) buildPulsarAdminBrokersHandler(readOnly bool) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (b *PulsarAdminBrokersToolBuilder) buildPulsarAdminBrokersHandler(mode toolMode) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Get Pulsar session from context session := mcpCtx.GetPulsarSession(ctx) @@ -166,6 +187,10 @@ func (b *PulsarAdminBrokersToolBuilder) buildPulsarAdminBrokersHandler(readOnly return mcp.NewToolResultError(errMsg), nil } + if !validateModeOperation(mode, operation, pulsarBrokerWriteOperations) { + return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil + } + // Process request based on resource type switch resource { case "brokers": @@ -173,11 +198,6 @@ func (b *PulsarAdminBrokersToolBuilder) buildPulsarAdminBrokersHandler(readOnly case "health": return b.handleHealthResource(client, operation, request) case "config": - // Check write operation permissions - if (operation == "update" || operation == "delete") && readOnly { - return mcp.NewToolResultError("Configuration update/delete operations not allowed in read-only mode. " + - "Please contact your administrator if you need to modify broker configurations."), nil - } return b.handleConfigResource(client, operation, request) case "namespaces": return b.handleNamespacesResource(client, operation, request) diff --git a/pkg/mcp/builders/pulsar/brokers_stats.go b/pkg/mcp/builders/pulsar/brokers_stats.go index 4c612153..34994ebd 100644 --- a/pkg/mcp/builders/pulsar/brokers_stats.go +++ b/pkg/mcp/builders/pulsar/brokers_stats.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -24,6 +24,7 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) // PulsarAdminBrokerStatsToolBuilder implements the ToolBuilder interface for Pulsar Broker Statistics @@ -106,6 +107,7 @@ func (b *PulsarAdminBrokerStatsToolBuilder) buildBrokerStatsTool() mcp.Tool { mcp.WithString("allocator_name", mcp.Description("The name of the allocator to get statistics for. Required only when resource=allocator_stats"), ), + toolannotations.ReadOnly("Read Pulsar Broker Statistics"), ) } diff --git a/pkg/mcp/builders/pulsar/cluster.go b/pkg/mcp/builders/pulsar/cluster.go index 02ce2e61..0a1f85d3 100644 --- a/pkg/mcp/builders/pulsar/cluster.go +++ b/pkg/mcp/builders/pulsar/cluster.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -25,8 +25,15 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) +var pulsarClusterWriteOperations = map[string]struct{}{ + "create": {}, + "update": {}, + "delete": {}, +} + // PulsarAdminClusterToolBuilder implements the ToolBuilder interface for Pulsar Admin Cluster tools // It provides functionality to build Pulsar cluster management tools // /nolint:revive @@ -69,21 +76,25 @@ func (b *PulsarAdminClusterToolBuilder) BuildTools(_ context.Context, config bui return nil, err } - // Build tools - tool := b.buildClusterTool() - handler := b.buildClusterHandler(config.ReadOnly) - - return []server.ServerTool{ + tools := []server.ServerTool{ { - Tool: tool, - Handler: handler, + Tool: b.buildClusterTool(toolModeRead), + Handler: b.buildClusterHandler(toolModeRead), }, - }, nil + } + if !config.ReadOnly { + tools = append(tools, server.ServerTool{ + Tool: b.buildClusterTool(toolModeWrite), + Handler: b.buildClusterHandler(toolModeWrite), + }) + } + + return tools, nil } // buildClusterTool builds the Pulsar Admin Cluster MCP tool definition // Migrated from the original tool definition logic -func (b *PulsarAdminClusterToolBuilder) buildClusterTool() mcp.Tool { +func (b *PulsarAdminClusterToolBuilder) buildClusterTool(mode toolMode) mcp.Tool { toolDesc := "Unified tool for managing Apache Pulsar clusters.\n" + "This tool provides access to various cluster resources and operations, including:\n" + "1. Manage clusters (resource=cluster): List, get, create, update, delete clusters\n" + @@ -108,13 +119,23 @@ func (b *PulsarAdminClusterToolBuilder) buildClusterTool() mcp.Tool { "- update: Update an existing resource (used with cluster, peer_clusters, failure_domain)\n" + "- delete: Delete a resource (used with cluster, failure_domain)" - return mcp.NewTool("pulsar_admin_cluster", + operationEnum := []string{"list", "get"} + toolName := "pulsar_admin_cluster_read" + annotation := toolannotations.ReadOnly("Read Pulsar Clusters") + if isToolModeWrite(mode) { + operationEnum = []string{"create", "update", "delete"} + toolName = "pulsar_admin_cluster_write" + annotation = toolannotations.Destructive("Manage Pulsar Clusters") + } + + return mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), ), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), + mcp.Enum(operationEnum...), ), mcp.WithString("cluster_name", mcp.Description("Name of the Pulsar cluster, required for all operations except 'list' with resource=cluster"), @@ -154,12 +175,13 @@ func (b *PulsarAdminClusterToolBuilder) buildClusterTool() mcp.Tool { }, ), ), + annotation, ) } // buildClusterHandler builds the Pulsar Admin Cluster handler function // Migrated from the original handler logic -func (b *PulsarAdminClusterToolBuilder) buildClusterHandler(readOnly bool) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (b *PulsarAdminClusterToolBuilder) buildClusterHandler(mode toolMode) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Get Pulsar session from context session := mcpCtx.GetPulsarSession(ctx) @@ -191,10 +213,8 @@ func (b *PulsarAdminClusterToolBuilder) buildClusterHandler(readOnly bool) func( return mcp.NewToolResultError(errMsg), nil } - // Check write operation permissions - if (operation == "create" || operation == "update" || operation == "delete") && readOnly { - return mcp.NewToolResultError("Create/update/delete operations not allowed in read-only mode. " + - "Please contact your administrator if you need to modify cluster resources."), nil + if !validateModeOperation(mode, operation, pulsarClusterWriteOperations) { + return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil } // Process request based on resource type diff --git a/pkg/mcp/builders/pulsar/consume.go b/pkg/mcp/builders/pulsar/consume.go index 865f13f0..abbb4c08 100644 --- a/pkg/mcp/builders/pulsar/consume.go +++ b/pkg/mcp/builders/pulsar/consume.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -26,6 +26,7 @@ import ( "github.com/mark3labs/mcp-go/server" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) const ( @@ -68,6 +69,10 @@ func NewPulsarClientConsumeToolBuilder() *PulsarClientConsumeToolBuilder { // BuildTools builds the Pulsar Client Consumer tool list // This is the core method implementing the ToolBuilder interface func (b *PulsarClientConsumeToolBuilder) BuildTools(_ context.Context, config builders.ToolBuildConfig) ([]server.ServerTool, error) { + if config.ReadOnly { + return nil, nil + } + // Check features - return empty list if no required features are present if !b.HasAnyRequiredFeature(config.Features) { return nil, nil @@ -104,6 +109,7 @@ func (b *PulsarClientConsumeToolBuilder) buildConsumeTool() mcp.Tool { return mcp.NewTool("pulsar_client_consume", mcp.WithDescription(toolDesc), + toolannotations.Destructive("Consume Pulsar Messages"), mcp.WithString("topic", mcp.Required(), mcp.Description("The fully qualified topic name to consume from (format: [persistent|non-persistent]://tenant/namespace/topic). "+ "For partitioned topics, you can consume from all partitions by specifying the base topic name "+ diff --git a/pkg/mcp/builders/pulsar/feature_gates_test.go b/pkg/mcp/builders/pulsar/feature_gates_test.go index 7cde0f9d..3e6b30ae 100644 --- a/pkg/mcp/builders/pulsar/feature_gates_test.go +++ b/pkg/mcp/builders/pulsar/feature_gates_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -32,8 +32,9 @@ func TestPulsarAdminNsIsolationPolicyToolBuilder_FeatureGate(t *testing.T) { Features: []string{"pulsar-admin-ns-isolation-policy"}, }) require.NoError(t, err) - require.Len(t, tools, 1) - assert.Equal(t, "pulsar_admin_nsisolationpolicy", tools[0].Tool.Name) + require.Len(t, tools, 2) + assert.Equal(t, "pulsar_admin_nsisolationpolicy_read", tools[0].Tool.Name) + assert.Equal(t, "pulsar_admin_nsisolationpolicy_write", tools[1].Tool.Name) } func TestPulsarAdminResourceQuotasToolBuilder_FeatureGate(t *testing.T) { @@ -45,6 +46,7 @@ func TestPulsarAdminResourceQuotasToolBuilder_FeatureGate(t *testing.T) { Features: []string{"pulsar-admin-resource-quotas"}, }) require.NoError(t, err) - require.Len(t, tools, 1) - assert.Equal(t, "pulsar_admin_resourcequota", tools[0].Tool.Name) + require.Len(t, tools, 2) + assert.Equal(t, "pulsar_admin_resourcequota_read", tools[0].Tool.Name) + assert.Equal(t, "pulsar_admin_resourcequota_write", tools[1].Tool.Name) } diff --git a/pkg/mcp/builders/pulsar/functions.go b/pkg/mcp/builders/pulsar/functions.go index 6935bbe3..1d234cc0 100644 --- a/pkg/mcp/builders/pulsar/functions.go +++ b/pkg/mcp/builders/pulsar/functions.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -28,6 +28,7 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" "gopkg.in/yaml.v2" ) @@ -73,21 +74,25 @@ func (b *PulsarAdminFunctionsToolBuilder) BuildTools(_ context.Context, config b return nil, err } - // Build tools - tool := b.buildPulsarAdminFunctionsTool() - handler := b.buildPulsarAdminFunctionsHandler(config.ReadOnly) - - return []server.ServerTool{ + tools := []server.ServerTool{ { - Tool: tool, - Handler: handler, + Tool: b.buildPulsarAdminFunctionsTool(toolModeRead), + Handler: b.buildPulsarAdminFunctionsHandler(toolModeRead), }, - }, nil + } + if !config.ReadOnly { + tools = append(tools, server.ServerTool{ + Tool: b.buildPulsarAdminFunctionsTool(toolModeWrite), + Handler: b.buildPulsarAdminFunctionsHandler(toolModeWrite), + }) + } + + return tools, nil } // buildPulsarAdminFunctionsTool builds the Pulsar admin functions MCP tool definition // Migrated from the original tool definition logic -func (b *PulsarAdminFunctionsToolBuilder) buildPulsarAdminFunctionsTool() mcp.Tool { +func (b *PulsarAdminFunctionsToolBuilder) buildPulsarAdminFunctionsTool(mode toolMode) mcp.Tool { toolDesc := "Manage Apache Pulsar Functions for stream processing. " + "Pulsar Functions are lightweight compute processes that can consume messages from one or more Pulsar topics, " + "apply user-defined processing logic, and produce results to another topic. " + @@ -115,10 +120,20 @@ func (b *PulsarAdminFunctionsToolBuilder) buildPulsarAdminFunctionsTool() mcp.To "- trigger: Manually trigger a function with a specific value\n" + "- upload: Upload a local file into Pulsar function package storage" - return mcp.NewTool("pulsar_admin_functions", + operationEnum := []string{"list", "get", "status", "stats", "querystate", "download"} + toolName := "pulsar_admin_functions_read" + annotation := toolannotations.ReadOnly("Read Pulsar Functions") + if isToolModeWrite(mode) { + operationEnum = []string{"create", "update", "delete", "start", "stop", "restart", "putstate", "trigger", "upload"} + toolName = "pulsar_admin_functions_write" + annotation = toolannotations.Destructive("Manage Pulsar Functions") + } + + return mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("operation", mcp.Required(), - mcp.Description(operationDesc)), + mcp.Description(operationDesc), + mcp.Enum(operationEnum...)), mcp.WithString("fqfn", mcp.Description("The Fully Qualified Function Name in the form tenant/namespace/name. "+ "Mutually exclusive with tenant, namespace, and name parameters.")), @@ -301,12 +316,13 @@ func (b *PulsarAdminFunctionsToolBuilder) buildPulsarAdminFunctionsTool() mcp.To "The function processes this value just like a normal message.")), mcp.WithString("triggerFile", mcp.Description("Path to a file containing the trigger value. Required for 'trigger' operation unless triggerValue is set.")), + annotation, ) } // buildPulsarAdminFunctionsHandler builds the Pulsar admin functions handler function // Migrated from the original handler logic -func (b *PulsarAdminFunctionsToolBuilder) buildPulsarAdminFunctionsHandler(readOnly bool) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (b *PulsarAdminFunctionsToolBuilder) buildPulsarAdminFunctionsHandler(mode toolMode) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Get Pulsar session from context session := mcpCtx.GetPulsarSession(ctx) @@ -330,9 +346,8 @@ func (b *PulsarAdminFunctionsToolBuilder) buildPulsarAdminFunctionsHandler(readO return b.handleError("validate operation", fmt.Errorf("invalid operation: '%s'. Supported operations: list, get, status, stats, querystate, create, update, delete, download, start, stop, restart, putstate, trigger, upload", operation)), nil } - // Check write permissions for write operations - if readOnly && isReadOnlyRestrictedFunctionOperation(operation) { - return b.handleError("check permissions", fmt.Errorf("operation '%s' not allowed in read-only mode. Read-only mode restricts modifications and package transfer operations for Pulsar Functions", operation)), nil + if !validateModeOperation(mode, operation, readOnlyRestrictedFunctionOperations) { + return b.handleError("check permissions", fmt.Errorf("operation %q is not available in %s mode", operation, mode)), nil } var identity functionIdentity @@ -798,7 +813,6 @@ var readOnlyRestrictedFunctionOperations = map[string]struct{}{ "create": {}, "update": {}, "delete": {}, - "download": {}, "start": {}, "stop": {}, "restart": {}, diff --git a/pkg/mcp/builders/pulsar/functions_parity_test.go b/pkg/mcp/builders/pulsar/functions_parity_test.go index ed514e6b..7bee4a35 100644 --- a/pkg/mcp/builders/pulsar/functions_parity_test.go +++ b/pkg/mcp/builders/pulsar/functions_parity_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -72,7 +72,7 @@ func TestParseFunctionIdentity(t *testing.T) { func TestFunctionOperationCoverageIncludesFileTransfer(t *testing.T) { require.True(t, isSupportedFunctionOperation("download")) require.True(t, isSupportedFunctionOperation("upload")) - require.True(t, isReadOnlyRestrictedFunctionOperation("download")) + require.False(t, isReadOnlyRestrictedFunctionOperation("download")) require.True(t, isReadOnlyRestrictedFunctionOperation("upload")) } diff --git a/pkg/mcp/builders/pulsar/functions_test.go b/pkg/mcp/builders/pulsar/functions_test.go index 51e29ec9..45052138 100644 --- a/pkg/mcp/builders/pulsar/functions_test.go +++ b/pkg/mcp/builders/pulsar/functions_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -44,8 +44,9 @@ func TestPulsarAdminFunctionsToolBuilder(t *testing.T) { tools, err := builder.BuildTools(context.Background(), config) require.NoError(t, err) - assert.Len(t, tools, 1) - assert.Equal(t, "pulsar_admin_functions", tools[0].Tool.Name) + assert.Len(t, tools, 2) + assert.Equal(t, "pulsar_admin_functions_read", tools[0].Tool.Name) + assert.Equal(t, "pulsar_admin_functions_write", tools[1].Tool.Name) assert.NotNil(t, tools[0].Handler) }) diff --git a/pkg/mcp/builders/pulsar/functions_worker.go b/pkg/mcp/builders/pulsar/functions_worker.go index d2445855..5d3de341 100644 --- a/pkg/mcp/builders/pulsar/functions_worker.go +++ b/pkg/mcp/builders/pulsar/functions_worker.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -24,6 +24,7 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) // PulsarAdminFunctionsWorkerToolBuilder implements the ToolBuilder interface for Pulsar Admin Functions Worker tools @@ -104,6 +105,7 @@ func (b *PulsarAdminFunctionsWorkerToolBuilder) buildFunctionsWorkerTool() mcp.T mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), ), + toolannotations.ReadOnly("Read Pulsar Functions Worker"), ) } diff --git a/pkg/mcp/builders/pulsar/namespace.go b/pkg/mcp/builders/pulsar/namespace.go index ebef3f4f..a91125c4 100644 --- a/pkg/mcp/builders/pulsar/namespace.go +++ b/pkg/mcp/builders/pulsar/namespace.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -26,8 +26,18 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) +var pulsarNamespaceWriteOperations = map[string]struct{}{ + "create": {}, + "delete": {}, + "clear_backlog": {}, + "unsubscribe": {}, + "unload": {}, + "split_bundle": {}, +} + // PulsarAdminNamespaceToolBuilder implements the ToolBuilder interface for Pulsar Admin Namespace tools // It provides functionality to build Pulsar namespace management tools // /nolint:revive @@ -70,21 +80,25 @@ func (b *PulsarAdminNamespaceToolBuilder) BuildTools(_ context.Context, config b return nil, err } - // Build tools - tool := b.buildNamespaceTool() - handler := b.buildNamespaceHandler(config.ReadOnly) - - return []server.ServerTool{ + tools := []server.ServerTool{ { - Tool: tool, - Handler: handler, + Tool: b.buildNamespaceTool(toolModeRead), + Handler: b.buildNamespaceHandler(toolModeRead), }, - }, nil + } + if !config.ReadOnly { + tools = append(tools, server.ServerTool{ + Tool: b.buildNamespaceTool(toolModeWrite), + Handler: b.buildNamespaceHandler(toolModeWrite), + }) + } + + return tools, nil } // buildNamespaceTool builds the Pulsar Admin Namespace MCP tool definition // Migrated from the original tool definition logic -func (b *PulsarAdminNamespaceToolBuilder) buildNamespaceTool() mcp.Tool { +func (b *PulsarAdminNamespaceToolBuilder) buildNamespaceTool(mode toolMode) mcp.Tool { toolDesc := "Manage Pulsar namespaces with various operations. " + "This tool provides functionality to work with namespaces in Apache Pulsar, " + "including listing, creating, deleting, and performing various operations on namespaces." @@ -99,10 +113,20 @@ func (b *PulsarAdminNamespaceToolBuilder) buildNamespaceTool() mcp.Tool { "- unload: Unload a namespace from the current serving broker\n" + "- split_bundle: Split a namespace bundle" - return mcp.NewTool("pulsar_admin_namespace", + operationEnum := []string{"list", "get_topics"} + toolName := "pulsar_admin_namespace_read" + annotation := toolannotations.ReadOnly("Read Pulsar Namespaces") + if isToolModeWrite(mode) { + operationEnum = []string{"create", "delete", "clear_backlog", "unsubscribe", "unload", "split_bundle"} + toolName = "pulsar_admin_namespace_write" + annotation = toolannotations.Destructive("Manage Pulsar Namespaces") + } + + return mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), + mcp.Enum(operationEnum...), ), mcp.WithString("tenant", mcp.Description("The tenant name. Required for 'list' operation."), @@ -134,12 +158,13 @@ func (b *PulsarAdminNamespaceToolBuilder) buildNamespaceTool() mcp.Tool { mcp.WithString("unload", mcp.Description("Unload newly split bundles after splitting (true/false). Used with 'split_bundle' operation."), ), + annotation, ) } // buildNamespaceHandler builds the Pulsar Admin Namespace handler function // Migrated from the original handler logic -func (b *PulsarAdminNamespaceToolBuilder) buildNamespaceHandler(readOnly bool) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (b *PulsarAdminNamespaceToolBuilder) buildNamespaceHandler(mode toolMode) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Get operation parameter operation, err := request.RequireString("operation") @@ -159,6 +184,10 @@ func (b *PulsarAdminNamespaceToolBuilder) buildNamespaceHandler(readOnly bool) f return mcp.NewToolResultError(fmt.Sprintf("Failed to get admin client: %v", err)), nil } + if !validateModeOperation(mode, operation, pulsarNamespaceWriteOperations) { + return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil + } + // Route to appropriate handler based on operation switch operation { case "list": @@ -166,11 +195,6 @@ func (b *PulsarAdminNamespaceToolBuilder) buildNamespaceHandler(readOnly bool) f case "get_topics": return b.handleNamespaceGetTopics(ctx, client, request) case "create", "delete", "clear_backlog", "unsubscribe", "unload", "split_bundle": - // Check if write operations are allowed - if readOnly { - return mcp.NewToolResultError(fmt.Sprintf("Operation '%s' not allowed in read-only mode", operation)), nil - } - // Route to appropriate write operation handler switch operation { case "create": diff --git a/pkg/mcp/builders/pulsar/namespace_policy.go b/pkg/mcp/builders/pulsar/namespace_policy.go index 364f040d..0dd25a4b 100644 --- a/pkg/mcp/builders/pulsar/namespace_policy.go +++ b/pkg/mcp/builders/pulsar/namespace_policy.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -30,6 +30,7 @@ import ( pulsarctlutils "github.com/streamnative/pulsarctl/pkg/ctl/utils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) var supportedNamespaceSetPolicies = []string{ @@ -190,6 +191,7 @@ func (b *PulsarAdminNamespacePolicyToolBuilder) buildNamespaceGetPoliciesTool() mcp.WithString("namespace", mcp.Required(), mcp.Description("The namespace name (tenant/namespace) to get policies for"), ), + toolannotations.ReadOnly("Get Pulsar Namespace Policies"), ) } @@ -311,6 +313,7 @@ func (b *PulsarAdminNamespacePolicyToolBuilder) buildNamespaceSetPolicyTool() mc mcp.WithNumber("subscribe-rate", mcp.Description("Subscribe rate per consumer used by subscribe-rate"), ), + toolannotations.Destructive("Set Pulsar Namespace Policies"), ) } @@ -336,6 +339,7 @@ func (b *PulsarAdminNamespacePolicyToolBuilder) buildNamespaceRemovePolicyTool() mcp.WithString("type", mcp.Description("Type of backlog quota to remove"), ), + toolannotations.Destructive("Remove Pulsar Namespace Policies"), ) } @@ -353,6 +357,7 @@ func (b *PulsarAdminNamespacePolicyToolBuilder) buildNamespaceGetAntiAffinityNam mcp.WithString("tenant", mcp.Description("Tenant name used for authorization. Optional, but recommended when the caller administers multiple tenants."), ), + toolannotations.ReadOnly("Get Pulsar Namespace Anti-Affinity Namespaces"), ) } diff --git a/pkg/mcp/builders/pulsar/nsisolationpolicy.go b/pkg/mcp/builders/pulsar/nsisolationpolicy.go index a1be7a79..a3bcb6c4 100644 --- a/pkg/mcp/builders/pulsar/nsisolationpolicy.go +++ b/pkg/mcp/builders/pulsar/nsisolationpolicy.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -27,8 +27,14 @@ import ( "github.com/streamnative/streamnative-mcp-server/pkg/common" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) +var pulsarNsIsolationPolicyWriteOperations = map[string]struct{}{ + "set": {}, + "delete": {}, +} + // PulsarAdminNsIsolationPolicyToolBuilder implements the ToolBuilder interface for Pulsar admin namespace isolation policies // /nolint:revive type PulsarAdminNsIsolationPolicyToolBuilder struct { @@ -69,20 +75,24 @@ func (b *PulsarAdminNsIsolationPolicyToolBuilder) BuildTools(_ context.Context, return nil, err } - // Build tools - tool := b.buildNsIsolationPolicyTool() - handler := b.buildNsIsolationPolicyHandler(config.ReadOnly) - - return []server.ServerTool{ + tools := []server.ServerTool{ { - Tool: tool, - Handler: handler, + Tool: b.buildNsIsolationPolicyTool(toolModeRead), + Handler: b.buildNsIsolationPolicyHandler(toolModeRead), }, - }, nil + } + if !config.ReadOnly { + tools = append(tools, server.ServerTool{ + Tool: b.buildNsIsolationPolicyTool(toolModeWrite), + Handler: b.buildNsIsolationPolicyHandler(toolModeWrite), + }) + } + + return tools, nil } // buildNsIsolationPolicyTool builds the Pulsar admin namespace isolation policy MCP tool definition -func (b *PulsarAdminNsIsolationPolicyToolBuilder) buildNsIsolationPolicyTool() mcp.Tool { +func (b *PulsarAdminNsIsolationPolicyToolBuilder) buildNsIsolationPolicyTool(mode toolMode) mcp.Tool { toolDesc := "Manage namespace isolation policies in a Pulsar cluster. " + "Allows viewing, creating, updating, and deleting namespace isolation policies. " + "Some operations require super-user permissions." @@ -98,13 +108,23 @@ func (b *PulsarAdminNsIsolationPolicyToolBuilder) buildNsIsolationPolicyTool() m "- set: Create or update a resource (requires super-user permissions)\n" + "- delete: Delete a resource (requires super-user permissions)" - return mcp.NewTool("pulsar_admin_nsisolationpolicy", + operationEnum := []string{"get", "list"} + toolName := "pulsar_admin_nsisolationpolicy_read" + annotation := toolannotations.ReadOnly("Read Pulsar Namespace Isolation Policies") + if isToolModeWrite(mode) { + operationEnum = []string{"set", "delete"} + toolName = "pulsar_admin_nsisolationpolicy_write" + annotation = toolannotations.Destructive("Manage Pulsar Namespace Isolation Policies") + } + + return mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), ), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), + mcp.Enum(operationEnum...), ), mcp.WithString("cluster", mcp.Required(), mcp.Description("Cluster name"), @@ -146,11 +166,12 @@ func (b *PulsarAdminNsIsolationPolicyToolBuilder) buildNsIsolationPolicyTool() m mcp.WithObject("autoFailoverPolicyParams", mcp.Description("Auto failover policy parameters as an object (e.g., {'min_limit': '1', 'usage_threshold': '100'}). Optional for policy.set"), ), + annotation, ) } // buildNsIsolationPolicyHandler builds the Pulsar admin namespace isolation policy handler function -func (b *PulsarAdminNsIsolationPolicyToolBuilder) buildNsIsolationPolicyHandler(readOnly bool) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (b *PulsarAdminNsIsolationPolicyToolBuilder) buildNsIsolationPolicyHandler(mode toolMode) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Get Pulsar session from context session := mcpCtx.GetPulsarSession(ctx) @@ -183,9 +204,8 @@ func (b *PulsarAdminNsIsolationPolicyToolBuilder) buildNsIsolationPolicyHandler( resource = strings.ToLower(resource) operation = strings.ToLower(operation) - // Validate write operations in read-only mode - if readOnly && (operation == "set" || operation == "delete") { - return mcp.NewToolResultError("Write operations are not allowed in read-only mode"), nil + if !validateModeOperation(mode, operation, pulsarNsIsolationPolicyWriteOperations) { + return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil } // Dispatch based on resource type diff --git a/pkg/mcp/builders/pulsar/packages.go b/pkg/mcp/builders/pulsar/packages.go index 7d4d0f03..f8051395 100644 --- a/pkg/mcp/builders/pulsar/packages.go +++ b/pkg/mcp/builders/pulsar/packages.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -25,8 +25,15 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) +var pulsarPackageWriteOperations = map[string]struct{}{ + "update": {}, + "delete": {}, + "upload": {}, +} + // PulsarAdminPackagesToolBuilder implements the ToolBuilder interface for Pulsar admin packages // /nolint:revive type PulsarAdminPackagesToolBuilder struct { @@ -67,20 +74,24 @@ func (b *PulsarAdminPackagesToolBuilder) BuildTools(_ context.Context, config bu return nil, err } - // Build tools - tool := b.buildPackagesTool() - handler := b.buildPackagesHandler(config.ReadOnly) - - return []server.ServerTool{ + tools := []server.ServerTool{ { - Tool: tool, - Handler: handler, + Tool: b.buildPackagesTool(toolModeRead), + Handler: b.buildPackagesHandler(toolModeRead), }, - }, nil + } + if !config.ReadOnly { + tools = append(tools, server.ServerTool{ + Tool: b.buildPackagesTool(toolModeWrite), + Handler: b.buildPackagesHandler(toolModeWrite), + }) + } + + return tools, nil } // buildPackagesTool builds the Pulsar admin packages MCP tool definition -func (b *PulsarAdminPackagesToolBuilder) buildPackagesTool() mcp.Tool { +func (b *PulsarAdminPackagesToolBuilder) buildPackagesTool(mode toolMode) mcp.Tool { toolDesc := "Manage packages in Apache Pulsar. Support package scheme: `function://`, `source://`, `sink://`" + "Allows listing, viewing, updating, downloading and uploading packages. " + "Some operations require super-user permissions." @@ -97,13 +108,23 @@ func (b *PulsarAdminPackagesToolBuilder) buildPackagesTool() mcp.Tool { "- download: Download a package (requires super-user permissions)\n" + "- upload: Upload a package (requires super-user permissions)" - return mcp.NewTool("pulsar_admin_package", + operationEnum := []string{"list", "get", "download"} + toolName := "pulsar_admin_package_read" + annotation := toolannotations.ReadOnly("Read Pulsar Packages") + if isToolModeWrite(mode) { + operationEnum = []string{"update", "delete", "upload"} + toolName = "pulsar_admin_package_write" + annotation = toolannotations.Destructive("Manage Pulsar Packages") + } + + return mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), ), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), + mcp.Enum(operationEnum...), ), mcp.WithString("packageName", mcp.Description("Name of the package to operate on. "+ @@ -127,11 +148,12 @@ func (b *PulsarAdminPackagesToolBuilder) buildPackagesTool() mcp.Tool { mcp.WithObject("properties", mcp.Description("Additional properties for the package as key-value pairs. Optional for update and upload operations"), ), + annotation, ) } // buildPackagesHandler builds the Pulsar admin packages handler function -func (b *PulsarAdminPackagesToolBuilder) buildPackagesHandler(readOnly bool) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (b *PulsarAdminPackagesToolBuilder) buildPackagesHandler(mode toolMode) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Get required parameters resource, err := request.RequireString("resource") @@ -148,9 +170,8 @@ func (b *PulsarAdminPackagesToolBuilder) buildPackagesHandler(readOnly bool) fun resource = strings.ToLower(resource) operation = strings.ToLower(operation) - // Validate write operations in read-only mode - if readOnly && (operation == "update" || operation == "delete" || operation == "download" || operation == "upload") { - return mcp.NewToolResultError("Write operations are not allowed in read-only mode"), nil + if !validateModeOperation(mode, operation, pulsarPackageWriteOperations) { + return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil } // Get Pulsar session from context diff --git a/pkg/mcp/builders/pulsar/produce.go b/pkg/mcp/builders/pulsar/produce.go index 59b7f9f3..a1a5d0ab 100644 --- a/pkg/mcp/builders/pulsar/produce.go +++ b/pkg/mcp/builders/pulsar/produce.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -26,6 +26,7 @@ import ( "github.com/mark3labs/mcp-go/server" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) // PulsarClientProduceToolBuilder implements the ToolBuilder interface for Pulsar Client Producer tools @@ -100,6 +101,7 @@ func (b *PulsarClientProduceToolBuilder) buildProduceTool() mcp.Tool { return mcp.NewTool("pulsar_client_produce", mcp.WithDescription(toolDesc), + toolannotations.Destructive("Produce Pulsar Messages"), mcp.WithString("topic", mcp.Required(), mcp.Description("The fully qualified topic name to produce to (format: [persistent|non-persistent]://tenant/namespace/topic). "+ "For partitioned topics, messages will be distributed across partitions based on the partitioning scheme. "+ diff --git a/pkg/mcp/builders/pulsar/resourcequotas.go b/pkg/mcp/builders/pulsar/resourcequotas.go index a1e3766b..125a4572 100644 --- a/pkg/mcp/builders/pulsar/resourcequotas.go +++ b/pkg/mcp/builders/pulsar/resourcequotas.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -26,8 +26,14 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) +var pulsarResourceQuotaWriteOperations = map[string]struct{}{ + "set": {}, + "reset": {}, +} + // PulsarAdminResourceQuotasToolBuilder implements the ToolBuilder interface for Pulsar admin resource quotas // /nolint:revive type PulsarAdminResourceQuotasToolBuilder struct { @@ -68,20 +74,24 @@ func (b *PulsarAdminResourceQuotasToolBuilder) BuildTools(_ context.Context, con return nil, err } - // Build tools - tool := b.buildResourceQuotasTool() - handler := b.buildResourceQuotasHandler(config.ReadOnly) - - return []server.ServerTool{ + tools := []server.ServerTool{ { - Tool: tool, - Handler: handler, + Tool: b.buildResourceQuotasTool(toolModeRead), + Handler: b.buildResourceQuotasHandler(toolModeRead), }, - }, nil + } + if !config.ReadOnly { + tools = append(tools, server.ServerTool{ + Tool: b.buildResourceQuotasTool(toolModeWrite), + Handler: b.buildResourceQuotasHandler(toolModeWrite), + }) + } + + return tools, nil } // buildResourceQuotasTool builds the Pulsar admin resource quotas MCP tool definition -func (b *PulsarAdminResourceQuotasToolBuilder) buildResourceQuotasTool() mcp.Tool { +func (b *PulsarAdminResourceQuotasToolBuilder) buildResourceQuotasTool(mode toolMode) mcp.Tool { toolDesc := "Manage Apache Pulsar resource quotas for brokers, namespaces and bundles. " + "Resource quotas define limits for resource usage such as message rates, bandwidth, and memory. " + "These quotas help prevent resource abuse and ensure fair resource allocation across the Pulsar cluster. " + @@ -96,13 +106,23 @@ func (b *PulsarAdminResourceQuotasToolBuilder) buildResourceQuotasTool() mcp.Too "- set: Set the resource quota for a specified namespace bundle or default quota (requires super-user permissions)\n" + "- reset: Reset a namespace bundle's resource quota to default value (requires super-user permissions)" - return mcp.NewTool("pulsar_admin_resourcequota", + operationEnum := []string{"get"} + toolName := "pulsar_admin_resourcequota_read" + annotation := toolannotations.ReadOnly("Read Pulsar Resource Quotas") + if isToolModeWrite(mode) { + operationEnum = []string{"set", "reset"} + toolName = "pulsar_admin_resourcequota_write" + annotation = toolannotations.Destructive("Manage Pulsar Resource Quotas") + } + + return mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), ), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), + mcp.Enum(operationEnum...), ), mcp.WithString("namespace", mcp.Description("The namespace name in the format 'tenant/namespace'. "+ @@ -137,11 +157,12 @@ func (b *PulsarAdminResourceQuotasToolBuilder) buildResourceQuotasTool() mcp.Too mcp.Description("Whether to allow quota to be dynamically re-calculated. Optional for 'set' operation. "+ "If true, the broker can dynamically adjust the quota based on the current usage patterns."), ), + annotation, ) } // buildResourceQuotasHandler builds the Pulsar admin resource quotas handler function -func (b *PulsarAdminResourceQuotasToolBuilder) buildResourceQuotasHandler(readOnly bool) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (b *PulsarAdminResourceQuotasToolBuilder) buildResourceQuotasHandler(mode toolMode) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Get required parameters resource, err := request.RequireString("resource") @@ -158,9 +179,8 @@ func (b *PulsarAdminResourceQuotasToolBuilder) buildResourceQuotasHandler(readOn resource = strings.ToLower(resource) operation = strings.ToLower(operation) - // Validate write operations in read-only mode - if readOnly && (operation == "set" || operation == "reset") { - return mcp.NewToolResultError("Write operations are not allowed in read-only mode"), nil + if !validateModeOperation(mode, operation, pulsarResourceQuotaWriteOperations) { + return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil } // Verify resource type diff --git a/pkg/mcp/builders/pulsar/schema.go b/pkg/mcp/builders/pulsar/schema.go index 13fc41a7..f8043544 100644 --- a/pkg/mcp/builders/pulsar/schema.go +++ b/pkg/mcp/builders/pulsar/schema.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -29,8 +29,14 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) +var pulsarSchemaWriteOperations = map[string]struct{}{ + "upload": {}, + "delete": {}, +} + // PulsarAdminSchemaToolBuilder implements the ToolBuilder interface for Pulsar Admin Schema tools // It provides functionality to build Pulsar schema management tools // /nolint:revive @@ -73,21 +79,25 @@ func (b *PulsarAdminSchemaToolBuilder) BuildTools(_ context.Context, config buil return nil, err } - // Build tools - tool := b.buildSchemaTool() - handler := b.buildSchemaHandler(config.ReadOnly) - - return []server.ServerTool{ + tools := []server.ServerTool{ { - Tool: tool, - Handler: handler, + Tool: b.buildSchemaTool(toolModeRead), + Handler: b.buildSchemaHandler(toolModeRead), }, - }, nil + } + if !config.ReadOnly { + tools = append(tools, server.ServerTool{ + Tool: b.buildSchemaTool(toolModeWrite), + Handler: b.buildSchemaHandler(toolModeWrite), + }) + } + + return tools, nil } // buildSchemaTool builds the Pulsar Admin Schema MCP tool definition // Migrated from the original tool definition logic -func (b *PulsarAdminSchemaToolBuilder) buildSchemaTool() mcp.Tool { +func (b *PulsarAdminSchemaToolBuilder) buildSchemaTool(mode toolMode) mcp.Tool { toolDesc := "Manage Apache Pulsar schemas for topics. " + "Schemas in Pulsar define the structure of message data, enabling data validation, evolution, and interoperability. " + "Pulsar supports multiple schema types including AVRO, JSON, PROTOBUF, etc., allowing strong typing of message content. " + @@ -103,13 +113,23 @@ func (b *PulsarAdminSchemaToolBuilder) buildSchemaTool() mcp.Tool { "- upload: Upload a new schema for a topic (requires namespace admin permissions)\n" + "- delete: Delete the schema for a topic (requires namespace admin permissions)" - return mcp.NewTool("pulsar_admin_schema", + operationEnum := []string{"get"} + toolName := "pulsar_admin_schema_read" + annotation := toolannotations.ReadOnly("Read Pulsar Schemas") + if isToolModeWrite(mode) { + operationEnum = []string{"upload", "delete"} + toolName = "pulsar_admin_schema_write" + annotation = toolannotations.Destructive("Manage Pulsar Schemas") + } + + return mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), ), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), + mcp.Enum(operationEnum...), ), mcp.WithString("topic", mcp.Required(), mcp.Description("The fully qualified topic name in the format 'persistent://tenant/namespace/topic'. "+ @@ -126,12 +146,13 @@ func (b *PulsarAdminSchemaToolBuilder) buildSchemaTool() mcp.Tool { "The file should contain a JSON object with 'type', 'schema', and optionally 'properties' fields. "+ "Supported schema types include: AVRO, JSON, PROTOBUF, PROTOBUF_NATIVE, KEY_VALUE, BYTES, STRING, INT8, INT16, INT32, INT64, FLOAT, DOUBLE, BOOLEAN, NONE."), ), + annotation, ) } // buildSchemaHandler builds the Pulsar Admin Schema handler function // Migrated from the original handler logic -func (b *PulsarAdminSchemaToolBuilder) buildSchemaHandler(readOnly bool) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (b *PulsarAdminSchemaToolBuilder) buildSchemaHandler(mode toolMode) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Get required parameters resource, err := request.RequireString("resource") @@ -153,9 +174,8 @@ func (b *PulsarAdminSchemaToolBuilder) buildSchemaHandler(readOnly bool) func(co resource = strings.ToLower(resource) operation = strings.ToLower(operation) - // Validate write operations in read-only mode - if readOnly && (operation == "upload" || operation == "delete") { - return mcp.NewToolResultError("Write operations are not allowed in read-only mode"), nil + if !validateModeOperation(mode, operation, pulsarSchemaWriteOperations) { + return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil } // Verify resource type diff --git a/pkg/mcp/builders/pulsar/sinks.go b/pkg/mcp/builders/pulsar/sinks.go index 7bd7e708..4239cb30 100644 --- a/pkg/mcp/builders/pulsar/sinks.go +++ b/pkg/mcp/builders/pulsar/sinks.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -28,9 +28,19 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" "gopkg.in/yaml.v2" ) +var pulsarSinkWriteOperations = map[string]struct{}{ + "create": {}, + "update": {}, + "delete": {}, + "start": {}, + "stop": {}, + "restart": {}, +} + // PulsarAdminSinksToolBuilder implements the ToolBuilder interface for Pulsar admin sinks // /nolint:revive type PulsarAdminSinksToolBuilder struct { @@ -71,20 +81,24 @@ func (b *PulsarAdminSinksToolBuilder) BuildTools(_ context.Context, config build return nil, err } - // Build tools - tool := b.buildSinksTool() - handler := b.buildSinksHandler(config.ReadOnly) - - return []server.ServerTool{ + tools := []server.ServerTool{ { - Tool: tool, - Handler: handler, + Tool: b.buildSinksTool(toolModeRead), + Handler: b.buildSinksHandler(toolModeRead), }, - }, nil + } + if !config.ReadOnly { + tools = append(tools, server.ServerTool{ + Tool: b.buildSinksTool(toolModeWrite), + Handler: b.buildSinksHandler(toolModeWrite), + }) + } + + return tools, nil } // buildSinksTool builds the Pulsar admin sinks MCP tool definition -func (b *PulsarAdminSinksToolBuilder) buildSinksTool() mcp.Tool { +func (b *PulsarAdminSinksToolBuilder) buildSinksTool(mode toolMode) mcp.Tool { toolDesc := "Manage Apache Pulsar Sinks for data movement and integration. " + "Pulsar Sinks are connectors that export data from Pulsar topics to external systems such as databases, " + "storage services, messaging systems, and third-party applications. " + @@ -108,10 +122,20 @@ func (b *PulsarAdminSinksToolBuilder) buildSinksTool() mcp.Tool { "- restart: Restart a sink\n" + "- list-built-in: List all built-in sink connectors available in the system" - return mcp.NewTool("pulsar_admin_sinks", + operationEnum := []string{"list", "get", "status", "list-built-in"} + toolName := "pulsar_admin_sinks_read" + annotation := toolannotations.ReadOnly("Read Pulsar Sinks") + if isToolModeWrite(mode) { + operationEnum = []string{"create", "update", "delete", "start", "stop", "restart"} + toolName = "pulsar_admin_sinks_write" + annotation = toolannotations.Destructive("Manage Pulsar Sinks") + } + + return mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("operation", mcp.Required(), - mcp.Description(operationDesc)), + mcp.Description(operationDesc), + mcp.Enum(operationEnum...)), mcp.WithString("tenant", mcp.Description("The tenant name. Tenants are the primary organizational unit in Pulsar, "+ "providing multi-tenancy and resource isolation. Sinks deployed within a tenant "+ @@ -232,11 +256,12 @@ func (b *PulsarAdminSinksToolBuilder) buildSinksTool() mcp.Tool { mcp.Description("Transform function configuration. Optional for 'create' and 'update'.")), mcp.WithBoolean("update-auth-data", mcp.Description("Whether to update authentication data during sink update. Optional for 'update' only.")), + annotation, ) } // buildSinksHandler builds the Pulsar admin sinks handler function -func (b *PulsarAdminSinksToolBuilder) buildSinksHandler(readOnly bool) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (b *PulsarAdminSinksToolBuilder) buildSinksHandler(mode toolMode) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Extract and validate operation parameter operation, err := request.RequireString("operation") @@ -254,14 +279,8 @@ func (b *PulsarAdminSinksToolBuilder) buildSinksHandler(readOnly bool) func(cont return mcp.NewToolResultError(fmt.Sprintf("Invalid operation: '%s'. Supported operations: list, get, status, create, update, delete, start, stop, restart, list-built-in", operation)), nil } - // Check write permissions for write operations - writeOperations := map[string]bool{ - "create": true, "update": true, "delete": true, "start": true, - "stop": true, "restart": true, - } - - if readOnly && writeOperations[operation] { - return mcp.NewToolResultError(fmt.Sprintf("Operation '%s' not allowed in read-only mode. Read-only mode restricts modifications to Pulsar Sinks.", operation)), nil + if !validateModeOperation(mode, operation, pulsarSinkWriteOperations) { + return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil } // Get Pulsar session from context diff --git a/pkg/mcp/builders/pulsar/sources.go b/pkg/mcp/builders/pulsar/sources.go index 2344ba2e..58c7bb77 100644 --- a/pkg/mcp/builders/pulsar/sources.go +++ b/pkg/mcp/builders/pulsar/sources.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -28,9 +28,19 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" "gopkg.in/yaml.v2" ) +var pulsarSourceWriteOperations = map[string]struct{}{ + "create": {}, + "update": {}, + "delete": {}, + "start": {}, + "stop": {}, + "restart": {}, +} + // PulsarAdminSourcesToolBuilder implements the ToolBuilder interface for Pulsar admin sources // /nolint:revive type PulsarAdminSourcesToolBuilder struct { @@ -71,20 +81,24 @@ func (b *PulsarAdminSourcesToolBuilder) BuildTools(_ context.Context, config bui return nil, err } - // Build tools - tool := b.buildSourcesTool() - handler := b.buildSourcesHandler(config.ReadOnly) - - return []server.ServerTool{ + tools := []server.ServerTool{ { - Tool: tool, - Handler: handler, + Tool: b.buildSourcesTool(toolModeRead), + Handler: b.buildSourcesHandler(toolModeRead), }, - }, nil + } + if !config.ReadOnly { + tools = append(tools, server.ServerTool{ + Tool: b.buildSourcesTool(toolModeWrite), + Handler: b.buildSourcesHandler(toolModeWrite), + }) + } + + return tools, nil } // buildSourcesTool builds the Pulsar admin sources MCP tool definition -func (b *PulsarAdminSourcesToolBuilder) buildSourcesTool() mcp.Tool { +func (b *PulsarAdminSourcesToolBuilder) buildSourcesTool(mode toolMode) mcp.Tool { toolDesc := "Manage Apache Pulsar Sources for data ingestion and integration. " + "Pulsar Sources are connectors that import data from external systems into Pulsar topics. " + "Sources connect to external systems such as databases, messaging platforms, storage services, " + @@ -107,10 +121,20 @@ func (b *PulsarAdminSourcesToolBuilder) buildSourcesTool() mcp.Tool { "- restart: Restart a source\n" + "- list-built-in: List all built-in source connectors available in the system" - return mcp.NewTool("pulsar_admin_sources", + operationEnum := []string{"list", "get", "status", "list-built-in"} + toolName := "pulsar_admin_sources_read" + annotation := toolannotations.ReadOnly("Read Pulsar Sources") + if isToolModeWrite(mode) { + operationEnum = []string{"create", "update", "delete", "start", "stop", "restart"} + toolName = "pulsar_admin_sources_write" + annotation = toolannotations.Destructive("Manage Pulsar Sources") + } + + return mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("operation", mcp.Required(), - mcp.Description(operationDesc)), + mcp.Description(operationDesc), + mcp.Enum(operationEnum...)), mcp.WithString("tenant", mcp.Description("The tenant name. Tenants are the primary organizational unit in Pulsar, "+ "providing multi-tenancy and resource isolation. Sources deployed within a tenant "+ @@ -199,11 +223,12 @@ func (b *PulsarAdminSourcesToolBuilder) buildSourcesTool() mcp.Tool { "Example: {\"topic\": \"external-kafka-topic\", \"bootstrapServers\": \"kafka:9092\"}")), mcp.WithBoolean("update-auth-data", mcp.Description("Whether to update authentication data during source update. Optional for 'update' only.")), + annotation, ) } // buildSourcesHandler builds the Pulsar admin sources handler function -func (b *PulsarAdminSourcesToolBuilder) buildSourcesHandler(readOnly bool) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (b *PulsarAdminSourcesToolBuilder) buildSourcesHandler(mode toolMode) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Extract and validate operation parameter operation, err := request.RequireString("operation") @@ -221,14 +246,8 @@ func (b *PulsarAdminSourcesToolBuilder) buildSourcesHandler(readOnly bool) func( return mcp.NewToolResultError(fmt.Sprintf("Invalid operation: '%s'. Supported operations: list, get, status, create, update, delete, start, stop, restart, list-built-in", operation)), nil } - // Check write permissions for write operations - writeOperations := map[string]bool{ - "create": true, "update": true, "delete": true, "start": true, - "stop": true, "restart": true, - } - - if readOnly && writeOperations[operation] { - return mcp.NewToolResultError(fmt.Sprintf("Operation '%s' not allowed in read-only mode. Read-only mode restricts modifications to Pulsar Sources.", operation)), nil + if !validateModeOperation(mode, operation, pulsarSourceWriteOperations) { + return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil } // Get Pulsar session from context diff --git a/pkg/mcp/builders/pulsar/status.go b/pkg/mcp/builders/pulsar/status.go index 18184251..56950d84 100644 --- a/pkg/mcp/builders/pulsar/status.go +++ b/pkg/mcp/builders/pulsar/status.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -23,6 +23,7 @@ import ( "github.com/mark3labs/mcp-go/server" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) // PulsarAdminStatusToolBuilder implements the ToolBuilder interface for Pulsar status checks. @@ -75,6 +76,7 @@ func (b *PulsarAdminStatusToolBuilder) buildStatusTool() mcp.Tool { return mcp.NewTool("pulsar_admin_status", mcp.WithDescription("Check Pulsar broker or proxy service status via the /status.html endpoint. "+ "This is equivalent to `pulsarctl status check` and requires super-user permissions."), + toolannotations.ReadOnly("Check Pulsar Status"), ) } diff --git a/pkg/mcp/builders/pulsar/subscription.go b/pkg/mcp/builders/pulsar/subscription.go index 2c9b9c1e..ef8feded 100644 --- a/pkg/mcp/builders/pulsar/subscription.go +++ b/pkg/mcp/builders/pulsar/subscription.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -29,6 +29,7 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) var supportedSubscriptionOperations = map[string]struct{}{ @@ -103,21 +104,25 @@ func (b *PulsarAdminSubscriptionToolBuilder) BuildTools(_ context.Context, confi return nil, err } - // Build tools - tool := b.buildSubscriptionTool() - handler := b.buildSubscriptionHandler(config.ReadOnly) - - return []server.ServerTool{ + tools := []server.ServerTool{ { - Tool: tool, - Handler: handler, + Tool: b.buildSubscriptionTool(toolModeRead), + Handler: b.buildSubscriptionHandler(toolModeRead), }, - }, nil + } + if !config.ReadOnly { + tools = append(tools, server.ServerTool{ + Tool: b.buildSubscriptionTool(toolModeWrite), + Handler: b.buildSubscriptionHandler(toolModeWrite), + }) + } + + return tools, nil } // buildSubscriptionTool builds the Pulsar Admin Subscription MCP tool definition // Migrated from the original tool definition logic -func (b *PulsarAdminSubscriptionToolBuilder) buildSubscriptionTool() mcp.Tool { +func (b *PulsarAdminSubscriptionToolBuilder) buildSubscriptionTool(mode toolMode) mcp.Tool { toolDesc := "Manage Apache Pulsar subscriptions on topics. " + "Subscriptions are named entities representing consumer groups that maintain their position in a topic. " + "Pulsar supports multiple subscription modes (Exclusive, Shared, Failover, Key_Shared) to accommodate different messaging patterns. " + @@ -139,13 +144,23 @@ func (b *PulsarAdminSubscriptionToolBuilder) buildSubscriptionTool() mcp.Tool { "- peek: Peek one or more messages for a subscription without advancing the cursor\n" + "- get-message-by-id: Read a message by ledger ID and entry ID for topic-level debugging" - return mcp.NewTool("pulsar_admin_subscription", + operationEnum := []string{"list", "peek", "get-message-by-id"} + toolName := "pulsar_admin_subscription_read" + annotation := toolannotations.ReadOnly("Read Pulsar Subscriptions") + if isToolModeWrite(mode) { + operationEnum = []string{"create", "delete", "skip", "expire", "reset-cursor"} + toolName = "pulsar_admin_subscription_write" + annotation = toolannotations.Destructive("Manage Pulsar Subscriptions") + } + + return mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), ), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), + mcp.Enum(operationEnum...), ), mcp.WithString("topic", mcp.Required(), mcp.Description("The fully qualified topic name in the format 'persistent://tenant/namespace/topic'. "+ @@ -184,12 +199,13 @@ func (b *PulsarAdminSubscriptionToolBuilder) buildSubscriptionTool() mcp.Tool { "When true, all consumers will be forcefully disconnected and the subscription will be deleted. "+ "Use with caution as it can interrupt active message processing."), ), + annotation, ) } // buildSubscriptionHandler builds the Pulsar Admin Subscription handler function // Migrated from the original handler logic -func (b *PulsarAdminSubscriptionToolBuilder) buildSubscriptionHandler(readOnly bool) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (b *PulsarAdminSubscriptionToolBuilder) buildSubscriptionHandler(mode toolMode) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Get required parameters resource, err := request.RequireString("resource") @@ -215,9 +231,8 @@ func (b *PulsarAdminSubscriptionToolBuilder) buildSubscriptionHandler(readOnly b return mcp.NewToolResultError(fmt.Sprintf("Unknown operation: %s", operation)), nil } - // Validate write operations in read-only mode - if readOnly && isReadOnlyRestrictedSubscriptionOperation(operation) { - return mcp.NewToolResultError("Write operations are not allowed in read-only mode"), nil + if !validateModeOperation(mode, operation, readOnlyRestrictedSubscriptionOperations) { + return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil } // Verify resource type diff --git a/pkg/mcp/builders/pulsar/tenant.go b/pkg/mcp/builders/pulsar/tenant.go index 8a0d4573..3202f4ac 100644 --- a/pkg/mcp/builders/pulsar/tenant.go +++ b/pkg/mcp/builders/pulsar/tenant.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -26,8 +26,15 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) +var pulsarTenantWriteOperations = map[string]struct{}{ + "create": {}, + "update": {}, + "delete": {}, +} + // PulsarAdminTenantToolBuilder implements the ToolBuilder interface for Pulsar Admin Tenant tools // It provides functionality to build Pulsar tenant management tools // /nolint:revive @@ -70,21 +77,25 @@ func (b *PulsarAdminTenantToolBuilder) BuildTools(_ context.Context, config buil return nil, err } - // Build tools - tool := b.buildTenantTool() - handler := b.buildTenantHandler(config.ReadOnly) - - return []server.ServerTool{ + tools := []server.ServerTool{ { - Tool: tool, - Handler: handler, + Tool: b.buildTenantTool(toolModeRead), + Handler: b.buildTenantHandler(toolModeRead), }, - }, nil + } + if !config.ReadOnly { + tools = append(tools, server.ServerTool{ + Tool: b.buildTenantTool(toolModeWrite), + Handler: b.buildTenantHandler(toolModeWrite), + }) + } + + return tools, nil } // buildTenantTool builds the Pulsar Admin Tenant MCP tool definition // Migrated from the original tool definition logic -func (b *PulsarAdminTenantToolBuilder) buildTenantTool() mcp.Tool { +func (b *PulsarAdminTenantToolBuilder) buildTenantTool(mode toolMode) mcp.Tool { toolDesc := "Manage Apache Pulsar tenants. " + "Tenants are the highest level administrative unit in Pulsar's multi-tenancy hierarchy. " + "Each tenant can contain multiple namespaces, allowing for logical isolation of applications. " + @@ -104,13 +115,23 @@ func (b *PulsarAdminTenantToolBuilder) buildTenantTool() mcp.Tool { "- update: Update configuration for an existing tenant\n" + "- delete: Delete an existing tenant (must not have any active namespaces)" - return mcp.NewTool("pulsar_admin_tenant", + operationEnum := []string{"list", "get"} + toolName := "pulsar_admin_tenant_read" + annotation := toolannotations.ReadOnly("Read Pulsar Tenants") + if isToolModeWrite(mode) { + operationEnum = []string{"create", "update", "delete"} + toolName = "pulsar_admin_tenant_write" + annotation = toolannotations.Destructive("Manage Pulsar Tenants") + } + + return mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), ), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), + mcp.Enum(operationEnum...), ), mcp.WithString("tenant", mcp.Description("The tenant name to operate on. Required for all operations except 'list'. "+ @@ -143,12 +164,13 @@ func (b *PulsarAdminTenantToolBuilder) buildTenantTool() mcp.Tool { }, ), ), + annotation, ) } // buildTenantHandler builds the Pulsar Admin Tenant handler function // Migrated from the original handler logic -func (b *PulsarAdminTenantToolBuilder) buildTenantHandler(readOnly bool) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (b *PulsarAdminTenantToolBuilder) buildTenantHandler(mode toolMode) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Get required parameters resource, err := request.RequireString("resource") @@ -170,9 +192,8 @@ func (b *PulsarAdminTenantToolBuilder) buildTenantHandler(readOnly bool) func(co return mcp.NewToolResultError(fmt.Sprintf("Invalid resource: %s. Only 'tenant' is supported.", resource)), nil } - // Validate write operations in read-only mode - if readOnly && (operation == "create" || operation == "update" || operation == "delete") { - return mcp.NewToolResultError("Write operations are not allowed in read-only mode"), nil + if !validateModeOperation(mode, operation, pulsarTenantWriteOperations) { + return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil } // Get Pulsar session from context diff --git a/pkg/mcp/builders/pulsar/tool_mode.go b/pkg/mcp/builders/pulsar/tool_mode.go new file mode 100644 index 00000000..17467786 --- /dev/null +++ b/pkg/mcp/builders/pulsar/tool_mode.go @@ -0,0 +1,37 @@ +// Copyright 2026 StreamNative +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pulsar + +import "strings" + +type toolMode string + +const ( + toolModeRead toolMode = "read" + toolModeWrite toolMode = "write" +) + +func isToolModeWrite(mode toolMode) bool { + return mode == toolModeWrite +} + +func isWriteOperation(operation string, writeOperations map[string]struct{}) bool { + _, ok := writeOperations[strings.ToLower(operation)] + return ok +} + +func validateModeOperation(mode toolMode, operation string, writeOperations map[string]struct{}) bool { + return (mode == toolModeWrite) == isWriteOperation(operation, writeOperations) +} diff --git a/pkg/mcp/builders/pulsar/topic.go b/pkg/mcp/builders/pulsar/topic.go index f75db319..a0cde8d9 100644 --- a/pkg/mcp/builders/pulsar/topic.go +++ b/pkg/mcp/builders/pulsar/topic.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -29,6 +29,7 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) var readOnlyRestrictedTopicOperations = map[string]struct{}{ @@ -91,21 +92,25 @@ func (b *PulsarAdminTopicToolBuilder) BuildTools(_ context.Context, config build return nil, err } - // Build tools - tool := b.buildTopicTool() - handler := b.buildTopicHandler(config.ReadOnly) - - return []server.ServerTool{ + tools := []server.ServerTool{ { - Tool: tool, - Handler: handler, + Tool: b.buildTopicTool(toolModeRead), + Handler: b.buildTopicHandler(toolModeRead), }, - }, nil + } + if !config.ReadOnly { + tools = append(tools, server.ServerTool{ + Tool: b.buildTopicTool(toolModeWrite), + Handler: b.buildTopicHandler(toolModeWrite), + }) + } + + return tools, nil } // buildTopicTool builds the Pulsar Admin Topic MCP tool definition // Migrated from the original tool definition logic -func (b *PulsarAdminTopicToolBuilder) buildTopicTool() mcp.Tool { +func (b *PulsarAdminTopicToolBuilder) buildTopicTool(mode toolMode) mcp.Tool { toolDesc := "Manage Apache Pulsar topics. " + "Topics are the core messaging entities in Pulsar that store and transmit messages. " + "Pulsar supports two types of topics: persistent (durable storage with guaranteed delivery) " + @@ -144,13 +149,23 @@ func (b *PulsarAdminTopicToolBuilder) buildTopicTool() mcp.Tool { "- offload: Offload data from a topic to long-term storage\n" + "- offload-status: Check the status of data offloading for a topic" - return mcp.NewTool("pulsar_admin_topic", + operationEnum := []string{"list", "get", "get-permissions", "stats", "lookup", "internal-stats", "internal-info", "bundle-range", "last-message-id", "compact-status", "offload-status"} + toolName := "pulsar_admin_topic_read" + annotation := toolannotations.ReadOnly("Read Pulsar Topics") + if isToolModeWrite(mode) { + operationEnum = []string{"grant-permissions", "revoke-permissions", "create", "delete", "unload", "terminate", "compact", "update", "offload"} + toolName = "pulsar_admin_topic_write" + annotation = toolannotations.Destructive("Manage Pulsar Topics") + } + + return mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), ), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), + mcp.Enum(operationEnum...), ), mcp.WithString("topic", mcp.Description("The fully qualified topic name (format: [persistent|non-persistent]://tenant/namespace/topic). "+ @@ -214,12 +229,13 @@ func (b *PulsarAdminTopicToolBuilder) buildTopicTool() mcp.Tool { }, ), ), + annotation, ) } // buildTopicHandler builds the Pulsar Admin Topic handler function // Migrated from the original handler logic -func (b *PulsarAdminTopicToolBuilder) buildTopicHandler(readOnly bool) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (b *PulsarAdminTopicToolBuilder) buildTopicHandler(mode toolMode) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Get required parameters resource, err := request.RequireString("resource") @@ -236,9 +252,8 @@ func (b *PulsarAdminTopicToolBuilder) buildTopicHandler(readOnly bool) func(cont resource = strings.ToLower(resource) operation = normalizeTopicOperation(operation) - // Validate write operations in read-only mode - if readOnly && isReadOnlyRestrictedTopicOperation(operation) { - return mcp.NewToolResultError("Write operations are not allowed in read-only mode"), nil + if !validateModeOperation(mode, operation, readOnlyRestrictedTopicOperations) { + return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil } // Get Pulsar session from context diff --git a/pkg/mcp/builders/pulsar/topic_policy.go b/pkg/mcp/builders/pulsar/topic_policy.go index df04042f..055f9425 100644 --- a/pkg/mcp/builders/pulsar/topic_policy.go +++ b/pkg/mcp/builders/pulsar/topic_policy.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -29,6 +29,7 @@ import ( ctlutil "github.com/streamnative/pulsarctl/pkg/ctl/utils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) var readOnlyRestrictedTopicPolicyOperations = map[string]struct{}{ @@ -66,6 +67,60 @@ var readOnlyRestrictedTopicPolicyOperations = map[string]struct{}{ "remove-subscription-types": {}, } +var readOnlyTopicPolicyOperations = []string{ + "get-retention", + "get-message-ttl", + "get-max-producers", + "get-max-consumers", + "get-max-unacked-messages-per-consumer", + "get-max-unacked-messages-per-subscription", + "get-persistence", + "get-delayed-delivery", + "get-dispatch-rate", + "get-subscription-dispatch-rate", + "get-deduplication", + "get-backlog-quotas", + "get-compaction-threshold", + "get-publish-rate", + "get-inactive-topic-policies", + "get-subscription-types", +} + +var writeTopicPolicyOperations = []string{ + "set-retention", + "remove-retention", + "set-message-ttl", + "remove-message-ttl", + "set-max-producers", + "remove-max-producers", + "set-max-consumers", + "remove-max-consumers", + "set-max-unacked-messages-per-consumer", + "remove-max-unacked-messages-per-consumer", + "set-max-unacked-messages-per-subscription", + "remove-max-unacked-messages-per-subscription", + "set-persistence", + "remove-persistence", + "set-delayed-delivery", + "remove-delayed-delivery", + "set-dispatch-rate", + "remove-dispatch-rate", + "set-subscription-dispatch-rate", + "remove-subscription-dispatch-rate", + "set-deduplication", + "remove-deduplication", + "set-backlog-quota", + "remove-backlog-quota", + "set-compaction-threshold", + "remove-compaction-threshold", + "set-publish-rate", + "remove-publish-rate", + "set-inactive-topic-policies", + "remove-inactive-topic-policies", + "set-subscription-types", + "remove-subscription-types", +} + var topicPolicyOperationAliases = map[string]string{ "get_ttl": "get-message-ttl", "set_ttl": "set-message-ttl", @@ -121,16 +176,24 @@ func (b *PulsarAdminTopicPolicyToolBuilder) BuildTools(_ context.Context, config return nil, err } - return []server.ServerTool{ + tools := []server.ServerTool{ { - Tool: b.buildTopicPolicyTool(), - Handler: b.buildTopicPolicyHandler(config.ReadOnly), + Tool: b.buildTopicPolicyTool(toolModeRead), + Handler: b.buildTopicPolicyHandler(toolModeRead), }, - }, nil + } + if !config.ReadOnly { + tools = append(tools, server.ServerTool{ + Tool: b.buildTopicPolicyTool(toolModeWrite), + Handler: b.buildTopicPolicyHandler(toolModeWrite), + }) + } + + return tools, nil } // buildTopicPolicyTool builds the Pulsar Admin Topic Policy MCP tool definition -func (b *PulsarAdminTopicPolicyToolBuilder) buildTopicPolicyTool() mcp.Tool { +func (b *PulsarAdminTopicPolicyToolBuilder) buildTopicPolicyTool(mode toolMode) mcp.Tool { toolDesc := "Manage Pulsar topic-level policies with operation names aligned to pulsarctl topic policy commands. " + "This tool covers retention, message TTL, producer and consumer limits, persistence, delayed delivery, " + "dispatch throttling, deduplication, backlog quotas, compaction thresholds, publish rates, inactive topic policies, " + @@ -156,10 +219,20 @@ func (b *PulsarAdminTopicPolicyToolBuilder) buildTopicPolicyTool() mcp.Tool { "- get-subscription-types / set-subscription-types / remove-subscription-types: additional MCP-only compatibility operations", }, "\n") - return mcp.NewTool("pulsar_admin_topic_policy", + operationEnum := readOnlyTopicPolicyOperations + toolName := "pulsar_admin_topic_policy_read" + annotation := toolannotations.ReadOnly("Read Pulsar Topic Policies") + if isToolModeWrite(mode) { + operationEnum = writeTopicPolicyOperations + toolName = "pulsar_admin_topic_policy_write" + annotation = toolannotations.Destructive("Manage Pulsar Topic Policies") + } + + return mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), + mcp.Enum(operationEnum...), ), mcp.WithString("topic", mcp.Required(), mcp.Description("Topic name in the format 'persistent://tenant/namespace/topic' or 'tenant/namespace/topic'."), @@ -245,11 +318,12 @@ func (b *PulsarAdminTopicPolicyToolBuilder) buildTopicPolicyTool() mcp.Tool { }, ), ), + annotation, ) } // buildTopicPolicyHandler builds the Pulsar Admin Topic Policy handler function -func (b *PulsarAdminTopicPolicyToolBuilder) buildTopicPolicyHandler(readOnly bool) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (b *PulsarAdminTopicPolicyToolBuilder) buildTopicPolicyHandler(mode toolMode) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { operation, err := request.RequireString("operation") if err != nil { @@ -262,8 +336,8 @@ func (b *PulsarAdminTopicPolicyToolBuilder) buildTopicPolicyHandler(readOnly boo return mcp.NewToolResultError("Missing required parameter 'topic'"), nil } - if readOnly && isReadOnlyRestrictedTopicPolicyOperation(operation) { - return mcp.NewToolResultError("Write operations not allowed in read-only mode"), nil + if !validateModeOperation(mode, operation, readOnlyRestrictedTopicPolicyOperations) { + return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil } session := mcpCtx.GetPulsarSession(ctx) diff --git a/pkg/mcp/builders/pulsar/topic_policy_test.go b/pkg/mcp/builders/pulsar/topic_policy_test.go index d64f1658..c2849821 100644 --- a/pkg/mcp/builders/pulsar/topic_policy_test.go +++ b/pkg/mcp/builders/pulsar/topic_policy_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -26,7 +26,7 @@ import ( func TestBuildTopicPolicyToolIncludesPulsarctlParityOperations(t *testing.T) { builder := NewPulsarAdminTopicPolicyToolBuilder() - tool := builder.buildTopicPolicyTool() + tool := builder.buildTopicPolicyTool(toolModeRead) operationSchema, ok := tool.InputSchema.Properties["operation"].(map[string]any) require.True(t, ok) @@ -42,7 +42,7 @@ func TestBuildTopicPolicyToolIncludesPulsarctlParityOperations(t *testing.T) { func TestBuildTopicPolicyToolIncludesTopicPolicyParameters(t *testing.T) { builder := NewPulsarAdminTopicPolicyToolBuilder() - tool := builder.buildTopicPolicyTool() + tool := builder.buildTopicPolicyTool(toolModeRead) require.Contains(t, tool.InputSchema.Properties, "applied") require.Contains(t, tool.InputSchema.Properties, "count") @@ -76,7 +76,7 @@ func TestTopicPolicyWriteOperationsRespectReadOnly(t *testing.T) { func TestTopicPolicyHandlerBlocksWriteBeforeSessionLookup(t *testing.T) { builder := NewPulsarAdminTopicPolicyToolBuilder() - handler := builder.buildTopicPolicyHandler(true) + handler := builder.buildTopicPolicyHandler(toolModeRead) result, err := handler(context.Background(), mcp.CallToolRequest{ Params: mcp.CallToolParams{ @@ -93,7 +93,7 @@ func TestTopicPolicyHandlerBlocksWriteBeforeSessionLookup(t *testing.T) { text, ok := result.Content[0].(mcp.TextContent) require.True(t, ok) - require.Contains(t, text.Text, "read-only mode") + require.Contains(t, text.Text, "not available in read mode") } func TestBuildDelayedDeliveryDataUsesPulsarctlStyleArguments(t *testing.T) { diff --git a/pkg/mcp/builders/pulsar/topic_test.go b/pkg/mcp/builders/pulsar/topic_test.go index 43ab1eb2..ad0d2ef1 100644 --- a/pkg/mcp/builders/pulsar/topic_test.go +++ b/pkg/mcp/builders/pulsar/topic_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -27,7 +27,7 @@ import ( func TestBuildTopicToolIncludesPermissionOperations(t *testing.T) { builder := NewPulsarAdminTopicToolBuilder() - tool := builder.buildTopicTool() + tool := builder.buildTopicTool(toolModeRead) require.Contains(t, tool.InputSchema.Properties, "role") require.Contains(t, tool.InputSchema.Properties, "actions") @@ -67,7 +67,7 @@ func TestNormalizeTopicOperationSupportsLegacyAliases(t *testing.T) { func TestTopicGrantPermissionsBlockedInReadOnlyMode(t *testing.T) { builder := NewPulsarAdminTopicToolBuilder() - handler := builder.buildTopicHandler(true) + handler := builder.buildTopicHandler(toolModeRead) result, err := handler(context.Background(), mcp.CallToolRequest{ Params: mcp.CallToolParams{ @@ -86,7 +86,7 @@ func TestTopicGrantPermissionsBlockedInReadOnlyMode(t *testing.T) { text, ok := result.Content[0].(mcp.TextContent) require.True(t, ok) - require.Contains(t, text.Text, "read-only mode") + require.Contains(t, text.Text, "not available in read mode") } func TestWaitForTopicLongRunningStatusStopsOnContextCancellation(t *testing.T) { diff --git a/pkg/mcp/pftools/manager.go b/pkg/mcp/pftools/manager.go index 08f799f2..ce14a94a 100644 --- a/pkg/mcp/pftools/manager.go +++ b/pkg/mcp/pftools/manager.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -32,6 +32,7 @@ import ( "github.com/mark3labs/mcp-go/server" "github.com/sirupsen/logrus" "github.com/streamnative/streamnative-mcp-server/pkg/kafka" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" "github.com/streamnative/streamnative-mcp-server/pkg/pulsar" "github.com/streamnative/streamnative-mcp-server/pkg/schema" ) @@ -123,6 +124,10 @@ func NewPulsarFunctionManager(snServer *Server, readOnly bool, options *ManagerO // Start starts polling for functions func (m *PulsarFunctionManager) Start() { + if m.readOnly { + m.logger.Info("Skipping Pulsar Functions-as-Tools registration in read-only mode") + return + } go m.pollFunctions() } @@ -583,7 +588,10 @@ func (m *PulsarFunctionManager) convertFunctionToTool(fn *utils.FunctionConfig) return nil, errors.Join(ErrSchemaConversionFailed, err) } - toolInputSchemaProperties = append(toolInputSchemaProperties, mcp.WithDescription(description)) + toolInputSchemaProperties = append(toolInputSchemaProperties, + mcp.WithDescription(description), + toolannotations.Destructive(fmt.Sprintf("Invoke Pulsar Function %s", toolName)), + ) // Create the tool tool := mcp.NewTool(toolName, diff --git a/pkg/mcp/sncontext_tools.go b/pkg/mcp/sncontext_tools.go index 27104c07..60a92885 100644 --- a/pkg/mcp/sncontext_tools.go +++ b/pkg/mcp/sncontext_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -24,10 +24,11 @@ import ( "github.com/mark3labs/mcp-go/server" "github.com/streamnative/streamnative-mcp-server/pkg/auth/store" "github.com/streamnative/streamnative-mcp-server/pkg/common" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) // RegisterContextTools registers context-related tools on the server. -func RegisterContextTools(s *server.MCPServer, features []string, skipContextTools bool) { +func RegisterContextTools(s *server.MCPServer, features []string, readOnly bool, skipContextTools bool) { if !slices.Contains(features, string(FeatureStreamNativeCloud)) && !slices.Contains(features, string(FeatureAll)) { return } @@ -36,6 +37,7 @@ func RegisterContextTools(s *server.MCPServer, features []string, skipContextToo whoamiTool := mcp.NewTool("sncloud_context_whoami", mcp.WithDescription("Display the currently logged-in service account. "+ "Returns the name of the authenticated service account and the organization."), + toolannotations.ReadOnly("Show StreamNative Cloud Identity"), ) s.AddTool(whoamiTool, handleWhoami) @@ -48,12 +50,14 @@ func RegisterContextTools(s *server.MCPServer, features []string, skipContextToo mcp.WithString("clusterName", mcp.Required(), mcp.Description("The name of the pulsar cluster to use"), ), + toolannotations.Destructive("Use StreamNative Cloud Cluster Context"), ) resetContextTool := mcp.NewTool("sncloud_context_reset", mcp.WithDescription("Reset the current StreamNative Cloud cluster context. After reset, the session has no bound Pulsar or Kafka cluster connection; use `sncloud_context_use_cluster` before calling cluster-specific tools again."), + toolannotations.Destructive("Reset StreamNative Cloud Cluster Context"), ) - // Skip registering context mutation tools if context is already provided - if !skipContextTools { + // Skip registering context mutation tools when context is already provided or the server is read-only. + if !skipContextTools && !readOnly { s.AddTool(setContextTool, handleSetContext) s.AddTool(resetContextTool, handleResetContext) } @@ -61,6 +65,7 @@ func RegisterContextTools(s *server.MCPServer, features []string, skipContextToo // Add available-contexts tool availableContextsTool := mcp.NewTool("sncloud_context_available_clusters", mcp.WithDescription("Display the context-bindable Pulsar clusters for the current organization. You can use `sncloud_context_use_cluster` to change the context to a specific cluster. You will need to ask for the USER to confirm the target context cluster if there are multiple clusters."), + toolannotations.ReadOnly("List StreamNative Cloud Cluster Contexts"), ) s.AddTool(availableContextsTool, handleAvailableContexts) } diff --git a/pkg/mcp/static_tool_annotations_test.go b/pkg/mcp/static_tool_annotations_test.go new file mode 100644 index 00000000..c289b704 --- /dev/null +++ b/pkg/mcp/static_tool_annotations_test.go @@ -0,0 +1,86 @@ +// Copyright 2026 StreamNative +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mcp + +import ( + "testing" + + mcpgotypes "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/require" +) + +func TestStreamNativeStaticToolAnnotations(t *testing.T) { + tools := []struct { + name string + readOnly bool + destructive bool + }{ + {name: "sncloud_logs", readOnly: true}, + {name: "sncloud_resources_apply", destructive: true}, + {name: "sncloud_resources_delete", destructive: true}, + } + + definitions := map[string]struct { + readOnly bool + destructive bool + }{ + "sncloud_logs": {readOnly: true}, + "sncloud_resources_apply": {destructive: true}, + "sncloud_resources_delete": {destructive: true}, + } + + constructed := map[string]mcpgotypes.Tool{ + "sncloud_logs": NewSNCloudLogsTool(), + "sncloud_resources_apply": NewSNCloudResourcesApplyTool(), + "sncloud_resources_delete": NewSNCloudResourcesDeleteTool(), + } + + for _, tt := range tools { + tool := constructed[tt.name] + expected := definitions[tt.name] + require.NotEmpty(t, tool.Annotations.Title, tt.name) + require.NotNil(t, tool.Annotations.ReadOnlyHint, tt.name) + require.NotNil(t, tool.Annotations.DestructiveHint, tt.name) + require.Equal(t, expected.readOnly, *tool.Annotations.ReadOnlyHint, tt.name) + require.Equal(t, expected.destructive, *tool.Annotations.DestructiveHint, tt.name) + } +} + +func TestStreamNativeContextToolAnnotations(t *testing.T) { + server := mcpserver.NewMCPServer("test", "test") + RegisterContextTools(server, []string{string(FeatureStreamNativeCloud)}, false, false) + + expectations := map[string]struct { + readOnly bool + destructive bool + }{ + "sncloud_context_whoami": {readOnly: true}, + "sncloud_context_available_clusters": {readOnly: true}, + "sncloud_context_use_cluster": {destructive: true}, + "sncloud_context_reset": {destructive: true}, + } + + for name, expected := range expectations { + serverTool := server.GetTool(name) + require.NotNil(t, serverTool, name) + tool := serverTool.Tool + require.NotEmpty(t, tool.Annotations.Title, name) + require.NotNil(t, tool.Annotations.ReadOnlyHint, name) + require.NotNil(t, tool.Annotations.DestructiveHint, name) + require.Equal(t, expected.readOnly, *tool.Annotations.ReadOnlyHint, name) + require.Equal(t, expected.destructive, *tool.Annotations.DestructiveHint, name) + } +} diff --git a/pkg/mcp/streamnative_resources_log_tools.go b/pkg/mcp/streamnative_resources_log_tools.go index 37fbc886..8321b4a7 100644 --- a/pkg/mcp/streamnative_resources_log_tools.go +++ b/pkg/mcp/streamnative_resources_log_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -29,6 +29,7 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" context2 "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) // FunctionConnectorList lists supported log components. @@ -81,6 +82,7 @@ func NewSNCloudLogsTool() mcp.Tool { mcp.Description("Return previous terminated container logs, defaults to false."), mcp.DefaultBool(false), ), + toolannotations.ReadOnly("Read StreamNative Cloud Logs"), ) } diff --git a/pkg/mcp/streamnative_resources_tools.go b/pkg/mcp/streamnative_resources_tools.go index 0decb6bb..3493c354 100644 --- a/pkg/mcp/streamnative_resources_tools.go +++ b/pkg/mcp/streamnative_resources_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -28,6 +28,7 @@ import ( "github.com/streamnative/streamnative-mcp-server/pkg/common" "github.com/streamnative/streamnative-mcp-server/pkg/config" context2 "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" sncloud "github.com/streamnative/streamnative-mcp-server/sdk/sdk-apiserver" ) @@ -54,9 +55,7 @@ func NewSNCloudResourcesApplyTool() mcp.Tool { mcp.Description("If true, only validate the resource without applying it to the server."), mcp.DefaultBool(false), ), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: "Apply StreamNative Cloud Resources", - }), + toolannotations.Destructive("Apply StreamNative Cloud Resources"), ) } @@ -71,10 +70,7 @@ func NewSNCloudResourcesDeleteTool() mcp.Tool { mcp.Description("The type of the resource to delete, it can be Instance, PulsarInstance, PulsarCluster, or KafkaCluster."), mcp.Enum("Instance", "PulsarInstance", "PulsarCluster", "KafkaCluster"), ), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: "Delete StreamNative Cloud Resources", - DestructiveHint: &[]bool{true}[0], - }), + toolannotations.Destructive("Delete StreamNative Cloud Resources"), ) } diff --git a/pkg/mcp/toolannotations/annotations.go b/pkg/mcp/toolannotations/annotations.go new file mode 100644 index 00000000..af2cf43e --- /dev/null +++ b/pkg/mcp/toolannotations/annotations.go @@ -0,0 +1,54 @@ +// Copyright 2026 StreamNative +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Copyright 2025 StreamNative +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package toolannotations provides helpers for setting MCP tool safety annotations. +package toolannotations + +import "github.com/mark3labs/mcp-go/mcp" + +// ReadOnly annotates a tool that does not modify external or session state. +func ReadOnly(title string) mcp.ToolOption { + return mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: title, + ReadOnlyHint: boolPtr(true), + DestructiveHint: boolPtr(false), + }) +} + +// Destructive annotates a tool that may modify external resources or session state. +func Destructive(title string) mcp.ToolOption { + return mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: title, + ReadOnlyHint: boolPtr(false), + DestructiveHint: boolPtr(true), + }) +} + +func boolPtr(v bool) *bool { + return &v +} From e9ab4bf6ea8cab47174ba6e5f8921414fa5dba6d Mon Sep 17 00:00:00 2001 From: Rui Fu Date: Tue, 19 May 2026 15:18:08 +0800 Subject: [PATCH 02/10] fix: address PR review comments --- docs/tools/pulsar_admin_functions.md | 2 +- pkg/mcp/static_tool_annotations_test.go | 34 +++++++------------------ pkg/mcp/toolannotations/annotations.go | 14 ---------- 3 files changed, 10 insertions(+), 40 deletions(-) diff --git a/docs/tools/pulsar_admin_functions.md b/docs/tools/pulsar_admin_functions.md index 85a7b85f..adfc70c3 100644 --- a/docs/tools/pulsar_admin_functions.md +++ b/docs/tools/pulsar_admin_functions.md @@ -1,6 +1,6 @@ #### pulsar_admin_functions -**Claude connector safety:** Actual MCP tools: `pulsar_admin_functions_read` (`list`, `get`, `status`, `stats`, `querystate`) and `pulsar_admin_functions_write` (`create`, `update`, `delete`, `download`, `start`, `stop`, `restart`, `putstate`, `trigger`, `upload`). +**Claude connector safety:** Actual MCP tools: `pulsar_admin_functions_read` (`list`, `get`, `status`, `stats`, `querystate`, `download`) and `pulsar_admin_functions_write` (`create`, `update`, `delete`, `start`, `stop`, `restart`, `putstate`, `trigger`, `upload`). Manage Apache Pulsar Functions for stream processing. Pulsar Functions are lightweight compute processes that can consume messages from one or more Pulsar topics, apply user-defined processing logic, and produce results to another topic. Functions support Java, Python, and Go runtimes, enabling complex event processing, data transformations, filtering, and integration with external systems. diff --git a/pkg/mcp/static_tool_annotations_test.go b/pkg/mcp/static_tool_annotations_test.go index c289b704..47e7a157 100644 --- a/pkg/mcp/static_tool_annotations_test.go +++ b/pkg/mcp/static_tool_annotations_test.go @@ -25,37 +25,21 @@ import ( func TestStreamNativeStaticToolAnnotations(t *testing.T) { tools := []struct { name string + tool mcpgotypes.Tool readOnly bool destructive bool }{ - {name: "sncloud_logs", readOnly: true}, - {name: "sncloud_resources_apply", destructive: true}, - {name: "sncloud_resources_delete", destructive: true}, - } - - definitions := map[string]struct { - readOnly bool - destructive bool - }{ - "sncloud_logs": {readOnly: true}, - "sncloud_resources_apply": {destructive: true}, - "sncloud_resources_delete": {destructive: true}, - } - - constructed := map[string]mcpgotypes.Tool{ - "sncloud_logs": NewSNCloudLogsTool(), - "sncloud_resources_apply": NewSNCloudResourcesApplyTool(), - "sncloud_resources_delete": NewSNCloudResourcesDeleteTool(), + {name: "sncloud_logs", tool: NewSNCloudLogsTool(), readOnly: true}, + {name: "sncloud_resources_apply", tool: NewSNCloudResourcesApplyTool(), destructive: true}, + {name: "sncloud_resources_delete", tool: NewSNCloudResourcesDeleteTool(), destructive: true}, } for _, tt := range tools { - tool := constructed[tt.name] - expected := definitions[tt.name] - require.NotEmpty(t, tool.Annotations.Title, tt.name) - require.NotNil(t, tool.Annotations.ReadOnlyHint, tt.name) - require.NotNil(t, tool.Annotations.DestructiveHint, tt.name) - require.Equal(t, expected.readOnly, *tool.Annotations.ReadOnlyHint, tt.name) - require.Equal(t, expected.destructive, *tool.Annotations.DestructiveHint, tt.name) + require.NotEmpty(t, tt.tool.Annotations.Title, tt.name) + require.NotNil(t, tt.tool.Annotations.ReadOnlyHint, tt.name) + require.NotNil(t, tt.tool.Annotations.DestructiveHint, tt.name) + require.Equal(t, tt.readOnly, *tt.tool.Annotations.ReadOnlyHint, tt.name) + require.Equal(t, tt.destructive, *tt.tool.Annotations.DestructiveHint, tt.name) } } diff --git a/pkg/mcp/toolannotations/annotations.go b/pkg/mcp/toolannotations/annotations.go index af2cf43e..520a1e33 100644 --- a/pkg/mcp/toolannotations/annotations.go +++ b/pkg/mcp/toolannotations/annotations.go @@ -12,20 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Copyright 2025 StreamNative -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - // Package toolannotations provides helpers for setting MCP tool safety annotations. package toolannotations From 0291a912c0465218f4952d7d00f0af55b255dcbb Mon Sep 17 00:00:00 2001 From: Rui Fu Date: Tue, 19 May 2026 16:45:39 +0800 Subject: [PATCH 03/10] fix: split mixed read write mcp tools --- .ci/publish_chart.sh | 4 +- .ci/release.sh | 4 +- .licenserc.yaml | 4 + Dockerfile | 3 +- PLAN.md | 59 ++++-- charts/snmcp/e2e/test-tokens.env | 14 ++ charts/snmcp/templates/_helpers.tpl | 15 +- cmd/snmcp-e2e/main.go | 2 +- cmd/snmcp-e2e/testdata/functions/echo.py | 14 ++ cmd/streamnative-mcp-server/main.go | 2 +- docs/tools/kafka_admin_connect.md | 49 +++-- docs/tools/kafka_admin_groups.md | 44 ++-- docs/tools/kafka_admin_schema_registry.md | 63 +++--- docs/tools/kafka_admin_topics.md | 35 ++-- docs/tools/pulsar_admin_brokers.md | 33 +-- docs/tools/pulsar_admin_clusters.md | 80 +++---- docs/tools/pulsar_admin_functions.md | 197 ++++++------------ docs/tools/pulsar_admin_namespaces.md | 45 ++-- docs/tools/pulsar_admin_nsisolationpolicy.md | 59 +++--- docs/tools/pulsar_admin_packages.md | 73 ++++--- docs/tools/pulsar_admin_resource_quotas.md | 55 ++--- docs/tools/pulsar_admin_schemas.md | 25 ++- docs/tools/pulsar_admin_sinks.md | 108 +++------- docs/tools/pulsar_admin_sources.md | 97 +++------ docs/tools/pulsar_admin_subscriptions.md | 85 ++++---- docs/tools/pulsar_admin_tenants.md | 38 ++-- docs/tools/pulsar_admin_topic_policy.md | 110 ++++++---- docs/tools/pulsar_admin_topics.md | 112 +++++----- hack/common.sh | 14 ++ pkg/auth/auth.go | 2 +- pkg/auth/authorization_tokenretriever.go | 2 +- pkg/auth/cache/cache.go | 2 +- pkg/auth/client_credentials_flow.go | 2 +- pkg/auth/client_credentials_provider.go | 2 +- pkg/auth/config_tokenprovider.go | 2 +- pkg/auth/data_url.go | 2 +- pkg/auth/oidc_endpoint_provider.go | 2 +- pkg/auth/store/keyring.go | 2 +- pkg/auth/store/store.go | 2 +- pkg/cmd/mcp/mcp.go | 2 +- pkg/cmd/mcp/sse.go | 2 +- pkg/cmd/mcp/stdio.go | 2 +- pkg/common/utils.go | 2 +- pkg/config/apiclient.go | 2 +- pkg/config/apiclient_test.go | 2 +- pkg/config/auth.go | 2 +- pkg/config/config.go | 2 +- pkg/config/external_kafka.go | 2 +- pkg/config/external_pulsar.go | 2 +- pkg/config/options.go | 2 +- pkg/kafka/connection.go | 2 +- pkg/kafka/connection_test.go | 2 +- pkg/kafka/kafkaconnect.go | 2 +- pkg/log/io.go | 2 +- pkg/mcp/auth_utils.go | 2 +- pkg/mcp/builders/base.go | 2 +- pkg/mcp/builders/base_test.go | 2 +- pkg/mcp/builders/config.go | 2 +- pkg/mcp/builders/config_test.go | 2 +- .../kafka/annotation_compliance_test.go | 40 ++++ pkg/mcp/builders/kafka/connect.go | 96 ++++----- pkg/mcp/builders/kafka/connect_test.go | 6 +- pkg/mcp/builders/kafka/consume_test.go | 2 +- pkg/mcp/builders/kafka/groups.go | 74 ++++--- pkg/mcp/builders/kafka/produce_test.go | 2 +- pkg/mcp/builders/kafka/schema_registry.go | 60 +++--- pkg/mcp/builders/kafka/tool_mode.go | 30 ++- pkg/mcp/builders/kafka/topics.go | 73 +++---- .../pulsar/annotation_compliance_test.go | 44 ++++ pkg/mcp/builders/pulsar/brokers.go | 39 ++-- pkg/mcp/builders/pulsar/brokers_stats_test.go | 2 +- pkg/mcp/builders/pulsar/cluster.go | 29 +-- pkg/mcp/builders/pulsar/consume_retry_test.go | 2 +- pkg/mcp/builders/pulsar/functions.go | 55 +++-- pkg/mcp/builders/pulsar/namespace.go | 30 ++- .../builders/pulsar/namespace_policy_test.go | 2 +- pkg/mcp/builders/pulsar/nsisolationpolicy.go | 25 ++- pkg/mcp/builders/pulsar/packages.go | 29 ++- pkg/mcp/builders/pulsar/resourcequotas.go | 27 ++- pkg/mcp/builders/pulsar/schema.go | 24 ++- pkg/mcp/builders/pulsar/sinks.go | 37 ++-- pkg/mcp/builders/pulsar/sinks_parity_test.go | 2 +- pkg/mcp/builders/pulsar/sources.go | 34 +-- .../builders/pulsar/sources_parity_test.go | 2 +- pkg/mcp/builders/pulsar/status_test.go | 2 +- pkg/mcp/builders/pulsar/subscription.go | 32 +-- pkg/mcp/builders/pulsar/subscription_test.go | 2 +- pkg/mcp/builders/pulsar/tenant.go | 21 +- pkg/mcp/builders/pulsar/tool_mode.go | 53 ++++- pkg/mcp/builders/pulsar/topic.go | 42 ++-- pkg/mcp/builders/pulsar/topic_policy.go | 67 ++++-- pkg/mcp/builders/pulsar/topic_policy_test.go | 22 +- pkg/mcp/builders/pulsar/topic_test.go | 34 +-- pkg/mcp/builders/registry.go | 2 +- pkg/mcp/builders/registry_test.go | 2 +- pkg/mcp/ctx.go | 2 +- pkg/mcp/features.go | 2 +- pkg/mcp/instructions.go | 2 +- pkg/mcp/internal/context/ctx.go | 2 +- pkg/mcp/kafka_admin_connect_tools.go | 2 +- pkg/mcp/kafka_admin_groups_tools.go | 2 +- pkg/mcp/kafka_admin_partitions_tools.go | 2 +- pkg/mcp/kafka_admin_sr_tools.go | 2 +- pkg/mcp/kafka_admin_topics_tools.go | 2 +- pkg/mcp/kafka_client_consume_tools.go | 2 +- pkg/mcp/kafka_client_produce_tools.go | 2 +- pkg/mcp/pftools/circuit_breaker.go | 2 +- pkg/mcp/pftools/errors.go | 2 +- pkg/mcp/pftools/errors_test.go | 14 ++ pkg/mcp/pftools/invocation.go | 2 +- pkg/mcp/pftools/manager_annotation_test.go | 49 +++++ pkg/mcp/pftools/schema.go | 2 +- pkg/mcp/pftools/types.go | 2 +- pkg/mcp/prompts.go | 2 +- pkg/mcp/pulsar_admin_brokers_stats_tools.go | 2 +- pkg/mcp/pulsar_admin_brokers_tools.go | 2 +- pkg/mcp/pulsar_admin_cluster_tools.go | 2 +- pkg/mcp/pulsar_admin_functions_tools.go | 2 +- .../pulsar_admin_functions_worker_tools.go | 2 +- .../pulsar_admin_namespace_policy_tools.go | 2 +- pkg/mcp/pulsar_admin_namespace_tools.go | 2 +- .../pulsar_admin_nsisolationpolicy_tools.go | 2 +- pkg/mcp/pulsar_admin_packages_tools.go | 2 +- pkg/mcp/pulsar_admin_resourcequotas_tools.go | 2 +- pkg/mcp/pulsar_admin_schemas_tools.go | 2 +- pkg/mcp/pulsar_admin_sinks_tools.go | 2 +- pkg/mcp/pulsar_admin_sources_tools.go | 2 +- pkg/mcp/pulsar_admin_status_tools.go | 2 +- pkg/mcp/pulsar_admin_subscription_tools.go | 2 +- pkg/mcp/pulsar_admin_tenant_tools.go | 2 +- pkg/mcp/pulsar_admin_topic_policy_tools.go | 2 +- pkg/mcp/pulsar_admin_topic_tools.go | 2 +- pkg/mcp/pulsar_client_consume_tools.go | 2 +- pkg/mcp/pulsar_client_produce_tools.go | 2 +- pkg/mcp/pulsar_functions_as_tools.go | 2 +- pkg/mcp/pulsar_resources.go | 2 +- pkg/mcp/pulsar_resources_test.go | 2 +- pkg/mcp/server.go | 2 +- pkg/mcp/server_test.go | 2 +- pkg/mcp/session/context.go | 2 +- pkg/mcp/session/pulsar_session_manager.go | 2 +- .../session/pulsar_session_manager_test.go | 2 +- pkg/mcp/sncontext_tools_test.go | 2 +- pkg/mcp/sncontext_utils.go | 2 +- pkg/mcp/streamnative_cloud_primitives_test.go | 14 ++ pkg/pulsar/connection.go | 2 +- pkg/pulsar/connection_test.go | 2 +- pkg/schema/avro.go | 2 +- pkg/schema/avro_core.go | 2 +- pkg/schema/avro_core_test.go | 2 +- pkg/schema/avro_test.go | 2 +- pkg/schema/boolean.go | 2 +- pkg/schema/boolean_test.go | 2 +- pkg/schema/common.go | 2 +- pkg/schema/common_test.go | 2 +- pkg/schema/converter.go | 2 +- pkg/schema/converter_test.go | 2 +- pkg/schema/json.go | 2 +- pkg/schema/json_test.go | 2 +- pkg/schema/number.go | 2 +- pkg/schema/number_test.go | 2 +- pkg/schema/string.go | 2 +- pkg/schema/string_test.go | 2 +- scripts/e2e-test.sh | 14 ++ 164 files changed, 1572 insertions(+), 1197 deletions(-) create mode 100644 pkg/mcp/pftools/manager_annotation_test.go diff --git a/.ci/publish_chart.sh b/.ci/publish_chart.sh index 5bb24b43..f7cdf6e6 100755 --- a/.ci/publish_chart.sh +++ b/.ci/publish_chart.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash -# Copyright 2024 StreamNative +# Copyright 2026 StreamNative # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # -# http://www.apache.org/licenses/LICENSE-2.0 +# http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, diff --git a/.ci/release.sh b/.ci/release.sh index fbb25702..e35867b3 100755 --- a/.ci/release.sh +++ b/.ci/release.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash -# Copyright 2024 StreamNative +# Copyright 2026 StreamNative # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # -# http://www.apache.org/licenses/LICENSE-2.0 +# http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, diff --git a/.licenserc.yaml b/.licenserc.yaml index 51906195..a2a38c28 100644 --- a/.licenserc.yaml +++ b/.licenserc.yaml @@ -36,5 +36,9 @@ header: - '**/*.yml' - 'Makefile' - '.gitignore' + - 'Dockerfile.goreleaser' + - '**/.gitkeep' + - 'charts/snmcp/e2e/test-secret.key' + - 'charts/snmcp/templates/NOTES.txt' comment: on-failure diff --git a/Dockerfile b/Dockerfile index 0bb3d6be..e305d967 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# Copyright 2025 StreamNative +# Copyright 2026 StreamNative # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. + # Multi-stage build for multi-platform support FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder diff --git a/PLAN.md b/PLAN.md index 9b52f4c7..f5b17b55 100644 --- a/PLAN.md +++ b/PLAN.md @@ -30,6 +30,8 @@ Reference implementation checked: ## Current findings +Current follow-up focus: previous read/write split only separated tool names and operation enums in some builders. It still leaves mixed mode descriptions and write-only schema fields visible on read tools. Examples: `pkg/mcp/builders/kafka/topics.go` and `pkg/mcp/builders/pulsar/namespace.go`; same class can exist in other split builders. Connector review can still treat this as a mixed surface because `tools/list` exposes write verbs/examples/parameters through read tools. + Static `mcp.NewTool(...)` definitions found under `pkg/`: 36 tool definitions plus dynamic Pulsar Functions-as-Tools. Current gaps: @@ -39,6 +41,7 @@ Current gaps: - Only `sncloud_resources_apply` and `sncloud_resources_delete` currently set `WithToolAnnotation`; `apply` sets title only. - Dynamic Pulsar Functions-as-Tools in `pkg/mcp/pftools/manager.go` create tools without title/read-only/destructive annotations. - Many admin tools multiplex read and write operations through one `operation` parameter. Claude review criteria says mixed read/write catch-all tools can be rejected even if description documents safe/unsafe operations. +- Some already-split tools still have mixed descriptions and schemas. Mode-specific enum is not enough; read tools must not expose write operations, examples, or write-only parameters. ## Proposed design @@ -48,7 +51,7 @@ Follow `mcp-auth0-proxy` pattern: - split mixed tools into separate read and write tool names - keep shared internal implementation where practical -- make operation enum mode-specific, so tool schema itself prevents mixed use +- make operation enum, description, examples, and parameters mode-specific, so tool schema itself prevents mixed use - keep read-only runtime mode simple: register only read tools - in read-write runtime mode: register read tools and write tools as separate entries - do not expose legacy mixed tools in Claude-submitted surface @@ -71,8 +74,8 @@ Pure write/side-effect tools can keep current names if description and annotatio Compatibility policy: -- Recommended for Claude readiness: remove mixed legacy tool registration from default surface. -- If backward compatibility is required, add opt-in legacy registration behind a feature/config flag and keep it disabled for connector submission. +- Remove mixed legacy tool registration from default surface. +- Do not add opt-in legacy aliases or compatibility flags for old mixed tool names. - Do not keep mixed legacy tools visible in submitted connector, even with destructive annotation. ### Shared helper APIs @@ -101,6 +104,16 @@ Build functions should accept mode: - `validateOperation(mode, operation)` - `isWriteOperation(operation)` +Mode-specific tool builders should also split: + +- `toolDesc` +- `resourceDesc` when resource meanings differ by mode +- `operationDesc` +- `operationEnum` +- parameter set (`mcp.WithString`, `mcp.WithNumber`, `mcp.WithObject`, etc.) + +Read tools must not expose write-only fields. Write tools should not expose read-only-only fields unless a write operation genuinely needs them. + ## Split inventory ### Kafka builders @@ -394,18 +407,22 @@ Plan: - Add local read/write mode helpers in builders with mixed operations. - Add reusable operation validation helpers where a builder already has operation maps. -### Phase 2: split Kafka tools +### Phase 2: split Kafka tools completely -- Update Kafka builders to build mode-specific tools. +- Update all Kafka builders to build mode-specific tools. +- Ensure read/write tools have mode-specific descriptions, examples, operation enums, and parameter schemas. - Read-only config returns only read tools. - Read-write config returns read + write tools, except pure write tools remain write-only. +- Remove old mixed tool surface; no compatibility alias. - Update wrapper tests/docs. -### Phase 3: split Pulsar tools +### Phase 3: split Pulsar tools completely -- Update Pulsar builders to build mode-specific tools. +- Update all Pulsar builders to build mode-specific tools. +- Ensure read/write tools have mode-specific descriptions, examples, operation enums, and parameter schemas. - Preserve existing read-only behavior by not registering write tools in read-only config. - Ensure mode-specific operation enums and validation errors. +- Remove old mixed tool surface; no compatibility alias. - Add/extend parity tests for operation coverage. ### Phase 4: StreamNative Cloud/static tool annotations @@ -419,19 +436,17 @@ Plan: - Add annotations to Functions-as-Tools. - Validate read-only exposure behavior. -### Phase 6: docs and compatibility +### Phase 6: runtime-visible docs -Update runtime-visible docs: +Update runtime-visible docs together with schema changes: - `README.md` feature/tool examples if names change. - `docs/tools/*.md` matching renamed/split tools. +- Split docs into explicit read/write sections where a family has both tool modes. +- Ensure read docs do not mention write-only operations or parameters. +- Ensure write docs do not rely on old mixed tool names. - Any design notes under `agents/` if tool surface changes are architectural. -Compatibility decision needed before implementation: - -- Preferred: breaking but review-safe tool surface; remove mixed tool names. -- Alternative: temporary alias support hidden behind explicit opt-in flag, disabled by default and not used for Claude submission. - ## Tests / compliance guard Add focused tests: @@ -444,13 +459,17 @@ Add focused tests: - read tools: `ReadOnlyHint=true`, `DestructiveHint=false` - write tools: `DestructiveHint=true`, `ReadOnlyHint=false` - read-only config returns no write tools + - read tools do not expose known write-only parameters + - read tool descriptions do not mention known write-only operations, examples, or destructive verbs for that family + - write tool schemas do not expose read-only-only parameters unless genuinely shared - StreamNative Cloud/context/log/resource tools have valid annotations. - PFTools dynamic tool creation has valid annotation. - Operation validation rejects read operations on write tools and write operations on read tools. -Optional static test: +Static guard: - Build all feature sets and assert no `operation` enum contains both read and write verbs in one tool. +- For split tool families, assert mode-specific schema/description purity with family-specific allow/deny lists. ## Risks @@ -460,12 +479,12 @@ Optional static test: - Some current tools may have read-only-mode logic embedded in handlers; after split, registration and handler validation must both enforce mode to prevent write leakage. - `mcp-go` default annotations are unsafe for compliance because title empty and destructive default true. -## Questions to confirm +## Confirmed decisions -1. Can we remove legacy mixed tool names from default registration, accepting breaking tool-name changes for Claude readiness? -2. Should we add an opt-in legacy compatibility flag, disabled by default, or avoid compatibility layer entirely? -3. For consume tools, should we conservatively classify as destructive, or implement a true non-mutating read variant first? -4. Should session-only context changes (`sncloud_context_use_cluster/reset`) be marked destructive for Claude safety? +- Fix all current mixed read/write surfaces, not only `kafka/topics.go` and `pulsar/namespace.go`. +- Do not preserve old mixed tool names or old mixed builder/schema patterns. +- Runtime-visible docs must be updated with read/write split and must avoid mixed read/write wording. +- Conservative safety annotations are acceptable for ambiguous side-effect tools unless implementation proves true read-only behavior. ## Recommended validation diff --git a/charts/snmcp/e2e/test-tokens.env b/charts/snmcp/e2e/test-tokens.env index 1c47a00b..c5656934 100644 --- a/charts/snmcp/e2e/test-tokens.env +++ b/charts/snmcp/e2e/test-tokens.env @@ -1,2 +1,16 @@ +# Copyright 2026 StreamNative +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + ADMIN_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjQxMDI0NDQ4MDAsImlhdCI6MTcwMDAwMDAwMCwic3ViIjoiYWRtaW4ifQ.fvMIzcCv16QvecEd8rJS6GZaJP_FeFw-XndtfRMfZyc TEST_USER_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjQxMDI0NDQ4MDAsImlhdCI6MTcwMDAwMDAwMCwic3ViIjoidGVzdC11c2VyIn0.gv49qzkZrtc-6aXMGxSGFpRLk_C3pnFI4SprgewhN54 diff --git a/charts/snmcp/templates/_helpers.tpl b/charts/snmcp/templates/_helpers.tpl index e214e7ac..1d5a3148 100644 --- a/charts/snmcp/templates/_helpers.tpl +++ b/charts/snmcp/templates/_helpers.tpl @@ -1,6 +1,17 @@ {{/* -Copyright 2025 StreamNative -SPDX-License-Identifier: Apache-2.0 +Copyright 2026 StreamNative + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. */}} {{/* diff --git a/cmd/snmcp-e2e/main.go b/cmd/snmcp-e2e/main.go index b3b94559..1de727a5 100644 --- a/cmd/snmcp-e2e/main.go +++ b/cmd/snmcp-e2e/main.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/cmd/snmcp-e2e/testdata/functions/echo.py b/cmd/snmcp-e2e/testdata/functions/echo.py index cfe7b9b7..6215fc17 100644 --- a/cmd/snmcp-e2e/testdata/functions/echo.py +++ b/cmd/snmcp-e2e/testdata/functions/echo.py @@ -1,3 +1,17 @@ +# Copyright 2026 StreamNative +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + class EchoFunction(object): def process(self, input, context): return input diff --git a/cmd/streamnative-mcp-server/main.go b/cmd/streamnative-mcp-server/main.go index 46863102..0e8ff505 100644 --- a/cmd/streamnative-mcp-server/main.go +++ b/cmd/streamnative-mcp-server/main.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/docs/tools/kafka_admin_connect.md b/docs/tools/kafka_admin_connect.md index afb6d066..95f856cf 100644 --- a/docs/tools/kafka_admin_connect.md +++ b/docs/tools/kafka_admin_connect.md @@ -1,37 +1,40 @@ #### kafka-admin-connect -**Claude connector safety:** Actual MCP tools: `kafka_admin_connect_read` (`list`, `get`) and `kafka_admin_connect_write` (`create`, `update`, `delete`, `restart`, `pause`, `resume`). +**Claude connector safety:** Actual MCP tools are split into `kafka_admin_connect_read` and `kafka_admin_connect_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. +### `kafka_admin_connect_read` -Kafka Connect is a framework for integrating Kafka with external systems. The following resources and operations are supported: +Read Kafka Connect cluster, connector, and plugin information. - **kafka-connect-cluster** - - **get**: Get information about the Kafka Connect cluster - - _Parameters_: None + - **get**: Get Kafka Connect cluster information - **connectors** - - **list**: List all connectors in the cluster - - _Parameters_: None + - **list**: List connectors in the cluster - **connector** - - **get**: Get details of a specific connector - - `name` (string, required): The connector name - - **create**: Create a new connector - - `name` (string, required): The connector name + - **get**: Get connector details + - `name` (string, required): Connector name + +- **connector-plugins** + - **list**: List available connector plugins + +### `kafka_admin_connect_write` + +Manage Kafka Connect connector lifecycle and configuration. + +- **connector** + - **create**: Create a connector + - `name` (string, required): Connector name - `config` (object, required): Connector configuration - - Must include at least `connector.class` and other required fields for the connector type - - **update**: Update an existing connector - - `name` (string, required): The connector name - - `config` (object, required): Updated configuration + - **update**: Update connector configuration + - `name` (string, required): Connector name + - `config` (object, required): Updated connector configuration - **delete**: Delete a connector - - `name` (string, required): The connector name + - `name` (string, required): Connector name - **restart**: Restart a connector - - `name` (string, required): The connector name + - `name` (string, required): Connector name - **pause**: Pause a connector - - `name` (string, required): The connector name - - **resume**: Resume a paused connector - - `name` (string, required): The connector name - -- **connector-plugins** - - **list**: List all available connector plugins - - _Parameters_: None + - `name` (string, required): Connector name + - **resume**: Resume a connector + - `name` (string, required): Connector name diff --git a/docs/tools/kafka_admin_groups.md b/docs/tools/kafka_admin_groups.md index 3889762c..24e8c8a1 100644 --- a/docs/tools/kafka_admin_groups.md +++ b/docs/tools/kafka_admin_groups.md @@ -1,27 +1,33 @@ #### kafka-admin-groups -**Claude connector safety:** Actual MCP tools: `kafka_admin_groups_read` (`list`, `describe`, `offsets`) and `kafka_admin_groups_write` (`remove-members`, `delete-offset`, `set-offset`). +**Claude connector safety:** Actual MCP tools are split into `kafka_admin_groups_read` and `kafka_admin_groups_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. +### `kafka_admin_groups_read` -This tool provides access to Kafka consumer group operations including listing, describing, and managing group membership. +Read Kafka consumer group metadata and committed offsets. - **groups** - - **list**: List all Kafka Consumer Groups in the cluster - - _Parameters_: None + - **list**: List Kafka consumer groups - **group** - - **describe**: Get detailed information about a specific Consumer Group - - `group` (string, required): The name of the Kafka Consumer Group - - **remove-members**: Remove specific members from a Consumer Group - - `group` (string, required): The name of the Kafka Consumer Group - - `members` (string, required): Comma-separated list of member instance IDs (e.g. "consumer-instance-1,consumer-instance-2") - - **offsets**: Get offsets for a specific consumer group - - `group` (string, required): The name of the Kafka Consumer Group - - **delete-offset**: Delete a specific offset for a consumer group of a topic - - `group` (string, required): The name of the Kafka Consumer Group - - `topic` (string, required): The name of the Kafka topic - - **set-offset**: Set a specific offset for a consumer group's topic-partition - - `group` (string, required): The name of the Kafka Consumer Group - - `topic` (string, required): The name of the Kafka topic - - `partition` (number, required): The partition number - - `offset` (number, required): The offset value to set (use -1 for earliest, -2 for latest, or a specific value) + - **describe**: Get detailed information about a consumer group + - `group` (string, required): Consumer group name + - **offsets**: Get committed offsets for a consumer group + - `group` (string, required): Consumer group name + +### `kafka_admin_groups_write` + +Change consumer group membership or committed offsets. + +- **group** + - **remove-members**: Remove specific members from a consumer group + - `group` (string, required): Consumer group name + - `members` (string, required): Comma-separated member instance IDs + - **delete-offset**: Delete offsets for a consumer group topic + - `group` (string, required): Consumer group name + - `topic` (string, required): Kafka topic name + - **set-offset**: Set a consumer group offset for one topic partition + - `group` (string, required): Consumer group name + - `topic` (string, required): Kafka topic name + - `partition` (number, required): Partition number + - `offset` (number, required): Offset value diff --git a/docs/tools/kafka_admin_schema_registry.md b/docs/tools/kafka_admin_schema_registry.md index 74e35f1d..0d2c3700 100644 --- a/docs/tools/kafka_admin_schema_registry.md +++ b/docs/tools/kafka_admin_schema_registry.md @@ -1,41 +1,52 @@ #### kafka-admin-schema-registry -**Claude connector safety:** Actual MCP tools: `kafka_admin_sr_read` (`list`, `get`) and `kafka_admin_sr_write` (`set`, `create`, `delete`). +**Claude connector safety:** Actual MCP tools are split into `kafka_admin_sr_read` and `kafka_admin_sr_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. +### `kafka_admin_sr_read` -This tool provides access to Kafka Schema Registry operations, including managing subjects, versions, and compatibility settings. +Read Schema Registry subjects, versions, schemas, compatibility levels, and supported schema types. - **subjects** - - **list**: List all schema subjects in the Schema Registry - - _Parameters_: None + - **list**: List all schema subjects - **subject** - **get**: Get the latest schema for a subject - - `subject` (string, required): The subject name - - **create**: Register a new schema for a subject - - `subject` (string, required): The subject name - - `schema` (string, required): The schema definition (in AVRO/JSON/PROTOBUF, etc.) - - `type` (string, optional): The schema type (e.g. AVRO, JSON, PROTOBUF) - - **delete**: Delete a schema subject - - `subject` (string, required): The subject name + - `subject` (string, required): Subject name - **versions** - - **list**: List all versions for a specific subject - - `subject` (string, required): The subject name - - **get**: Get a specific version of a subject's schema - - `subject` (string, required): The subject name - - `version` (number, required): The version number - - **delete**: Delete a specific version of a subject's schema - - `subject` (string, required): The subject name - - `version` (number, required): The version number + - **list**: List versions for a subject + - `subject` (string, required): Subject name + +- **version** + - **get**: Get one version of a subject schema + - `subject` (string, required): Subject name + - `version` (string, required): Version number or `latest` - **compatibility** - - **get**: Get compatibility setting for a subject - - `subject` (string, required): The subject name - - **set**: Set compatibility level for a subject - - `subject` (string, required): The subject name - - `level` (string, required): The compatibility level (e.g. BACKWARD, FORWARD, FULL, NONE) + - **get**: Get compatibility level globally or for a subject + - `subject` (string, optional): Subject name for subject-specific compatibility - **types** - - **list**: List supported schema types (e.g. AVRO, JSON, PROTOBUF) - - _Parameters_: None + - **list**: List supported schema types + +### `kafka_admin_sr_write` + +Register/delete schemas and change compatibility levels. + +- **subject** + - **create**: Register a new schema for a subject + - `subject` (string, required): Subject name + - `schemaType` (string, required): Schema type, such as `AVRO`, `JSON`, or `PROTOBUF` + - `schema` (object, required): Schema definition + - **delete**: Delete a schema subject + - `subject` (string, required): Subject name + +- **version** + - **delete**: Delete one version of a subject schema + - `subject` (string, required): Subject name + - `version` (string, required): Version number + +- **compatibility** + - **set**: Set compatibility level globally or for a subject + - `compatibility` (string, required): Compatibility level, such as `BACKWARD`, `FORWARD`, `FULL`, or `NONE` + - `subject` (string, optional): Subject name for subject-specific compatibility diff --git a/docs/tools/kafka_admin_topics.md b/docs/tools/kafka_admin_topics.md index 3616ed91..89d82844 100644 --- a/docs/tools/kafka_admin_topics.md +++ b/docs/tools/kafka_admin_topics.md @@ -1,21 +1,30 @@ #### kafka-admin-topics -**Claude connector safety:** Actual MCP tools: `kafka_admin_topics_read` (`list`, `get`, `metadata`) and `kafka_admin_topics_write` (`create`, `delete`). +**Claude connector safety:** Actual MCP tools are split into `kafka_admin_topics_read` and `kafka_admin_topics_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. +### `kafka_admin_topics_read` -This tool provides access to various Kafka topic operations, including creation, deletion, listing, and configuration retrieval. +Read Kafka topic lists, configurations, and metadata. - **topics** - - **list**: List all topics in the Kafka cluster - - `include-internal` (boolean, optional): Whether to include internal Kafka topics (those starting with an underscore). Default: false + - **list**: List topics in the Kafka cluster + - `includeInternal` (boolean, optional): Include internal Kafka topics. Default: false - **topic** - - **get**: Get detailed configuration for a specific topic - - `name` (string, required): The name of the Kafka topic - - **create**: Create a new topic - - `name` (string, required): The name of the Kafka topic - - `partitions` (number, optional): Number of partitions. Default: 1 - - `replication-factor` (number, optional): Replication factor. Default: 1 - - `configs` (array of string, optional): Topic configuration overrides as key-value strings, e.g. ["cleanup.policy=compact", "retention.ms=604800000"] - - **delete**: Delete an existing topic - - `name` (string, required): The name of the Kafka topic + - **get**: Get detailed configuration for a topic + - `name` (string, required): Kafka topic name + - **metadata**: Get metadata for a topic + - `name` (string, required): Kafka topic name + +### `kafka_admin_topics_write` + +Create or delete Kafka topics. + +- **topic** + - **create**: Create a topic + - `name` (string, required): Kafka topic name + - `partitions` (number, optional): Number of partitions + - `replicationFactor` (number, optional): Replication factor + - `configs` (object, optional): Topic configuration overrides + - **delete**: Delete a topic + - `name` (string, required): Kafka topic name diff --git a/docs/tools/pulsar_admin_brokers.md b/docs/tools/pulsar_admin_brokers.md index 36e2d96c..c133f016 100644 --- a/docs/tools/pulsar_admin_brokers.md +++ b/docs/tools/pulsar_admin_brokers.md @@ -1,30 +1,31 @@ #### pulsar_admin_brokers -**Claude connector safety:** Actual MCP tools: `pulsar_admin_brokers_read` (`list`, `get`) and `pulsar_admin_brokers_write` (`update`, `delete` broker dynamic configuration). +**Claude connector safety:** Actual MCP tools are split into `pulsar_admin_brokers_read` and `pulsar_admin_brokers_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. +### `pulsar_admin_brokers_read` -Unified tool for managing Apache Pulsar broker resources. +Read broker lists, health, configurations, and namespace ownership. - **brokers** - - **list**: List all active brokers in a cluster - - `clusterName` (string, required): The cluster name - + - **list**: List active brokers in a cluster + - `clusterName` (string, required): Cluster name - **health** - - **get**: Check the health status of a broker - + - **get**: Check broker health status - **config** - **get**: Get broker configuration - - `configType` (string, required): Configuration type, available options: - - `dynamic`: Get list of dynamically modifiable configuration names - - `runtime`: Get all runtime configurations (including static and dynamic configs) - - `internal`: Get internal configuration information - - `all_dynamic`: Get all dynamic configurations and their current values + - `configType` (string, required): `dynamic`, `runtime`, `internal`, or `all_dynamic` +- **namespaces** + - **get**: Get namespaces managed by a broker + - `clusterName` (string, required): Cluster name + - `brokerUrl` (string, required): Broker URL, such as `127.0.0.1:8080` + +### `pulsar_admin_brokers_write` + +Manage broker dynamic configuration values. + +- **config** - **update**: Update broker configuration - `configName` (string, required): Configuration parameter name - `configValue` (string, required): Configuration parameter value - **delete**: Delete broker configuration - `configName` (string, required): Configuration parameter name - -- **namespaces** - - **get**: Get namespaces managed by a broker - - `brokerUrl` (string, required): Broker URL, e.g., '127.0.0.1:8080' diff --git a/docs/tools/pulsar_admin_clusters.md b/docs/tools/pulsar_admin_clusters.md index 6ee652c2..8cbe6dd4 100644 --- a/docs/tools/pulsar_admin_clusters.md +++ b/docs/tools/pulsar_admin_clusters.md @@ -1,48 +1,52 @@ #### pulsar_admin_cluster -**Claude connector safety:** Actual MCP tools: `pulsar_admin_cluster_read` (`list`, `get`) and `pulsar_admin_cluster_write` (`create`, `update`, `delete`). +**Claude connector safety:** Actual MCP tools are split into `pulsar_admin_cluster_read` and `pulsar_admin_cluster_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. +### `pulsar_admin_cluster_read` -Unified tool for managing Apache Pulsar clusters. +Read Pulsar cluster, peer cluster, and failure domain configuration. - **cluster** - - **list**: List all clusters - - **get**: Get configuration for a specific cluster - - `cluster_name` (string, required): The cluster name - - **create**: Create a new cluster - - `cluster_name` (string, required): The cluster name - - `service_url` (string, optional): Cluster web service URL - - `service_url_tls` (string, optional): Cluster TLS web service URL - - `broker_service_url` (string, optional): Cluster broker service URL - - `broker_service_url_tls` (string, optional): Cluster TLS broker service URL - - `peer_cluster_names` (array, optional): List of peer clusters - - **update**: Update an existing cluster - - `cluster_name` (string, required): The cluster name - - Same optional parameters as create - - **delete**: Delete a cluster - - `cluster_name` (string, required): The cluster name - + - **list**: List clusters + - **get**: Get cluster configuration + - `cluster_name` (string, required): Cluster name - **peer_clusters** - - **get**: Get list of peer clusters - - `cluster_name` (string, required): The cluster name - - **update**: Update peer clusters list - - `cluster_name` (string, required): The cluster name - - `peer_cluster_names` (array, required): List of peer cluster names + - **get**: Get peer clusters + - `cluster_name` (string, required): Cluster name +- **failure_domain** + - **list**: List failure domains in a cluster + - `cluster_name` (string, required): Cluster name + - **get**: Get failure domain configuration + - `cluster_name` (string, required): Cluster name + - `domain_name` (string, required): Failure domain name + +### `pulsar_admin_cluster_write` +Manage clusters, peer clusters, and failure domains. + +- **cluster** + - **create**: Create a cluster + - `cluster_name` (string, required): Cluster name + - `service_url` (string, optional): Web service URL + - `service_url_tls` (string, optional): TLS web service URL + - `broker_service_url` (string, optional): Broker service URL + - `broker_service_url_tls` (string, optional): TLS broker service URL + - `peer_cluster_names` (array, optional): Peer clusters + - **update**: Update a cluster + - Same parameters as `create` + - **delete**: Delete a cluster + - `cluster_name` (string, required): Cluster name +- **peer_clusters** + - **update**: Update peer clusters + - `cluster_name` (string, required): Cluster name + - `peer_cluster_names` (array, required): Peer cluster names - **failure_domain** - - **list**: List all failure domains in a cluster - - `cluster_name` (string, required): The cluster name - - **get**: Get configuration for a specific failure domain - - `cluster_name` (string, required): The cluster name - - `domain_name` (string, required): The failure domain name - - **create**: Create a new failure domain - - `cluster_name` (string, required): The cluster name - - `domain_name` (string, required): The failure domain name - - `brokers` (array, required): List of brokers in the domain - - **update**: Update an existing failure domain - - `cluster_name` (string, required): The cluster name - - `domain_name` (string, required): The failure domain name - - `brokers` (array, required): List of brokers in the domain + - **create**: Create a failure domain + - `cluster_name` (string, required): Cluster name + - `domain_name` (string, required): Failure domain name + - `brokers` (array, required): Brokers in the domain + - **update**: Update a failure domain + - Same parameters as `create` - **delete**: Delete a failure domain - - `cluster_name` (string, required): The cluster name - - `domain_name` (string, required): The failure domain name + - `cluster_name` (string, required): Cluster name + - `domain_name` (string, required): Failure domain name diff --git a/docs/tools/pulsar_admin_functions.md b/docs/tools/pulsar_admin_functions.md index adfc70c3..d07e4d8b 100644 --- a/docs/tools/pulsar_admin_functions.md +++ b/docs/tools/pulsar_admin_functions.md @@ -1,149 +1,76 @@ #### pulsar_admin_functions -**Claude connector safety:** Actual MCP tools: `pulsar_admin_functions_read` (`list`, `get`, `status`, `stats`, `querystate`, `download`) and `pulsar_admin_functions_write` (`create`, `update`, `delete`, `start`, `stop`, `restart`, `putstate`, `trigger`, `upload`). +**Claude connector safety:** Actual MCP tools are split into `pulsar_admin_functions_read` and `pulsar_admin_functions_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. +Pulsar Functions are lightweight compute processes that consume messages from Pulsar topics, apply user-defined logic, and optionally produce results to another topic. -Manage Apache Pulsar Functions for stream processing. Pulsar Functions are lightweight compute processes that can consume messages from one or more Pulsar topics, apply user-defined processing logic, and produce results to another topic. Functions support Java, Python, and Go runtimes, enabling complex event processing, data transformations, filtering, and integration with external systems. +### `pulsar_admin_functions_read` -This tool provides a comprehensive set of operations to manage the entire function lifecycle: +Read function lists, configuration, runtime status, statistics, state, and package data. -- **list**: List all functions in a namespace - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) +Common identity parameters: -- **get**: Get function configuration - - `fqfn` (string, optional): Fully qualified function name `tenant/namespace/name` - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The function name (unless `fqfn` is provided) - -- **status**: Get runtime status of a function (instances, metrics) - - `fqfn` (string, optional): Fully qualified function name `tenant/namespace/name` - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The function name (unless `fqfn` is provided) - - `instanceId` (string, optional): Instance ID for per-instance status - -- **stats**: Get detailed statistics of a function (throughput, processing latency) - - `fqfn` (string, optional): Fully qualified function name `tenant/namespace/name` - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The function name (unless `fqfn` is provided) - - `instanceId` (string, optional): Instance ID for per-instance stats - -- **create**: Deploy a new function with specified parameters - - `fqfn` (string, optional): Fully qualified function name `tenant/namespace/name` - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, optional): The function name (can be inferred from `classname`) - - `classname` (string, optional): The fully qualified class name implementing the function - - `functionType` (string, optional): Built-in function type (translated to `builtin://`) - - `inputs` (array, optional): The input topics for the function - - `topicsPattern` (string, optional): Topic pattern to consume from - - `inputSpecs` (object, optional): Map of input topics to consumer config - - `output` (string, optional): The output topic for function results - - `jar` (string, optional): Path to the JAR file for Java functions - - `py` (string, optional): Path to the Python file for Python functions - - `go` (string, optional): Path to the Go binary for Go functions - - `parallelism` (number, optional): The parallelism factor of the function (default: 1) - - `cpu` (number, optional): CPU cores per instance - - `ram` (number, optional): RAM bytes per instance - - `disk` (number, optional): Disk bytes per instance - - `userConfig` (object, optional): User-defined config key/values - - `producerConfig` (object, optional): Custom producer configuration - - `logTopic` (string, optional): Topic for function logs - - `schemaType` (string, optional): Output schema type or class - - `outputSerdeClassName` (string, optional): Output SerDe class - - `customSerdeInputs` (object, optional): Map of input topics to SerDe class - - `customSchemaInputs` (object, optional): Map of input topics to Schema class - - `customSchemaOutputs` (object, optional): Map of output topics to schema properties - - `inputTypeClassName` (string, optional): Input type class name - - `outputTypeClassName` (string, optional): Output type class name - - `processingGuarantees` (string, optional): Delivery semantics - - `retainOrdering` (boolean, optional): Process messages in order - - `retainKeyOrdering` (boolean, optional): Process messages in key order - - `batchBuilder` (string, optional): Batch builder type - - `forwardSourceMessageProperty` (boolean, optional): Forward properties to output - - `autoAck` (boolean, optional): Automatically acknowledge messages - - `subsName` (string, optional): Subscription name for inputs - - `subsPosition` (string, optional): Subscription position - - `skipToLatest` (boolean, optional): Skip to latest on restart - - `timeoutMs` (number, optional): Message timeout in ms - - `maxMessageRetries` (number, optional): Max retries - - `deadLetterTopic` (string, optional): Dead letter topic - - `customRuntimeOptions` (string, optional): Custom runtime options - - `secrets` (object, optional): Secrets map - - `cleanupSubscription` (boolean, optional): Clean up subscription on delete - - `windowLengthCount` (number, optional): Window length count - - `windowLengthDurationMs` (number, optional): Window length duration in ms - - `slidingIntervalCount` (number, optional): Sliding interval count - - `slidingIntervalDurationMs` (number, optional): Sliding interval duration in ms - - `functionConfigFile` (string, optional): YAML config file path +- `fqfn` (string, optional): Fully qualified function name in `tenant/namespace/name` form +- `tenant` (string, optional): Tenant name; default `public` +- `namespace` (string, optional): Namespace name; default `default` +- `name` (string, required for operations targeting one function unless `fqfn` is provided): Function name -- **update**: Update an existing function - - `fqfn` (string, optional): Fully qualified function name `tenant/namespace/name` - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The function name (unless `fqfn` is provided) - - Parameters similar to `create` operation - - `updateAuthData` (boolean, optional): Whether to update auth data +Operations: +- **list**: List functions in a namespace + - `tenant` (string, optional) + - `namespace` (string, optional) +- **get**: Get function configuration + - Common identity parameters +- **status**: Get runtime status + - Common identity parameters + - `instanceId` (number, optional): Function instance ID for per-instance status +- **stats**: Get runtime statistics + - Common identity parameters + - `instanceId` (number, optional): Function instance ID for per-instance stats +- **querystate**: Query state for a key + - Common identity parameters + - `key` (string, required): State key +- **download**: Download function package data + - `destinationFile` (string, required): Local destination path + - `path` (string, optional): Direct Pulsar package storage path + - Common identity parameters when downloading by function identity + +### `pulsar_admin_functions_write` + +Manage function lifecycle, runtime state, manual triggers, and package uploads. + +Common identity parameters: + +- `fqfn` (string, optional): Fully qualified function name in `tenant/namespace/name` form +- `tenant` (string, optional): Tenant name; default `public` +- `namespace` (string, optional): Namespace name; default `default` +- `name` (string, required for operations targeting one function unless `fqfn` is provided): Function name + +Operations: + +- **create**: Deploy a function + - Common identity parameters + - Deployment parameters include `classname`, `functionType`, `inputs`, `topicsPattern`, `inputSpecs`, `output`, `jar`, `py`, `go`, `parallelism`, resource settings (`cpu`, `ram`, `disk`), schemas/serde settings, processing guarantees, subscription settings, retry/dead-letter settings, secrets, window settings, and `functionConfigFile` +- **update**: Update function configuration + - Common identity parameters + - Same deployment parameters as `create`, plus `updateAuthData` - **delete**: Delete a function - - `fqfn` (string, optional): Fully qualified function name `tenant/namespace/name` - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The function name (unless `fqfn` is provided) - -- **download**: Download function package data from Pulsar to local storage - - `destinationFile` (string, required): Local file path where the downloaded content should be written - - `path` (string, optional): Direct Pulsar package storage path to download from - - `fqfn` (string, optional): Fully qualified function name `tenant/namespace/name` when downloading by function identity - - `tenant` (string, optional): The tenant name (default: `public`) when downloading by function identity - - `namespace` (string, optional): The namespace name (default: `default`) when downloading by function identity - - `name` (string, required unless `path` or `fqfn` is provided): The function name when downloading by function identity - + - Common identity parameters - **start**: Start a stopped function - - `fqfn` (string, optional): Fully qualified function name `tenant/namespace/name` - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The function name (unless `fqfn` is provided) - + - Common identity parameters - **stop**: Stop a running function - - `fqfn` (string, optional): Fully qualified function name `tenant/namespace/name` - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The function name (unless `fqfn` is provided) - + - Common identity parameters - **restart**: Restart a function - - `fqfn` (string, optional): Fully qualified function name `tenant/namespace/name` - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The function name (unless `fqfn` is provided) - -- **querystate**: Query state stored by a stateful function for a specific key - - `fqfn` (string, optional): Fully qualified function name `tenant/namespace/name` - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The function name (unless `fqfn` is provided) - - `key` (string, required): The state key to query - -- **putstate**: Store state in a function's state store - - `fqfn` (string, optional): Fully qualified function name `tenant/namespace/name` - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The function name (unless `fqfn` is provided) - - `key` (string, required): The state key - - `value` (string, required): The state value - -- **trigger**: Manually trigger a function with a specific value - - `fqfn` (string, optional): Fully qualified function name `tenant/namespace/name` - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The function name (unless `fqfn` is provided) - - `topic` (string, optional): The specific topic to trigger on - - `triggerValue` (string, optional): The value to trigger the function with - - `triggerFile` (string, optional): File path containing the trigger value - + - Common identity parameters +- **putstate**: Store a value in a function state store + - Common identity parameters + - `key` (string, required): State key + - `value` (string, required): State value +- **trigger**: Trigger a function manually + - Common identity parameters + - `topic` (string, optional): Input topic to trigger on + - exactly one of `triggerValue` or `triggerFile` - **upload**: Upload a local file into Pulsar function package storage - - `sourceFile` (string, required): Local file path whose content should be uploaded - - `path` (string, required): Pulsar package storage path where the file should be stored + - `sourceFile` (string, required): Local source path + - `path` (string, required): Pulsar package storage destination path diff --git a/docs/tools/pulsar_admin_namespaces.md b/docs/tools/pulsar_admin_namespaces.md index 938fd474..f4858de0 100644 --- a/docs/tools/pulsar_admin_namespaces.md +++ b/docs/tools/pulsar_admin_namespaces.md @@ -1,40 +1,39 @@ #### pulsar_admin_namespace -**Claude connector safety:** Actual MCP tools: `pulsar_admin_namespace_read` (`list`, `get_topics`) and `pulsar_admin_namespace_write` (`create`, `delete`, `clear_backlog`, `unsubscribe`, `unload`, `split_bundle`). +**Claude connector safety:** Actual MCP tools are split into `pulsar_admin_namespace_read` and `pulsar_admin_namespace_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. +### `pulsar_admin_namespace_read` -Manage Pulsar namespaces with various operations. +Read namespace lists and namespace topic lists. -- **list**: List all namespaces for a tenant - - `tenant` (string, required): The tenant name +- **list**: List namespaces for a tenant + - `tenant` (string, required): Tenant name +- **get_topics**: List topics in a namespace + - `namespace` (string, required): Namespace name in `tenant/namespace` format -- **get_topics**: Get all topics within a namespace - - `namespace` (string, required): The namespace name (format: tenant/namespace) +### `pulsar_admin_namespace_write` -- **create**: Create a new namespace - - `namespace` (string, required): The namespace name (format: tenant/namespace) - - `bundles` (string, optional): Number of bundles to activate - - `clusters` (array, optional): List of clusters to assign +Manage namespace lifecycle, backlog, subscriptions, unloads, and bundles. +- **create**: Create a namespace + - `namespace` (string, required): Namespace name in `tenant/namespace` format + - `bundles` (string, optional): Number of bundles to activate + - `clusters` (array, optional): Clusters to assign - **delete**: Delete a namespace - - `namespace` (string, required): The namespace name (format: tenant/namespace) - -- **clear_backlog**: Clear backlog for all topics in a namespace - - `namespace` (string, required): The namespace name (format: tenant/namespace) + - `namespace` (string, required): Namespace name in `tenant/namespace` format +- **clear_backlog**: Clear backlog for topics in a namespace + - `namespace` (string, required): Namespace name in `tenant/namespace` format - `subscription` (string, optional): Subscription name - `bundle` (string, optional): Bundle name or range - - `force` (string, optional): Force clear backlog (true/false) - -- **unsubscribe**: Unsubscribe from a subscription for all topics in a namespace - - `namespace` (string, required): The namespace name (format: tenant/namespace) + - `force` (string, optional): Force clear backlog (`true`/`false`) +- **unsubscribe**: Unsubscribe a subscription from topics in a namespace + - `namespace` (string, required): Namespace name in `tenant/namespace` format - `subscription` (string, required): Subscription name - `bundle` (string, optional): Bundle name or range - - **unload**: Unload a namespace from the current serving broker - - `namespace` (string, required): The namespace name (format: tenant/namespace) + - `namespace` (string, required): Namespace name in `tenant/namespace` format - `bundle` (string, optional): Bundle name or range - - **split_bundle**: Split a namespace bundle - - `namespace` (string, required): The namespace name (format: tenant/namespace) + - `namespace` (string, required): Namespace name in `tenant/namespace` format - `bundle` (string, required): Bundle name or range - - `unload` (string, optional): Unload newly split bundles (true/false) + - `unload` (string, optional): Unload newly split bundles (`true`/`false`) diff --git a/docs/tools/pulsar_admin_nsisolationpolicy.md b/docs/tools/pulsar_admin_nsisolationpolicy.md index 0661dea0..b0c8e48c 100644 --- a/docs/tools/pulsar_admin_nsisolationpolicy.md +++ b/docs/tools/pulsar_admin_nsisolationpolicy.md @@ -1,35 +1,40 @@ #### pulsar_admin_nsisolationpolicy -**Claude connector safety:** Actual MCP tools: `pulsar_admin_nsisolationpolicy_read` (`get`, `list`) and `pulsar_admin_nsisolationpolicy_write` (`set`, `delete`). +**Claude connector safety:** Actual MCP tools are split into `pulsar_admin_nsisolationpolicy_read` and `pulsar_admin_nsisolationpolicy_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. +Namespace isolation policies control which brokers specific namespaces can use. -Manage namespace isolation policies in a Pulsar cluster. Namespace isolation policies enable physical isolation of namespaces by controlling which brokers specific namespaces can use. This helps provide predictable performance and resource isolation, especially in multi-tenant environments. +### `pulsar_admin_nsisolationpolicy_read` -This tool provides operations across three resource types: +Read namespace isolation policies and related broker assignments. -- **policy** (Namespace isolation policy): - - **get**: Get details of a specific isolation policy - - `cluster` (string, required): The cluster name - - `name` (string, required): Name of the isolation policy - - **list**: List all isolation policies in a cluster - - `cluster` (string, required): The cluster name - - **set**: Create or update an isolation policy - - `cluster` (string, required): The cluster name - - `name` (string, required): Name of the isolation policy - - `namespaces` (array, required): List of namespaces to apply the isolation policy - - `primary` (array, required): List of primary brokers for the namespaces - - `secondary` (array, optional): List of secondary brokers for the namespaces - - `autoFailoverPolicyType` (string, optional): Auto failover policy type (e.g., min_available) - - `autoFailoverPolicyParams` (object, optional): Auto failover policy parameters (e.g., {'min_limit': '1', 'usage_threshold': '100'}) - - **delete**: Delete an isolation policy - - `cluster` (string, required): The cluster name - - `name` (string, required): Name of the isolation policy +- **policy** + - **get**: Get an isolation policy + - `cluster` (string, required): Cluster name + - `name` (string, required): Isolation policy name + - **list**: List isolation policies in a cluster + - `cluster` (string, required): Cluster name +- **broker** + - **get**: Get a broker with its isolation policies + - `cluster` (string, required): Cluster name + - `name` (string, required): Broker name +- **brokers** + - **list**: List brokers with isolation policies + - `cluster` (string, required): Cluster name + +### `pulsar_admin_nsisolationpolicy_write` -- **broker** (Broker with isolation policies): - - **get**: Get details of a specific broker with its isolation policies - - `cluster` (string, required): The cluster name - - `name` (string, required): Name of the broker +Create, update, or delete namespace isolation policies. -- **brokers** (All brokers with isolation policies): - - **list**: List all brokers with their isolation policies - - `cluster` (string, required): The cluster name +- **policy** + - **set**: Create or update an isolation policy + - `cluster` (string, required): Cluster name + - `name` (string, required): Isolation policy name + - `namespaces` (array, required): Namespaces to apply the policy to + - `primary` (array, required): Primary brokers + - `secondary` (array, optional): Secondary brokers + - `autoFailoverPolicyType` (string, optional): Auto failover policy type + - `autoFailoverPolicyParams` (object, optional): Auto failover policy parameters + - **delete**: Delete an isolation policy + - `cluster` (string, required): Cluster name + - `name` (string, required): Isolation policy name diff --git a/docs/tools/pulsar_admin_packages.md b/docs/tools/pulsar_admin_packages.md index 45d7c046..6c2f3507 100644 --- a/docs/tools/pulsar_admin_packages.md +++ b/docs/tools/pulsar_admin_packages.md @@ -1,35 +1,42 @@ #### pulsar_admin_package -**Claude connector safety:** Actual MCP tools: `pulsar_admin_package_read` (`list`, `get`, `download`) and `pulsar_admin_package_write` (`update`, `delete`, `upload`). - - -Manage packages in Apache Pulsar. Packages are reusable components that can be shared across functions, sources, and sinks. The system supports package schemes including `function://`, `source://`, and `sink://` for different component types. - -This tool provides operations across two resource types: - -- **package** (A specific package): - - **list**: List all versions of a specific package - - `packageName` (string, required): Name of the package - - **get**: Get metadata of a specific package - - `packageName` (string, required): Name of the package - - **update**: Update metadata of a specific package - - `packageName` (string, required): Name of the package - - `description` (string, required): Description of the package - - `contact` (string, optional): Contact information for the package - - `properties` (object, optional): Additional properties as key-value pairs - - **delete**: Delete a specific package - - `packageName` (string, required): Name of the package - - **download**: Download a package to local storage - - `packageName` (string, required): Name of the package - - `path` (string, required): Path to download the package to - - **upload**: Upload a package from local storage - - `packageName` (string, required): Name of the package - - `path` (string, required): Path to upload the package from - - `description` (string, required): Description of the package - - `contact` (string, optional): Contact information for the package - - `properties` (object, optional): Additional properties as key-value pairs - -- **packages** (Packages of a specific type): - - **list**: List all packages of a specific type in a namespace - - `type` (string, required): Package type (function, source, sink) - - `namespace` (string, required): The namespace name +**Claude connector safety:** Actual MCP tools are split into `pulsar_admin_package_read` and `pulsar_admin_package_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. + +Supported package schemes include `function://`, `source://`, and `sink://`. + +### `pulsar_admin_package_read` + +Read package metadata, versions, package lists, and package contents. + +- **package** + - **list**: List versions of a package + - `packageName` (string, required): Package name + - **get**: Get package metadata + - `packageName` (string, required): Package name + - **download**: Download package contents to local storage + - `packageName` (string, required): Package name + - `path` (string, required): Local destination path + +- **packages** + - **list**: List packages of a type in a namespace + - `type` (string, required): Package type: `function`, `source`, or `sink` + - `namespace` (string, required): Namespace name + +### `pulsar_admin_package_write` + +Manage package metadata and package contents. + +- **package** + - **update**: Update package metadata + - `packageName` (string, required): Package name + - `description` (string, required): Package description + - `contact` (string, optional): Contact information + - `properties` (object, optional): Additional properties + - **delete**: Delete a package + - `packageName` (string, required): Package name + - **upload**: Upload package contents from local storage + - `packageName` (string, required): Package name + - `path` (string, required): Local source path + - `description` (string, required): Package description + - `contact` (string, optional): Contact information + - `properties` (object, optional): Additional properties diff --git a/docs/tools/pulsar_admin_resource_quotas.md b/docs/tools/pulsar_admin_resource_quotas.md index 362b8db9..2168cc71 100644 --- a/docs/tools/pulsar_admin_resource_quotas.md +++ b/docs/tools/pulsar_admin_resource_quotas.md @@ -1,27 +1,32 @@ #### pulsar_admin_resourcequota -**Claude connector safety:** Actual MCP tools: `pulsar_admin_resourcequota_read` (`get`) and `pulsar_admin_resourcequota_write` (`set`, `reset`). - - -Manage Apache Pulsar resource quotas for brokers, namespaces and bundles. Resource quotas define limits for resource usage such as message rates, bandwidth, and memory. These quotas help prevent resource abuse and ensure fair resource allocation across the Pulsar cluster. - -This tool provides operations on the following resource: - -- **quota** (Resource quota configuration): - - **get**: Get resource quota for a namespace bundle or the default quota - - `namespace` (string, optional): The namespace name in format 'tenant/namespace' - - `bundle` (string, optional): The bundle range in format '{start-boundary}_{end-boundary}' - - Note: If namespace and bundle are both omitted, returns the default quota - - Note: If namespace is specified, bundle must also be specified and vice versa - - **set**: Set resource quota for a namespace bundle or the default quota - - `namespace` (string, optional): The namespace name in format 'tenant/namespace' - - `bundle` (string, optional): The bundle range in format '{start-boundary}_{end-boundary}' - - `msgRateIn` (number, required): Maximum incoming messages per second - - `msgRateOut` (number, required): Maximum outgoing messages per second - - `bandwidthIn` (number, required): Maximum inbound bandwidth in bytes per second - - `bandwidthOut` (number, required): Maximum outbound bandwidth in bytes per second - - `memory` (number, required): Maximum memory usage in Mbytes - - `dynamic` (boolean, optional): Whether to allow quota to be dynamically re-calculated - - **reset**: Reset a namespace bundle's resource quota to default value - - `namespace` (string, required): The namespace name in format 'tenant/namespace' - - `bundle` (string, required): The bundle range in format '{start-boundary}_{end-boundary}' +**Claude connector safety:** Actual MCP tools are split into `pulsar_admin_resourcequota_read` and `pulsar_admin_resourcequota_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. + +### `pulsar_admin_resourcequota_read` + +Read resource quota configuration for default quotas or namespace bundles. + +- **quota** + - **get**: Get a resource quota + - `namespace` (string, optional): Namespace name in `tenant/namespace` format + - `bundle` (string, optional): Bundle range in `{start-boundary}_{end-boundary}` format + +If `namespace` and `bundle` are omitted, the default quota is returned. If one of `namespace` or `bundle` is specified, the other must also be specified. + +### `pulsar_admin_resourcequota_write` + +Set or reset resource quota configuration. + +- **quota** + - **set**: Set a resource quota + - `namespace` (string, optional): Namespace name in `tenant/namespace` format + - `bundle` (string, optional): Bundle range in `{start-boundary}_{end-boundary}` format + - `msgRateIn` (number, required): Incoming messages per second + - `msgRateOut` (number, required): Outgoing messages per second + - `bandwidthIn` (number, required): Inbound bandwidth in bytes per second + - `bandwidthOut` (number, required): Outbound bandwidth in bytes per second + - `memory` (number, required): Memory usage in Mbytes + - `dynamic` (boolean, optional): Allow dynamic recalculation + - **reset**: Reset a namespace bundle resource quota to the default + - `namespace` (string, required): Namespace name in `tenant/namespace` format + - `bundle` (string, required): Bundle range in `{start-boundary}_{end-boundary}` format diff --git a/docs/tools/pulsar_admin_schemas.md b/docs/tools/pulsar_admin_schemas.md index cbf67e05..a6696d54 100644 --- a/docs/tools/pulsar_admin_schemas.md +++ b/docs/tools/pulsar_admin_schemas.md @@ -1,16 +1,23 @@ #### pulsar_admin_schema -**Claude connector safety:** Actual MCP tools: `pulsar_admin_schema_read` (`get`) and `pulsar_admin_schema_write` (`upload`, `delete`). +**Claude connector safety:** Actual MCP tools are split into `pulsar_admin_schema_read` and `pulsar_admin_schema_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. +### `pulsar_admin_schema_read` -Manage Apache Pulsar schemas for topics. +Read schema information for a topic. - **schema** - - **get**: Get the schema for a topic - - `topic` (string, required): The fully qualified topic name - - `version` (number, optional): Schema version number - - **upload**: Upload a new schema for a topic - - `topic` (string, required): The fully qualified topic name + - **get**: Get topic schema + - `topic` (string, required): Fully qualified topic name + - `version` (number, optional): Schema version + +### `pulsar_admin_schema_write` + +Upload or delete topic schemas. + +- **schema** + - **upload**: Upload a schema for a topic + - `topic` (string, required): Fully qualified topic name - `filename` (string, required): Path to the schema definition file - - **delete**: Delete the schema for a topic - - `topic` (string, required): The fully qualified topic name + - **delete**: Delete a topic schema + - `topic` (string, required): Fully qualified topic name diff --git a/docs/tools/pulsar_admin_sinks.md b/docs/tools/pulsar_admin_sinks.md index 9d6ed21d..9ed6ec88 100644 --- a/docs/tools/pulsar_admin_sinks.md +++ b/docs/tools/pulsar_admin_sinks.md @@ -1,89 +1,49 @@ #### pulsar_admin_sinks -**Claude connector safety:** Actual MCP tools: `pulsar_admin_sinks_read` (`list`, `get`, `status`, `list-built-in`) and `pulsar_admin_sinks_write` (`create`, `update`, `delete`, `start`, `stop`, `restart`). +**Claude connector safety:** Actual MCP tools are split into `pulsar_admin_sinks_read` and `pulsar_admin_sinks_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. +Pulsar Sinks export data from Pulsar topics to external systems. -Manage Apache Pulsar Sinks for data movement and integration. Pulsar Sinks are connectors that export data from Pulsar topics to external systems such as databases, storage services, messaging systems, and third-party applications. Sinks consume messages from one or more Pulsar topics, transform the data if needed, and write it to external systems in a format compatible with the target destination. +### `pulsar_admin_sinks_read` -This tool provides complete lifecycle management for sink connectors: - -- **list**: List all sinks in a namespace - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) +Read sink lists, configuration, runtime status, and built-in sink connector types. +- **list**: List sinks in a namespace + - `tenant` (string, optional): Tenant name; default `public` + - `namespace` (string, optional): Namespace name; default `default` - **get**: Get sink configuration - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The sink name + - `tenant` (string, optional): Tenant name; default `public` + - `namespace` (string, optional): Namespace name; default `default` + - `name` (string, required): Sink name +- **status**: Get sink runtime status + - `tenant` (string, optional): Tenant name; default `public` + - `namespace` (string, optional): Namespace name; default `default` + - `name` (string, required): Sink name +- **list-built-in**: List built-in sink connectors -- **status**: Get runtime status of a sink (instances, metrics) - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The sink name +### `pulsar_admin_sinks_write` -- **create**: Deploy a new sink connector - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The sink name (can be provided via `sink-config-file`) - - Either `archive` or `sink-type` must be specified (but not both): - - `archive` (string): Path to the archive file containing sink code - - `sink-type` (string): Built-in connector type to use (e.g., 'jdbc', 'elastic-search', 'kafka') - - Either `inputs` or `topics-pattern` must be specified: - - `inputs` (array): The sink's input topics (array of strings) - - `topics-pattern` (string): TopicsPattern to consume from topics matching the pattern (regex) - - `subs-name` (string, optional): Pulsar subscription name for input topic consumer - - `subs-position` (string, optional): Subscription position (`Latest` or `Earliest`) - - `classname` (string, optional): Sink class name for custom archives - - `processing-guarantees` (string, optional): Delivery semantics - - `retain-ordering` (boolean, optional): Preserve message ordering - - `retain-key-ordering` (boolean, optional): Preserve key ordering - - `auto-ack` (boolean, optional): Auto-ack messages - - `cleanup-subscription` (boolean, optional): Delete subscription on sink delete (default: true) - - `parallelism` (number, optional): Number of instances to run concurrently (default: 1) - - `cpu` / `ram` / `disk` (number, optional): Resource allocation per instance - - `custom-serde-inputs` (object, optional): Map of input topics to SerDe class names - - `custom-schema-inputs` (object, optional): Map of input topics to schema type/class - - `input-specs` (object, optional): Map of input topics to consumer config - - `max-redeliver-count` (number, optional): Max redeliver attempts - - `dead-letter-topic` (string, optional): Dead letter topic - - `timeout-ms` (number, optional): Processing timeout in milliseconds - - `negative-ack-redelivery-delay-ms` (number, optional): Negative ack redelivery delay - - `custom-runtime-options` (string, optional): Runtime customization options - - `secrets` (object, optional): Secrets configuration map - - `sink-config-file` (string, optional): Path to YAML sink config file - - `sink-config` (object, optional): Connector-specific configuration parameters - - `transform-function` (string, optional): Transform function - - `transform-function-classname` (string, optional): Transform class name - - `transform-function-config` (string, optional): Transform config +Manage sink connector lifecycle and runtime state. -- **update**: Update an existing sink connector - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The sink name (can be provided via `sink-config-file`) - - Parameters similar to `create` operation (all optional during update) - - `update-auth-data` (boolean, optional): Update auth data during update +Common identity parameters: -- **delete**: Delete a sink - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The sink name +- `tenant` (string, optional): Tenant name; default `public` +- `namespace` (string, optional): Namespace name; default `default` +- `name` (string, required for operations targeting one sink): Sink name -- **start**: Start a stopped sink - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The sink name +Operations: +- **create**: Deploy a sink connector + - Common identity parameters + - Connector/package parameters include `archive` or `sink-type`, input selection (`inputs` or `topics-pattern`), subscription settings, `classname`, processing guarantees, ordering flags, acknowledgement settings, resources (`cpu`, `ram`, `disk`), schema/serde settings, retry/dead-letter settings, secrets, `sink-config-file`, `sink-config`, transform settings, and runtime options +- **update**: Update sink connector configuration + - Common identity parameters + - Same configuration parameters as `create`, plus `update-auth-data` +- **delete**: Delete a sink + - Common identity parameters +- **start**: Start a stopped sink + - Common identity parameters - **stop**: Stop a running sink - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The sink name - + - Common identity parameters - **restart**: Restart a sink - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The sink name - -- **list-built-in**: List all built-in sink connectors available in the system - - No parameters required - -Built-in sink connectors are available for common systems like Kafka, JDBC, Elasticsearch, and cloud storage. Sinks follow the tenant/namespace/name hierarchy for organization and access control, can scale through parallelism configuration, and support configurable subscription types. Sinks require proper permissions to access their input topics. + - Common identity parameters diff --git a/docs/tools/pulsar_admin_sources.md b/docs/tools/pulsar_admin_sources.md index 80adcd20..e1365291 100644 --- a/docs/tools/pulsar_admin_sources.md +++ b/docs/tools/pulsar_admin_sources.md @@ -1,78 +1,49 @@ #### pulsar_admin_sources -**Claude connector safety:** Actual MCP tools: `pulsar_admin_sources_read` (`list`, `get`, `status`, `list-built-in`) and `pulsar_admin_sources_write` (`create`, `update`, `delete`, `start`, `stop`, `restart`). +**Claude connector safety:** Actual MCP tools are split into `pulsar_admin_sources_read` and `pulsar_admin_sources_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. +Pulsar Sources import data from external systems into Pulsar topics. -Manage Apache Pulsar Sources for data ingestion and integration. Pulsar Sources are connectors that import data from external systems into Pulsar topics. Sources connect to external systems such as databases, messaging platforms, storage services, and real-time data streams to pull data and publish it to Pulsar topics. +### `pulsar_admin_sources_read` -This tool provides complete lifecycle management for source connectors: - -- **list**: List all sources in a namespace - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) +Read source lists, configuration, runtime status, and built-in source connector types. +- **list**: List sources in a namespace + - `tenant` (string, optional): Tenant name; default `public` + - `namespace` (string, optional): Namespace name; default `default` - **get**: Get source configuration - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The source name + - `tenant` (string, optional): Tenant name; default `public` + - `namespace` (string, optional): Namespace name; default `default` + - `name` (string, required): Source name +- **status**: Get source runtime status + - `tenant` (string, optional): Tenant name; default `public` + - `namespace` (string, optional): Namespace name; default `default` + - `name` (string, required): Source name +- **list-built-in**: List built-in source connectors -- **status**: Get runtime status of a source (instances, metrics) - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The source name +### `pulsar_admin_sources_write` -- **create**: Deploy a new source connector - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The source name (can be provided via `source-config-file`) - - `destination-topic-name` (string, required): Topic where data will be written - - Either `archive` or `source-type` must be specified (but not both): - - `archive` (string): Path to the archive file containing source code - - `source-type` (string): Built-in connector type to use (e.g., 'kafka', 'jdbc') - - `source-config-file` (string, optional): YAML file with source configuration - - `deserialization-classname` (string, optional): SerDe class for the source - - `schema-type` (string, optional): Schema type for encoding messages (e.g., 'avro', 'json') - - `classname` (string, optional): Source class name if using custom implementation - - `processing-guarantees` (string, optional): Delivery semantics ('atleast_once', 'atmost_once', 'effectively_once') - - `parallelism` (number, optional): Number of instances to run concurrently (default: 1) - - `cpu` (number, optional): CPU cores per instance - - `ram` (number, optional): RAM bytes per instance - - `disk` (number, optional): Disk bytes per instance - - `source-config` (object, optional): Connector-specific configuration parameters - - `producer-config` (object, optional): Producer configuration - - `batch-builder` (string, optional): Batch builder type - - `batch-source-config` (object, optional): Batch source configuration - - `custom-runtime-options` (string, optional): Runtime customization options - - `secrets` (object, optional): Secrets configuration map +Manage source connector lifecycle and runtime state. -- **update**: Update an existing source connector - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The source name (can be provided via `source-config-file`) - - Parameters similar to `create` operation (all optional during update) - - `update-auth-data` (boolean, optional): Whether to update auth data +Common identity parameters: -- **delete**: Delete a source - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The source name +- `tenant` (string, optional): Tenant name; default `public` +- `namespace` (string, optional): Namespace name; default `default` +- `name` (string, required for operations targeting one source): Source name -- **start**: Start a stopped source - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The source name +Operations: +- **create**: Deploy a source connector + - Common identity parameters + - Connector/package parameters include `destination-topic-name`, `archive` or `source-type`, `source-config-file`, serialization settings, `classname`, processing guarantees, `parallelism`, resources (`cpu`, `ram`, `disk`), `source-config`, producer config, batch config, secrets, and runtime options +- **update**: Update source connector configuration + - Common identity parameters + - Same configuration parameters as `create`, plus `update-auth-data` +- **delete**: Delete a source + - Common identity parameters +- **start**: Start a stopped source + - Common identity parameters - **stop**: Stop a running source - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The source name - + - Common identity parameters - **restart**: Restart a source - - `tenant` (string, optional): The tenant name (default: `public`) - - `namespace` (string, optional): The namespace name (default: `default`) - - `name` (string, required): The source name - -- **list-built-in**: List all built-in source connectors available in the system - - No parameters required - -Built-in source connectors are available for common systems like Kafka, JDBC, AWS services, and more. Sources follow the tenant/namespace/name hierarchy for organization and access control, can scale through parallelism configuration, and support various processing guarantees. + - Common identity parameters diff --git a/docs/tools/pulsar_admin_subscriptions.md b/docs/tools/pulsar_admin_subscriptions.md index 79497081..6e67e797 100644 --- a/docs/tools/pulsar_admin_subscriptions.md +++ b/docs/tools/pulsar_admin_subscriptions.md @@ -1,42 +1,47 @@ #### pulsar_admin_subscription -**Claude connector safety:** Actual MCP tools: `pulsar_admin_subscription_read` (`list`, `peek`, `get-message-by-id`) and `pulsar_admin_subscription_write` (`create`, `delete`, `skip`, `expire`, `reset-cursor`). - - -Manage Pulsar topic subscriptions, which represent consumer groups reading from topics. - -- **list**: List all subscriptions for a topic - - `topic` (string, required): The fully qualified topic name -- **create**: Create a new subscription - - `topic` (string, required): The fully qualified topic name - - `subscription` (string, required): The subscription name - - `messageId` (string, optional): Initial position, default is latest -- **delete**: Delete a subscription - - `topic` (string, required): The fully qualified topic name - - `subscription` (string, required): The subscription name - - `force` (boolean, optional): Force delete active consumers -- **skip**: Skip messages for a subscription - - `topic` (string, required): The fully qualified topic name - - `subscription` (string, required): The subscription name - - `count` (number, required): Number of messages to skip -- **expire**: Expire messages for a subscription - - `topic` (string, required): The fully qualified topic name - - `subscription` (string, required): The subscription name - - `expireTimeInSeconds` (number, required): Expiry time in seconds -- **reset-cursor**: Reset subscription position - - `topic` (string, required): The fully qualified topic name - - `subscription` (string, required): The subscription name - - `messageId` (string, required): Message ID to reset to -- **peek**: Peek messages for a subscription without advancing the cursor - - `topic` (string, required): The fully qualified topic name - - `subscription` (string, required): The subscription name - - `count` (number, optional): Number of messages to return, default is 1, maximum is 100 -- **get-message-by-id**: Read a message by ledger ID and entry ID - - `topic` (string, required): The fully qualified topic name - - `ledgerId` (number, required): Non-negative ledger ID of the message - - `entryId` (number, required): Non-negative entry ID of the message - -Message payloads returned by `peek` and `get-message-by-id` include: -- `payload`: text view of the payload -- `payloadBase64`: lossless base64 encoding of the raw bytes -- `payloadHex`: hexadecimal dump for debugging +**Claude connector safety:** Actual MCP tools are split into `pulsar_admin_subscription_read` and `pulsar_admin_subscription_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. + +### `pulsar_admin_subscription_read` + +Read subscription names and inspect messages without advancing subscription cursors. + +- **subscription** + - **list**: List subscriptions for a topic + - `topic` (string, required): Fully qualified topic name + - **peek**: Peek messages for a subscription without advancing the cursor + - `topic` (string, required): Fully qualified topic name + - `subscription` (string, required): Subscription name + - `count` (number, optional): Number of messages to return; default is 1, maximum is 100 + - **get-message-by-id**: Read a message by ledger ID and entry ID + - `topic` (string, required): Fully qualified topic name + - `ledgerId` (number, required): Non-negative ledger ID + - `entryId` (number, required): Non-negative entry ID + +Message payloads returned by `peek` and `get-message-by-id` include `payload`, `payloadBase64`, and `payloadHex`. + +### `pulsar_admin_subscription_write` + +Manage subscription lifecycle and cursor position. + +- **subscription** + - **create**: Create a subscription + - `topic` (string, required): Fully qualified topic name + - `subscription` (string, required): Subscription name + - `messageId` (string, optional): Initial position, such as `latest`, `earliest`, or `ledgerId:entryId` + - **delete**: Delete a subscription + - `topic` (string, required): Fully qualified topic name + - `subscription` (string, required): Subscription name + - `force` (boolean, optional): Force delete active consumers + - **skip**: Skip messages for a subscription + - `topic` (string, required): Fully qualified topic name + - `subscription` (string, required): Subscription name + - `count` (number, required): Number of messages to skip + - **expire**: Expire messages for a subscription + - `topic` (string, required): Fully qualified topic name + - `subscription` (string, required): Subscription name + - `expireTimeInSeconds` (number, required): Expiry time in seconds + - **reset-cursor**: Reset subscription cursor position + - `topic` (string, required): Fully qualified topic name + - `subscription` (string, required): Subscription name + - `messageId` (string, required): Message ID to reset to diff --git a/docs/tools/pulsar_admin_tenants.md b/docs/tools/pulsar_admin_tenants.md index a183ecc8..1268f794 100644 --- a/docs/tools/pulsar_admin_tenants.md +++ b/docs/tools/pulsar_admin_tenants.md @@ -1,20 +1,28 @@ #### pulsar_admin_tenant -**Claude connector safety:** Actual MCP tools: `pulsar_admin_tenant_read` (`list`, `get`) and `pulsar_admin_tenant_write` (`create`, `update`, `delete`). +**Claude connector safety:** Actual MCP tools are split into `pulsar_admin_tenant_read` and `pulsar_admin_tenant_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. +### `pulsar_admin_tenant_read` -Manage Pulsar tenants, which are the highest level administrative units. +Read tenant names and tenant configuration. -- **list**: List all tenants in the Pulsar instance -- **get**: Get configuration details for a specific tenant - - `tenant` (string, required): The tenant name -- **create**: Create a new tenant - - `tenant` (string, required): The tenant name - - `admin_roles` (array, optional): List of roles with admin permissions - - `allowed_clusters` (array, required): List of clusters tenant can access -- **update**: Update configuration for an existing tenant - - `tenant` (string, required): The tenant name - - `admin_roles` (array, optional): List of roles with admin permissions - - `allowed_clusters` (array, required): List of clusters tenant can access -- **delete**: Delete a tenant - - `tenant` (string, required): The tenant name +- **tenant** + - **list**: List tenants + - **get**: Get tenant configuration + - `tenant` (string, required): Tenant name + +### `pulsar_admin_tenant_write` + +Manage tenant lifecycle and configuration. + +- **tenant** + - **create**: Create a tenant + - `tenant` (string, required): Tenant name + - `adminRoles` (array, optional): Admin roles + - `allowedClusters` (array, required): Clusters the tenant can access + - **update**: Update tenant configuration + - `tenant` (string, required): Tenant name + - `adminRoles` (array, optional): Admin roles + - `allowedClusters` (array, required): Clusters the tenant can access + - **delete**: Delete a tenant + - `tenant` (string, required): Tenant name diff --git a/docs/tools/pulsar_admin_topic_policy.md b/docs/tools/pulsar_admin_topic_policy.md index 25f03390..f77abe2a 100644 --- a/docs/tools/pulsar_admin_topic_policy.md +++ b/docs/tools/pulsar_admin_topic_policy.md @@ -1,44 +1,70 @@ #### pulsar_admin_topic_policy -**Claude connector safety:** Actual MCP tools: `pulsar_admin_topic_policy_read` (get policy operations) and `pulsar_admin_topic_policy_write` (set/remove policy operations). - - -Manage Pulsar topic-level policies with operation names aligned to `pulsarctl topics`. - -- **Core operations** - - Retention: `get-retention`, `set-retention`, `remove-retention` - - Message TTL: `get-message-ttl`, `set-message-ttl`, `remove-message-ttl` - - Producer and consumer limits: - - `get-max-producers`, `set-max-producers`, `remove-max-producers` - - `get-max-consumers`, `set-max-consumers`, `remove-max-consumers` - - `get-max-unacked-messages-per-consumer`, `set-max-unacked-messages-per-consumer`, `remove-max-unacked-messages-per-consumer` - - `get-max-unacked-messages-per-subscription`, `set-max-unacked-messages-per-subscription`, `remove-max-unacked-messages-per-subscription` - - Persistence: `get-persistence`, `set-persistence`, `remove-persistence` - - Delayed delivery: `get-delayed-delivery`, `set-delayed-delivery`, `remove-delayed-delivery` - - Dispatch throttling: - - `get-dispatch-rate`, `set-dispatch-rate`, `remove-dispatch-rate` - - `get-subscription-dispatch-rate`, `set-subscription-dispatch-rate`, `remove-subscription-dispatch-rate` - - `get-publish-rate`, `set-publish-rate`, `remove-publish-rate` - - Topic-level toggles: `get-deduplication`, `set-deduplication`, `remove-deduplication` - - Storage and cleanup: - - `get-backlog-quotas`, `set-backlog-quota`, `remove-backlog-quota` - - `get-compaction-threshold`, `set-compaction-threshold`, `remove-compaction-threshold` - - `get-inactive-topic-policies`, `set-inactive-topic-policies`, `remove-inactive-topic-policies` - - Additional MCP-only compatibility operations: `get-subscription-types`, `set-subscription-types`, `remove-subscription-types` - -- **Shared parameters** - - `topic` (string, required): Fully qualified topic name - - `applied` (boolean, optional): Return the effective inherited policy for `get-retention`, `get-backlog-quotas`, `get-compaction-threshold`, and `get-inactive-topic-policies` - -- **Operation-specific parameters** - - Retention: `retention-time`, `retention-size` - - Message TTL: `ttl-seconds` - - Max limits: `count` - - Persistence: `bookkeeper-ensemble`, `bookkeeper-write-quorum`, `bookkeeper-ack-quorum`, `ml-mark-delete-max-rate` - - Delayed delivery: `enable`, `disable`, `time` - - Dispatch throttling: `msg-rate`, `byte-rate`, `period`, `relative-to-publish-rate` - - Backlog quota: `limit-size`, `limit-time`, `policy`, `type` - - Inactive topic policies: `delete-while-inactive`, `max-inactive-duration`, `delete-mode` - - Subscription type restriction: `subscription-types` - -Legacy underscore operation aliases from the older MCP implementation are still accepted for backward compatibility. +**Claude connector safety:** Actual MCP tools are split into `pulsar_admin_topic_policy_read` and `pulsar_admin_topic_policy_write`. The read tool is read-only and only exposes get operations/parameters. The write tool is destructive and is not registered in read-only mode. + +### `pulsar_admin_topic_policy_read` + +Read topic-level policies. + +Read operations: + +- `get-retention` +- `get-message-ttl` +- `get-max-producers` +- `get-max-consumers` +- `get-max-unacked-messages-per-consumer` +- `get-max-unacked-messages-per-subscription` +- `get-persistence` +- `get-delayed-delivery` +- `get-dispatch-rate` +- `get-subscription-dispatch-rate` +- `get-deduplication` +- `get-backlog-quotas` +- `get-compaction-threshold` +- `get-publish-rate` +- `get-inactive-topic-policies` +- `get-subscription-types` + +Read parameters: + +- `topic` (string, required): Fully qualified topic name +- `applied` (boolean, optional): Return effective inherited policy where supported +- `type` (string, optional): Backlog quota type for backlog quota reads + +### `pulsar_admin_topic_policy_write` + +Set or remove topic-level policies. + +Write operations: + +- `set-retention`, `remove-retention` +- `set-message-ttl`, `remove-message-ttl` +- `set-max-producers`, `remove-max-producers` +- `set-max-consumers`, `remove-max-consumers` +- `set-max-unacked-messages-per-consumer`, `remove-max-unacked-messages-per-consumer` +- `set-max-unacked-messages-per-subscription`, `remove-max-unacked-messages-per-subscription` +- `set-persistence`, `remove-persistence` +- `set-delayed-delivery`, `remove-delayed-delivery` +- `set-dispatch-rate`, `remove-dispatch-rate` +- `set-subscription-dispatch-rate`, `remove-subscription-dispatch-rate` +- `set-deduplication`, `remove-deduplication` +- `set-backlog-quota`, `remove-backlog-quota` +- `set-compaction-threshold`, `remove-compaction-threshold` +- `set-publish-rate`, `remove-publish-rate` +- `set-inactive-topic-policies`, `remove-inactive-topic-policies` +- `set-subscription-types`, `remove-subscription-types` + +Write parameters: + +- `topic` (string, required): Fully qualified topic name +- Retention: `retention-time`, `retention-size` +- Message TTL: `ttl-seconds` +- Max limits: `count` +- Persistence: `bookkeeper-ensemble`, `bookkeeper-write-quorum`, `bookkeeper-ack-quorum`, `ml-mark-delete-max-rate` +- Delayed delivery: `enable`, `disable`, `time` +- Dispatch/publish throttling: `msg-rate`, `byte-rate`, `period`, `relative-to-publish-rate` +- Backlog quota: `limit-size`, `limit-time`, `policy`, `type` +- Inactive topic policies: `delete-while-inactive`, `max-inactive-duration`, `delete-mode` +- Subscription type restriction: `subscription-types` + +Legacy underscore operation aliases from the older MCP implementation are still accepted by the handlers. diff --git a/docs/tools/pulsar_admin_topics.md b/docs/tools/pulsar_admin_topics.md index 8f003ecf..682c892f 100644 --- a/docs/tools/pulsar_admin_topics.md +++ b/docs/tools/pulsar_admin_topics.md @@ -1,62 +1,70 @@ #### pulsar_admin_topic -**Claude connector safety:** Actual MCP tools: `pulsar_admin_topic_read` (`list`, `get`, `get-permissions`, stats/lookup/status operations) and `pulsar_admin_topic_write` (permission grants/revokes and mutating topic lifecycle operations). +**Claude connector safety:** Actual MCP tools are split into `pulsar_admin_topic_read` and `pulsar_admin_topic_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. +### `pulsar_admin_topic_read` -Manage Apache Pulsar topics. Topics are the core messaging entities in Pulsar that store and transmit messages. Pulsar supports two types of topics: persistent (durable storage with guaranteed delivery) and non-persistent (in-memory with at-most-once delivery). Topics can be partitioned for parallel processing and higher throughput. +Read topic metadata, permissions, statistics, lookup details, and long-running operation status. + +- **topics** + - **list**: List topics in a namespace + - `namespace` (string, required): Namespace name in `tenant/namespace` format - **topic** - - **get**: Get metadata for a topic - - `topic` (string, required): The fully qualified topic name - - **get-permissions**: Get the current topic permissions for every role - - `topic` (string, required): The fully qualified topic name - - **grant-permissions**: Grant topic permissions to a role - - `topic` (string, required): The fully qualified topic name - - `role` (string, required): The role to grant permissions to - - `actions` (array of strings, required): Allowed values are `produce`, `consume`, `sources`, `sinks`, `functions`, `packages` - - **revoke-permissions**: Revoke all topic permissions from a role - - `topic` (string, required): The fully qualified topic name - - `role` (string, required): The role to revoke permissions from - - **create**: Create a new topic with optional partitions - - `topic` (string, required): The fully qualified topic name - - `partitions` (number, required): The number of partitions (0 for non-partitioned) - - **delete**: Delete a topic - - `topic` (string, required): The fully qualified topic name - - `force` (boolean, optional): Force operation even if it disrupts producers/consumers - - `non-partitioned` (boolean, optional): Delete only the non-partitioned topic with the same name - - **stats**: Get statistics for a topic - - `topic` (string, required): The fully qualified topic name - - `partitioned` (boolean, optional): Get stats for a partitioned topic + - **get**: Get topic metadata + - `topic` (string, required): Fully qualified topic name + - **get-permissions**: Get topic permissions for all roles + - `topic` (string, required): Fully qualified topic name + - **stats**: Get topic statistics + - `topic` (string, required): Fully qualified topic name + - `partitioned` (boolean, optional): Treat topic as partitioned - `per-partition` (boolean, optional): Include per-partition stats - **lookup**: Look up the broker serving a topic - - `topic` (string, required): The fully qualified topic name - - **internal-stats**: Get internal stats for a topic - - `topic` (string, required): The fully qualified topic name - - **internal-info**: Get internal info for a topic - - `topic` (string, required): The fully qualified topic name - - **bundle-range**: Get the bundle range of a topic - - `topic` (string, required): The fully qualified topic name + - `topic` (string, required): Fully qualified topic name + - **internal-stats**: Get topic internal stats + - `topic` (string, required): Fully qualified topic name + - **internal-info**: Get topic internal info + - `topic` (string, required): Fully qualified topic name + - **bundle-range**: Get topic bundle range + - `topic` (string, required): Fully qualified topic name - **last-message-id**: Get the last message ID of a topic - - `topic` (string, required): The fully qualified topic name - - **compact-status**: Get compaction status for a topic (`status` is still accepted as a legacy alias) - - `topic` (string, required): The fully qualified topic name - - `wait` (boolean, optional): Poll until the compaction finishes - - **unload**: Unload a topic from broker memory - - `topic` (string, required): The fully qualified topic name - - **terminate**: Terminate a topic (close all producers and mark as inactive) - - `topic` (string, required): The fully qualified topic name - - **compact**: Trigger compaction on a topic - - `topic` (string, required): The fully qualified topic name - - **update**: Update the number of partitions for a topic - - `topic` (string, required): The fully qualified topic name - - `partitions` (number, required): The new number of partitions - - **offload**: Offload data from a topic to long-term storage - - `topic` (string, required): The fully qualified topic name - - `messageId` (string, required): Message ID up to which to offload (format: ledgerId:entryId) - - **offload-status**: Check the status of data offloading for a topic - - `topic` (string, required): The fully qualified topic name - - `wait` (boolean, optional): Poll until the offload finishes + - `topic` (string, required): Fully qualified topic name + - **compact-status**: Get topic compaction status (`status` is accepted as a legacy alias) + - `topic` (string, required): Fully qualified topic name + - `wait` (boolean, optional): Poll until compaction status is final + - **offload-status**: Get topic offload status + - `topic` (string, required): Fully qualified topic name + - `wait` (boolean, optional): Poll until offload status is final -- **topics** - - **list**: List all topics in a namespace - - `namespace` (string, required): The namespace name (format: tenant/namespace) +### `pulsar_admin_topic_write` + +Manage topic lifecycle, permissions, partitioning, compaction, and offload state. + +- **topic** + - **grant-permissions**: Grant topic permissions to a role + - `topic` (string, required): Fully qualified topic name + - `role` (string, required): Role to grant permissions to + - `actions` (array, required): Allowed values include `produce`, `consume`, `sources`, `sinks`, `functions`, `packages` + - **revoke-permissions**: Revoke all topic permissions from a role + - `topic` (string, required): Fully qualified topic name + - `role` (string, required): Role to revoke permissions from + - **create**: Create a topic + - `topic` (string, required): Fully qualified topic name + - `partitions` (number, required): Number of partitions; use `0` for non-partitioned + - **delete**: Delete a topic + - `topic` (string, required): Fully qualified topic name + - `force` (boolean, optional): Force operation even if producers or consumers are active + - `non-partitioned` (boolean, optional): Delete only a non-partitioned topic with this name + - **unload**: Unload a topic from broker memory + - `topic` (string, required): Fully qualified topic name + - **terminate**: Terminate a topic + - `topic` (string, required): Fully qualified topic name + - **compact**: Trigger topic compaction + - `topic` (string, required): Fully qualified topic name + - **update**: Update topic partitions or configuration + - `topic` (string, required): Fully qualified topic name + - `partitions` (number, optional): New partition count + - `config` (string, optional): JSON topic configuration + - **offload**: Offload topic data to long-term storage + - `topic` (string, required): Fully qualified topic name + - `messageId` (string, required): Message ID up to which data should be offloaded diff --git a/hack/common.sh b/hack/common.sh index 4662131f..412e27e4 100644 --- a/hack/common.sh +++ b/hack/common.sh @@ -1,4 +1,18 @@ #!/usr/bin/env bash +# Copyright 2026 StreamNative +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + # # Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 1bc1b142..386d510c 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/auth/authorization_tokenretriever.go b/pkg/auth/authorization_tokenretriever.go index 730e9ff0..22d99839 100644 --- a/pkg/auth/authorization_tokenretriever.go +++ b/pkg/auth/authorization_tokenretriever.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/auth/cache/cache.go b/pkg/auth/cache/cache.go index 6e56dda8..e7380314 100644 --- a/pkg/auth/cache/cache.go +++ b/pkg/auth/cache/cache.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/auth/client_credentials_flow.go b/pkg/auth/client_credentials_flow.go index 39704e86..8c4415c1 100644 --- a/pkg/auth/client_credentials_flow.go +++ b/pkg/auth/client_credentials_flow.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/auth/client_credentials_provider.go b/pkg/auth/client_credentials_provider.go index f562e480..f3b1ee23 100644 --- a/pkg/auth/client_credentials_provider.go +++ b/pkg/auth/client_credentials_provider.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/auth/config_tokenprovider.go b/pkg/auth/config_tokenprovider.go index 5276a7c2..5cfe9883 100644 --- a/pkg/auth/config_tokenprovider.go +++ b/pkg/auth/config_tokenprovider.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/auth/data_url.go b/pkg/auth/data_url.go index 473d63dd..7257f2ef 100644 --- a/pkg/auth/data_url.go +++ b/pkg/auth/data_url.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/auth/oidc_endpoint_provider.go b/pkg/auth/oidc_endpoint_provider.go index 62ecf1da..66820b17 100644 --- a/pkg/auth/oidc_endpoint_provider.go +++ b/pkg/auth/oidc_endpoint_provider.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/auth/store/keyring.go b/pkg/auth/store/keyring.go index f2c47f20..ca46247b 100644 --- a/pkg/auth/store/keyring.go +++ b/pkg/auth/store/keyring.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/auth/store/store.go b/pkg/auth/store/store.go index b225db5e..ed762c35 100644 --- a/pkg/auth/store/store.go +++ b/pkg/auth/store/store.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/cmd/mcp/mcp.go b/pkg/cmd/mcp/mcp.go index f457cb0a..517ef812 100644 --- a/pkg/cmd/mcp/mcp.go +++ b/pkg/cmd/mcp/mcp.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/cmd/mcp/sse.go b/pkg/cmd/mcp/sse.go index 1540abdc..bf34d71d 100644 --- a/pkg/cmd/mcp/sse.go +++ b/pkg/cmd/mcp/sse.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/cmd/mcp/stdio.go b/pkg/cmd/mcp/stdio.go index c029231f..1969fe13 100644 --- a/pkg/cmd/mcp/stdio.go +++ b/pkg/cmd/mcp/stdio.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/common/utils.go b/pkg/common/utils.go index a119f759..b9844579 100644 --- a/pkg/common/utils.go +++ b/pkg/common/utils.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/config/apiclient.go b/pkg/config/apiclient.go index 5d6604f9..24eef7ba 100644 --- a/pkg/config/apiclient.go +++ b/pkg/config/apiclient.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/config/apiclient_test.go b/pkg/config/apiclient_test.go index 60b2b1f8..0804b188 100644 --- a/pkg/config/apiclient_test.go +++ b/pkg/config/apiclient_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/config/auth.go b/pkg/config/auth.go index c7656b96..f20f8fb0 100644 --- a/pkg/config/auth.go +++ b/pkg/config/auth.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/config/config.go b/pkg/config/config.go index e21de1d1..52669a5f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/config/external_kafka.go b/pkg/config/external_kafka.go index a87c9bb9..1bb1d357 100644 --- a/pkg/config/external_kafka.go +++ b/pkg/config/external_kafka.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/config/external_pulsar.go b/pkg/config/external_pulsar.go index 0e9ff322..157c10d0 100644 --- a/pkg/config/external_pulsar.go +++ b/pkg/config/external_pulsar.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/config/options.go b/pkg/config/options.go index c0185129..de6b0cca 100644 --- a/pkg/config/options.go +++ b/pkg/config/options.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/kafka/connection.go b/pkg/kafka/connection.go index f046aea9..53c78ddd 100644 --- a/pkg/kafka/connection.go +++ b/pkg/kafka/connection.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/kafka/connection_test.go b/pkg/kafka/connection_test.go index 29759036..5462f422 100644 --- a/pkg/kafka/connection_test.go +++ b/pkg/kafka/connection_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/kafka/kafkaconnect.go b/pkg/kafka/kafkaconnect.go index 9e2e31c2..ec503a01 100644 --- a/pkg/kafka/kafkaconnect.go +++ b/pkg/kafka/kafkaconnect.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/log/io.go b/pkg/log/io.go index 8ebcb3a7..fa298345 100644 --- a/pkg/log/io.go +++ b/pkg/log/io.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/auth_utils.go b/pkg/mcp/auth_utils.go index 319c48df..dbaa344c 100644 --- a/pkg/mcp/auth_utils.go +++ b/pkg/mcp/auth_utils.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/builders/base.go b/pkg/mcp/builders/base.go index 61411a8f..aceb7b54 100644 --- a/pkg/mcp/builders/base.go +++ b/pkg/mcp/builders/base.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/builders/base_test.go b/pkg/mcp/builders/base_test.go index b67a8aa1..025b0434 100644 --- a/pkg/mcp/builders/base_test.go +++ b/pkg/mcp/builders/base_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/builders/config.go b/pkg/mcp/builders/config.go index 78035a37..782b50a7 100644 --- a/pkg/mcp/builders/config.go +++ b/pkg/mcp/builders/config.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/builders/config_test.go b/pkg/mcp/builders/config_test.go index 41fb2799..a69e07cf 100644 --- a/pkg/mcp/builders/config_test.go +++ b/pkg/mcp/builders/config_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/builders/kafka/annotation_compliance_test.go b/pkg/mcp/builders/kafka/annotation_compliance_test.go index ba9cb08c..cdeaaa7b 100644 --- a/pkg/mcp/builders/kafka/annotation_compliance_test.go +++ b/pkg/mcp/builders/kafka/annotation_compliance_test.go @@ -19,6 +19,7 @@ import ( "strings" "testing" + "github.com/mark3labs/mcp-go/mcp" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" "github.com/stretchr/testify/require" ) @@ -61,6 +62,14 @@ func TestKafkaToolAnnotationCompliance(t *testing.T) { } } +func toolPropertyNames(tool mcp.Tool) []string { + names := make([]string, 0, len(tool.InputSchema.Properties)) + for name := range tool.InputSchema.Properties { + names = append(names, name) + } + return names +} + func assertOperationEnumMode(t *testing.T, toolName string, operationSchema any) { t.Helper() schema, ok := operationSchema.(map[string]any) @@ -86,6 +95,37 @@ func assertOperationEnumMode(t *testing.T, toolName string, operationSchema any) require.False(t, seenRead && seenWrite, toolName) } +func TestKafkaSplitToolsExposeModeSpecificParameters(t *testing.T) { + builderList := []builders.ToolBuilder{ + NewKafkaTopicsToolBuilder(), + NewKafkaGroupsToolBuilder(), + NewKafkaSchemaRegistryToolBuilder(), + NewKafkaConnectToolBuilder(), + } + + expectedProperties := map[string][]string{ + "kafka_admin_topics_read": {"resource", "operation", "name", "includeInternal"}, + "kafka_admin_topics_write": {"resource", "operation", "name", "partitions", "replicationFactor", "configs"}, + "kafka_admin_groups_read": {"resource", "operation", "group"}, + "kafka_admin_groups_write": {"resource", "operation", "group", "members", "topic", "partition", "offset"}, + "kafka_admin_sr_read": {"resource", "operation", "subject", "version"}, + "kafka_admin_sr_write": {"resource", "operation", "subject", "version", "compatibility", "schemaType", "schema"}, + "kafka_admin_connect_read": {"resource", "operation", "name"}, + "kafka_admin_connect_write": {"resource", "operation", "name", "config"}, + } + + for _, builder := range builderList { + tools, err := builder.BuildTools(context.Background(), builders.ToolBuildConfig{ + Features: []string{"all", "all-kafka", "kafka-admin", "kafka-admin-kafka-connect", "kafka-admin-schema-registry"}, + }) + require.NoError(t, err) + for _, serverTool := range tools { + tool := serverTool.Tool + require.ElementsMatch(t, expectedProperties[tool.Name], toolPropertyNames(tool), tool.Name) + } + } +} + func TestKafkaReadOnlyBuildsNoWriteTools(t *testing.T) { builderList := []builders.ToolBuilder{ NewKafkaTopicsToolBuilder(), diff --git a/pkg/mcp/builders/kafka/connect.go b/pkg/mcp/builders/kafka/connect.go index a476096b..550cddc1 100644 --- a/pkg/mcp/builders/kafka/connect.go +++ b/pkg/mcp/builders/kafka/connect.go @@ -125,18 +125,9 @@ func (b *KafkaConnectToolBuilder) buildKafkaConnectTool(mode toolMode) mcp.Tool annotation = toolannotations.Destructive("Manage Kafka Connect") } - toolDesc := "Unified tool for managing Apache Kafka Connect.\n" + - "Kafka Connect is a framework for connecting Kafka with external systems such as databases, key-value stores, search indexes, and file systems. " + - "It provides a standardized way to stream data in and out of Kafka, without requiring custom integration code.\n\n" + - "Key concepts in Kafka Connect:\n\n" + - "- Connectors: The high-level abstraction that coordinates data streaming by managing tasks\n" + - "- Tasks: The implementation of how data is copied to or from Kafka\n" + - "- Workers: The running processes that execute connectors and tasks\n" + - "- Plugins: Reusable connector implementations for specific external systems\n" + - "- Source Connectors: Import data from external systems into Kafka topics\n" + - "- Sink Connectors: Export data from Kafka topics to external systems\n\n" + - "Kafka Connect simplifies data integration, enables scalable and reliable streaming pipelines, " + - "and reduces the operational burden of managing data flows.\n\n" + + toolDesc := "Read Apache Kafka Connect cluster, connector, and plugin information.\n" + + "Kafka Connect is a framework for connecting Kafka with external systems through reusable connectors.\n" + + "This read-only tool lists connectors and plugins and retrieves cluster or connector details.\n\n" + "Usage Examples:\n\n" + "1. List all connectors in the Kafka Connect cluster:\n" + " resource: \"connectors\"\n" + @@ -145,55 +136,35 @@ func (b *KafkaConnectToolBuilder) buildKafkaConnectTool(mode toolMode) mcp.Tool " resource: \"connector\"\n" + " operation: \"get\"\n" + " name: \"my-jdbc-source\"\n\n" + - "3. Create a new JDBC source connector:\n" + - " resource: \"connector\"\n" + - " operation: \"create\"\n" + - " name: \"my-jdbc-source\"\n" + - " config: {\n" + - " \"connector.class\": \"io.confluent.connect.jdbc.JdbcSourceConnector\",\n" + - " \"connection.url\": \"jdbc:mysql://mysql:3306/mydb\",\n" + - " \"connection.user\": \"user\",\n" + - " \"connection.password\": \"password\",\n" + - " \"topic.prefix\": \"mysql-\",\n" + - " \"table.whitelist\": \"users,orders\",\n" + - " \"mode\": \"incrementing\",\n" + - " \"incrementing.column.name\": \"id\",\n" + - " \"tasks.max\": \"1\"\n" + - " }\n\n" + - "4. Update an existing connector's configuration:\n" + - " resource: \"connector\"\n" + - " operation: \"update\"\n" + - " name: \"my-jdbc-source\"\n" + - " config: {\n" + - " \"connector.class\": \"io.confluent.connect.jdbc.JdbcSourceConnector\",\n" + - " \"tasks.max\": \"2\",\n" + - " \"table.whitelist\": \"users,orders,products\"\n" + - " }\n\n" + - "5. Delete a connector:\n" + - " resource: \"connector\"\n" + - " operation: \"delete\"\n" + - " name: \"my-jdbc-source\"\n\n" + - "6. Restart a connector after configuration changes or errors:\n" + - " resource: \"connector\"\n" + - " operation: \"restart\"\n" + - " name: \"my-jdbc-source\"\n\n" + - "7. Pause a connector temporarily:\n" + - " resource: \"connector\"\n" + - " operation: \"pause\"\n" + - " name: \"my-jdbc-source\"\n\n" + - "8. Resume a paused connector:\n" + - " resource: \"connector\"\n" + - " operation: \"resume\"\n" + - " name: \"my-jdbc-source\"\n\n" + - "9. List all available connector plugins:\n" + + "3. List all available connector plugins:\n" + " resource: \"connector-plugins\"\n" + " operation: \"list\"\n\n" + - "10. Get information about the Kafka Connect cluster:\n" + - " resource: \"kafka-connect-cluster\"\n" + - " operation: \"get\"\n\n" + - "This tool requires appropriate Kafka Connect permissions." + "4. Get information about the Kafka Connect cluster:\n" + + " resource: \"kafka-connect-cluster\"\n" + + " operation: \"get\"\n\n" + + "This tool requires appropriate Kafka Connect read permissions." + if isToolModeWrite(mode) { + toolDesc = "Manage Apache Kafka Connect connectors.\n" + + "This write tool creates, updates, deletes, restarts, pauses, or resumes connectors.\n\n" + + "Usage Examples:\n\n" + + "1. Create a new connector:\n" + + " resource: \"connector\"\n" + + " operation: \"create\"\n" + + " name: \"my-jdbc-source\"\n" + + " config: {...}\n\n" + + "2. Update an existing connector's configuration:\n" + + " resource: \"connector\"\n" + + " operation: \"update\"\n" + + " name: \"my-jdbc-source\"\n" + + " config: {...}\n\n" + + "3. Restart a connector:\n" + + " resource: \"connector\"\n" + + " operation: \"restart\"\n" + + " name: \"my-jdbc-source\"\n\n" + + "This tool requires appropriate Kafka Connect write permissions." + } - return mcp.NewTool(toolName, + tool := mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), @@ -203,8 +174,7 @@ func (b *KafkaConnectToolBuilder) buildKafkaConnectTool(mode toolMode) mcp.Tool mcp.Enum(operationEnum...), ), mcp.WithString("name", - mcp.Description("The name of the Kafka Connect connector to operate on. "+ - "Required for 'get', 'create', 'update', 'delete', 'restart', 'pause', and 'resume' operations on the 'connector' resource. "+ + mcp.Description("The name of the Kafka Connect connector to operate on. Required for operations that target one connector. "+ "Must be unique within the Kafka Connect cluster. "+ "Should be descriptive of the connector's purpose, such as 'mysql-inventory-source' or 'elasticsearch-logs-sink'.")), mcp.WithObject("config", @@ -220,6 +190,12 @@ func (b *KafkaConnectToolBuilder) buildKafkaConnectTool(mode toolMode) mcp.Tool "Additional fields depend on the specific connector type being used.")), annotation, ) + if isToolModeWrite(mode) { + pruneToolInputSchema(&tool, []string{"resource", "operation", "name", "config"}) + } else { + pruneToolInputSchema(&tool, []string{"resource", "operation", "name"}) + } + return tool } // buildKafkaConnectHandler builds the Kafka Connect handler function diff --git a/pkg/mcp/builders/kafka/connect_test.go b/pkg/mcp/builders/kafka/connect_test.go index 13cc0541..d1681dcc 100644 --- a/pkg/mcp/builders/kafka/connect_test.go +++ b/pkg/mcp/builders/kafka/connect_test.go @@ -175,7 +175,11 @@ func TestKafkaConnectToolBuilder_ToolDefinition(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "resource") assert.Contains(t, tool.InputSchema.Properties, "operation") assert.Contains(t, tool.InputSchema.Properties, "name") - assert.Contains(t, tool.InputSchema.Properties, "config") + assert.NotContains(t, tool.InputSchema.Properties, "config") + + writeTool := builder.buildKafkaConnectTool(toolModeWrite) + assert.Equal(t, "kafka_admin_connect_write", writeTool.Name) + assert.Contains(t, writeTool.InputSchema.Properties, "config") // Verify required fields assert.Contains(t, tool.InputSchema.Required, "resource") diff --git a/pkg/mcp/builders/kafka/consume_test.go b/pkg/mcp/builders/kafka/consume_test.go index f6e4a3ec..a0f2f630 100644 --- a/pkg/mcp/builders/kafka/consume_test.go +++ b/pkg/mcp/builders/kafka/consume_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/builders/kafka/groups.go b/pkg/mcp/builders/kafka/groups.go index 155ac983..f6927f29 100644 --- a/pkg/mcp/builders/kafka/groups.go +++ b/pkg/mcp/builders/kafka/groups.go @@ -92,8 +92,8 @@ func (b *KafkaGroupsToolBuilder) BuildTools(_ context.Context, config builders.T // buildKafkaGroupsTool builds the Kafka Groups MCP tool definition func (b *KafkaGroupsToolBuilder) buildKafkaGroupsTool(mode toolMode) mcp.Tool { resourceDesc := "Resource to operate on. Available resources:\n" + - "- group: A single Kafka Consumer Group for operations on individual groups (describe, remove-members, set-offset, delete-offset)\n" + - "- groups: Collection of Kafka Consumer Groups for bulk operations (list)" + "- group: A single Kafka Consumer Group for read operations (describe, offsets)\n" + + "- groups: Collection of Kafka Consumer Groups for list operations" operationDesc := "Operation to perform. Available operations:\n" + "- list: List all Kafka Consumer Groups in the cluster\n" + @@ -108,49 +108,52 @@ func (b *KafkaGroupsToolBuilder) buildKafkaGroupsTool(mode toolMode) mcp.Tool { "- delete-offset: Delete a specific offset for a consumer group of a topic\n" + "- set-offset: Set a specific offset for a consumer group's topic-partition" operationEnum = []string{"remove-members", "delete-offset", "set-offset"} + resourceDesc = "Resource to operate on. Available resources:\n" + + "- group: A single Kafka Consumer Group for membership and offset changes" toolName = "kafka_admin_groups_write" annotation = toolannotations.Destructive("Manage Kafka Consumer Groups") } - toolDesc := "Unified tool for managing Apache Kafka Consumer Groups.\n" + - "This tool provides access to Kafka consumer group operations including listing, describing, and managing group membership.\n" + - "Kafka Consumer Groups are a key concept for scalable consumption of Kafka topics. A consumer group consists of multiple consumer instances\n" + - "that collaborate to consume data from topic partitions. Kafka ensures that:\n" + - "- Each partition is consumed by exactly one consumer in the group\n" + - "- When consumers join or leave, Kafka triggers a 'rebalance' to redistribute partitions\n" + - "- Consumer groups track consumption progress through committed offsets\n\n" + + toolDesc := "Read Apache Kafka Consumer Groups.\n" + + "This read-only tool lists consumer groups, describes group state, and fetches committed offsets without changing group state.\n\n" + "Usage Examples:\n\n" + "1. List all Kafka Consumer Groups in the cluster:\n" + " resource: \"groups\"\n" + " operation: \"list\"\n\n" + - "2. Describe a specific Kafka Consumer Group to see its members and consumption details:\n" + + "2. Describe a specific Kafka Consumer Group:\n" + " resource: \"group\"\n" + " operation: \"describe\"\n" + " group: \"my-consumer-group\"\n\n" + - "3. Remove specific members from a Kafka Consumer Group to trigger rebalancing:\n" + - " resource: \"group\"\n" + - " operation: \"remove-members\"\n" + - " group: \"my-consumer-group\"\n" + - " members: \"consumer-instance-1,consumer-instance-2\"\n\n" + - "4. Get offsets for a specific consumer group:\n" + + "3. Get offsets for a specific consumer group:\n" + " resource: \"group\"\n" + " operation: \"offsets\"\n" + " group: \"my-consumer-group\"\n\n" + - "5. Delete a specific offset for a consumer group of a topic:\n" + - " resource: \"group\"\n" + - " operation: \"delete-offset\"\n" + - " group: \"my-consumer-group\"\n" + - " topic: \"my-topic\"\n\n" + - "6. Set a specific offset for a consumer group's topic-partition:\n" + - " resource: \"group\"\n" + - " operation: \"set-offset\"\n" + - " group: \"my-consumer-group\"\n" + - " topic: \"my-topic\"\n" + - " partition: 0\n" + - " offset: 1000\n\n" + - "This tool requires Kafka super-user permissions." - - return mcp.NewTool(toolName, + "This tool requires Kafka permissions for group reads." + if isToolModeWrite(mode) { + toolDesc = "Manage Apache Kafka Consumer Groups.\n" + + "This write tool removes group members and changes committed offsets.\n\n" + + "Usage Examples:\n\n" + + "1. Remove specific members from a Kafka Consumer Group:\n" + + " resource: \"group\"\n" + + " operation: \"remove-members\"\n" + + " group: \"my-consumer-group\"\n" + + " members: \"consumer-instance-1,consumer-instance-2\"\n\n" + + "2. Delete a specific offset for a consumer group of a topic:\n" + + " resource: \"group\"\n" + + " operation: \"delete-offset\"\n" + + " group: \"my-consumer-group\"\n" + + " topic: \"my-topic\"\n\n" + + "3. Set a specific offset for a consumer group's topic-partition:\n" + + " resource: \"group\"\n" + + " operation: \"set-offset\"\n" + + " group: \"my-consumer-group\"\n" + + " topic: \"my-topic\"\n" + + " partition: 0\n" + + " offset: 1000\n\n" + + "This tool requires Kafka super-user permissions." + } + + tool := mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), @@ -160,8 +163,7 @@ func (b *KafkaGroupsToolBuilder) buildKafkaGroupsTool(mode toolMode) mcp.Tool { mcp.Enum(operationEnum...), ), mcp.WithString("group", - mcp.Description("The name of the Kafka Consumer Group to operate on. "+ - "Required for the 'describe' and 'remove-members' operations. "+ + mcp.Description("The name of the Kafka Consumer Group to operate on. Required for operations that target one group. "+ "Must be an existing consumer group name in the Kafka cluster. "+ "Consumer Group names are case-sensitive and typically follow a naming convention like 'application-name'.")), mcp.WithString("members", @@ -176,6 +178,12 @@ func (b *KafkaGroupsToolBuilder) buildKafkaGroupsTool(mode toolMode) mcp.Tool { mcp.Description("The offset value to set. Required for 'set-offset' operation.")), annotation, ) + if isToolModeWrite(mode) { + pruneToolInputSchema(&tool, []string{"resource", "operation", "group", "members", "topic", "partition", "offset"}) + } else { + pruneToolInputSchema(&tool, []string{"resource", "operation", "group"}) + } + return tool } // buildKafkaGroupsHandler builds the Kafka Groups handler function diff --git a/pkg/mcp/builders/kafka/produce_test.go b/pkg/mcp/builders/kafka/produce_test.go index 6a88f5f7..7351f939 100644 --- a/pkg/mcp/builders/kafka/produce_test.go +++ b/pkg/mcp/builders/kafka/produce_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/builders/kafka/schema_registry.go b/pkg/mcp/builders/kafka/schema_registry.go index cc62f1b2..1936da8e 100644 --- a/pkg/mcp/builders/kafka/schema_registry.go +++ b/pkg/mcp/builders/kafka/schema_registry.go @@ -98,12 +98,12 @@ func (b *KafkaSchemaRegistryToolBuilder) buildKafkaSchemaRegistryTool(mode toolM "- subject: A specific schema subject (a named schema that can have multiple versions)\n" + "- versions: Collection of all versions for a specific subject\n" + "- version: A specific version of a subject's schema\n" + - "- compatibility: Compatibility settings that control schema evolution rules\n" + + "- compatibility: Compatibility levels that control schema evolution rules\n" + "- types: Supported schema format types (like AVRO, JSON, PROTOBUF)" operationDesc := "Operation to perform. Available operations:\n" + "- list: List all subjects, versions for a subject, or supported schema types\n" + - "- get: Get a subject's latest schema, a specific version, or compatibility setting" + "- get: Get a subject's latest schema, a specific version, or compatibility level" operationEnum := []string{"list", "get"} toolName := "kafka_admin_sr_read" annotation := toolannotations.ReadOnly("Read Kafka Schema Registry") @@ -117,19 +117,9 @@ func (b *KafkaSchemaRegistryToolBuilder) buildKafkaSchemaRegistryTool(mode toolM annotation = toolannotations.Destructive("Manage Kafka Schema Registry") } - toolDesc := "Unified tool for managing Apache Kafka Schema Registry.\n" + + toolDesc := "Read Apache Kafka Schema Registry metadata and schemas.\n" + "Schema Registry provides a centralized repository for managing and validating schemas for Kafka data.\n" + - "It enables schema evolution while maintaining compatibility between producers and consumers.\n\n" + - "Key concepts:\n" + - "- Subject: A named schema that can have multiple versions\n" + - "- Version: A specific instance of a schema for a subject\n" + - "- Compatibility: Rules that govern how schemas can evolve\n" + - "- Schema Types: Format types like AVRO, JSON Schema, and Protocol Buffers\n\n" + - "Compatibility levels:\n" + - "- BACKWARD: New schema can read data written with previous schema\n" + - "- FORWARD: Previous schema can read data written with new schema\n" + - "- FULL: Both backward and forward compatibility\n" + - "- NONE: No compatibility enforcement\n\n" + + "This read-only tool lists subjects, versions, schema types, and retrieves schemas or compatibility levels.\n\n" + "Usage Examples:\n\n" + "1. List all schema subjects:\n" + " resource: \"subjects\"\n" + @@ -138,20 +128,30 @@ func (b *KafkaSchemaRegistryToolBuilder) buildKafkaSchemaRegistryTool(mode toolM " resource: \"subject\"\n" + " operation: \"get\"\n" + " subject: \"user-events-value\"\n\n" + - "3. Register a new schema:\n" + - " resource: \"subject\"\n" + - " operation: \"create\"\n" + - " subject: \"user-events-value\"\n" + - " schema: {...}\n" + - " schemaType: \"AVRO\"\n\n" + - "4. Set compatibility level:\n" + + "3. Get compatibility level:\n" + " resource: \"compatibility\"\n" + - " operation: \"set\"\n" + - " subject: \"user-events-value\"\n" + - " compatibility: \"BACKWARD\"\n\n" + - "This tool requires appropriate Schema Registry permissions." - - return mcp.NewTool(toolName, + " operation: \"get\"\n" + + " subject: \"user-events-value\"\n\n" + + "This tool requires appropriate Schema Registry read permissions." + if isToolModeWrite(mode) { + toolDesc = "Manage Apache Kafka Schema Registry schemas and compatibility.\n" + + "This write tool registers or deletes schemas and changes compatibility settings.\n\n" + + "Usage Examples:\n\n" + + "1. Register a new schema:\n" + + " resource: \"subject\"\n" + + " operation: \"create\"\n" + + " subject: \"user-events-value\"\n" + + " schema: {...}\n" + + " schemaType: \"AVRO\"\n\n" + + "2. Set compatibility level:\n" + + " resource: \"compatibility\"\n" + + " operation: \"set\"\n" + + " subject: \"user-events-value\"\n" + + " compatibility: \"BACKWARD\"\n\n" + + "This tool requires appropriate Schema Registry write permissions." + } + + tool := mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), @@ -181,6 +181,12 @@ func (b *KafkaSchemaRegistryToolBuilder) buildKafkaSchemaRegistryTool(mode toolM "The structure depends on the schema type (AVRO, JSON Schema, or Protocol Buffers).")), annotation, ) + if isToolModeWrite(mode) { + pruneToolInputSchema(&tool, []string{"resource", "operation", "subject", "version", "compatibility", "schemaType", "schema"}) + } else { + pruneToolInputSchema(&tool, []string{"resource", "operation", "subject", "version"}) + } + return tool } // buildKafkaSchemaRegistryHandler builds the Kafka Schema Registry handler function diff --git a/pkg/mcp/builders/kafka/tool_mode.go b/pkg/mcp/builders/kafka/tool_mode.go index 740834ca..9cb9a953 100644 --- a/pkg/mcp/builders/kafka/tool_mode.go +++ b/pkg/mcp/builders/kafka/tool_mode.go @@ -14,7 +14,11 @@ package kafka -import "strings" +import ( + "strings" + + "github.com/mark3labs/mcp-go/mcp" +) type toolMode string @@ -31,3 +35,27 @@ func validateModeOperation(mode toolMode, operation string, writeOperations map[ _, isWrite := writeOperations[strings.ToLower(operation)] return (mode == toolModeWrite) == isWrite } + +func pruneToolInputSchema(tool *mcp.Tool, allowedProperties []string) { + allowed := make(map[string]struct{}, len(allowedProperties)) + for _, property := range allowedProperties { + allowed[property] = struct{}{} + } + + for property := range tool.InputSchema.Properties { + if _, ok := allowed[property]; !ok { + delete(tool.InputSchema.Properties, property) + } + } + + if len(tool.InputSchema.Required) == 0 { + return + } + required := tool.InputSchema.Required[:0] + for _, property := range tool.InputSchema.Required { + if _, ok := allowed[property]; ok { + required = append(required, property) + } + } + tool.InputSchema.Required = required +} diff --git a/pkg/mcp/builders/kafka/topics.go b/pkg/mcp/builders/kafka/topics.go index 33b5decb..b4b4021d 100644 --- a/pkg/mcp/builders/kafka/topics.go +++ b/pkg/mcp/builders/kafka/topics.go @@ -91,8 +91,8 @@ func (b *KafkaTopicsToolBuilder) BuildTools(_ context.Context, config builders.T // buildKafkaTopicsTool builds the Kafka Topics MCP tool definition func (b *KafkaTopicsToolBuilder) buildKafkaTopicsTool(mode toolMode) mcp.Tool { resourceDesc := "Resource to operate on. Available resources:\n" + - "- topic: A single Kafka topic for operations on individual topics (create, get, delete)\n" + - "- topics: Collection of Kafka topics for bulk operations (list)" + "- topic: A single Kafka topic for read operations (get, metadata)\n" + + "- topics: Collection of Kafka topics for list operations" operationDesc := "Operation to perform. Available operations:\n" + "- list: List all topics in the Kafka cluster, optionally including internal topics\n" + @@ -106,57 +106,45 @@ func (b *KafkaTopicsToolBuilder) buildKafkaTopicsTool(mode toolMode) mcp.Tool { "- create: Create a new topic with specified partitions, replication factor, and optional configs\n" + "- delete: Delete an existing topic\n" operationEnum = []string{"create", "delete"} + resourceDesc = "Resource to operate on. Available resources:\n" + + "- topic: A single Kafka topic for create or delete operations" toolName = "kafka_admin_topics_write" annotation = toolannotations.Destructive("Manage Kafka Topics") } - toolDesc := "Unified tool for managing Apache Kafka topics.\n" + - "This tool provides access to various Kafka topic operations, including creation, deletion, listing, and configuration retrieval.\n" + - "Kafka topics are the core abstraction for organizing and partitioning data streams. Topics:\n" + - "- Organize messages into categories for producers and consumers\n" + - "- Are divided into partitions for scalability and parallelism\n" + - "- Can be configured with replication factors for fault tolerance\n" + - "- Support various configuration options for retention, compression, and more\n\n" + + toolDesc := "Read Apache Kafka topic metadata and lists.\n" + + "This read-only tool lists topics and retrieves topic details or metadata without changing the cluster.\n\n" + "Usage Examples:\n\n" + "1. List all topics (excluding internal Kafka topics):\n" + " resource: \"topics\"\n" + " operation: \"list\"\n\n" + - "2. List all topics including internal ones:\n" + - " resource: \"topics\"\n" + - " operation: \"list\"\n" + - " includeInternal: true\n\n" + - "3. Create a new topic with default settings:\n" + - " resource: \"topic\"\n" + - " operation: \"create\"\n" + - " name: \"user-events\"\n" + - " partitions: 3\n" + - " replicationFactor: 2\n\n" + - "4. Create a topic with custom configuration:\n" + - " resource: \"topic\"\n" + - " operation: \"create\"\n" + - " name: \"log-aggregation\"\n" + - " partitions: 6\n" + - " replicationFactor: 3\n" + - " configs: {\n" + - " \"retention.ms\": \"604800000\",\n" + - " \"compression.type\": \"gzip\",\n" + - " \"cleanup.policy\": \"compact\"\n" + - " }\n\n" + - "5. Get detailed information about a topic:\n" + + "2. Get detailed information about a topic:\n" + " resource: \"topic\"\n" + " operation: \"get\"\n" + " name: \"user-events\"\n\n" + - "6. Get metadata for a topic:\n" + + "3. Get metadata for a topic:\n" + " resource: \"topic\"\n" + " operation: \"metadata\"\n" + " name: \"user-events\"\n\n" + - "7. Delete a topic:\n" + - " resource: \"topic\"\n" + - " operation: \"delete\"\n" + - " name: \"old-topic\"\n\n" + - "This tool requires appropriate Kafka permissions for topic management." + "This tool requires appropriate Kafka permissions for topic reads." + if isToolModeWrite(mode) { + toolDesc = "Manage Apache Kafka topic lifecycle.\n" + + "This write tool creates and deletes Kafka topics and may change cluster state.\n\n" + + "Usage Examples:\n\n" + + "1. Create a new topic with default settings:\n" + + " resource: \"topic\"\n" + + " operation: \"create\"\n" + + " name: \"user-events\"\n" + + " partitions: 3\n" + + " replicationFactor: 2\n\n" + + "2. Delete a topic:\n" + + " resource: \"topic\"\n" + + " operation: \"delete\"\n" + + " name: \"old-topic\"\n\n" + + "This tool requires appropriate Kafka permissions for topic management." + } - return mcp.NewTool(toolName, + tool := mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), @@ -166,8 +154,7 @@ func (b *KafkaTopicsToolBuilder) buildKafkaTopicsTool(mode toolMode) mcp.Tool { mcp.Enum(operationEnum...), ), mcp.WithString("name", - mcp.Description("The name of the Kafka topic to operate on. "+ - "Required for 'get', 'create', 'delete', and 'metadata' operations on the 'topic' resource. "+ + mcp.Description("The name of the Kafka topic to operate on. Required for operations that target one topic. "+ "Topic names should follow Kafka naming conventions (alphanumeric, dots, underscores, and hyphens).")), mcp.WithNumber("partitions", mcp.Description("The number of partitions for the topic. Required for 'create' operation. "+ @@ -191,6 +178,12 @@ func (b *KafkaTopicsToolBuilder) buildKafkaTopicsTool(mode toolMode) mcp.Tool { "Default: false")), annotation, ) + if isToolModeWrite(mode) { + pruneToolInputSchema(&tool, []string{"resource", "operation", "name", "partitions", "replicationFactor", "configs"}) + } else { + pruneToolInputSchema(&tool, []string{"resource", "operation", "name", "includeInternal"}) + } + return tool } // buildKafkaTopicsHandler builds the Kafka Topics handler function diff --git a/pkg/mcp/builders/pulsar/annotation_compliance_test.go b/pkg/mcp/builders/pulsar/annotation_compliance_test.go index 8de220f7..7f855447 100644 --- a/pkg/mcp/builders/pulsar/annotation_compliance_test.go +++ b/pkg/mcp/builders/pulsar/annotation_compliance_test.go @@ -19,6 +19,7 @@ import ( "strings" "testing" + "github.com/mark3labs/mcp-go/mcp" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" "github.com/stretchr/testify/require" ) @@ -105,6 +106,49 @@ func pulsarComplianceWriteOperations() map[string]struct{} { return writeOperations } +func TestPulsarSplitToolsExposeModeSpecificParameters(t *testing.T) { + expectedProperties := map[string][]string{ + "pulsar_admin_brokers_read": {"resource", "operation", "clusterName", "brokerUrl", "configType"}, + "pulsar_admin_brokers_write": {"resource", "operation", "configName", "configValue"}, + "pulsar_admin_cluster_read": {"resource", "operation", "cluster_name", "domain_name"}, + "pulsar_admin_functions_read": {"operation", "fqfn", "tenant", "namespace", "name", "instanceId", "key", "path", "destinationFile"}, + "pulsar_admin_namespace_read": {"operation", "tenant", "namespace"}, + "pulsar_admin_nsisolationpolicy_read": {"resource", "operation", "cluster", "name"}, + "pulsar_admin_package_read": {"resource", "operation", "packageName", "namespace", "type", "path"}, + "pulsar_admin_resourcequota_read": {"resource", "operation", "namespace", "bundle"}, + "pulsar_admin_schema_read": {"resource", "operation", "topic", "version"}, + "pulsar_admin_sinks_read": {"operation", "tenant", "namespace", "name"}, + "pulsar_admin_sources_read": {"operation", "tenant", "namespace", "name"}, + "pulsar_admin_subscription_read": {"resource", "operation", "topic", "subscription", "ledgerId", "entryId", "count"}, + "pulsar_admin_tenant_read": {"resource", "operation", "tenant"}, + "pulsar_admin_topic_policy_read": {"operation", "topic", "applied", "type"}, + "pulsar_admin_topic_read": {"resource", "operation", "topic", "namespace", "partitioned", "per-partition", "wait"}, + } + + for _, builder := range allPulsarComplianceBuilders() { + tools, err := builder.BuildTools(context.Background(), builders.ToolBuildConfig{ + Features: []string{"all", "all-pulsar", "pulsar-admin", "pulsar-client"}, + }) + require.NoError(t, err) + for _, serverTool := range tools { + tool := serverTool.Tool + expected, ok := expectedProperties[tool.Name] + if !ok { + continue + } + require.ElementsMatch(t, expected, pulsarToolPropertyNames(tool), tool.Name) + } + } +} + +func pulsarToolPropertyNames(tool mcp.Tool) []string { + names := make([]string, 0, len(tool.InputSchema.Properties)) + for name := range tool.InputSchema.Properties { + names = append(names, name) + } + return names +} + func TestPulsarReadOnlyBuildsNoWriteTools(t *testing.T) { for _, builder := range allPulsarComplianceBuilders() { tools, err := builder.BuildTools(context.Background(), builders.ToolBuildConfig{ diff --git a/pkg/mcp/builders/pulsar/brokers.go b/pkg/mcp/builders/pulsar/brokers.go index 305664d2..ba31a985 100644 --- a/pkg/mcp/builders/pulsar/brokers.go +++ b/pkg/mcp/builders/pulsar/brokers.go @@ -92,36 +92,33 @@ func (b *PulsarAdminBrokersToolBuilder) BuildTools(_ context.Context, config bui // buildPulsarAdminBrokersTool builds the Pulsar admin brokers MCP tool definition func (b *PulsarAdminBrokersToolBuilder) buildPulsarAdminBrokersTool(mode toolMode) mcp.Tool { operationEnum := []string{"list", "get"} + operationDesc := "Operation to perform, available options:\n" + + "- list: List resources (used with brokers)\n" + + "- get: Retrieve resource information (used with health, config, namespaces)" + toolDesc := "Read Apache Pulsar broker resources. This tool lists active brokers, checks broker health, reads broker configurations, and views namespaces owned by a broker." toolName := "pulsar_admin_brokers_read" annotation := toolannotations.ReadOnly("Read Pulsar Brokers") if isToolModeWrite(mode) { operationEnum = []string{"update", "delete"} + operationDesc = "Operation to perform, available options:\n" + + "- update: Update a broker dynamic configuration value\n" + + "- delete: Delete a broker dynamic configuration value" + toolDesc = "Manage Apache Pulsar broker configurations. This write tool updates or deletes broker dynamic configuration values." toolName = "pulsar_admin_brokers_write" annotation = toolannotations.Destructive("Manage Pulsar Brokers") } - return mcp.NewTool(toolName, - mcp.WithDescription("Unified tool for managing Apache Pulsar broker resources. This tool integrates multiple broker management functions, including:\n"+ - "1. List active brokers in a cluster (resource=brokers, operation=list)\n"+ - "2. Check broker health status (resource=health, operation=get)\n"+ - "3. Manage broker configurations (resource=config, operation=get/update/delete)\n"+ - "4. View namespaces owned by a broker (resource=namespaces, operation=get)\n\n"+ - "Different functions are accessed by combining resource and operation parameters, with other parameters used selectively based on operation type.\n"+ - "Example: {\"resource\": \"config\", \"operation\": \"get\", \"configType\": \"dynamic\"} retrieves all dynamic configuration names.\n"+ - "This tool requires Pulsar super-user permissions."), + tool := mcp.NewTool(toolName, + mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description("Type of resource to access, available options:\n"+ - "- brokers: Manage broker listings\n"+ - "- health: Check broker health status\n"+ - "- config: Manage broker configurations\n"+ - "- namespaces: Manage namespaces owned by a broker"), + "- brokers: Broker listings\n"+ + "- health: Broker health status\n"+ + "- config: Broker configurations\n"+ + "- namespaces: Namespaces owned by a broker"), ), mcp.WithString("operation", mcp.Required(), - mcp.Description("Operation to perform, available options:\n"+ - "- list: List resources (used with brokers)\n"+ - "- get: Retrieve resource information (used with health, config, namespaces)\n"+ - "- update: Update a resource (used with config)\n"+ - "- delete: Delete a resource (used with config)"), + mcp.Description(operationDesc), mcp.Enum(operationEnum...), ), mcp.WithString("clusterName", @@ -151,6 +148,12 @@ func (b *PulsarAdminBrokersToolBuilder) buildPulsarAdminBrokersTool(mode toolMod ), annotation, ) + if isToolModeWrite(mode) { + pruneToolInputSchema(&tool, []string{"resource", "operation", "configName", "configValue"}) + } else { + pruneToolInputSchema(&tool, []string{"resource", "operation", "clusterName", "brokerUrl", "configType"}) + } + return tool } // buildPulsarAdminBrokersHandler builds the Pulsar admin brokers handler function diff --git a/pkg/mcp/builders/pulsar/brokers_stats_test.go b/pkg/mcp/builders/pulsar/brokers_stats_test.go index 96376c1c..e24818b9 100644 --- a/pkg/mcp/builders/pulsar/brokers_stats_test.go +++ b/pkg/mcp/builders/pulsar/brokers_stats_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/builders/pulsar/cluster.go b/pkg/mcp/builders/pulsar/cluster.go index 0a1f85d3..1f48d888 100644 --- a/pkg/mcp/builders/pulsar/cluster.go +++ b/pkg/mcp/builders/pulsar/cluster.go @@ -95,12 +95,8 @@ func (b *PulsarAdminClusterToolBuilder) BuildTools(_ context.Context, config bui // buildClusterTool builds the Pulsar Admin Cluster MCP tool definition // Migrated from the original tool definition logic func (b *PulsarAdminClusterToolBuilder) buildClusterTool(mode toolMode) mcp.Tool { - toolDesc := "Unified tool for managing Apache Pulsar clusters.\n" + - "This tool provides access to various cluster resources and operations, including:\n" + - "1. Manage clusters (resource=cluster): List, get, create, update, delete clusters\n" + - "2. Manage peer clusters (resource=peer_clusters): Get, update peer clusters\n" + - "3. Manage failure domains (resource=failure_domain): List, get, create, update, delete failure domains\n\n" + - "Different functions are accessed by combining resource and operation parameters, with other parameters used selectively based on operation type.\n\n" + + toolDesc := "Read Apache Pulsar clusters.\n" + + "This read-only tool lists clusters and failure domains, and retrieves cluster, peer cluster, or failure domain details.\n\n" + "Examples:\n" + "- {\"resource\": \"cluster\", \"operation\": \"list\"} lists all clusters\n" + "- {\"resource\": \"cluster\", \"operation\": \"get\", \"cluster_name\": \"my-cluster\"} gets cluster configuration\n" + @@ -114,21 +110,24 @@ func (b *PulsarAdminClusterToolBuilder) buildClusterTool(mode toolMode) mcp.Tool operationDesc := "Operation to perform, available options (depend on resource):\n" + "- list: List resources (used with cluster, failure_domain)\n" + - "- get: Retrieve resource information (used with cluster, peer_clusters, failure_domain)\n" + - "- create: Create a new resource (used with cluster, failure_domain)\n" + - "- update: Update an existing resource (used with cluster, peer_clusters, failure_domain)\n" + - "- delete: Delete a resource (used with cluster, failure_domain)" + "- get: Retrieve resource information (used with cluster, peer_clusters, failure_domain)" operationEnum := []string{"list", "get"} toolName := "pulsar_admin_cluster_read" annotation := toolannotations.ReadOnly("Read Pulsar Clusters") if isToolModeWrite(mode) { + toolDesc = "Manage Apache Pulsar clusters.\n" + + "This write tool creates, updates, or deletes clusters and failure domains, and updates peer cluster settings." + operationDesc = "Operation to perform, available options (depend on resource):\n" + + "- create: Create a new resource (used with cluster, failure_domain)\n" + + "- update: Update an existing resource (used with cluster, peer_clusters, failure_domain)\n" + + "- delete: Delete a resource (used with cluster, failure_domain)" operationEnum = []string{"create", "update", "delete"} toolName = "pulsar_admin_cluster_write" annotation = toolannotations.Destructive("Manage Pulsar Clusters") } - return mcp.NewTool(toolName, + tool := mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), @@ -141,7 +140,7 @@ func (b *PulsarAdminClusterToolBuilder) buildClusterTool(mode toolMode) mcp.Tool mcp.Description("Name of the Pulsar cluster, required for all operations except 'list' with resource=cluster"), ), mcp.WithString("domain_name", - mcp.Description("Name of the failure domain, required when resource=failure_domain and operation is get, create, update, or delete"), + mcp.Description("Name of the failure domain. Required when resource=failure_domain and the operation targets one specific domain."), ), mcp.WithString("service_url", mcp.Description("Pulsar cluster web service URL (e.g., http://example.pulsar.io:8080), used when resource=cluster and operation is create or update"), @@ -177,6 +176,12 @@ func (b *PulsarAdminClusterToolBuilder) buildClusterTool(mode toolMode) mcp.Tool ), annotation, ) + if isToolModeWrite(mode) { + pruneToolInputSchema(&tool, []string{"resource", "operation", "cluster_name", "domain_name", "service_url", "service_url_tls", "broker_service_url", "broker_service_url_tls", "peer_cluster_names", "brokers"}) + } else { + pruneToolInputSchema(&tool, []string{"resource", "operation", "cluster_name", "domain_name"}) + } + return tool } // buildClusterHandler builds the Pulsar Admin Cluster handler function diff --git a/pkg/mcp/builders/pulsar/consume_retry_test.go b/pkg/mcp/builders/pulsar/consume_retry_test.go index c097b64d..08cd53a0 100644 --- a/pkg/mcp/builders/pulsar/consume_retry_test.go +++ b/pkg/mcp/builders/pulsar/consume_retry_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/builders/pulsar/functions.go b/pkg/mcp/builders/pulsar/functions.go index 1d234cc0..c71eb9af 100644 --- a/pkg/mcp/builders/pulsar/functions.go +++ b/pkg/mcp/builders/pulsar/functions.go @@ -93,15 +93,9 @@ func (b *PulsarAdminFunctionsToolBuilder) BuildTools(_ context.Context, config b // buildPulsarAdminFunctionsTool builds the Pulsar admin functions MCP tool definition // Migrated from the original tool definition logic func (b *PulsarAdminFunctionsToolBuilder) buildPulsarAdminFunctionsTool(mode toolMode) mcp.Tool { - toolDesc := "Manage Apache Pulsar Functions for stream processing. " + - "Pulsar Functions are lightweight compute processes that can consume messages from one or more Pulsar topics, " + - "apply user-defined processing logic, and produce results to another topic. " + - "Functions support Java, Python, and Go runtimes, enabling complex event processing, " + - "data transformations, filtering, and integration with external systems. " + - "Functions follow the tenant/namespace/name hierarchy for organization, " + - "can maintain state, and can scale through parallelism configuration. " + - "This tool provides complete lifecycle management including deployment, monitoring, scaling, " + - "state management, and triggering. Functions require proper permissions to access their topics." + toolDesc := "Read Apache Pulsar Functions for stream processing. " + + "Pulsar Functions are lightweight compute processes that can consume messages from one or more Pulsar topics, apply user-defined processing logic, and produce results to another topic. " + + "This read-only tool lists functions and retrieves configuration, status, statistics, state, or package data. Functions require proper permissions to access their topics." operationDesc := "Operation to perform. Available operations:\n" + "- list: List all functions under a specific tenant and namespace\n" + @@ -109,27 +103,30 @@ func (b *PulsarAdminFunctionsToolBuilder) buildPulsarAdminFunctionsTool(mode too "- status: Get the runtime status of a function (instances, metrics)\n" + "- stats: Get detailed statistics of a function (throughput, processing latency)\n" + "- querystate: Query state stored by a stateful function for a specific key\n" + - "- create: Deploy a new function with specified parameters\n" + - "- update: Update the configuration of an existing function\n" + - "- delete: Delete a function\n" + - "- download: Download function package data from Pulsar to a local file\n" + - "- start: Start a stopped function\n" + - "- stop: Stop a running function\n" + - "- restart: Restart a function\n" + - "- putstate: Store state in a function's state store\n" + - "- trigger: Manually trigger a function with a specific value\n" + - "- upload: Upload a local file into Pulsar function package storage" + "- download: Download function package data from Pulsar to a local file" operationEnum := []string{"list", "get", "status", "stats", "querystate", "download"} toolName := "pulsar_admin_functions_read" annotation := toolannotations.ReadOnly("Read Pulsar Functions") if isToolModeWrite(mode) { + toolDesc = "Manage Apache Pulsar Functions for stream processing. " + + "This write tool deploys, updates, deletes, starts, stops, restarts, stores state for, triggers, or uploads packages for functions." + operationDesc = "Operation to perform. Available operations:\n" + + "- create: Deploy a new function with specified parameters\n" + + "- update: Update the configuration of an existing function\n" + + "- delete: Delete a function\n" + + "- start: Start a stopped function\n" + + "- stop: Stop a running function\n" + + "- restart: Restart a function\n" + + "- putstate: Store state in a function's state store\n" + + "- trigger: Manually trigger a function with a specific value\n" + + "- upload: Upload a local file into Pulsar function package storage" operationEnum = []string{"create", "update", "delete", "start", "stop", "restart", "putstate", "trigger", "upload"} toolName = "pulsar_admin_functions_write" annotation = toolannotations.Destructive("Manage Pulsar Functions") } - return mcp.NewTool(toolName, + tool := mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), @@ -147,8 +144,7 @@ func (b *PulsarAdminFunctionsToolBuilder) buildPulsarAdminFunctionsTool(mode too "Functions in a namespace typically process topics within the same namespace. "+ "Defaults to 'default' if not provided.")), mcp.WithString("name", - mcp.Description("The function name. Required for all operations except 'list', "+ - "unless it can be inferred from classname during create/update. "+ + mcp.Description("The function name. Required for operations that target one function. "+ "Names should be descriptive of the function's purpose and must be unique within a namespace. "+ "Function names are used in metrics, logs, and when addressing the function via APIs.")), mcp.WithString("instanceId", @@ -286,18 +282,15 @@ func (b *PulsarAdminFunctionsToolBuilder) buildPulsarAdminFunctionsTool(mode too mcp.Description("Path to the local file that should be uploaded into Pulsar function package storage. "+ "Required for the 'upload' operation.")), mcp.WithString("path", - mcp.Description("Pulsar package storage path. Required for the 'upload' operation. "+ - "For 'download', provide this to download directly from package storage. "+ - "When omitted for 'download', identify the function with fqfn or tenant/namespace/name instead.")), + mcp.Description("Pulsar package storage path used by package transfer operations. For downloads, provide this to read directly from package storage.")), mcp.WithString("destinationFile", mcp.Description("Local file path where downloaded function package data should be written. "+ "Required for the 'download' operation.")), mcp.WithBoolean("updateAuthData", mcp.Description("Whether to update auth data on update operations.")), mcp.WithString("key", - mcp.Description("The state key. Required for 'querystate' and 'putstate' operations. "+ - "Keys are used to identify values in the function's state store. "+ - "They should be reasonable in length and follow a consistent pattern. "+ + mcp.Description("The state key. Required for operations that access one value in the function's state store. "+ + "Keys should be reasonable in length and follow a consistent pattern. "+ "State keys are typically limited to 128 characters.")), mcp.WithString("value", mcp.Description("The state value. Required for 'putstate' operation. "+ @@ -318,6 +311,12 @@ func (b *PulsarAdminFunctionsToolBuilder) buildPulsarAdminFunctionsTool(mode too mcp.Description("Path to a file containing the trigger value. Required for 'trigger' operation unless triggerValue is set.")), annotation, ) + if isToolModeWrite(mode) { + removeToolInputSchemaProperties(&tool, []string{"instanceId", "destinationFile"}) + } else { + pruneToolInputSchema(&tool, []string{"operation", "fqfn", "tenant", "namespace", "name", "instanceId", "key", "path", "destinationFile"}) + } + return tool } // buildPulsarAdminFunctionsHandler builds the Pulsar admin functions handler function diff --git a/pkg/mcp/builders/pulsar/namespace.go b/pkg/mcp/builders/pulsar/namespace.go index a91125c4..4ca37bf2 100644 --- a/pkg/mcp/builders/pulsar/namespace.go +++ b/pkg/mcp/builders/pulsar/namespace.go @@ -99,30 +99,32 @@ func (b *PulsarAdminNamespaceToolBuilder) BuildTools(_ context.Context, config b // buildNamespaceTool builds the Pulsar Admin Namespace MCP tool definition // Migrated from the original tool definition logic func (b *PulsarAdminNamespaceToolBuilder) buildNamespaceTool(mode toolMode) mcp.Tool { - toolDesc := "Manage Pulsar namespaces with various operations. " + - "This tool provides functionality to work with namespaces in Apache Pulsar, " + - "including listing, creating, deleting, and performing various operations on namespaces." + toolDesc := "Read Apache Pulsar namespaces. " + + "This read-only tool lists namespaces for a tenant and lists topics in a namespace without changing namespace state." operationDesc := "Operation to perform on namespaces. Available operations:\n" + "- list: List all namespaces for a tenant\n" + - "- get_topics: Get all topics within a namespace\n" + - "- create: Create a new namespace\n" + - "- delete: Delete an existing namespace\n" + - "- clear_backlog: Clear backlog for all topics in a namespace\n" + - "- unsubscribe: Unsubscribe from a subscription for all topics in a namespace\n" + - "- unload: Unload a namespace from the current serving broker\n" + - "- split_bundle: Split a namespace bundle" + "- get_topics: Get all topics within a namespace" operationEnum := []string{"list", "get_topics"} toolName := "pulsar_admin_namespace_read" annotation := toolannotations.ReadOnly("Read Pulsar Namespaces") if isToolModeWrite(mode) { + toolDesc = "Manage Apache Pulsar namespaces. " + + "This write tool creates, deletes, unloads, splits bundles, clears backlog, or unsubscribes namespace subscriptions." + operationDesc = "Operation to perform on namespaces. Available operations:\n" + + "- create: Create a new namespace\n" + + "- delete: Delete an existing namespace\n" + + "- clear_backlog: Clear backlog for all topics in a namespace\n" + + "- unsubscribe: Unsubscribe from a subscription for all topics in a namespace\n" + + "- unload: Unload a namespace from the current serving broker\n" + + "- split_bundle: Split a namespace bundle" operationEnum = []string{"create", "delete", "clear_backlog", "unsubscribe", "unload", "split_bundle"} toolName = "pulsar_admin_namespace_write" annotation = toolannotations.Destructive("Manage Pulsar Namespaces") } - return mcp.NewTool(toolName, + tool := mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), @@ -160,6 +162,12 @@ func (b *PulsarAdminNamespaceToolBuilder) buildNamespaceTool(mode toolMode) mcp. ), annotation, ) + if isToolModeWrite(mode) { + pruneToolInputSchema(&tool, []string{"operation", "namespace", "bundles", "clusters", "subscription", "bundle", "force", "unload"}) + } else { + pruneToolInputSchema(&tool, []string{"operation", "tenant", "namespace"}) + } + return tool } // buildNamespaceHandler builds the Pulsar Admin Namespace handler function diff --git a/pkg/mcp/builders/pulsar/namespace_policy_test.go b/pkg/mcp/builders/pulsar/namespace_policy_test.go index 3a2df609..179e04c3 100644 --- a/pkg/mcp/builders/pulsar/namespace_policy_test.go +++ b/pkg/mcp/builders/pulsar/namespace_policy_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/builders/pulsar/nsisolationpolicy.go b/pkg/mcp/builders/pulsar/nsisolationpolicy.go index a3bcb6c4..a01295dd 100644 --- a/pkg/mcp/builders/pulsar/nsisolationpolicy.go +++ b/pkg/mcp/builders/pulsar/nsisolationpolicy.go @@ -93,9 +93,8 @@ func (b *PulsarAdminNsIsolationPolicyToolBuilder) BuildTools(_ context.Context, // buildNsIsolationPolicyTool builds the Pulsar admin namespace isolation policy MCP tool definition func (b *PulsarAdminNsIsolationPolicyToolBuilder) buildNsIsolationPolicyTool(mode toolMode) mcp.Tool { - toolDesc := "Manage namespace isolation policies in a Pulsar cluster. " + - "Allows viewing, creating, updating, and deleting namespace isolation policies. " + - "Some operations require super-user permissions." + toolDesc := "Read namespace isolation policies in a Pulsar cluster. " + + "Allows viewing namespace isolation policies and related broker policy assignments." resourceDesc := "Resource to operate on. Available resources:\n" + "- policy: Namespace isolation policy\n" + @@ -104,20 +103,23 @@ func (b *PulsarAdminNsIsolationPolicyToolBuilder) buildNsIsolationPolicyTool(mod operationDesc := "Operation to perform. Available operations:\n" + "- get: Get resource details\n" + - "- list: List all instances of the resource\n" + - "- set: Create or update a resource (requires super-user permissions)\n" + - "- delete: Delete a resource (requires super-user permissions)" + "- list: List all instances of the resource" operationEnum := []string{"get", "list"} toolName := "pulsar_admin_nsisolationpolicy_read" annotation := toolannotations.ReadOnly("Read Pulsar Namespace Isolation Policies") if isToolModeWrite(mode) { + toolDesc = "Manage namespace isolation policies in a Pulsar cluster. " + + "This write tool creates, updates, or deletes namespace isolation policies." + operationDesc = "Operation to perform. Available operations:\n" + + "- set: Create or update a resource (requires super-user permissions)\n" + + "- delete: Delete a resource (requires super-user permissions)" operationEnum = []string{"set", "delete"} toolName = "pulsar_admin_nsisolationpolicy_write" annotation = toolannotations.Destructive("Manage Pulsar Namespace Isolation Policies") } - return mcp.NewTool(toolName, + tool := mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), @@ -130,8 +132,7 @@ func (b *PulsarAdminNsIsolationPolicyToolBuilder) buildNsIsolationPolicyTool(mod mcp.Description("Cluster name"), ), mcp.WithString("name", - mcp.Description("Name of the policy or broker to operate on, based on resource type.\n"+ - "Required for: policy.get, policy.delete, policy.set, broker.get"), + mcp.Description("Name of the policy or broker to operate on, based on resource type. Required for operations that target one policy or broker."), ), mcp.WithArray("namespaces", mcp.Description("List of namespaces to apply the isolation policy. Required for policy.set"), @@ -168,6 +169,12 @@ func (b *PulsarAdminNsIsolationPolicyToolBuilder) buildNsIsolationPolicyTool(mod ), annotation, ) + if isToolModeWrite(mode) { + pruneToolInputSchema(&tool, []string{"resource", "operation", "cluster", "name", "namespaces", "primary", "secondary", "autoFailoverPolicyType", "autoFailoverPolicyParams"}) + } else { + pruneToolInputSchema(&tool, []string{"resource", "operation", "cluster", "name"}) + } + return tool } // buildNsIsolationPolicyHandler builds the Pulsar admin namespace isolation policy handler function diff --git a/pkg/mcp/builders/pulsar/packages.go b/pkg/mcp/builders/pulsar/packages.go index f8051395..3e778c23 100644 --- a/pkg/mcp/builders/pulsar/packages.go +++ b/pkg/mcp/builders/pulsar/packages.go @@ -92,9 +92,8 @@ func (b *PulsarAdminPackagesToolBuilder) BuildTools(_ context.Context, config bu // buildPackagesTool builds the Pulsar admin packages MCP tool definition func (b *PulsarAdminPackagesToolBuilder) buildPackagesTool(mode toolMode) mcp.Tool { - toolDesc := "Manage packages in Apache Pulsar. Support package scheme: `function://`, `source://`, `sink://`" + - "Allows listing, viewing, updating, downloading and uploading packages. " + - "Some operations require super-user permissions." + toolDesc := "Read packages in Apache Pulsar. Support package schemes: `function://`, `source://`, `sink://`. " + + "Allows listing, viewing, and downloading packages." resourceDesc := "Resource to operate on. Available resources:\n" + "- package: A specific package\n" + @@ -103,21 +102,24 @@ func (b *PulsarAdminPackagesToolBuilder) buildPackagesTool(mode toolMode) mcp.To operationDesc := "Operation to perform. Available operations:\n" + "- list: List all packages of a specific type or versions of a package\n" + "- get: Get metadata of a package\n" + - "- update: Update metadata of a package (requires super-user permissions)\n" + - "- delete: Delete a package (requires super-user permissions)\n" + - "- download: Download a package (requires super-user permissions)\n" + - "- upload: Upload a package (requires super-user permissions)" + "- download: Download a package" operationEnum := []string{"list", "get", "download"} toolName := "pulsar_admin_package_read" annotation := toolannotations.ReadOnly("Read Pulsar Packages") if isToolModeWrite(mode) { + toolDesc = "Manage packages in Apache Pulsar. Support package schemes: `function://`, `source://`, `sink://`. " + + "This write tool updates metadata, deletes packages, or uploads package contents." + operationDesc = "Operation to perform. Available operations:\n" + + "- update: Update metadata of a package (requires super-user permissions)\n" + + "- delete: Delete a package (requires super-user permissions)\n" + + "- upload: Upload a package (requires super-user permissions)" operationEnum = []string{"update", "delete", "upload"} toolName = "pulsar_admin_package_write" annotation = toolannotations.Destructive("Manage Pulsar Packages") } - return mcp.NewTool(toolName, + tool := mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), @@ -127,8 +129,7 @@ func (b *PulsarAdminPackagesToolBuilder) buildPackagesTool(mode toolMode) mcp.To mcp.Enum(operationEnum...), ), mcp.WithString("packageName", - mcp.Description("Name of the package to operate on. "+ - "Required for operations on a specific package: get, update, delete, download, upload"), + mcp.Description("Name of the package to operate on. Required for operations that target one specific package."), ), mcp.WithString("namespace", mcp.Description("The namespace name. Required for listing packages of a specific type"), @@ -143,13 +144,19 @@ func (b *PulsarAdminPackagesToolBuilder) buildPackagesTool(mode toolMode) mcp.To mcp.Description("Contact information for the package. Optional for update and upload operations"), ), mcp.WithString("path", - mcp.Description("Path to download a package to or upload a package from. Required for download and upload operations"), + mcp.Description("Filesystem path used by package transfer operations. For downloads, this is the destination path."), ), mcp.WithObject("properties", mcp.Description("Additional properties for the package as key-value pairs. Optional for update and upload operations"), ), annotation, ) + if isToolModeWrite(mode) { + pruneToolInputSchema(&tool, []string{"resource", "operation", "packageName", "description", "contact", "path", "properties"}) + } else { + pruneToolInputSchema(&tool, []string{"resource", "operation", "packageName", "namespace", "type", "path"}) + } + return tool } // buildPackagesHandler builds the Pulsar admin packages handler function diff --git a/pkg/mcp/builders/pulsar/resourcequotas.go b/pkg/mcp/builders/pulsar/resourcequotas.go index 125a4572..b096921d 100644 --- a/pkg/mcp/builders/pulsar/resourcequotas.go +++ b/pkg/mcp/builders/pulsar/resourcequotas.go @@ -92,30 +92,31 @@ func (b *PulsarAdminResourceQuotasToolBuilder) BuildTools(_ context.Context, con // buildResourceQuotasTool builds the Pulsar admin resource quotas MCP tool definition func (b *PulsarAdminResourceQuotasToolBuilder) buildResourceQuotasTool(mode toolMode) mcp.Tool { - toolDesc := "Manage Apache Pulsar resource quotas for brokers, namespaces and bundles. " + + toolDesc := "Read Apache Pulsar resource quotas for brokers, namespaces and bundles. " + "Resource quotas define limits for resource usage such as message rates, bandwidth, and memory. " + - "These quotas help prevent resource abuse and ensure fair resource allocation across the Pulsar cluster. " + - "Operations include getting, setting, and resetting quotas. " + - "Requires super-user permissions for all operations." + "This read-only tool retrieves quota configuration without changing it." resourceDesc := "Resource to operate on. Available resources:\n" + "- quota: The resource quota configuration for a specific namespace bundle or the default quota" operationDesc := "Operation to perform. Available operations:\n" + - "- get: Get the resource quota for a specified namespace bundle or default quota\n" + - "- set: Set the resource quota for a specified namespace bundle or default quota (requires super-user permissions)\n" + - "- reset: Reset a namespace bundle's resource quota to default value (requires super-user permissions)" + "- get: Get the resource quota for a specified namespace bundle or default quota" operationEnum := []string{"get"} toolName := "pulsar_admin_resourcequota_read" annotation := toolannotations.ReadOnly("Read Pulsar Resource Quotas") if isToolModeWrite(mode) { + toolDesc = "Manage Apache Pulsar resource quotas for brokers, namespaces and bundles. " + + "This write tool sets or resets quota configuration." + operationDesc = "Operation to perform. Available operations:\n" + + "- set: Set the resource quota for a specified namespace bundle or default quota (requires super-user permissions)\n" + + "- reset: Reset a namespace bundle's resource quota to default value (requires super-user permissions)" operationEnum = []string{"set", "reset"} toolName = "pulsar_admin_resourcequota_write" annotation = toolannotations.Destructive("Manage Pulsar Resource Quotas") } - return mcp.NewTool(toolName, + tool := mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), @@ -125,9 +126,7 @@ func (b *PulsarAdminResourceQuotasToolBuilder) buildResourceQuotasTool(mode tool mcp.Enum(operationEnum...), ), mcp.WithString("namespace", - mcp.Description("The namespace name in the format 'tenant/namespace'. "+ - "Optional for 'get' and 'set' operations (to get/set default quota if omitted). "+ - "Required for 'reset' operation."), + mcp.Description("The namespace name in the format 'tenant/namespace'. Optional when targeting the default quota."), ), mcp.WithString("bundle", mcp.Description("The bundle range in the format '{start-boundary}_{end-boundary}'. "+ @@ -159,6 +158,12 @@ func (b *PulsarAdminResourceQuotasToolBuilder) buildResourceQuotasTool(mode tool ), annotation, ) + if isToolModeWrite(mode) { + pruneToolInputSchema(&tool, []string{"resource", "operation", "namespace", "bundle", "msgRateIn", "msgRateOut", "bandwidthIn", "bandwidthOut", "memory", "dynamic"}) + } else { + pruneToolInputSchema(&tool, []string{"resource", "operation", "namespace", "bundle"}) + } + return tool } // buildResourceQuotasHandler builds the Pulsar admin resource quotas handler function diff --git a/pkg/mcp/builders/pulsar/schema.go b/pkg/mcp/builders/pulsar/schema.go index f8043544..5282d8a5 100644 --- a/pkg/mcp/builders/pulsar/schema.go +++ b/pkg/mcp/builders/pulsar/schema.go @@ -98,31 +98,31 @@ func (b *PulsarAdminSchemaToolBuilder) BuildTools(_ context.Context, config buil // buildSchemaTool builds the Pulsar Admin Schema MCP tool definition // Migrated from the original tool definition logic func (b *PulsarAdminSchemaToolBuilder) buildSchemaTool(mode toolMode) mcp.Tool { - toolDesc := "Manage Apache Pulsar schemas for topics. " + + toolDesc := "Read Apache Pulsar schemas for topics. " + "Schemas in Pulsar define the structure of message data, enabling data validation, evolution, and interoperability. " + - "Pulsar supports multiple schema types including AVRO, JSON, PROTOBUF, etc., allowing strong typing of message content. " + - "Schema versioning ensures backward/forward compatibility as data structures evolve over time. " + - "Operations include getting, uploading, and deleting schemas. " + - "Requires namespace admin permissions for all operations." + "This read-only tool retrieves schema versions without changing topic schema configuration." resourceDesc := "Resource to operate on. Available resources:\n" + "- schema: The schema configuration for a specific topic" operationDesc := "Operation to perform. Available operations:\n" + - "- get: Get the schema for a topic (optionally by version)\n" + - "- upload: Upload a new schema for a topic (requires namespace admin permissions)\n" + - "- delete: Delete the schema for a topic (requires namespace admin permissions)" + "- get: Get the schema for a topic (optionally by version)" operationEnum := []string{"get"} toolName := "pulsar_admin_schema_read" annotation := toolannotations.ReadOnly("Read Pulsar Schemas") if isToolModeWrite(mode) { + toolDesc = "Manage Apache Pulsar schemas for topics. " + + "This write tool uploads or deletes topic schemas." + operationDesc = "Operation to perform. Available operations:\n" + + "- upload: Upload a new schema for a topic (requires namespace admin permissions)\n" + + "- delete: Delete the schema for a topic (requires namespace admin permissions)" operationEnum = []string{"upload", "delete"} toolName = "pulsar_admin_schema_write" annotation = toolannotations.Destructive("Manage Pulsar Schemas") } - return mcp.NewTool(toolName, + tool := mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), @@ -148,6 +148,12 @@ func (b *PulsarAdminSchemaToolBuilder) buildSchemaTool(mode toolMode) mcp.Tool { ), annotation, ) + if isToolModeWrite(mode) { + pruneToolInputSchema(&tool, []string{"resource", "operation", "topic", "filename"}) + } else { + pruneToolInputSchema(&tool, []string{"resource", "operation", "topic", "version"}) + } + return tool } // buildSchemaHandler builds the Pulsar Admin Schema handler function diff --git a/pkg/mcp/builders/pulsar/sinks.go b/pkg/mcp/builders/pulsar/sinks.go index 4239cb30..65ec7f48 100644 --- a/pkg/mcp/builders/pulsar/sinks.go +++ b/pkg/mcp/builders/pulsar/sinks.go @@ -99,39 +99,35 @@ func (b *PulsarAdminSinksToolBuilder) BuildTools(_ context.Context, config build // buildSinksTool builds the Pulsar admin sinks MCP tool definition func (b *PulsarAdminSinksToolBuilder) buildSinksTool(mode toolMode) mcp.Tool { - toolDesc := "Manage Apache Pulsar Sinks for data movement and integration. " + - "Pulsar Sinks are connectors that export data from Pulsar topics to external systems such as databases, " + - "storage services, messaging systems, and third-party applications. " + - "Sinks consume messages from one or more Pulsar topics, transform the data if needed, " + - "and write it to external systems in a format compatible with the target destination. " + - "Built-in sink connectors are available for common systems like Kafka, JDBC, Elasticsearch, and cloud storage. " + - "Sinks follow the tenant/namespace/name hierarchy for organization and access control, " + - "can scale through parallelism configuration, and support configurable subscription types. " + - "This tool provides complete lifecycle management including deployment, configuration, " + - "monitoring, and runtime control. Sinks require proper permissions to access their input topics." + toolDesc := "Read Apache Pulsar Sinks for data movement and integration. " + + "Pulsar Sinks are connectors that export data from Pulsar topics to external systems. " + + "This read-only tool lists sinks and built-in sink connectors and retrieves sink configuration or runtime status." operationDesc := "Operation to perform. Available operations:\n" + "- list: List all sinks under a specific tenant and namespace\n" + "- get: Get the configuration of a sink\n" + "- status: Get the runtime status of a sink (instances, metrics)\n" + - "- create: Deploy a new sink with specified parameters\n" + - "- update: Update the configuration of an existing sink\n" + - "- delete: Delete a sink\n" + - "- start: Start a stopped sink\n" + - "- stop: Stop a running sink\n" + - "- restart: Restart a sink\n" + "- list-built-in: List all built-in sink connectors available in the system" operationEnum := []string{"list", "get", "status", "list-built-in"} toolName := "pulsar_admin_sinks_read" annotation := toolannotations.ReadOnly("Read Pulsar Sinks") if isToolModeWrite(mode) { + toolDesc = "Manage Apache Pulsar Sinks for data movement and integration. " + + "This write tool deploys, updates, deletes, starts, stops, or restarts sinks." + operationDesc = "Operation to perform. Available operations:\n" + + "- create: Deploy a new sink with specified parameters\n" + + "- update: Update the configuration of an existing sink\n" + + "- delete: Delete a sink\n" + + "- start: Start a stopped sink\n" + + "- stop: Stop a running sink\n" + + "- restart: Restart a sink" operationEnum = []string{"create", "update", "delete", "start", "stop", "restart"} toolName = "pulsar_admin_sinks_write" annotation = toolannotations.Destructive("Manage Pulsar Sinks") } - return mcp.NewTool(toolName, + tool := mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), @@ -147,8 +143,7 @@ func (b *PulsarAdminSinksToolBuilder) buildSinksTool(mode toolMode) mcp.Tool { "Sinks in a namespace typically process topics within the same namespace. "+ "Defaults to 'default' if not provided.")), mcp.WithString("name", - mcp.Description("The sink name. Required for all operations except 'list' and 'list-built-in'. "+ - "Can be provided via sink-config-file for create/update. "+ + mcp.Description("The sink name. Required for operations that target one sink. "+ "Names should be descriptive of the sink's purpose and must be unique within a namespace. "+ "Sink names are used in metrics, logs, and when addressing the sink via APIs.")), mcp.WithString("archive", @@ -258,6 +253,10 @@ func (b *PulsarAdminSinksToolBuilder) buildSinksTool(mode toolMode) mcp.Tool { mcp.Description("Whether to update authentication data during sink update. Optional for 'update' only.")), annotation, ) + if !isToolModeWrite(mode) { + pruneToolInputSchema(&tool, []string{"operation", "tenant", "namespace", "name"}) + } + return tool } // buildSinksHandler builds the Pulsar admin sinks handler function diff --git a/pkg/mcp/builders/pulsar/sinks_parity_test.go b/pkg/mcp/builders/pulsar/sinks_parity_test.go index 7ca3d236..cbf82ed1 100644 --- a/pkg/mcp/builders/pulsar/sinks_parity_test.go +++ b/pkg/mcp/builders/pulsar/sinks_parity_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/builders/pulsar/sources.go b/pkg/mcp/builders/pulsar/sources.go index 58c7bb77..6beb6edf 100644 --- a/pkg/mcp/builders/pulsar/sources.go +++ b/pkg/mcp/builders/pulsar/sources.go @@ -99,38 +99,35 @@ func (b *PulsarAdminSourcesToolBuilder) BuildTools(_ context.Context, config bui // buildSourcesTool builds the Pulsar admin sources MCP tool definition func (b *PulsarAdminSourcesToolBuilder) buildSourcesTool(mode toolMode) mcp.Tool { - toolDesc := "Manage Apache Pulsar Sources for data ingestion and integration. " + + toolDesc := "Read Apache Pulsar Sources for data ingestion and integration. " + "Pulsar Sources are connectors that import data from external systems into Pulsar topics. " + - "Sources connect to external systems such as databases, messaging platforms, storage services, " + - "and real-time data streams to pull data and publish it to Pulsar topics. " + - "Built-in source connectors are available for common systems like Kafka, JDBC, AWS services, and more. " + - "Sources follow the tenant/namespace/name hierarchy for organization and access control, " + - "can scale through parallelism configuration, and support various processing guarantees. " + - "This tool provides complete lifecycle management including deployment, configuration, " + - "monitoring, and runtime control. Sources use schema types to ensure data compatibility." + "This read-only tool lists sources and built-in source connectors and retrieves source configuration or runtime status." operationDesc := "Operation to perform. Available operations:\n" + "- list: List all sources under a specific tenant and namespace\n" + "- get: Get the configuration of a source\n" + "- status: Get the runtime status of a source (instances, metrics)\n" + - "- create: Deploy a new source with specified parameters\n" + - "- update: Update the configuration of an existing source\n" + - "- delete: Delete a source\n" + - "- start: Start a stopped source\n" + - "- stop: Stop a running source\n" + - "- restart: Restart a source\n" + "- list-built-in: List all built-in source connectors available in the system" operationEnum := []string{"list", "get", "status", "list-built-in"} toolName := "pulsar_admin_sources_read" annotation := toolannotations.ReadOnly("Read Pulsar Sources") if isToolModeWrite(mode) { + toolDesc = "Manage Apache Pulsar Sources for data ingestion and integration. " + + "This write tool deploys, updates, deletes, starts, stops, or restarts sources." + operationDesc = "Operation to perform. Available operations:\n" + + "- create: Deploy a new source with specified parameters\n" + + "- update: Update the configuration of an existing source\n" + + "- delete: Delete a source\n" + + "- start: Start a stopped source\n" + + "- stop: Stop a running source\n" + + "- restart: Restart a source" operationEnum = []string{"create", "update", "delete", "start", "stop", "restart"} toolName = "pulsar_admin_sources_write" annotation = toolannotations.Destructive("Manage Pulsar Sources") } - return mcp.NewTool(toolName, + tool := mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), @@ -146,8 +143,7 @@ func (b *PulsarAdminSourcesToolBuilder) buildSourcesTool(mode toolMode) mcp.Tool "Sources in a namespace typically publish to topics within the same namespace. "+ "Defaults to 'default' if not provided.")), mcp.WithString("name", - mcp.Description("The source name. Required for all operations except 'list' and 'list-built-in'. "+ - "Can be provided via source-config-file for create/update. "+ + mcp.Description("The source name. Required for operations that target one source. "+ "Names should be descriptive of the source's purpose and must be unique within a namespace. "+ "Source names are used in metrics, logs, and when addressing the source via APIs.")), mcp.WithString("archive", @@ -225,6 +221,10 @@ func (b *PulsarAdminSourcesToolBuilder) buildSourcesTool(mode toolMode) mcp.Tool mcp.Description("Whether to update authentication data during source update. Optional for 'update' only.")), annotation, ) + if !isToolModeWrite(mode) { + pruneToolInputSchema(&tool, []string{"operation", "tenant", "namespace", "name"}) + } + return tool } // buildSourcesHandler builds the Pulsar admin sources handler function diff --git a/pkg/mcp/builders/pulsar/sources_parity_test.go b/pkg/mcp/builders/pulsar/sources_parity_test.go index 7207360e..41593119 100644 --- a/pkg/mcp/builders/pulsar/sources_parity_test.go +++ b/pkg/mcp/builders/pulsar/sources_parity_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/builders/pulsar/status_test.go b/pkg/mcp/builders/pulsar/status_test.go index 49645dc2..5412d81b 100644 --- a/pkg/mcp/builders/pulsar/status_test.go +++ b/pkg/mcp/builders/pulsar/status_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/builders/pulsar/subscription.go b/pkg/mcp/builders/pulsar/subscription.go index ef8feded..78605d9a 100644 --- a/pkg/mcp/builders/pulsar/subscription.go +++ b/pkg/mcp/builders/pulsar/subscription.go @@ -123,12 +123,9 @@ func (b *PulsarAdminSubscriptionToolBuilder) BuildTools(_ context.Context, confi // buildSubscriptionTool builds the Pulsar Admin Subscription MCP tool definition // Migrated from the original tool definition logic func (b *PulsarAdminSubscriptionToolBuilder) buildSubscriptionTool(mode toolMode) mcp.Tool { - toolDesc := "Manage Apache Pulsar subscriptions on topics. " + + toolDesc := "Read Apache Pulsar subscriptions on topics. " + "Subscriptions are named entities representing consumer groups that maintain their position in a topic. " + - "Pulsar supports multiple subscription modes (Exclusive, Shared, Failover, Key_Shared) to accommodate different messaging patterns. " + - "Each subscription tracks message acknowledgments independently, allowing multiple consumers to process messages at their own pace. " + - "Subscriptions persist even when all consumers disconnect, maintaining state and preventing message loss. " + - "Operations include listing, creating, deleting, and manipulating message cursors within subscriptions. " + + "This read-only tool lists subscriptions and inspects messages without advancing subscription cursors. " + "Most operations require namespace admin permissions plus produce/consume permissions on the topic." resourceDesc := "Resource to operate on. Available resources:\n" + @@ -136,11 +133,6 @@ func (b *PulsarAdminSubscriptionToolBuilder) buildSubscriptionTool(mode toolMode operationDesc := "Operation to perform. Available operations:\n" + "- list: List all subscriptions for a topic\n" + - "- create: Create a new subscription on a topic\n" + - "- delete: Delete a subscription from a topic\n" + - "- skip: Skip a specified number of messages for a subscription\n" + - "- expire: Expire messages older than specified time for a subscription\n" + - "- reset-cursor: Reset the cursor position for a subscription to a specific message ID\n" + "- peek: Peek one or more messages for a subscription without advancing the cursor\n" + "- get-message-by-id: Read a message by ledger ID and entry ID for topic-level debugging" @@ -148,12 +140,20 @@ func (b *PulsarAdminSubscriptionToolBuilder) buildSubscriptionTool(mode toolMode toolName := "pulsar_admin_subscription_read" annotation := toolannotations.ReadOnly("Read Pulsar Subscriptions") if isToolModeWrite(mode) { + toolDesc = "Manage Apache Pulsar subscriptions on topics. " + + "This write tool creates or deletes subscriptions and changes subscription cursor positions." + operationDesc = "Operation to perform. Available operations:\n" + + "- create: Create a new subscription on a topic\n" + + "- delete: Delete a subscription from a topic\n" + + "- skip: Skip a specified number of messages for a subscription\n" + + "- expire: Expire messages older than specified time for a subscription\n" + + "- reset-cursor: Reset the cursor position for a subscription to a specific message ID" operationEnum = []string{"create", "delete", "skip", "expire", "reset-cursor"} toolName = "pulsar_admin_subscription_write" annotation = toolannotations.Destructive("Manage Pulsar Subscriptions") } - return mcp.NewTool(toolName, + tool := mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), @@ -180,9 +180,7 @@ func (b *PulsarAdminSubscriptionToolBuilder) buildSubscriptionTool(mode toolMode "- specific position in 'ledgerId:entryId' format for precise positioning"), ), mcp.WithNumber("count", - mcp.Description("The number of messages to skip (required for 'skip' operation) or peek (optional for 'peek', default 1). "+ - "For 'skip', this moves the subscription cursor forward by the specified number of messages without processing them. "+ - "For 'peek', this limits how many messages are returned without moving the cursor. Maximum: 100."), + mcp.Description("The number of messages for operations that process a count. For peek, this limits how many messages are returned without moving the cursor. Maximum: 100."), ), mcp.WithNumber("expireTimeInSeconds", mcp.Description("Expire messages older than the specified seconds (required for 'expire' operation). "+ @@ -201,6 +199,12 @@ func (b *PulsarAdminSubscriptionToolBuilder) buildSubscriptionTool(mode toolMode ), annotation, ) + if isToolModeWrite(mode) { + pruneToolInputSchema(&tool, []string{"resource", "operation", "topic", "subscription", "messageId", "count", "expireTimeInSeconds", "force"}) + } else { + pruneToolInputSchema(&tool, []string{"resource", "operation", "topic", "subscription", "ledgerId", "entryId", "count"}) + } + return tool } // buildSubscriptionHandler builds the Pulsar Admin Subscription handler function diff --git a/pkg/mcp/builders/pulsar/subscription_test.go b/pkg/mcp/builders/pulsar/subscription_test.go index b616d94b..2e94dff3 100644 --- a/pkg/mcp/builders/pulsar/subscription_test.go +++ b/pkg/mcp/builders/pulsar/subscription_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/builders/pulsar/tenant.go b/pkg/mcp/builders/pulsar/tenant.go index 3202f4ac..ec57df6b 100644 --- a/pkg/mcp/builders/pulsar/tenant.go +++ b/pkg/mcp/builders/pulsar/tenant.go @@ -96,7 +96,7 @@ func (b *PulsarAdminTenantToolBuilder) BuildTools(_ context.Context, config buil // buildTenantTool builds the Pulsar Admin Tenant MCP tool definition // Migrated from the original tool definition logic func (b *PulsarAdminTenantToolBuilder) buildTenantTool(mode toolMode) mcp.Tool { - toolDesc := "Manage Apache Pulsar tenants. " + + toolDesc := "Read Apache Pulsar tenants. " + "Tenants are the highest level administrative unit in Pulsar's multi-tenancy hierarchy. " + "Each tenant can contain multiple namespaces, allowing for logical isolation of applications. " + "Tenant configuration controls admin access and cluster availability across organizations. " + @@ -110,21 +110,24 @@ func (b *PulsarAdminTenantToolBuilder) buildTenantTool(mode toolMode) mcp.Tool { operationDesc := "Operation to perform. Available operations:\n" + "- list: List all tenants in the Pulsar instance\n" + - "- get: Get configuration details for a specific tenant\n" + - "- create: Create a new tenant with specified configuration\n" + - "- update: Update configuration for an existing tenant\n" + - "- delete: Delete an existing tenant (must not have any active namespaces)" + "- get: Get configuration details for a specific tenant" operationEnum := []string{"list", "get"} toolName := "pulsar_admin_tenant_read" annotation := toolannotations.ReadOnly("Read Pulsar Tenants") if isToolModeWrite(mode) { + toolDesc = "Manage Apache Pulsar tenants. " + + "This write tool creates, updates, or deletes tenant configuration and may change cluster state." + operationDesc = "Operation to perform. Available operations:\n" + + "- create: Create a new tenant with specified configuration\n" + + "- update: Update configuration for an existing tenant\n" + + "- delete: Delete an existing tenant (must not have any active namespaces)" operationEnum = []string{"create", "update", "delete"} toolName = "pulsar_admin_tenant_write" annotation = toolannotations.Destructive("Manage Pulsar Tenants") } - return mcp.NewTool(toolName, + tool := mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), @@ -166,6 +169,12 @@ func (b *PulsarAdminTenantToolBuilder) buildTenantTool(mode toolMode) mcp.Tool { ), annotation, ) + if isToolModeWrite(mode) { + pruneToolInputSchema(&tool, []string{"resource", "operation", "tenant", "adminRoles", "allowedClusters"}) + } else { + pruneToolInputSchema(&tool, []string{"resource", "operation", "tenant"}) + } + return tool } // buildTenantHandler builds the Pulsar Admin Tenant handler function diff --git a/pkg/mcp/builders/pulsar/tool_mode.go b/pkg/mcp/builders/pulsar/tool_mode.go index 17467786..fc4dcecd 100644 --- a/pkg/mcp/builders/pulsar/tool_mode.go +++ b/pkg/mcp/builders/pulsar/tool_mode.go @@ -14,7 +14,11 @@ package pulsar -import "strings" +import ( + "strings" + + "github.com/mark3labs/mcp-go/mcp" +) type toolMode string @@ -35,3 +39,50 @@ func isWriteOperation(operation string, writeOperations map[string]struct{}) boo func validateModeOperation(mode toolMode, operation string, writeOperations map[string]struct{}) bool { return (mode == toolModeWrite) == isWriteOperation(operation, writeOperations) } + +func pruneToolInputSchema(tool *mcp.Tool, allowedProperties []string) { + allowed := make(map[string]struct{}, len(allowedProperties)) + for _, property := range allowedProperties { + allowed[property] = struct{}{} + } + + for property := range tool.InputSchema.Properties { + if _, ok := allowed[property]; !ok { + delete(tool.InputSchema.Properties, property) + } + } + + filterRequiredProperties(tool, allowed) +} + +func removeToolInputSchemaProperties(tool *mcp.Tool, properties []string) { + removed := make(map[string]struct{}, len(properties)) + for _, property := range properties { + removed[property] = struct{}{} + delete(tool.InputSchema.Properties, property) + } + + if len(tool.InputSchema.Required) == 0 { + return + } + required := tool.InputSchema.Required[:0] + for _, property := range tool.InputSchema.Required { + if _, ok := removed[property]; !ok { + required = append(required, property) + } + } + tool.InputSchema.Required = required +} + +func filterRequiredProperties(tool *mcp.Tool, allowed map[string]struct{}) { + if len(tool.InputSchema.Required) == 0 { + return + } + required := tool.InputSchema.Required[:0] + for _, property := range tool.InputSchema.Required { + if _, ok := allowed[property]; ok { + required = append(required, property) + } + } + tool.InputSchema.Required = required +} diff --git a/pkg/mcp/builders/pulsar/topic.go b/pkg/mcp/builders/pulsar/topic.go index a0cde8d9..aac43928 100644 --- a/pkg/mcp/builders/pulsar/topic.go +++ b/pkg/mcp/builders/pulsar/topic.go @@ -111,16 +111,10 @@ func (b *PulsarAdminTopicToolBuilder) BuildTools(_ context.Context, config build // buildTopicTool builds the Pulsar Admin Topic MCP tool definition // Migrated from the original tool definition logic func (b *PulsarAdminTopicToolBuilder) buildTopicTool(mode toolMode) mcp.Tool { - toolDesc := "Manage Apache Pulsar topics. " + + toolDesc := "Read Apache Pulsar topics. " + "Topics are the core messaging entities in Pulsar that store and transmit messages. " + - "Pulsar supports two types of topics: persistent (durable storage with guaranteed delivery) " + - "and non-persistent (in-memory with at-most-once delivery). " + - "Topics can be partitioned for parallel processing and higher throughput, where each partition " + - "functions as an independent topic with its own message log. " + - "Topics follow a hierarchical naming structure: persistent://tenant/namespace/topic. " + - "This tool supports various operations on topics including creation, deletion, lookup, compaction, " + - "offloading, and retrieving statistics. " + - "Do not use this tool for Kafka protocol operations. Use 'kafka_admin_topics' instead." + + "This read-only tool lists topics and retrieves metadata, permissions, statistics, lookup information, internal details, message IDs, and long-running operation status. " + + "Do not use this tool for Kafka protocol operations. Use 'kafka_admin_topics_read' instead. " + "Most operations require namespace admin permissions." resourceDesc := "Resource to operate on. Available resources:\n" + @@ -131,10 +125,6 @@ func (b *PulsarAdminTopicToolBuilder) buildTopicTool(mode toolMode) mcp.Tool { "- list: List all topics in a namespace\n" + "- get: Get metadata for a topic\n" + "- get-permissions: Get topic permissions for all roles\n" + - "- grant-permissions: Grant topic permissions to a role\n" + - "- revoke-permissions: Revoke topic permissions from a role\n" + - "- create: Create a new topic with optional partitions\n" + - "- delete: Delete a topic\n" + "- stats: Get stats for a topic\n" + "- lookup: Look up the broker serving a topic\n" + "- internal-stats: Get internal stats for a topic\n" + @@ -142,23 +132,31 @@ func (b *PulsarAdminTopicToolBuilder) buildTopicTool(mode toolMode) mcp.Tool { "- bundle-range: Get the bundle range of a topic\n" + "- last-message-id: Get the last message ID of a topic\n" + "- compact-status: Get compaction status for a topic (legacy alias: status)\n" + - "- unload: Unload a topic\n" + - "- terminate: Terminate a topic\n" + - "- compact: Trigger compaction on a topic\n" + - "- update: Update a topic partitions\n" + - "- offload: Offload data from a topic to long-term storage\n" + "- offload-status: Check the status of data offloading for a topic" operationEnum := []string{"list", "get", "get-permissions", "stats", "lookup", "internal-stats", "internal-info", "bundle-range", "last-message-id", "compact-status", "offload-status"} toolName := "pulsar_admin_topic_read" annotation := toolannotations.ReadOnly("Read Pulsar Topics") if isToolModeWrite(mode) { + toolDesc = "Manage Apache Pulsar topics. " + + "This write tool changes topic lifecycle, permissions, partitioning, compaction, or offload state. " + + "Do not use this tool for Kafka protocol operations. Use 'kafka_admin_topics_write' instead." + operationDesc = "Operation to perform. Available operations:\n" + + "- grant-permissions: Grant topic permissions to a role\n" + + "- revoke-permissions: Revoke topic permissions from a role\n" + + "- create: Create a new topic with optional partitions\n" + + "- delete: Delete a topic\n" + + "- unload: Unload a topic\n" + + "- terminate: Terminate a topic\n" + + "- compact: Trigger compaction on a topic\n" + + "- update: Update topic partitions\n" + + "- offload: Offload data from a topic to long-term storage" operationEnum = []string{"grant-permissions", "revoke-permissions", "create", "delete", "unload", "terminate", "compact", "update", "offload"} toolName = "pulsar_admin_topic_write" annotation = toolannotations.Destructive("Manage Pulsar Topics") } - return mcp.NewTool(toolName, + tool := mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), @@ -231,6 +229,12 @@ func (b *PulsarAdminTopicToolBuilder) buildTopicTool(mode toolMode) mcp.Tool { ), annotation, ) + if isToolModeWrite(mode) { + pruneToolInputSchema(&tool, []string{"resource", "operation", "topic", "partitions", "force", "non-partitioned", "config", "messageId", "role", "actions"}) + } else { + pruneToolInputSchema(&tool, []string{"resource", "operation", "topic", "namespace", "partitioned", "per-partition", "wait"}) + } + return tool } // buildTopicHandler builds the Pulsar Admin Topic handler function diff --git a/pkg/mcp/builders/pulsar/topic_policy.go b/pkg/mcp/builders/pulsar/topic_policy.go index 055f9425..2bc46d41 100644 --- a/pkg/mcp/builders/pulsar/topic_policy.go +++ b/pkg/mcp/builders/pulsar/topic_policy.go @@ -194,41 +194,58 @@ func (b *PulsarAdminTopicPolicyToolBuilder) BuildTools(_ context.Context, config // buildTopicPolicyTool builds the Pulsar Admin Topic Policy MCP tool definition func (b *PulsarAdminTopicPolicyToolBuilder) buildTopicPolicyTool(mode toolMode) mcp.Tool { - toolDesc := "Manage Pulsar topic-level policies with operation names aligned to pulsarctl topic policy commands. " + - "This tool covers retention, message TTL, producer and consumer limits, persistence, delayed delivery, " + - "dispatch throttling, deduplication, backlog quotas, compaction thresholds, publish rates, inactive topic policies, " + - "and subscription dispatch throttling. Legacy underscore operation aliases from the older MCP implementation remain supported." + toolDesc := "Read Pulsar topic-level policies with operation names aligned to pulsarctl topic policy commands. " + + "This read-only tool retrieves retention, TTL, limits, persistence, delayed delivery, throttling, deduplication, backlog quota, compaction, publish rate, inactive topic, and subscription type policies. " + + "Legacy underscore operation aliases from the older MCP implementation remain supported." operationDesc := strings.Join([]string{ "Operation to perform. Available operations:", - "- get-retention / set-retention / remove-retention: manage topic retention policy", - "- get-message-ttl / set-message-ttl / remove-message-ttl: manage topic message TTL", - "- get-max-producers / set-max-producers / remove-max-producers: manage producer limit", - "- get-max-consumers / set-max-consumers / remove-max-consumers: manage consumer limit", - "- get-max-unacked-messages-per-consumer / set-max-unacked-messages-per-consumer / remove-max-unacked-messages-per-consumer", - "- get-max-unacked-messages-per-subscription / set-max-unacked-messages-per-subscription / remove-max-unacked-messages-per-subscription", - "- get-persistence / set-persistence / remove-persistence: manage topic persistence", - "- get-delayed-delivery / set-delayed-delivery / remove-delayed-delivery: manage delayed delivery policy", - "- get-dispatch-rate / set-dispatch-rate / remove-dispatch-rate: manage topic dispatch throttling", - "- get-subscription-dispatch-rate / set-subscription-dispatch-rate / remove-subscription-dispatch-rate", - "- get-deduplication / set-deduplication / remove-deduplication: manage deduplication policy", - "- get-backlog-quotas / set-backlog-quota / remove-backlog-quota: manage backlog quotas", - "- get-compaction-threshold / set-compaction-threshold / remove-compaction-threshold: manage compaction threshold", - "- get-publish-rate / set-publish-rate / remove-publish-rate: manage publish rate policy", - "- get-inactive-topic-policies / set-inactive-topic-policies / remove-inactive-topic-policies", - "- get-subscription-types / set-subscription-types / remove-subscription-types: additional MCP-only compatibility operations", + "- get-retention: read topic retention policy", + "- get-message-ttl: read topic message TTL", + "- get-max-producers: read producer limit", + "- get-max-consumers: read consumer limit", + "- get-max-unacked-messages-per-consumer: read unacked message limit per consumer", + "- get-max-unacked-messages-per-subscription: read unacked message limit per subscription", + "- get-persistence: read topic persistence", + "- get-delayed-delivery: read delayed delivery policy", + "- get-dispatch-rate: read topic dispatch throttling", + "- get-subscription-dispatch-rate: read subscription dispatch throttling", + "- get-deduplication: read deduplication policy", + "- get-backlog-quotas: read backlog quotas", + "- get-compaction-threshold: read compaction threshold", + "- get-publish-rate: read publish rate policy", + "- get-inactive-topic-policies: read inactive topic policies", + "- get-subscription-types: read allowed subscription types", }, "\n") operationEnum := readOnlyTopicPolicyOperations toolName := "pulsar_admin_topic_policy_read" annotation := toolannotations.ReadOnly("Read Pulsar Topic Policies") if isToolModeWrite(mode) { + toolDesc = "Manage Pulsar topic-level policies with operation names aligned to pulsarctl topic policy commands. " + + "This write tool sets or removes topic-level policies. Legacy underscore operation aliases from the older MCP implementation remain supported." + operationDesc = strings.Join([]string{ + "Operation to perform. Available operations:", + "- set/remove-retention: manage topic retention policy", + "- set/remove-message-ttl: manage topic message TTL", + "- set/remove-max-producers: manage producer limit", + "- set/remove-max-consumers: manage consumer limit", + "- set/remove-persistence: manage topic persistence", + "- set/remove-delayed-delivery: manage delayed delivery policy", + "- set/remove-dispatch-rate: manage topic dispatch throttling", + "- set/remove-deduplication: manage deduplication policy", + "- set/remove-backlog-quota: manage backlog quotas", + "- set/remove-compaction-threshold: manage compaction threshold", + "- set/remove-publish-rate: manage publish rate policy", + "- set/remove-inactive-topic-policies: manage inactive topic policies", + "- set/remove-subscription-types: manage allowed subscription types", + }, "\n") operationEnum = writeTopicPolicyOperations toolName = "pulsar_admin_topic_policy_write" annotation = toolannotations.Destructive("Manage Pulsar Topic Policies") } - return mcp.NewTool(toolName, + tool := mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), @@ -298,7 +315,7 @@ func (b *PulsarAdminTopicPolicyToolBuilder) buildTopicPolicyTool(mode toolMode) mcp.Description("Backlog quota retention policy. Valid values: producer_request_hold, producer_exception, consumer_backlog_eviction. Used by set-backlog-quota."), ), mcp.WithString("type", - mcp.Description("Backlog quota type. Valid values: destination_storage or message_age. Used by get-backlog-quotas, set-backlog-quota, and remove-backlog-quota."), + mcp.Description("Backlog quota type. Valid values: destination_storage or message_age. Used by backlog quota operations."), ), mcp.WithBoolean("delete-while-inactive", mcp.Description("Whether inactive topics should be deleted. Used by set-inactive-topic-policies."), @@ -320,6 +337,12 @@ func (b *PulsarAdminTopicPolicyToolBuilder) buildTopicPolicyTool(mode toolMode) ), annotation, ) + if isToolModeWrite(mode) { + removeToolInputSchemaProperties(&tool, []string{"applied"}) + } else { + pruneToolInputSchema(&tool, []string{"operation", "topic", "applied", "type"}) + } + return tool } // buildTopicPolicyHandler builds the Pulsar Admin Topic Policy handler function diff --git a/pkg/mcp/builders/pulsar/topic_policy_test.go b/pkg/mcp/builders/pulsar/topic_policy_test.go index c2849821..6c8f0463 100644 --- a/pkg/mcp/builders/pulsar/topic_policy_test.go +++ b/pkg/mcp/builders/pulsar/topic_policy_test.go @@ -39,16 +39,22 @@ func TestBuildTopicPolicyToolIncludesPulsarctlParityOperations(t *testing.T) { require.Contains(t, description, "get-inactive-topic-policies") } -func TestBuildTopicPolicyToolIncludesTopicPolicyParameters(t *testing.T) { +func TestBuildTopicPolicyToolIncludesModeSpecificParameters(t *testing.T) { builder := NewPulsarAdminTopicPolicyToolBuilder() - tool := builder.buildTopicPolicyTool(toolModeRead) - - require.Contains(t, tool.InputSchema.Properties, "applied") - require.Contains(t, tool.InputSchema.Properties, "count") - require.Contains(t, tool.InputSchema.Properties, "limit-size") - require.Contains(t, tool.InputSchema.Properties, "delete-mode") - require.Contains(t, tool.InputSchema.Properties, "subscription-types") + readTool := builder.buildTopicPolicyTool(toolModeRead) + require.Contains(t, readTool.InputSchema.Properties, "applied") + require.NotContains(t, readTool.InputSchema.Properties, "count") + require.NotContains(t, readTool.InputSchema.Properties, "limit-size") + require.NotContains(t, readTool.InputSchema.Properties, "delete-mode") + require.NotContains(t, readTool.InputSchema.Properties, "subscription-types") + + writeTool := builder.buildTopicPolicyTool(toolModeWrite) + require.NotContains(t, writeTool.InputSchema.Properties, "applied") + require.Contains(t, writeTool.InputSchema.Properties, "count") + require.Contains(t, writeTool.InputSchema.Properties, "limit-size") + require.Contains(t, writeTool.InputSchema.Properties, "delete-mode") + require.Contains(t, writeTool.InputSchema.Properties, "subscription-types") } func TestNormalizeTopicPolicyOperationSupportsLegacyAliases(t *testing.T) { diff --git a/pkg/mcp/builders/pulsar/topic_test.go b/pkg/mcp/builders/pulsar/topic_test.go index ad0d2ef1..0efab967 100644 --- a/pkg/mcp/builders/pulsar/topic_test.go +++ b/pkg/mcp/builders/pulsar/topic_test.go @@ -24,24 +24,34 @@ import ( "github.com/stretchr/testify/require" ) -func TestBuildTopicToolIncludesPermissionOperations(t *testing.T) { +func TestBuildTopicToolIncludesModeSpecificPermissionOperations(t *testing.T) { builder := NewPulsarAdminTopicToolBuilder() - tool := builder.buildTopicTool(toolModeRead) + readTool := builder.buildTopicTool(toolModeRead) + require.NotContains(t, readTool.InputSchema.Properties, "role") + require.NotContains(t, readTool.InputSchema.Properties, "actions") + require.Contains(t, readTool.InputSchema.Properties, "wait") - require.Contains(t, tool.InputSchema.Properties, "role") - require.Contains(t, tool.InputSchema.Properties, "actions") - require.Contains(t, tool.InputSchema.Properties, "wait") - - operationSchema, ok := tool.InputSchema.Properties["operation"].(map[string]any) + readOperationSchema, ok := readTool.InputSchema.Properties["operation"].(map[string]any) + require.True(t, ok) + readDescription, ok := readOperationSchema["description"].(string) require.True(t, ok) + require.Contains(t, readDescription, "get-permissions") + require.NotContains(t, readDescription, "grant-permissions") + require.NotContains(t, readDescription, "revoke-permissions") + require.Contains(t, readDescription, "compact-status") - description, ok := operationSchema["description"].(string) + writeTool := builder.buildTopicTool(toolModeWrite) + require.Contains(t, writeTool.InputSchema.Properties, "role") + require.Contains(t, writeTool.InputSchema.Properties, "actions") + require.NotContains(t, writeTool.InputSchema.Properties, "wait") + + writeOperationSchema, ok := writeTool.InputSchema.Properties["operation"].(map[string]any) + require.True(t, ok) + writeDescription, ok := writeOperationSchema["description"].(string) require.True(t, ok) - require.Contains(t, description, "get-permissions") - require.Contains(t, description, "grant-permissions") - require.Contains(t, description, "revoke-permissions") - require.Contains(t, description, "compact-status") + require.Contains(t, writeDescription, "grant-permissions") + require.Contains(t, writeDescription, "revoke-permissions") } func TestParseTopicActions(t *testing.T) { diff --git a/pkg/mcp/builders/registry.go b/pkg/mcp/builders/registry.go index 03c3726e..28d36ff5 100644 --- a/pkg/mcp/builders/registry.go +++ b/pkg/mcp/builders/registry.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/builders/registry_test.go b/pkg/mcp/builders/registry_test.go index 766c2667..8fae9e10 100644 --- a/pkg/mcp/builders/registry_test.go +++ b/pkg/mcp/builders/registry_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/ctx.go b/pkg/mcp/ctx.go index 434e2ce1..8e21c2f2 100644 --- a/pkg/mcp/ctx.go +++ b/pkg/mcp/ctx.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/features.go b/pkg/mcp/features.go index 0d0c0936..f027778d 100644 --- a/pkg/mcp/features.go +++ b/pkg/mcp/features.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/instructions.go b/pkg/mcp/instructions.go index 4cc48256..3fa46547 100644 --- a/pkg/mcp/instructions.go +++ b/pkg/mcp/instructions.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/internal/context/ctx.go b/pkg/mcp/internal/context/ctx.go index 3008d4f1..e87bf3f3 100644 --- a/pkg/mcp/internal/context/ctx.go +++ b/pkg/mcp/internal/context/ctx.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/kafka_admin_connect_tools.go b/pkg/mcp/kafka_admin_connect_tools.go index d5a31ca3..abbfb6c5 100644 --- a/pkg/mcp/kafka_admin_connect_tools.go +++ b/pkg/mcp/kafka_admin_connect_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/kafka_admin_groups_tools.go b/pkg/mcp/kafka_admin_groups_tools.go index 143fd9c8..58b4aa94 100644 --- a/pkg/mcp/kafka_admin_groups_tools.go +++ b/pkg/mcp/kafka_admin_groups_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/kafka_admin_partitions_tools.go b/pkg/mcp/kafka_admin_partitions_tools.go index 9d510c81..06d76d95 100644 --- a/pkg/mcp/kafka_admin_partitions_tools.go +++ b/pkg/mcp/kafka_admin_partitions_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/kafka_admin_sr_tools.go b/pkg/mcp/kafka_admin_sr_tools.go index c6e01d59..244a9a07 100644 --- a/pkg/mcp/kafka_admin_sr_tools.go +++ b/pkg/mcp/kafka_admin_sr_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/kafka_admin_topics_tools.go b/pkg/mcp/kafka_admin_topics_tools.go index 0d5be44e..3668e3d3 100644 --- a/pkg/mcp/kafka_admin_topics_tools.go +++ b/pkg/mcp/kafka_admin_topics_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/kafka_client_consume_tools.go b/pkg/mcp/kafka_client_consume_tools.go index d94def25..8e9a293d 100644 --- a/pkg/mcp/kafka_client_consume_tools.go +++ b/pkg/mcp/kafka_client_consume_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/kafka_client_produce_tools.go b/pkg/mcp/kafka_client_produce_tools.go index e75da6af..d3319f6b 100644 --- a/pkg/mcp/kafka_client_produce_tools.go +++ b/pkg/mcp/kafka_client_produce_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pftools/circuit_breaker.go b/pkg/mcp/pftools/circuit_breaker.go index d64ff4c1..136aef8c 100644 --- a/pkg/mcp/pftools/circuit_breaker.go +++ b/pkg/mcp/pftools/circuit_breaker.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pftools/errors.go b/pkg/mcp/pftools/errors.go index f889f563..1bead642 100644 --- a/pkg/mcp/pftools/errors.go +++ b/pkg/mcp/pftools/errors.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pftools/errors_test.go b/pkg/mcp/pftools/errors_test.go index 0edcd17a..3e607c9a 100644 --- a/pkg/mcp/pftools/errors_test.go +++ b/pkg/mcp/pftools/errors_test.go @@ -1,3 +1,17 @@ +// Copyright 2026 StreamNative +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package pftools import ( diff --git a/pkg/mcp/pftools/invocation.go b/pkg/mcp/pftools/invocation.go index 24c3c378..1d888323 100644 --- a/pkg/mcp/pftools/invocation.go +++ b/pkg/mcp/pftools/invocation.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pftools/manager_annotation_test.go b/pkg/mcp/pftools/manager_annotation_test.go new file mode 100644 index 00000000..5e03e1ed --- /dev/null +++ b/pkg/mcp/pftools/manager_annotation_test.go @@ -0,0 +1,49 @@ +// Copyright 2026 StreamNative +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pftools + +import ( + "sync" + "testing" + + "github.com/apache/pulsar-client-go/pulsaradmin/pkg/utils" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" +) + +func TestConvertFunctionToToolSetsDestructiveAnnotation(t *testing.T) { + manager := &PulsarFunctionManager{ + logger: logrus.New(), + circuitBreakers: make(map[string]*CircuitBreaker), + mutex: sync.RWMutex{}, + } + + fnTool, err := manager.convertFunctionToTool(&utils.FunctionConfig{ + Tenant: "public", + Namespace: "default", + Name: "echo-fn", + InputSpecs: map[string]utils.ConsumerConfig{ + "persistent://public/default/input": {}, + }, + }) + require.NoError(t, err) + + annotations := fnTool.Tool.Annotations + require.NotEmpty(t, annotations.Title) + require.NotNil(t, annotations.ReadOnlyHint) + require.NotNil(t, annotations.DestructiveHint) + require.False(t, *annotations.ReadOnlyHint) + require.True(t, *annotations.DestructiveHint) +} diff --git a/pkg/mcp/pftools/schema.go b/pkg/mcp/pftools/schema.go index 62ae1551..1bbc6eaf 100644 --- a/pkg/mcp/pftools/schema.go +++ b/pkg/mcp/pftools/schema.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pftools/types.go b/pkg/mcp/pftools/types.go index 55b97aec..2b21568f 100644 --- a/pkg/mcp/pftools/types.go +++ b/pkg/mcp/pftools/types.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/prompts.go b/pkg/mcp/prompts.go index 4fbfa245..49850944 100644 --- a/pkg/mcp/prompts.go +++ b/pkg/mcp/prompts.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pulsar_admin_brokers_stats_tools.go b/pkg/mcp/pulsar_admin_brokers_stats_tools.go index d50b3981..0b1680d5 100644 --- a/pkg/mcp/pulsar_admin_brokers_stats_tools.go +++ b/pkg/mcp/pulsar_admin_brokers_stats_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pulsar_admin_brokers_tools.go b/pkg/mcp/pulsar_admin_brokers_tools.go index 6ce43ed7..45e90b46 100644 --- a/pkg/mcp/pulsar_admin_brokers_tools.go +++ b/pkg/mcp/pulsar_admin_brokers_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pulsar_admin_cluster_tools.go b/pkg/mcp/pulsar_admin_cluster_tools.go index 51f1f38d..22e503ac 100644 --- a/pkg/mcp/pulsar_admin_cluster_tools.go +++ b/pkg/mcp/pulsar_admin_cluster_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pulsar_admin_functions_tools.go b/pkg/mcp/pulsar_admin_functions_tools.go index 2b9203e4..ec7564b6 100644 --- a/pkg/mcp/pulsar_admin_functions_tools.go +++ b/pkg/mcp/pulsar_admin_functions_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pulsar_admin_functions_worker_tools.go b/pkg/mcp/pulsar_admin_functions_worker_tools.go index b4480c19..f03eeb60 100644 --- a/pkg/mcp/pulsar_admin_functions_worker_tools.go +++ b/pkg/mcp/pulsar_admin_functions_worker_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pulsar_admin_namespace_policy_tools.go b/pkg/mcp/pulsar_admin_namespace_policy_tools.go index c74cfe01..dd43aee6 100644 --- a/pkg/mcp/pulsar_admin_namespace_policy_tools.go +++ b/pkg/mcp/pulsar_admin_namespace_policy_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pulsar_admin_namespace_tools.go b/pkg/mcp/pulsar_admin_namespace_tools.go index 3d5f5326..a41a8fd3 100644 --- a/pkg/mcp/pulsar_admin_namespace_tools.go +++ b/pkg/mcp/pulsar_admin_namespace_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pulsar_admin_nsisolationpolicy_tools.go b/pkg/mcp/pulsar_admin_nsisolationpolicy_tools.go index f742f6cb..b75f6d35 100644 --- a/pkg/mcp/pulsar_admin_nsisolationpolicy_tools.go +++ b/pkg/mcp/pulsar_admin_nsisolationpolicy_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pulsar_admin_packages_tools.go b/pkg/mcp/pulsar_admin_packages_tools.go index afc44cda..9ccdddb4 100644 --- a/pkg/mcp/pulsar_admin_packages_tools.go +++ b/pkg/mcp/pulsar_admin_packages_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pulsar_admin_resourcequotas_tools.go b/pkg/mcp/pulsar_admin_resourcequotas_tools.go index 6382d0ce..82cc57a8 100644 --- a/pkg/mcp/pulsar_admin_resourcequotas_tools.go +++ b/pkg/mcp/pulsar_admin_resourcequotas_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pulsar_admin_schemas_tools.go b/pkg/mcp/pulsar_admin_schemas_tools.go index 9e79444b..1d7275b5 100644 --- a/pkg/mcp/pulsar_admin_schemas_tools.go +++ b/pkg/mcp/pulsar_admin_schemas_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pulsar_admin_sinks_tools.go b/pkg/mcp/pulsar_admin_sinks_tools.go index cc4abac0..b3f97b2c 100644 --- a/pkg/mcp/pulsar_admin_sinks_tools.go +++ b/pkg/mcp/pulsar_admin_sinks_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pulsar_admin_sources_tools.go b/pkg/mcp/pulsar_admin_sources_tools.go index 8abc6464..5f6af45a 100644 --- a/pkg/mcp/pulsar_admin_sources_tools.go +++ b/pkg/mcp/pulsar_admin_sources_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pulsar_admin_status_tools.go b/pkg/mcp/pulsar_admin_status_tools.go index 82c29af4..f415a79a 100644 --- a/pkg/mcp/pulsar_admin_status_tools.go +++ b/pkg/mcp/pulsar_admin_status_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pulsar_admin_subscription_tools.go b/pkg/mcp/pulsar_admin_subscription_tools.go index 925a4149..e95f0ba0 100644 --- a/pkg/mcp/pulsar_admin_subscription_tools.go +++ b/pkg/mcp/pulsar_admin_subscription_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pulsar_admin_tenant_tools.go b/pkg/mcp/pulsar_admin_tenant_tools.go index 3ab45a42..1663701d 100644 --- a/pkg/mcp/pulsar_admin_tenant_tools.go +++ b/pkg/mcp/pulsar_admin_tenant_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pulsar_admin_topic_policy_tools.go b/pkg/mcp/pulsar_admin_topic_policy_tools.go index 7eb1b1c3..2a476bb0 100644 --- a/pkg/mcp/pulsar_admin_topic_policy_tools.go +++ b/pkg/mcp/pulsar_admin_topic_policy_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pulsar_admin_topic_tools.go b/pkg/mcp/pulsar_admin_topic_tools.go index d2309f5a..79972d07 100644 --- a/pkg/mcp/pulsar_admin_topic_tools.go +++ b/pkg/mcp/pulsar_admin_topic_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pulsar_client_consume_tools.go b/pkg/mcp/pulsar_client_consume_tools.go index 3cb73eac..8a8e8124 100644 --- a/pkg/mcp/pulsar_client_consume_tools.go +++ b/pkg/mcp/pulsar_client_consume_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pulsar_client_produce_tools.go b/pkg/mcp/pulsar_client_produce_tools.go index 1ae9d207..c69bc535 100644 --- a/pkg/mcp/pulsar_client_produce_tools.go +++ b/pkg/mcp/pulsar_client_produce_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pulsar_functions_as_tools.go b/pkg/mcp/pulsar_functions_as_tools.go index 36635ce2..a339b3c7 100644 --- a/pkg/mcp/pulsar_functions_as_tools.go +++ b/pkg/mcp/pulsar_functions_as_tools.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pulsar_resources.go b/pkg/mcp/pulsar_resources.go index 60d56f67..cab35dfc 100644 --- a/pkg/mcp/pulsar_resources.go +++ b/pkg/mcp/pulsar_resources.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/pulsar_resources_test.go b/pkg/mcp/pulsar_resources_test.go index fdd2a60d..4ccee156 100644 --- a/pkg/mcp/pulsar_resources_test.go +++ b/pkg/mcp/pulsar_resources_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/server.go b/pkg/mcp/server.go index c922883e..370a2b13 100644 --- a/pkg/mcp/server.go +++ b/pkg/mcp/server.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/server_test.go b/pkg/mcp/server_test.go index 913b447a..5ab83871 100644 --- a/pkg/mcp/server_test.go +++ b/pkg/mcp/server_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/session/context.go b/pkg/mcp/session/context.go index 30659d40..7284fbea 100644 --- a/pkg/mcp/session/context.go +++ b/pkg/mcp/session/context.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/session/pulsar_session_manager.go b/pkg/mcp/session/pulsar_session_manager.go index 8f5e79eb..df93b8e8 100644 --- a/pkg/mcp/session/pulsar_session_manager.go +++ b/pkg/mcp/session/pulsar_session_manager.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/session/pulsar_session_manager_test.go b/pkg/mcp/session/pulsar_session_manager_test.go index 83ded258..c8f8f1ea 100644 --- a/pkg/mcp/session/pulsar_session_manager_test.go +++ b/pkg/mcp/session/pulsar_session_manager_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/sncontext_tools_test.go b/pkg/mcp/sncontext_tools_test.go index 9ff05d62..f15cba03 100644 --- a/pkg/mcp/sncontext_tools_test.go +++ b/pkg/mcp/sncontext_tools_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/sncontext_utils.go b/pkg/mcp/sncontext_utils.go index 9ece5ef7..ab0e128a 100644 --- a/pkg/mcp/sncontext_utils.go +++ b/pkg/mcp/sncontext_utils.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/mcp/streamnative_cloud_primitives_test.go b/pkg/mcp/streamnative_cloud_primitives_test.go index 904c1ac1..27e658bc 100644 --- a/pkg/mcp/streamnative_cloud_primitives_test.go +++ b/pkg/mcp/streamnative_cloud_primitives_test.go @@ -1,3 +1,17 @@ +// Copyright 2026 StreamNative +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package mcp import ( diff --git a/pkg/pulsar/connection.go b/pkg/pulsar/connection.go index 55d53791..67a77a6e 100644 --- a/pkg/pulsar/connection.go +++ b/pkg/pulsar/connection.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/pulsar/connection_test.go b/pkg/pulsar/connection_test.go index d0a5a2ae..f665e541 100644 --- a/pkg/pulsar/connection_test.go +++ b/pkg/pulsar/connection_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/schema/avro.go b/pkg/schema/avro.go index d2126073..2d634844 100644 --- a/pkg/schema/avro.go +++ b/pkg/schema/avro.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/schema/avro_core.go b/pkg/schema/avro_core.go index 6d8da4d4..7a8ae652 100644 --- a/pkg/schema/avro_core.go +++ b/pkg/schema/avro_core.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/schema/avro_core_test.go b/pkg/schema/avro_core_test.go index 37054b3d..d1a47fc4 100644 --- a/pkg/schema/avro_core_test.go +++ b/pkg/schema/avro_core_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/schema/avro_test.go b/pkg/schema/avro_test.go index 5a125a87..fdc87d83 100644 --- a/pkg/schema/avro_test.go +++ b/pkg/schema/avro_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/schema/boolean.go b/pkg/schema/boolean.go index 76c15e38..939fe4f1 100644 --- a/pkg/schema/boolean.go +++ b/pkg/schema/boolean.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/schema/boolean_test.go b/pkg/schema/boolean_test.go index c01abe1e..5203be45 100644 --- a/pkg/schema/boolean_test.go +++ b/pkg/schema/boolean_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/schema/common.go b/pkg/schema/common.go index 569eae5d..acdbbec5 100644 --- a/pkg/schema/common.go +++ b/pkg/schema/common.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/schema/common_test.go b/pkg/schema/common_test.go index 06510467..6bf7fb61 100644 --- a/pkg/schema/common_test.go +++ b/pkg/schema/common_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/schema/converter.go b/pkg/schema/converter.go index 534aa088..24c7cfdc 100644 --- a/pkg/schema/converter.go +++ b/pkg/schema/converter.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/schema/converter_test.go b/pkg/schema/converter_test.go index f2a12144..e342a14d 100644 --- a/pkg/schema/converter_test.go +++ b/pkg/schema/converter_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/schema/json.go b/pkg/schema/json.go index 17bdd8c1..9d0d5916 100644 --- a/pkg/schema/json.go +++ b/pkg/schema/json.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/schema/json_test.go b/pkg/schema/json_test.go index 5768b788..9e7e7df8 100644 --- a/pkg/schema/json_test.go +++ b/pkg/schema/json_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/schema/number.go b/pkg/schema/number.go index f946d71e..fb6b1032 100644 --- a/pkg/schema/number.go +++ b/pkg/schema/number.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/schema/number_test.go b/pkg/schema/number_test.go index 03207848..976d310b 100644 --- a/pkg/schema/number_test.go +++ b/pkg/schema/number_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/schema/string.go b/pkg/schema/string.go index 2d45361d..1cb5f5c7 100644 --- a/pkg/schema/string.go +++ b/pkg/schema/string.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/schema/string_test.go b/pkg/schema/string_test.go index c0c9f1f9..0b749673 100644 --- a/pkg/schema/string_test.go +++ b/pkg/schema/string_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 StreamNative +// Copyright 2026 StreamNative // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/scripts/e2e-test.sh b/scripts/e2e-test.sh index af5b62ad..62603ac6 100755 --- a/scripts/e2e-test.sh +++ b/scripts/e2e-test.sh @@ -1,4 +1,18 @@ #!/usr/bin/env bash +# Copyright 2026 StreamNative +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" From a37e2d403042f30e40c6bba6eff72fd4e7383959 Mon Sep 17 00:00:00 2001 From: Rui Fu Date: Tue, 19 May 2026 17:16:34 +0800 Subject: [PATCH 04/10] fix: complete read write tool surface split --- cmd/snmcp-e2e/main.go | 116 +++++++++--------- docs/tools/pulsar_admin_brokers.md | 2 +- docs/tools/pulsar_admin_clusters.md | 2 +- docs/tools/pulsar_admin_functions.md | 2 +- docs/tools/pulsar_admin_namespaces.md | 2 +- docs/tools/pulsar_admin_nsisolationpolicy.md | 2 +- docs/tools/pulsar_admin_packages.md | 2 +- docs/tools/pulsar_admin_resource_quotas.md | 2 +- docs/tools/pulsar_admin_schemas.md | 2 +- docs/tools/pulsar_admin_sinks.md | 2 +- docs/tools/pulsar_admin_sources.md | 2 +- docs/tools/pulsar_admin_subscriptions.md | 2 +- docs/tools/pulsar_admin_tenants.md | 2 +- docs/tools/pulsar_admin_topic_policy.md | 2 +- docs/tools/pulsar_admin_topics.md | 2 +- .../kafka/annotation_compliance_test.go | 33 ++++- pkg/mcp/builders/kafka/connect.go | 5 + pkg/mcp/builders/kafka/groups.go | 3 + pkg/mcp/builders/kafka/partitions.go | 5 +- pkg/mcp/builders/kafka/schema_registry.go | 7 ++ pkg/mcp/builders/kafka/topics.go | 3 + .../pulsar/annotation_compliance_test.go | 71 ++++++++--- pkg/mcp/builders/pulsar/brokers.go | 18 ++- pkg/mcp/builders/pulsar/cluster.go | 4 +- pkg/mcp/builders/pulsar/functions.go | 5 +- pkg/mcp/builders/pulsar/namespace.go | 5 +- pkg/mcp/builders/pulsar/nsisolationpolicy.go | 16 ++- pkg/mcp/builders/pulsar/packages.go | 12 +- pkg/mcp/builders/pulsar/resourcequotas.go | 3 +- pkg/mcp/builders/pulsar/sinks.go | 5 +- pkg/mcp/builders/pulsar/sources.go | 5 +- pkg/mcp/builders/pulsar/tool_mode.go | 7 ++ pkg/mcp/builders/pulsar/topic.go | 5 + pkg/mcp/instructions.go | 2 +- pkg/mcp/streamnative_resources_log_tools.go | 2 +- 35 files changed, 239 insertions(+), 121 deletions(-) diff --git a/cmd/snmcp-e2e/main.go b/cmd/snmcp-e2e/main.go index 1de727a5..0c90e0ce 100644 --- a/cmd/snmcp-e2e/main.go +++ b/cmd/snmcp-e2e/main.go @@ -129,7 +129,7 @@ func run(ctx context.Context, cfg config) error { return err } if len(clusters) == 0 { - return errors.New("no clusters returned from pulsar_admin_cluster") + return errors.New("no clusters returned from pulsar_admin_cluster_read") } cluster := clusters[0] @@ -149,23 +149,23 @@ func run(ctx context.Context, cfg config) error { sourceName := fmt.Sprintf("e2e-source-%d", suffix) sourceParallelismUpdated := 2 - result, err := callTool(ctx, adminClient, "pulsar_admin_tenant", map[string]any{ + result, err := callTool(ctx, adminClient, "pulsar_admin_tenant_write", map[string]any{ "resource": "tenant", "operation": "create", "tenant": tenant, "adminRoles": []string{"admin"}, "allowedClusters": []string{cluster}, }) - if err := requireToolOK(result, err, "pulsar_admin_tenant create"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_tenant_write create"); err != nil { return err } - result, err = callTool(ctx, adminClient, "pulsar_admin_namespace", map[string]any{ + result, err = callTool(ctx, adminClient, "pulsar_admin_namespace_write", map[string]any{ "operation": "create", "namespace": namespace, "clusters": []string{cluster}, }) - if err := requireToolOK(result, err, "pulsar_admin_namespace create"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_namespace_write create"); err != nil { return err } @@ -179,13 +179,13 @@ func run(ctx context.Context, cfg config) error { return err } - result, err = callTool(ctx, adminClient, "pulsar_admin_topic", map[string]any{ + result, err = callTool(ctx, adminClient, "pulsar_admin_topic_write", map[string]any{ "resource": "topic", "operation": "create", "topic": topic, "partitions": float64(0), }) - if err := requireToolOK(result, err, "pulsar_admin_topic create"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_topic_write create"); err != nil { return err } @@ -220,23 +220,23 @@ func run(ctx context.Context, cfg config) error { return err } - result, err = callTool(ctx, adminClient, "pulsar_admin_topic", map[string]any{ + result, err = callTool(ctx, adminClient, "pulsar_admin_topic_write", map[string]any{ "resource": "topic", "operation": "create", "topic": functionInputTopic, "partitions": float64(0), }) - if err := requireToolOK(result, err, "pulsar_admin_topic create function input"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_topic_write create function input"); err != nil { return err } - result, err = callTool(ctx, adminClient, "pulsar_admin_topic", map[string]any{ + result, err = callTool(ctx, adminClient, "pulsar_admin_topic_write", map[string]any{ "resource": "topic", "operation": "create", "topic": functionOutputTopic, "partitions": float64(0), }) - if err := requireToolOK(result, err, "pulsar_admin_topic create function output"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_topic_write create function output"); err != nil { return err } @@ -249,7 +249,7 @@ func run(ctx context.Context, cfg config) error { "/server/e2e/functions/echo.py", "echo.EchoFunction", ) - result, err = callTool(ctx, adminClient, "pulsar_admin_functions", map[string]any{ + result, err = callTool(ctx, adminClient, "pulsar_admin_functions_write", map[string]any{ "operation": "create", "tenant": tenant, "namespace": namespaceName, @@ -259,7 +259,7 @@ func run(ctx context.Context, cfg config) error { "output": functionOutputTopic, "py": "/server/e2e/functions/echo.py", }) - if err := requireToolOK(result, err, "pulsar_admin_functions create"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_functions_write create"); err != nil { return err } @@ -267,19 +267,19 @@ func run(ctx context.Context, cfg config) error { return err } - result, err = callTool(ctx, adminClient, "pulsar_admin_functions", map[string]any{ + result, err = callTool(ctx, adminClient, "pulsar_admin_functions_read", map[string]any{ "operation": "stats", "tenant": tenant, "namespace": namespaceName, "name": functionName, "instanceId": float64(0), }) - if err := requireToolOK(result, err, "pulsar_admin_functions stats"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_functions_read stats"); err != nil { return err } triggerValue := "e2e-trigger" - result, err = callTool(ctx, adminClient, "pulsar_admin_functions", map[string]any{ + result, err = callTool(ctx, adminClient, "pulsar_admin_functions_write", map[string]any{ "operation": "trigger", "tenant": tenant, "namespace": namespaceName, @@ -287,7 +287,7 @@ func run(ctx context.Context, cfg config) error { "topic": functionInputTopic, "triggerValue": triggerValue, }) - if err := requireToolOK(result, err, "pulsar_admin_functions trigger"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_functions_write trigger"); err != nil { return err } triggerResult := firstText(result) @@ -298,47 +298,47 @@ func run(ctx context.Context, cfg config) error { } } - result, err = callTool(ctx, adminClient, "pulsar_admin_functions", map[string]any{ + result, err = callTool(ctx, adminClient, "pulsar_admin_functions_write", map[string]any{ "operation": "update", "tenant": tenant, "namespace": namespaceName, "name": functionName, "userConfig": map[string]any{"updated": true}, }) - if err := requireToolOK(result, err, "pulsar_admin_functions update"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_functions_write update"); err != nil { return err } - result, err = callTool(ctx, adminClient, "pulsar_admin_functions", map[string]any{ + result, err = callTool(ctx, adminClient, "pulsar_admin_functions_read", map[string]any{ "operation": "get", "tenant": tenant, "namespace": namespaceName, "name": functionName, }) - if err := requireToolOK(result, err, "pulsar_admin_functions get"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_functions_read get"); err != nil { return err } if err := assertFunctionUserConfig(firstText(result), "updated"); err != nil { return err } - result, err = callTool(ctx, adminClient, "pulsar_admin_functions", map[string]any{ + result, err = callTool(ctx, adminClient, "pulsar_admin_functions_write", map[string]any{ "operation": "delete", "tenant": tenant, "namespace": namespaceName, "name": functionName, }) - if err := requireToolOK(result, err, "pulsar_admin_functions delete"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_functions_write delete"); err != nil { return err } - result, err = callTool(ctx, adminClient, "pulsar_admin_topic", map[string]any{ + result, err = callTool(ctx, adminClient, "pulsar_admin_topic_write", map[string]any{ "resource": "topic", "operation": "create", "topic": sinkInputTopic, "partitions": float64(0), }) - if err := requireToolOK(result, err, "pulsar_admin_topic create sink input"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_topic_write create sink input"); err != nil { return err } @@ -351,7 +351,7 @@ func run(ctx context.Context, cfg config) error { return err } - result, err = callTool(ctx, adminClient, "pulsar_admin_sinks", map[string]any{ + result, err = callTool(ctx, adminClient, "pulsar_admin_sinks_write", map[string]any{ "operation": "create", "tenant": tenant, "namespace": namespaceName, @@ -359,7 +359,7 @@ func run(ctx context.Context, cfg config) error { "sink-type": sinkType, "inputs": []string{sinkInputTopic}, }) - if err := requireToolOK(result, err, "pulsar_admin_sinks create"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_sinks_write create"); err != nil { return err } @@ -367,7 +367,7 @@ func run(ctx context.Context, cfg config) error { return err } - result, err = callTool(ctx, adminClient, "pulsar_admin_sinks", map[string]any{ + result, err = callTool(ctx, adminClient, "pulsar_admin_sinks_write", map[string]any{ "operation": "update", "tenant": tenant, "namespace": namespaceName, @@ -375,40 +375,40 @@ func run(ctx context.Context, cfg config) error { "sink-type": sinkType, "parallelism": float64(sinkParallelismUpdated), }) - if err := requireToolOK(result, err, "pulsar_admin_sinks update"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_sinks_write update"); err != nil { return err } - result, err = callTool(ctx, adminClient, "pulsar_admin_sinks", map[string]any{ + result, err = callTool(ctx, adminClient, "pulsar_admin_sinks_read", map[string]any{ "operation": "get", "tenant": tenant, "namespace": namespaceName, "name": sinkName, }) - if err := requireToolOK(result, err, "pulsar_admin_sinks get"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_sinks_read get"); err != nil { return err } if err := assertSinkParallelism(firstText(result), sinkParallelismUpdated); err != nil { return err } - result, err = callTool(ctx, adminClient, "pulsar_admin_sinks", map[string]any{ + result, err = callTool(ctx, adminClient, "pulsar_admin_sinks_write", map[string]any{ "operation": "delete", "tenant": tenant, "namespace": namespaceName, "name": sinkName, }) - if err := requireToolOK(result, err, "pulsar_admin_sinks delete"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_sinks_write delete"); err != nil { return err } - result, err = callTool(ctx, adminClient, "pulsar_admin_topic", map[string]any{ + result, err = callTool(ctx, adminClient, "pulsar_admin_topic_write", map[string]any{ "resource": "topic", "operation": "create", "topic": sourceOutputTopic, "partitions": float64(0), }) - if err := requireToolOK(result, err, "pulsar_admin_topic create source output"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_topic_write create source output"); err != nil { return err } @@ -421,7 +421,7 @@ func run(ctx context.Context, cfg config) error { return err } - result, err = callTool(ctx, adminClient, "pulsar_admin_sources", map[string]any{ + result, err = callTool(ctx, adminClient, "pulsar_admin_sources_write", map[string]any{ "operation": "create", "tenant": tenant, "namespace": namespaceName, @@ -432,7 +432,7 @@ func run(ctx context.Context, cfg config) error { "sleepBetweenMessages": "60000", }, }) - if err := requireToolOK(result, err, "pulsar_admin_sources create"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_sources_write create"); err != nil { return err } @@ -440,7 +440,7 @@ func run(ctx context.Context, cfg config) error { return err } - result, err = callTool(ctx, adminClient, "pulsar_admin_sources", map[string]any{ + result, err = callTool(ctx, adminClient, "pulsar_admin_sources_write", map[string]any{ "operation": "update", "tenant": tenant, "namespace": namespaceName, @@ -448,40 +448,40 @@ func run(ctx context.Context, cfg config) error { "source-type": sourceType, "parallelism": float64(sourceParallelismUpdated), }) - if err := requireToolOK(result, err, "pulsar_admin_sources update"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_sources_write update"); err != nil { return err } - result, err = callTool(ctx, adminClient, "pulsar_admin_sources", map[string]any{ + result, err = callTool(ctx, adminClient, "pulsar_admin_sources_read", map[string]any{ "operation": "get", "tenant": tenant, "namespace": namespaceName, "name": sourceName, }) - if err := requireToolOK(result, err, "pulsar_admin_sources get"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_sources_read get"); err != nil { return err } if err := assertSourceParallelism(firstText(result), sourceParallelismUpdated); err != nil { return err } - result, err = callTool(ctx, adminClient, "pulsar_admin_sources", map[string]any{ + result, err = callTool(ctx, adminClient, "pulsar_admin_sources_write", map[string]any{ "operation": "delete", "tenant": tenant, "namespace": namespaceName, "name": sourceName, }) - if err := requireToolOK(result, err, "pulsar_admin_sources delete"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_sources_write delete"); err != nil { return err } - result, err = callTool(ctx, adminClient, "pulsar_admin_topic", map[string]any{ + result, err = callTool(ctx, adminClient, "pulsar_admin_topic_write", map[string]any{ "resource": "topic", "operation": "create", "topic": concurrentTopic, "partitions": float64(0), }) - if err := requireToolOK(result, err, "pulsar_admin_topic create concurrent"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_topic_write create concurrent"); err != nil { return err } @@ -565,7 +565,7 @@ func expectUnauthorized(ctx context.Context, sseURL, token string, verbose bool) return fmt.Errorf("expected auth error during initialize for %s, got %v", sseURL, err) } - result, err := callTool(ctx, c, "pulsar_admin_cluster", map[string]any{ + result, err := callTool(ctx, c, "pulsar_admin_cluster_read", map[string]any{ "resource": "cluster", "operation": "list", }) @@ -710,11 +710,11 @@ func isAuthText(text string) bool { } func listClusters(ctx context.Context, c *client.Client) ([]string, error) { - result, err := callTool(ctx, c, "pulsar_admin_cluster", map[string]any{ + result, err := callTool(ctx, c, "pulsar_admin_cluster_read", map[string]any{ "resource": "cluster", "operation": "list", }) - if err := requireToolOK(result, err, "pulsar_admin_cluster list"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_cluster_read list"); err != nil { return nil, err } raw := firstText(result) @@ -749,13 +749,13 @@ func waitForFunctionRunning(ctx context.Context, c *client.Client, tenant, names } func getFunctionStatus(ctx context.Context, c *client.Client, tenant, namespace, name string) (functionStatus, error) { - result, err := callTool(ctx, c, "pulsar_admin_functions", map[string]any{ + result, err := callTool(ctx, c, "pulsar_admin_functions_read", map[string]any{ "operation": "status", "tenant": tenant, "namespace": namespace, "name": name, }) - if err := requireToolOK(result, err, "pulsar_admin_functions status"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_functions_read status"); err != nil { return functionStatus{}, err } raw := firstText(result) @@ -792,10 +792,10 @@ type connectorDefinition struct { } func listBuiltInSinks(ctx context.Context, c *client.Client) ([]connectorDefinition, error) { - result, err := callTool(ctx, c, "pulsar_admin_sinks", map[string]any{ + result, err := callTool(ctx, c, "pulsar_admin_sinks_read", map[string]any{ "operation": "list-built-in", }) - if err := requireToolOK(result, err, "pulsar_admin_sinks list-built-in"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_sinks_read list-built-in"); err != nil { return nil, err } raw := firstText(result) @@ -828,10 +828,10 @@ func selectSinkType(definitions []connectorDefinition, preferred []string) (stri } func listBuiltInSources(ctx context.Context, c *client.Client) ([]connectorDefinition, error) { - result, err := callTool(ctx, c, "pulsar_admin_sources", map[string]any{ + result, err := callTool(ctx, c, "pulsar_admin_sources_read", map[string]any{ "operation": "list-built-in", }) - if err := requireToolOK(result, err, "pulsar_admin_sources list-built-in"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_sources_read list-built-in"); err != nil { return nil, err } raw := firstText(result) @@ -880,13 +880,13 @@ type sinkInstanceStatusData struct { } func getSinkStatus(ctx context.Context, c *client.Client, tenant, namespace, name string) (sinkStatus, error) { - result, err := callTool(ctx, c, "pulsar_admin_sinks", map[string]any{ + result, err := callTool(ctx, c, "pulsar_admin_sinks_read", map[string]any{ "operation": "status", "tenant": tenant, "namespace": namespace, "name": name, }) - if err := requireToolOK(result, err, "pulsar_admin_sinks status"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_sinks_read status"); err != nil { return sinkStatus{}, err } raw := firstText(result) @@ -933,13 +933,13 @@ func waitForSourceRunning(ctx context.Context, c *client.Client, tenant, namespa } func getSourceStatus(ctx context.Context, c *client.Client, tenant, namespace, name string) (sourceStatus, error) { - result, err := callTool(ctx, c, "pulsar_admin_sources", map[string]any{ + result, err := callTool(ctx, c, "pulsar_admin_sources_read", map[string]any{ "operation": "status", "tenant": tenant, "namespace": namespace, "name": name, }) - if err := requireToolOK(result, err, "pulsar_admin_sources status"); err != nil { + if err := requireToolOK(result, err, "pulsar_admin_sources_read status"); err != nil { return sourceStatus{}, err } raw := firstText(result) diff --git a/docs/tools/pulsar_admin_brokers.md b/docs/tools/pulsar_admin_brokers.md index c133f016..cb9cd5e6 100644 --- a/docs/tools/pulsar_admin_brokers.md +++ b/docs/tools/pulsar_admin_brokers.md @@ -1,4 +1,4 @@ -#### pulsar_admin_brokers +#### pulsar_admin_brokers_read / pulsar_admin_brokers_write **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_brokers_read` and `pulsar_admin_brokers_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. diff --git a/docs/tools/pulsar_admin_clusters.md b/docs/tools/pulsar_admin_clusters.md index 8cbe6dd4..f0fc9132 100644 --- a/docs/tools/pulsar_admin_clusters.md +++ b/docs/tools/pulsar_admin_clusters.md @@ -1,4 +1,4 @@ -#### pulsar_admin_cluster +#### pulsar_admin_cluster_read / pulsar_admin_cluster_write **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_cluster_read` and `pulsar_admin_cluster_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. diff --git a/docs/tools/pulsar_admin_functions.md b/docs/tools/pulsar_admin_functions.md index d07e4d8b..33b6f50a 100644 --- a/docs/tools/pulsar_admin_functions.md +++ b/docs/tools/pulsar_admin_functions.md @@ -1,4 +1,4 @@ -#### pulsar_admin_functions +#### pulsar_admin_functions_read / pulsar_admin_functions_write **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_functions_read` and `pulsar_admin_functions_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. diff --git a/docs/tools/pulsar_admin_namespaces.md b/docs/tools/pulsar_admin_namespaces.md index f4858de0..d7377c02 100644 --- a/docs/tools/pulsar_admin_namespaces.md +++ b/docs/tools/pulsar_admin_namespaces.md @@ -1,4 +1,4 @@ -#### pulsar_admin_namespace +#### pulsar_admin_namespace_read / pulsar_admin_namespace_write **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_namespace_read` and `pulsar_admin_namespace_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. diff --git a/docs/tools/pulsar_admin_nsisolationpolicy.md b/docs/tools/pulsar_admin_nsisolationpolicy.md index b0c8e48c..ee6a9941 100644 --- a/docs/tools/pulsar_admin_nsisolationpolicy.md +++ b/docs/tools/pulsar_admin_nsisolationpolicy.md @@ -1,4 +1,4 @@ -#### pulsar_admin_nsisolationpolicy +#### pulsar_admin_nsisolationpolicy_read / pulsar_admin_nsisolationpolicy_write **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_nsisolationpolicy_read` and `pulsar_admin_nsisolationpolicy_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. diff --git a/docs/tools/pulsar_admin_packages.md b/docs/tools/pulsar_admin_packages.md index 6c2f3507..c1a29b54 100644 --- a/docs/tools/pulsar_admin_packages.md +++ b/docs/tools/pulsar_admin_packages.md @@ -1,4 +1,4 @@ -#### pulsar_admin_package +#### pulsar_admin_package_read / pulsar_admin_package_write **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_package_read` and `pulsar_admin_package_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. diff --git a/docs/tools/pulsar_admin_resource_quotas.md b/docs/tools/pulsar_admin_resource_quotas.md index 2168cc71..d616227e 100644 --- a/docs/tools/pulsar_admin_resource_quotas.md +++ b/docs/tools/pulsar_admin_resource_quotas.md @@ -1,4 +1,4 @@ -#### pulsar_admin_resourcequota +#### pulsar_admin_resourcequota_read / pulsar_admin_resourcequota_write **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_resourcequota_read` and `pulsar_admin_resourcequota_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. diff --git a/docs/tools/pulsar_admin_schemas.md b/docs/tools/pulsar_admin_schemas.md index a6696d54..da700880 100644 --- a/docs/tools/pulsar_admin_schemas.md +++ b/docs/tools/pulsar_admin_schemas.md @@ -1,4 +1,4 @@ -#### pulsar_admin_schema +#### pulsar_admin_schema_read / pulsar_admin_schema_write **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_schema_read` and `pulsar_admin_schema_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. diff --git a/docs/tools/pulsar_admin_sinks.md b/docs/tools/pulsar_admin_sinks.md index 9ed6ec88..cdb77e43 100644 --- a/docs/tools/pulsar_admin_sinks.md +++ b/docs/tools/pulsar_admin_sinks.md @@ -1,4 +1,4 @@ -#### pulsar_admin_sinks +#### pulsar_admin_sinks_read / pulsar_admin_sinks_write **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_sinks_read` and `pulsar_admin_sinks_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. diff --git a/docs/tools/pulsar_admin_sources.md b/docs/tools/pulsar_admin_sources.md index e1365291..27d8aa28 100644 --- a/docs/tools/pulsar_admin_sources.md +++ b/docs/tools/pulsar_admin_sources.md @@ -1,4 +1,4 @@ -#### pulsar_admin_sources +#### pulsar_admin_sources_read / pulsar_admin_sources_write **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_sources_read` and `pulsar_admin_sources_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. diff --git a/docs/tools/pulsar_admin_subscriptions.md b/docs/tools/pulsar_admin_subscriptions.md index 6e67e797..65eef8eb 100644 --- a/docs/tools/pulsar_admin_subscriptions.md +++ b/docs/tools/pulsar_admin_subscriptions.md @@ -1,4 +1,4 @@ -#### pulsar_admin_subscription +#### pulsar_admin_subscription_read / pulsar_admin_subscription_write **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_subscription_read` and `pulsar_admin_subscription_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. diff --git a/docs/tools/pulsar_admin_tenants.md b/docs/tools/pulsar_admin_tenants.md index 1268f794..6cdff348 100644 --- a/docs/tools/pulsar_admin_tenants.md +++ b/docs/tools/pulsar_admin_tenants.md @@ -1,4 +1,4 @@ -#### pulsar_admin_tenant +#### pulsar_admin_tenant_read / pulsar_admin_tenant_write **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_tenant_read` and `pulsar_admin_tenant_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. diff --git a/docs/tools/pulsar_admin_topic_policy.md b/docs/tools/pulsar_admin_topic_policy.md index f77abe2a..3afd47c8 100644 --- a/docs/tools/pulsar_admin_topic_policy.md +++ b/docs/tools/pulsar_admin_topic_policy.md @@ -1,4 +1,4 @@ -#### pulsar_admin_topic_policy +#### pulsar_admin_topic_policy_read / pulsar_admin_topic_policy_write **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_topic_policy_read` and `pulsar_admin_topic_policy_write`. The read tool is read-only and only exposes get operations/parameters. The write tool is destructive and is not registered in read-only mode. diff --git a/docs/tools/pulsar_admin_topics.md b/docs/tools/pulsar_admin_topics.md index 682c892f..c02e6913 100644 --- a/docs/tools/pulsar_admin_topics.md +++ b/docs/tools/pulsar_admin_topics.md @@ -1,4 +1,4 @@ -#### pulsar_admin_topic +#### pulsar_admin_topic_read / pulsar_admin_topic_write **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_topic_read` and `pulsar_admin_topic_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. diff --git a/pkg/mcp/builders/kafka/annotation_compliance_test.go b/pkg/mcp/builders/kafka/annotation_compliance_test.go index cdeaaa7b..e1b4d420 100644 --- a/pkg/mcp/builders/kafka/annotation_compliance_test.go +++ b/pkg/mcp/builders/kafka/annotation_compliance_test.go @@ -72,11 +72,7 @@ func toolPropertyNames(tool mcp.Tool) []string { func assertOperationEnumMode(t *testing.T, toolName string, operationSchema any) { t.Helper() - schema, ok := operationSchema.(map[string]any) - if !ok { - return - } - rawEnum, ok := schema["enum"].([]string) + rawEnum, ok := stringEnum(operationSchema) if !ok { return } @@ -95,6 +91,22 @@ func assertOperationEnumMode(t *testing.T, toolName string, operationSchema any) require.False(t, seenRead && seenWrite, toolName) } +func requireStringEnum(t *testing.T, toolName string, propertySchema any, expected []string) { + t.Helper() + actual, ok := stringEnum(propertySchema) + require.True(t, ok, "%s resource enum must be explicit", toolName) + require.ElementsMatch(t, expected, actual, toolName) +} + +func stringEnum(propertySchema any) ([]string, bool) { + schema, ok := propertySchema.(map[string]any) + if !ok { + return nil, false + } + rawEnum, ok := schema["enum"].([]string) + return rawEnum, ok +} + func TestKafkaSplitToolsExposeModeSpecificParameters(t *testing.T) { builderList := []builders.ToolBuilder{ NewKafkaTopicsToolBuilder(), @@ -113,6 +125,16 @@ func TestKafkaSplitToolsExposeModeSpecificParameters(t *testing.T) { "kafka_admin_connect_read": {"resource", "operation", "name"}, "kafka_admin_connect_write": {"resource", "operation", "name", "config"}, } + expectedResourceEnums := map[string][]string{ + "kafka_admin_topics_read": {"topic", "topics"}, + "kafka_admin_topics_write": {"topic"}, + "kafka_admin_groups_read": {"group", "groups"}, + "kafka_admin_groups_write": {"group"}, + "kafka_admin_sr_read": {"subjects", "subject", "versions", "version", "compatibility", "types"}, + "kafka_admin_sr_write": {"subject", "version", "compatibility"}, + "kafka_admin_connect_read": {"kafka-connect-cluster", "connector", "connectors", "connector-plugins"}, + "kafka_admin_connect_write": {"connector"}, + } for _, builder := range builderList { tools, err := builder.BuildTools(context.Background(), builders.ToolBuildConfig{ @@ -122,6 +144,7 @@ func TestKafkaSplitToolsExposeModeSpecificParameters(t *testing.T) { for _, serverTool := range tools { tool := serverTool.Tool require.ElementsMatch(t, expectedProperties[tool.Name], toolPropertyNames(tool), tool.Name) + requireStringEnum(t, tool.Name, tool.InputSchema.Properties["resource"], expectedResourceEnums[tool.Name]) } } } diff --git a/pkg/mcp/builders/kafka/connect.go b/pkg/mcp/builders/kafka/connect.go index 550cddc1..d4e78268 100644 --- a/pkg/mcp/builders/kafka/connect.go +++ b/pkg/mcp/builders/kafka/connect.go @@ -105,6 +105,7 @@ func (b *KafkaConnectToolBuilder) buildKafkaConnectTool(mode toolMode) mcp.Tool "- connector: A single Kafka Connect connector instance that moves data between Kafka and external systems.\n" + "- connectors: Collection of all Kafka Connect connectors in a cluster.\n" + "- connector-plugins: Collection of all Kafka Connect connector plugins, StreamNative Cloud provides a set of built-in connectors via this resource." + resourceEnum := []string{"kafka-connect-cluster", "connector", "connectors", "connector-plugins"} operationDesc := "Operation to perform. Available operations:\n" + "- list: List all connectors or connector plugins in a cluster.\n" + @@ -113,6 +114,9 @@ func (b *KafkaConnectToolBuilder) buildKafkaConnectTool(mode toolMode) mcp.Tool toolName := "kafka_admin_connect_read" annotation := toolannotations.ReadOnly("Read Kafka Connect") if isToolModeWrite(mode) { + resourceDesc = "Resource to operate on. Available resources:\n" + + "- connector: A single Kafka Connect connector instance that moves data between Kafka and external systems." + resourceEnum = []string{"connector"} operationDesc = "Operation to perform. Available operations:\n" + "- create: Create a new connector with specified configuration.\n" + "- update: Modify an existing connector's configuration.\n" + @@ -168,6 +172,7 @@ func (b *KafkaConnectToolBuilder) buildKafkaConnectTool(mode toolMode) mcp.Tool mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), + mcp.Enum(resourceEnum...), ), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), diff --git a/pkg/mcp/builders/kafka/groups.go b/pkg/mcp/builders/kafka/groups.go index f6927f29..a5c2da83 100644 --- a/pkg/mcp/builders/kafka/groups.go +++ b/pkg/mcp/builders/kafka/groups.go @@ -94,6 +94,7 @@ func (b *KafkaGroupsToolBuilder) buildKafkaGroupsTool(mode toolMode) mcp.Tool { resourceDesc := "Resource to operate on. Available resources:\n" + "- group: A single Kafka Consumer Group for read operations (describe, offsets)\n" + "- groups: Collection of Kafka Consumer Groups for list operations" + resourceEnum := []string{"group", "groups"} operationDesc := "Operation to perform. Available operations:\n" + "- list: List all Kafka Consumer Groups in the cluster\n" + @@ -110,6 +111,7 @@ func (b *KafkaGroupsToolBuilder) buildKafkaGroupsTool(mode toolMode) mcp.Tool { operationEnum = []string{"remove-members", "delete-offset", "set-offset"} resourceDesc = "Resource to operate on. Available resources:\n" + "- group: A single Kafka Consumer Group for membership and offset changes" + resourceEnum = []string{"group"} toolName = "kafka_admin_groups_write" annotation = toolannotations.Destructive("Manage Kafka Consumer Groups") } @@ -157,6 +159,7 @@ func (b *KafkaGroupsToolBuilder) buildKafkaGroupsTool(mode toolMode) mcp.Tool { mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), + mcp.Enum(resourceEnum...), ), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), diff --git a/pkg/mcp/builders/kafka/partitions.go b/pkg/mcp/builders/kafka/partitions.go index b4ff90db..f34d26b7 100644 --- a/pkg/mcp/builders/kafka/partitions.go +++ b/pkg/mcp/builders/kafka/partitions.go @@ -91,8 +91,8 @@ func (b *KafkaPartitionsToolBuilder) buildKafkaPartitionsTool() mcp.Tool { operationDesc := "Operation to perform. Available operations:\n" + "- update: Update the number of partitions for an existing Kafka topic. This operation can only increase the number of partitions, not decrease them." - toolDesc := "Unified tool for managing Apache Kafka partitions.\n" + - "This tool provides access to Kafka partition operations, particularly adding partitions to existing topics.\n" + + toolDesc := "Manage Apache Kafka partitions.\n" + + "This write tool adds partitions to existing topics and may change producer key-to-partition mapping.\n" + "Kafka partitions are the fundamental unit of parallelism and scalability in Kafka. Each partition is an ordered, " + "immutable sequence of records that is continually appended to. Partitions can be distributed across multiple brokers " + "to enable parallel processing of a topic.\n\n" + @@ -118,6 +118,7 @@ func (b *KafkaPartitionsToolBuilder) buildKafkaPartitionsTool() mcp.Tool { mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), + mcp.Enum("partition"), ), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), diff --git a/pkg/mcp/builders/kafka/schema_registry.go b/pkg/mcp/builders/kafka/schema_registry.go index 1936da8e..7257ceea 100644 --- a/pkg/mcp/builders/kafka/schema_registry.go +++ b/pkg/mcp/builders/kafka/schema_registry.go @@ -100,6 +100,7 @@ func (b *KafkaSchemaRegistryToolBuilder) buildKafkaSchemaRegistryTool(mode toolM "- version: A specific version of a subject's schema\n" + "- compatibility: Compatibility levels that control schema evolution rules\n" + "- types: Supported schema format types (like AVRO, JSON, PROTOBUF)" + resourceEnum := []string{"subjects", "subject", "versions", "version", "compatibility", "types"} operationDesc := "Operation to perform. Available operations:\n" + "- list: List all subjects, versions for a subject, or supported schema types\n" + @@ -108,6 +109,11 @@ func (b *KafkaSchemaRegistryToolBuilder) buildKafkaSchemaRegistryTool(mode toolM toolName := "kafka_admin_sr_read" annotation := toolannotations.ReadOnly("Read Kafka Schema Registry") if isToolModeWrite(mode) { + resourceDesc = "Resource to operate on. Available resources:\n" + + "- subject: A specific schema subject to register or delete\n" + + "- version: A specific version of a subject's schema to delete\n" + + "- compatibility: Compatibility levels that control schema evolution rules" + resourceEnum = []string{"subject", "version", "compatibility"} operationDesc = "Operation to perform. Available operations:\n" + "- set: Set compatibility level for global or subject-specific schema evolution\n" + "- create: Register a new schema for a subject\n" + @@ -155,6 +161,7 @@ func (b *KafkaSchemaRegistryToolBuilder) buildKafkaSchemaRegistryTool(mode toolM mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), + mcp.Enum(resourceEnum...), ), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), diff --git a/pkg/mcp/builders/kafka/topics.go b/pkg/mcp/builders/kafka/topics.go index b4b4021d..493f488a 100644 --- a/pkg/mcp/builders/kafka/topics.go +++ b/pkg/mcp/builders/kafka/topics.go @@ -93,6 +93,7 @@ func (b *KafkaTopicsToolBuilder) buildKafkaTopicsTool(mode toolMode) mcp.Tool { resourceDesc := "Resource to operate on. Available resources:\n" + "- topic: A single Kafka topic for read operations (get, metadata)\n" + "- topics: Collection of Kafka topics for list operations" + resourceEnum := []string{"topic", "topics"} operationDesc := "Operation to perform. Available operations:\n" + "- list: List all topics in the Kafka cluster, optionally including internal topics\n" + @@ -108,6 +109,7 @@ func (b *KafkaTopicsToolBuilder) buildKafkaTopicsTool(mode toolMode) mcp.Tool { operationEnum = []string{"create", "delete"} resourceDesc = "Resource to operate on. Available resources:\n" + "- topic: A single Kafka topic for create or delete operations" + resourceEnum = []string{"topic"} toolName = "kafka_admin_topics_write" annotation = toolannotations.Destructive("Manage Kafka Topics") } @@ -148,6 +150,7 @@ func (b *KafkaTopicsToolBuilder) buildKafkaTopicsTool(mode toolMode) mcp.Tool { mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), + mcp.Enum(resourceEnum...), ), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), diff --git a/pkg/mcp/builders/pulsar/annotation_compliance_test.go b/pkg/mcp/builders/pulsar/annotation_compliance_test.go index 7f855447..e7556adb 100644 --- a/pkg/mcp/builders/pulsar/annotation_compliance_test.go +++ b/pkg/mcp/builders/pulsar/annotation_compliance_test.go @@ -56,11 +56,7 @@ func TestPulsarToolAnnotationCompliance(t *testing.T) { func assertOperationEnumMode(t *testing.T, toolName string, operationSchema any) { t.Helper() - schema, ok := operationSchema.(map[string]any) - if !ok { - return - } - rawEnum, ok := schema["enum"].([]string) + rawEnum, ok := pulsarStringEnum(operationSchema) if !ok { return } @@ -76,6 +72,22 @@ func assertOperationEnumMode(t *testing.T, toolName string, operationSchema any) require.False(t, seenRead && seenWrite, toolName) } +func requirePulsarStringEnum(t *testing.T, toolName string, propertySchema any, expected []string) { + t.Helper() + actual, ok := pulsarStringEnum(propertySchema) + require.True(t, ok, "%s resource enum must be explicit", toolName) + require.ElementsMatch(t, expected, actual, toolName) +} + +func pulsarStringEnum(propertySchema any) ([]string, bool) { + schema, ok := propertySchema.(map[string]any) + if !ok { + return nil, false + } + rawEnum, ok := schema["enum"].([]string) + return rawEnum, ok +} + func pulsarComplianceWriteOperations() map[string]struct{} { writeOperations := map[string]struct{}{ "create": {}, @@ -108,21 +120,37 @@ func pulsarComplianceWriteOperations() map[string]struct{} { func TestPulsarSplitToolsExposeModeSpecificParameters(t *testing.T) { expectedProperties := map[string][]string{ - "pulsar_admin_brokers_read": {"resource", "operation", "clusterName", "brokerUrl", "configType"}, - "pulsar_admin_brokers_write": {"resource", "operation", "configName", "configValue"}, - "pulsar_admin_cluster_read": {"resource", "operation", "cluster_name", "domain_name"}, - "pulsar_admin_functions_read": {"operation", "fqfn", "tenant", "namespace", "name", "instanceId", "key", "path", "destinationFile"}, - "pulsar_admin_namespace_read": {"operation", "tenant", "namespace"}, - "pulsar_admin_nsisolationpolicy_read": {"resource", "operation", "cluster", "name"}, - "pulsar_admin_package_read": {"resource", "operation", "packageName", "namespace", "type", "path"}, - "pulsar_admin_resourcequota_read": {"resource", "operation", "namespace", "bundle"}, - "pulsar_admin_schema_read": {"resource", "operation", "topic", "version"}, - "pulsar_admin_sinks_read": {"operation", "tenant", "namespace", "name"}, - "pulsar_admin_sources_read": {"operation", "tenant", "namespace", "name"}, - "pulsar_admin_subscription_read": {"resource", "operation", "topic", "subscription", "ledgerId", "entryId", "count"}, - "pulsar_admin_tenant_read": {"resource", "operation", "tenant"}, - "pulsar_admin_topic_policy_read": {"operation", "topic", "applied", "type"}, - "pulsar_admin_topic_read": {"resource", "operation", "topic", "namespace", "partitioned", "per-partition", "wait"}, + "pulsar_admin_brokers_read": {"resource", "operation", "clusterName", "brokerUrl", "configType"}, + "pulsar_admin_brokers_write": {"resource", "operation", "configName", "configValue"}, + "pulsar_admin_cluster_read": {"resource", "operation", "cluster_name", "domain_name"}, + "pulsar_admin_cluster_write": {"resource", "operation", "cluster_name", "domain_name", "service_url", "service_url_tls", "broker_service_url", "broker_service_url_tls", "peer_cluster_names", "brokers"}, + "pulsar_admin_functions_read": {"operation", "fqfn", "tenant", "namespace", "name", "instanceId", "key", "path", "destinationFile"}, + "pulsar_admin_namespace_read": {"operation", "tenant", "namespace"}, + "pulsar_admin_nsisolationpolicy_read": {"resource", "operation", "cluster", "name"}, + "pulsar_admin_nsisolationpolicy_write": {"resource", "operation", "cluster", "name", "namespaces", "primary", "secondary", "autoFailoverPolicyType", "autoFailoverPolicyParams"}, + "pulsar_admin_package_read": {"resource", "operation", "packageName", "namespace", "type", "path"}, + "pulsar_admin_package_write": {"resource", "operation", "packageName", "description", "contact", "path", "properties"}, + "pulsar_admin_resourcequota_read": {"resource", "operation", "namespace", "bundle"}, + "pulsar_admin_schema_read": {"resource", "operation", "topic", "version"}, + "pulsar_admin_sinks_read": {"operation", "tenant", "namespace", "name"}, + "pulsar_admin_sources_read": {"operation", "tenant", "namespace", "name"}, + "pulsar_admin_subscription_read": {"resource", "operation", "topic", "subscription", "ledgerId", "entryId", "count"}, + "pulsar_admin_tenant_read": {"resource", "operation", "tenant"}, + "pulsar_admin_topic_policy_read": {"operation", "topic", "applied", "type"}, + "pulsar_admin_topic_read": {"resource", "operation", "topic", "namespace", "partitioned", "per-partition", "wait"}, + "pulsar_admin_topic_write": {"resource", "operation", "topic", "partitions", "force", "non-partitioned", "config", "messageId", "role", "actions"}, + } + expectedResourceEnums := map[string][]string{ + "pulsar_admin_brokers_read": {"brokers", "health", "config", "namespaces"}, + "pulsar_admin_brokers_write": {"config"}, + "pulsar_admin_cluster_read": {"cluster", "peer_clusters", "failure_domain"}, + "pulsar_admin_cluster_write": {"cluster", "peer_clusters", "failure_domain"}, + "pulsar_admin_nsisolationpolicy_read": {"policy", "broker", "brokers"}, + "pulsar_admin_nsisolationpolicy_write": {"policy"}, + "pulsar_admin_package_read": {"package", "packages"}, + "pulsar_admin_package_write": {"package"}, + "pulsar_admin_topic_read": {"topic", "topics"}, + "pulsar_admin_topic_write": {"topic"}, } for _, builder := range allPulsarComplianceBuilders() { @@ -137,6 +165,9 @@ func TestPulsarSplitToolsExposeModeSpecificParameters(t *testing.T) { continue } require.ElementsMatch(t, expected, pulsarToolPropertyNames(tool), tool.Name) + if expectedEnum, ok := expectedResourceEnums[tool.Name]; ok { + requirePulsarStringEnum(t, tool.Name, tool.InputSchema.Properties["resource"], expectedEnum) + } } } } diff --git a/pkg/mcp/builders/pulsar/brokers.go b/pkg/mcp/builders/pulsar/brokers.go index ba31a985..a4b5cdde 100644 --- a/pkg/mcp/builders/pulsar/brokers.go +++ b/pkg/mcp/builders/pulsar/brokers.go @@ -91,6 +91,12 @@ func (b *PulsarAdminBrokersToolBuilder) BuildTools(_ context.Context, config bui // buildPulsarAdminBrokersTool builds the Pulsar admin brokers MCP tool definition func (b *PulsarAdminBrokersToolBuilder) buildPulsarAdminBrokersTool(mode toolMode) mcp.Tool { + resourceDesc := "Type of broker resource to access, available options:\n" + + "- brokers: Broker listings\n" + + "- health: Broker health status\n" + + "- config: Broker configurations\n" + + "- namespaces: Namespaces owned by a broker" + resourceEnum := []string{"brokers", "health", "config", "namespaces"} operationEnum := []string{"list", "get"} operationDesc := "Operation to perform, available options:\n" + "- list: List resources (used with brokers)\n" + @@ -99,6 +105,9 @@ func (b *PulsarAdminBrokersToolBuilder) buildPulsarAdminBrokersTool(mode toolMod toolName := "pulsar_admin_brokers_read" annotation := toolannotations.ReadOnly("Read Pulsar Brokers") if isToolModeWrite(mode) { + resourceDesc = "Type of broker resource to access, available options:\n" + + "- config: Broker dynamic configuration values" + resourceEnum = []string{"config"} operationEnum = []string{"update", "delete"} operationDesc = "Operation to perform, available options:\n" + "- update: Update a broker dynamic configuration value\n" + @@ -111,11 +120,8 @@ func (b *PulsarAdminBrokersToolBuilder) buildPulsarAdminBrokersTool(mode toolMod tool := mcp.NewTool(toolName, mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), - mcp.Description("Type of resource to access, available options:\n"+ - "- brokers: Broker listings\n"+ - "- health: Broker health status\n"+ - "- config: Broker configurations\n"+ - "- namespaces: Namespaces owned by a broker"), + mcp.Description(resourceDesc), + mcp.Enum(resourceEnum...), ), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), @@ -181,7 +187,7 @@ func (b *PulsarAdminBrokersToolBuilder) buildPulsarAdminBrokersHandler(mode tool operation, err := request.RequireString("operation") if err != nil { return mcp.NewToolResultError("Missing required operation parameter. " + - "Please specify one of: list, get, update, delete based on the resource type."), nil + "Please specify one of: " + modeSupportedOperations(mode, []string{"list", "get"}, []string{"update", "delete"}) + " based on the resource type."), nil } // Validate if the parameter combination is valid diff --git a/pkg/mcp/builders/pulsar/cluster.go b/pkg/mcp/builders/pulsar/cluster.go index 1f48d888..44070a25 100644 --- a/pkg/mcp/builders/pulsar/cluster.go +++ b/pkg/mcp/builders/pulsar/cluster.go @@ -107,6 +107,7 @@ func (b *PulsarAdminClusterToolBuilder) buildClusterTool(mode toolMode) mcp.Tool "- cluster: Pulsar cluster configuration\n" + "- peer_clusters: Peer clusters for geo-replication\n" + "- failure_domain: Failure domains for fault tolerance" + resourceEnum := []string{"cluster", "peer_clusters", "failure_domain"} operationDesc := "Operation to perform, available options (depend on resource):\n" + "- list: List resources (used with cluster, failure_domain)\n" + @@ -131,6 +132,7 @@ func (b *PulsarAdminClusterToolBuilder) buildClusterTool(mode toolMode) mcp.Tool mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), + mcp.Enum(resourceEnum...), ), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), @@ -209,7 +211,7 @@ func (b *PulsarAdminClusterToolBuilder) buildClusterHandler(mode toolMode) func( operation, err := request.RequireString("operation") if err != nil { return mcp.NewToolResultError("Missing required operation parameter. " + - "Please specify one of: list, get, create, update, delete based on the resource type."), nil + "Please specify one of: " + modeSupportedOperations(mode, []string{"list", "get"}, []string{"create", "update", "delete"}) + " based on the resource type."), nil } // Validate if the parameter combination is valid diff --git a/pkg/mcp/builders/pulsar/functions.go b/pkg/mcp/builders/pulsar/functions.go index c71eb9af..3d3311b9 100644 --- a/pkg/mcp/builders/pulsar/functions.go +++ b/pkg/mcp/builders/pulsar/functions.go @@ -342,7 +342,10 @@ func (b *PulsarAdminFunctionsToolBuilder) buildPulsarAdminFunctionsHandler(mode // Check if the operation is valid if !isSupportedFunctionOperation(operation) { - return b.handleError("validate operation", fmt.Errorf("invalid operation: '%s'. Supported operations: list, get, status, stats, querystate, create, update, delete, download, start, stop, restart, putstate, trigger, upload", operation)), nil + return b.handleError("validate operation", fmt.Errorf("invalid operation: '%s'. Supported operations: %s", operation, + modeSupportedOperations(mode, + []string{"list", "get", "status", "stats", "querystate", "download"}, + []string{"create", "update", "delete", "start", "stop", "restart", "putstate", "trigger", "upload"}))), nil } if !validateModeOperation(mode, operation, readOnlyRestrictedFunctionOperations) { diff --git a/pkg/mcp/builders/pulsar/namespace.go b/pkg/mcp/builders/pulsar/namespace.go index 4ca37bf2..fbac718b 100644 --- a/pkg/mcp/builders/pulsar/namespace.go +++ b/pkg/mcp/builders/pulsar/namespace.go @@ -219,7 +219,10 @@ func (b *PulsarAdminNamespaceToolBuilder) buildNamespaceHandler(mode toolMode) f return b.handleSplitBundle(ctx, client, request) } default: - return mcp.NewToolResultError(fmt.Sprintf("Unknown operation: %s. Supported operations: list, get_topics, create, delete, clear_backlog, unsubscribe, unload, split_bundle", operation)), nil + return mcp.NewToolResultError(fmt.Sprintf("Unknown operation: %s. Supported operations: %s", operation, + modeSupportedOperations(mode, + []string{"list", "get_topics"}, + []string{"create", "delete", "clear_backlog", "unsubscribe", "unload", "split_bundle"}))), nil } // Should not reach here diff --git a/pkg/mcp/builders/pulsar/nsisolationpolicy.go b/pkg/mcp/builders/pulsar/nsisolationpolicy.go index a01295dd..926cda3b 100644 --- a/pkg/mcp/builders/pulsar/nsisolationpolicy.go +++ b/pkg/mcp/builders/pulsar/nsisolationpolicy.go @@ -100,6 +100,7 @@ func (b *PulsarAdminNsIsolationPolicyToolBuilder) buildNsIsolationPolicyTool(mod "- policy: Namespace isolation policy\n" + "- broker: Broker with namespace isolation policies\n" + "- brokers: All brokers with namespace isolation policies" + resourceEnum := []string{"policy", "broker", "brokers"} operationDesc := "Operation to perform. Available operations:\n" + "- get: Get resource details\n" + @@ -111,9 +112,12 @@ func (b *PulsarAdminNsIsolationPolicyToolBuilder) buildNsIsolationPolicyTool(mod if isToolModeWrite(mode) { toolDesc = "Manage namespace isolation policies in a Pulsar cluster. " + "This write tool creates, updates, or deletes namespace isolation policies." + resourceDesc = "Resource to operate on. Available resources:\n" + + "- policy: Namespace isolation policy" + resourceEnum = []string{"policy"} operationDesc = "Operation to perform. Available operations:\n" + - "- set: Create or update a resource (requires super-user permissions)\n" + - "- delete: Delete a resource (requires super-user permissions)" + "- set: Create or update a namespace isolation policy (requires super-user permissions)\n" + + "- delete: Delete a namespace isolation policy (requires super-user permissions)" operationEnum = []string{"set", "delete"} toolName = "pulsar_admin_nsisolationpolicy_write" annotation = toolannotations.Destructive("Manage Pulsar Namespace Isolation Policies") @@ -123,6 +127,7 @@ func (b *PulsarAdminNsIsolationPolicyToolBuilder) buildNsIsolationPolicyTool(mod mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), + mcp.Enum(resourceEnum...), ), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), @@ -218,7 +223,7 @@ func (b *PulsarAdminNsIsolationPolicyToolBuilder) buildNsIsolationPolicyHandler( // Dispatch based on resource type switch resource { case "policy": - return b.handlePolicyResource(client, operation, cluster, request) + return b.handlePolicyResource(client, operation, cluster, request, mode) case "broker": return b.handleBrokerResource(client, operation, cluster, request) case "brokers": @@ -232,7 +237,7 @@ func (b *PulsarAdminNsIsolationPolicyToolBuilder) buildNsIsolationPolicyHandler( // Helper functions // handlePolicyResource handles operations on the "policy" resource -func (b *PulsarAdminNsIsolationPolicyToolBuilder) handlePolicyResource(client cmdutils.Client, operation, cluster string, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (b *PulsarAdminNsIsolationPolicyToolBuilder) handlePolicyResource(client cmdutils.Client, operation, cluster string, request mcp.CallToolRequest, mode toolMode) (*mcp.CallToolResult, error) { switch operation { case "get": name, err := request.RequireString("name") @@ -331,7 +336,8 @@ func (b *PulsarAdminNsIsolationPolicyToolBuilder) handlePolicyResource(client cm return mcp.NewToolResultText(fmt.Sprintf("Create/Update namespace isolation policy %s successfully", name)), nil default: - return mcp.NewToolResultError(fmt.Sprintf("Invalid operation for resource 'policy': %s. Available operations: get, list, delete, set", operation)), nil + return mcp.NewToolResultError(fmt.Sprintf("Invalid operation for resource 'policy': %s. Available operations: %s", operation, + modeSupportedOperations(mode, []string{"get", "list"}, []string{"set", "delete"}))), nil } } diff --git a/pkg/mcp/builders/pulsar/packages.go b/pkg/mcp/builders/pulsar/packages.go index 3e778c23..affd6145 100644 --- a/pkg/mcp/builders/pulsar/packages.go +++ b/pkg/mcp/builders/pulsar/packages.go @@ -98,6 +98,7 @@ func (b *PulsarAdminPackagesToolBuilder) buildPackagesTool(mode toolMode) mcp.To resourceDesc := "Resource to operate on. Available resources:\n" + "- package: A specific package\n" + "- packages: All packages of a specific type" + resourceEnum := []string{"package", "packages"} operationDesc := "Operation to perform. Available operations:\n" + "- list: List all packages of a specific type or versions of a package\n" + @@ -110,6 +111,9 @@ func (b *PulsarAdminPackagesToolBuilder) buildPackagesTool(mode toolMode) mcp.To if isToolModeWrite(mode) { toolDesc = "Manage packages in Apache Pulsar. Support package schemes: `function://`, `source://`, `sink://`. " + "This write tool updates metadata, deletes packages, or uploads package contents." + resourceDesc = "Resource to operate on. Available resources:\n" + + "- package: A specific package" + resourceEnum = []string{"package"} operationDesc = "Operation to perform. Available operations:\n" + "- update: Update metadata of a package (requires super-user permissions)\n" + "- delete: Delete a package (requires super-user permissions)\n" + @@ -123,6 +127,7 @@ func (b *PulsarAdminPackagesToolBuilder) buildPackagesTool(mode toolMode) mcp.To mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), + mcp.Enum(resourceEnum...), ), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), @@ -195,7 +200,7 @@ func (b *PulsarAdminPackagesToolBuilder) buildPackagesHandler(mode toolMode) fun // Dispatch based on resource type switch resource { case "package": - return b.handlePackageResource(client, operation, request) + return b.handlePackageResource(client, operation, request, mode) case "packages": return b.handlePackagesResource(client, operation, request) default: @@ -207,7 +212,7 @@ func (b *PulsarAdminPackagesToolBuilder) buildPackagesHandler(mode toolMode) fun // Helper functions // handlePackageResource handles operations on a specific package -func (b *PulsarAdminPackagesToolBuilder) handlePackageResource(client cmdutils.Client, operation string, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (b *PulsarAdminPackagesToolBuilder) handlePackageResource(client cmdutils.Client, operation string, request mcp.CallToolRequest, mode toolMode) (*mcp.CallToolResult, error) { packageName, err := request.RequireString("packageName") if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Missing required parameter 'packageName' for package operations: %v", err)), nil @@ -311,7 +316,8 @@ func (b *PulsarAdminPackagesToolBuilder) handlePackageResource(client cmdutils.C ), nil default: - return mcp.NewToolResultError(fmt.Sprintf("Invalid operation for resource 'package': %s. Available operations: list, get, update, delete, download, upload", operation)), nil + return mcp.NewToolResultError(fmt.Sprintf("Invalid operation for resource 'package': %s. Available operations: %s", operation, + modeSupportedOperations(mode, []string{"list", "get", "download"}, []string{"update", "delete", "upload"}))), nil } } diff --git a/pkg/mcp/builders/pulsar/resourcequotas.go b/pkg/mcp/builders/pulsar/resourcequotas.go index b096921d..07929fe8 100644 --- a/pkg/mcp/builders/pulsar/resourcequotas.go +++ b/pkg/mcp/builders/pulsar/resourcequotas.go @@ -213,7 +213,8 @@ func (b *PulsarAdminResourceQuotasToolBuilder) buildResourceQuotasHandler(mode t case "reset": return b.handleQuotaReset(admin, request) default: - return mcp.NewToolResultError(fmt.Sprintf("Invalid operation: %s. Available operations: get, set, reset", operation)), nil + return mcp.NewToolResultError(fmt.Sprintf("Invalid operation: %s. Available operations: %s", operation, + modeSupportedOperations(mode, []string{"get"}, []string{"set", "reset"}))), nil } } } diff --git a/pkg/mcp/builders/pulsar/sinks.go b/pkg/mcp/builders/pulsar/sinks.go index 65ec7f48..e42af864 100644 --- a/pkg/mcp/builders/pulsar/sinks.go +++ b/pkg/mcp/builders/pulsar/sinks.go @@ -275,7 +275,10 @@ func (b *PulsarAdminSinksToolBuilder) buildSinksHandler(mode toolMode) func(cont } if !validOperations[operation] { - return mcp.NewToolResultError(fmt.Sprintf("Invalid operation: '%s'. Supported operations: list, get, status, create, update, delete, start, stop, restart, list-built-in", operation)), nil + return mcp.NewToolResultError(fmt.Sprintf("Invalid operation: '%s'. Supported operations: %s", operation, + modeSupportedOperations(mode, + []string{"list", "get", "status", "list-built-in"}, + []string{"create", "update", "delete", "start", "stop", "restart"}))), nil } if !validateModeOperation(mode, operation, pulsarSinkWriteOperations) { diff --git a/pkg/mcp/builders/pulsar/sources.go b/pkg/mcp/builders/pulsar/sources.go index 6beb6edf..05bb6f40 100644 --- a/pkg/mcp/builders/pulsar/sources.go +++ b/pkg/mcp/builders/pulsar/sources.go @@ -243,7 +243,10 @@ func (b *PulsarAdminSourcesToolBuilder) buildSourcesHandler(mode toolMode) func( } if !validOperations[operation] { - return mcp.NewToolResultError(fmt.Sprintf("Invalid operation: '%s'. Supported operations: list, get, status, create, update, delete, start, stop, restart, list-built-in", operation)), nil + return mcp.NewToolResultError(fmt.Sprintf("Invalid operation: '%s'. Supported operations: %s", operation, + modeSupportedOperations(mode, + []string{"list", "get", "status", "list-built-in"}, + []string{"create", "update", "delete", "start", "stop", "restart"}))), nil } if !validateModeOperation(mode, operation, pulsarSourceWriteOperations) { diff --git a/pkg/mcp/builders/pulsar/tool_mode.go b/pkg/mcp/builders/pulsar/tool_mode.go index fc4dcecd..aa7ce712 100644 --- a/pkg/mcp/builders/pulsar/tool_mode.go +++ b/pkg/mcp/builders/pulsar/tool_mode.go @@ -40,6 +40,13 @@ func validateModeOperation(mode toolMode, operation string, writeOperations map[ return (mode == toolModeWrite) == isWriteOperation(operation, writeOperations) } +func modeSupportedOperations(mode toolMode, readOperations, writeOperations []string) string { + if isToolModeWrite(mode) { + return strings.Join(writeOperations, ", ") + } + return strings.Join(readOperations, ", ") +} + func pruneToolInputSchema(tool *mcp.Tool, allowedProperties []string) { allowed := make(map[string]struct{}, len(allowedProperties)) for _, property := range allowedProperties { diff --git a/pkg/mcp/builders/pulsar/topic.go b/pkg/mcp/builders/pulsar/topic.go index aac43928..08b2fec8 100644 --- a/pkg/mcp/builders/pulsar/topic.go +++ b/pkg/mcp/builders/pulsar/topic.go @@ -120,6 +120,7 @@ func (b *PulsarAdminTopicToolBuilder) buildTopicTool(mode toolMode) mcp.Tool { resourceDesc := "Resource to operate on. Available resources:\n" + "- topic: A Pulsar topic\n" + "- topics: Multiple topics within a namespace" + resourceEnum := []string{"topic", "topics"} operationDesc := "Operation to perform. Available operations:\n" + "- list: List all topics in a namespace\n" + @@ -141,6 +142,9 @@ func (b *PulsarAdminTopicToolBuilder) buildTopicTool(mode toolMode) mcp.Tool { toolDesc = "Manage Apache Pulsar topics. " + "This write tool changes topic lifecycle, permissions, partitioning, compaction, or offload state. " + "Do not use this tool for Kafka protocol operations. Use 'kafka_admin_topics_write' instead." + resourceDesc = "Resource to operate on. Available resources:\n" + + "- topic: A Pulsar topic" + resourceEnum = []string{"topic"} operationDesc = "Operation to perform. Available operations:\n" + "- grant-permissions: Grant topic permissions to a role\n" + "- revoke-permissions: Revoke topic permissions from a role\n" + @@ -160,6 +164,7 @@ func (b *PulsarAdminTopicToolBuilder) buildTopicTool(mode toolMode) mcp.Tool { mcp.WithDescription(toolDesc), mcp.WithString("resource", mcp.Required(), mcp.Description(resourceDesc), + mcp.Enum(resourceEnum...), ), mcp.WithString("operation", mcp.Required(), mcp.Description(operationDesc), diff --git a/pkg/mcp/instructions.go b/pkg/mcp/instructions.go index 3fa46547..b5b84f80 100644 --- a/pkg/mcp/instructions.go +++ b/pkg/mcp/instructions.go @@ -57,7 +57,7 @@ func GetStreamNativeCloudServerInstructions(userName string, snConfig *config.Sn 5. **Service Accounts** - **Concept**: A service account represents an application that programmatically accesses StreamNative Cloud resources and Pulsar resources within clusters. - **Relationship**: Service accounts belong to an organization and can be used across multiple instances, though authentication credentials or API keys differ per instance. - - **Service Account Binding**: Service account binding in StreamNative involves associating a service account with specific resources or permissions within the StreamNative Cloud environment. This process is crucial for managing access and ensuring that service accounts have the necessary permissions to interact with Pulsar clusters and other resources. It is often used by manage Pulsar Functions, Pulsar IO Connectors, and Kafka Connect connectors. Related tools: 'pulsar_admin_functions', 'pulsar_admin_sinks', 'pulsar_admin_sources', and 'kafka_admin_connect'. + - **Service Account Binding**: Service account binding in StreamNative involves associating a service account with specific resources or permissions within the StreamNative Cloud environment. This process is crucial for managing access and ensuring that service accounts have the necessary permissions to interact with Pulsar clusters and other resources. It is often used by manage Pulsar Functions, Pulsar IO Connectors, and Kafka Connect connectors. Related tools use split read/write surfaces, such as 'pulsar_admin_functions_read'/'pulsar_admin_functions_write', 'pulsar_admin_sinks_read'/'pulsar_admin_sinks_write', 'pulsar_admin_sources_read'/'pulsar_admin_sources_write', and 'kafka_admin_connect_read'/'kafka_admin_connect_write'. 6. **Secrets** - **Concept**: Secrets are used to store and manage sensitive data such as passwords, tokens, and private keys. Secrets can be referenced in Connectors and Pulsar Functions. diff --git a/pkg/mcp/streamnative_resources_log_tools.go b/pkg/mcp/streamnative_resources_log_tools.go index 8321b4a7..67e8cceb 100644 --- a/pkg/mcp/streamnative_resources_log_tools.go +++ b/pkg/mcp/streamnative_resources_log_tools.go @@ -48,7 +48,7 @@ func StreamNativeAddLogTools(s *server.MCPServer, _ bool, features []string) { func NewSNCloudLogsTool() mcp.Tool { return mcp.NewTool("sncloud_logs", mcp.WithDescription("Display the logs of resources in StreamNative Cloud, including pulsar functions, pulsar source connectors, pulsar sink connectors, and kafka connect connectors logs running along with PulsarInstance and PulsarCluster."+ - "This tool is used to help you debug issues in the cluster currently bound to the session. This tool is suggested to be used with 'pulsar_admin_functions', 'pulsar_admin_sinks', 'pulsar_admin_sources', and 'kafka_admin_connect'."), + "This tool is used to help you debug issues in the cluster currently bound to the session. This tool is suggested to be used with the relevant split read/write admin tools, such as 'pulsar_admin_functions_read', 'pulsar_admin_sinks_read', 'pulsar_admin_sources_read', and 'kafka_admin_connect_read' for inspection workflows."), mcp.WithString("component", mcp.Required(), mcp.Description("The component to get logs from, including "+strings.Join(FunctionConnectorList, ", ")), mcp.Enum(FunctionConnectorList...), From a6b91fc2a0db7649fdb3db0be296c32ab1de7302 Mon Sep 17 00:00:00 2001 From: Rui Fu Date: Tue, 19 May 2026 17:51:52 +0800 Subject: [PATCH 05/10] refactor: centralize MCP operation specs --- PLAN.md | 134 +++++++++++++- docs/tools/kafka_admin_connect.md | 8 + docs/tools/kafka_admin_groups.md | 8 + docs/tools/kafka_admin_schema_registry.md | 8 + docs/tools/kafka_admin_topics.md | 8 + docs/tools/pulsar_admin_brokers.md | 8 + docs/tools/pulsar_admin_clusters.md | 8 + docs/tools/pulsar_admin_functions.md | 8 + docs/tools/pulsar_admin_namespaces.md | 8 + docs/tools/pulsar_admin_nsisolationpolicy.md | 8 + docs/tools/pulsar_admin_packages.md | 8 + docs/tools/pulsar_admin_resource_quotas.md | 8 + docs/tools/pulsar_admin_schemas.md | 8 + docs/tools/pulsar_admin_sinks.md | 8 + docs/tools/pulsar_admin_sources.md | 8 + docs/tools/pulsar_admin_subscriptions.md | 8 + docs/tools/pulsar_admin_tenants.md | 8 + docs/tools/pulsar_admin_topic_policy.md | 8 + docs/tools/pulsar_admin_topics.md | 8 + .../kafka/annotation_compliance_test.go | 41 ++++- pkg/mcp/builders/kafka/connect.go | 28 +-- pkg/mcp/builders/kafka/groups.go | 23 +-- pkg/mcp/builders/kafka/operation_docs_test.go | 83 +++++++++ pkg/mcp/builders/kafka/schema_registry.go | 22 +-- pkg/mcp/builders/kafka/tool_mode.go | 36 +--- pkg/mcp/builders/kafka/topics.go | 21 +-- pkg/mcp/builders/operation_annotations.go | 30 ++++ pkg/mcp/builders/operations.go | 170 ++++++++++++++++++ pkg/mcp/builders/operations_test.go | 57 ++++++ .../pulsar/annotation_compliance_test.go | 82 +++++---- pkg/mcp/builders/pulsar/brokers.go | 22 +-- pkg/mcp/builders/pulsar/cluster.go | 24 +-- pkg/mcp/builders/pulsar/functions.go | 71 +++----- pkg/mcp/builders/pulsar/namespace.go | 32 ++-- pkg/mcp/builders/pulsar/nsisolationpolicy.go | 22 +-- .../builders/pulsar/operation_docs_test.go | 93 ++++++++++ pkg/mcp/builders/pulsar/packages.go | 25 +-- pkg/mcp/builders/pulsar/resourcequotas.go | 21 ++- pkg/mcp/builders/pulsar/schema.go | 19 +- pkg/mcp/builders/pulsar/sinks.go | 34 ++-- pkg/mcp/builders/pulsar/sources.go | 34 ++-- pkg/mcp/builders/pulsar/subscription.go | 44 ++--- pkg/mcp/builders/pulsar/tenant.go | 22 +-- pkg/mcp/builders/pulsar/tool_mode.go | 66 ++----- pkg/mcp/builders/pulsar/topic.go | 47 +++-- pkg/mcp/builders/pulsar/topic_policy.go | 154 ++++++---------- pkg/mcp/builders/tool_schema.go | 69 +++++++ 47 files changed, 1182 insertions(+), 488 deletions(-) create mode 100644 pkg/mcp/builders/kafka/operation_docs_test.go create mode 100644 pkg/mcp/builders/operation_annotations.go create mode 100644 pkg/mcp/builders/operations.go create mode 100644 pkg/mcp/builders/operations_test.go create mode 100644 pkg/mcp/builders/pulsar/operation_docs_test.go create mode 100644 pkg/mcp/builders/tool_schema.go diff --git a/PLAN.md b/PLAN.md index f5b17b55..87ddb31f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -4,6 +4,33 @@ Prepare StreamNative MCP Server for Claude connector submission and review. +## Current review follow-up: operation spec registry + +Reviewer feedback is valid: current branch split read/write tool names, but duplicated `toolMode` helpers and per-builder write-operation maps still create scatter-shot maintenance. Adding one operation can require enum updates, write map updates, handler switch updates, docs, and compliance-test classification. `validateModeOperation` also classifies any operation missing from the write map as read, so an unclassified future write can pass read-mode validation until the handler switch rejects or mishandles it. + +Recommended next design: make each tool family declare operation metadata once, then derive mode-specific tool schemas, annotations, validation, and tests from that registry. + +```go +type OperationMode string + +const ( + OperationModeRead OperationMode = "read" + OperationModeWrite OperationMode = "write" +) + +type OperationSpec struct { + Name string + Mode OperationMode + Destructive bool + Idempotent bool + Resources []string + Params []ParamSpec + Handler OperationHandler +} +``` + +Scope is incremental but complete: add shared spec helpers, migrate `kafka/topics.go` and `pulsar/namespace.go` as reference implementations, then migrate every remaining split builder in batches. Keep current read/write tool names unchanged. Use docs generated operation-table blocks first; do not rewrite full Markdown documents. + Hard requirements from Claude docs: - every MCP tool has non-empty `annotations.title` @@ -403,12 +430,16 @@ Plan: ### Phase 1: shared annotation + mode helpers +Status: implemented on current branch. + - Add `pkg/mcp/toolannotations` helper. - Add local read/write mode helpers in builders with mixed operations. - Add reusable operation validation helpers where a builder already has operation maps. ### Phase 2: split Kafka tools completely +Status: implemented on current branch; follow-up refactor still needed to remove duplicated operation classification. + - Update all Kafka builders to build mode-specific tools. - Ensure read/write tools have mode-specific descriptions, examples, operation enums, and parameter schemas. - Read-only config returns only read tools. @@ -418,6 +449,8 @@ Plan: ### Phase 3: split Pulsar tools completely +Status: implemented on current branch; follow-up refactor still needed to remove duplicated operation classification. + - Update all Pulsar builders to build mode-specific tools. - Ensure read/write tools have mode-specific descriptions, examples, operation enums, and parameter schemas. - Preserve existing read-only behavior by not registering write tools in read-only config. @@ -427,26 +460,110 @@ Plan: ### Phase 4: StreamNative Cloud/static tool annotations +Status: implemented on current branch. + - Add annotations to context/log/resource tools. - Keep already split apply/delete tools. - Ensure no new mixed resource tool appears. ### Phase 5: dynamic tools +Status: implemented on current branch. + - Add annotations to Functions-as-Tools. - Validate read-only exposure behavior. ### Phase 6: runtime-visible docs +Status: implemented on current branch, with generated operation-table guard tests for migrated split builders. + Update runtime-visible docs together with schema changes: -- `README.md` feature/tool examples if names change. -- `docs/tools/*.md` matching renamed/split tools. +- Keep current read/write tool names unchanged. +- `README.md` feature/tool examples only if behavior changes. +- `docs/tools/*.md` matching current split tools. - Split docs into explicit read/write sections where a family has both tool modes. - Ensure read docs do not mention write-only operations or parameters. - Ensure write docs do not rely on old mixed tool names. - Any design notes under `agents/` if tool surface changes are architectural. +### Phase 7: shared operation spec registry follow-up + +Status: implemented on current branch. + +Goal: make operation metadata single-source-of-truth and remove copy-paste `toolMode` + `writeOperations` maps. + +1. Add shared operation metadata API, likely under `pkg/mcp/builders/operations.go`: + - `OperationModeRead` / `OperationModeWrite` + - `OperationSpec` + - `ParamSpec` + - `OperationHandler` type alias or adapter + - `OperationRegistry` or helper functions over `[]OperationSpec` +2. Shared helpers must derive: + - read operation enum + - write operation enum + - operation description fragments/table rows + - mode-specific validation + - unknown-operation rejection + - read/write annotation selection + - compliance-test classification +3. Validation semantics: + - operation absent from spec => reject, never default to read + - operation present with wrong mode => reject with mode-specific error + - operation present with matching mode => dispatch allowed +4. Migrate two reference builders first: + - `pkg/mcp/builders/kafka/topics.go` + - `pkg/mcp/builders/pulsar/namespace.go` +5. Reference builder acceptance criteria: + - no local `xxxWriteOperations` map + - operation enum generated from specs + - handler validation uses specs + - unknown operation test added + - read-only build still excludes write tools + - tool names unchanged + - docs operation table generated or checked from specs +6. Migrate remaining Kafka split builders: + - `connect.go` + - `groups.go` + - `schema_registry.go` + - `topics.go` +7. Migrate remaining Pulsar split builders: + - `brokers.go` + - `cluster.go` + - `functions.go` + - `namespace.go` + - `nsisolationpolicy.go` + - `packages.go` + - `resourcequotas.go` + - `schema.go` + - `sinks.go` + - `sources.go` + - `subscription.go` + - `tenant.go` + - `topic.go` + - `topic_policy.go` +8. Keep pure read/write client tools out of forced registry migration unless helper reuse is cheap: + - `kafka_client_produce` + - `kafka_client_consume` + - `pulsar_client_produce` + - `pulsar_client_consume` + - pure read Pulsar admin status/stats/worker tools +9. Replace compliance-test manual classification: + - derive write/read operation sets from specs + - assert no enum mixes read/write specs + - assert every enum value exists in specs + - assert specs cover every handler switch operation +10. Docs generation/check: + - do not rewrite whole Markdown documents + - add generated operation table blocks only: + `` / `` + - add `go generate` or test helper to refresh/check blocks +11. Cleanup after migration: + - delete or shrink duplicated Kafka/Pulsar `tool_mode.go` + - move shared `pruneToolInputSchema`/required filtering helper if still duplicated + - remove obsolete per-builder write maps + - remove obsolete compliance-test write maps + ## Tests / compliance guard Add focused tests: @@ -465,25 +582,36 @@ Add focused tests: - StreamNative Cloud/context/log/resource tools have valid annotations. - PFTools dynamic tool creation has valid annotation. - Operation validation rejects read operations on write tools and write operations on read tools. +- Operation validation rejects unknown operations instead of treating them as read. +- Operation enum values are derived from or checked against `OperationSpec`. +- Compliance tests derive read/write classification from `OperationSpec`, not hand-maintained write maps. +- Docs generated operation table blocks match `OperationSpec`. Static guard: - Build all feature sets and assert no `operation` enum contains both read and write verbs in one tool. - For split tool families, assert mode-specific schema/description purity with family-specific allow/deny lists. +- Assert every handler switch operation has a matching `OperationSpec` entry. ## Risks - Tool split is runtime-visible and likely breaking for clients/prompts that call old names. -- Docs under `docs/tools/` can drift if not updated with split names. +- Operation spec refactor can accidentally change schema ordering, descriptions, or enum content even when tool names stay unchanged. +- Docs under `docs/tools/` can drift if generated operation blocks are not checked in tests or `go generate`. - Some operations are ambiguous (`consume`, `trigger`, cursor operations, context reset). Conservative destructive annotation may add confirmations but avoids unsafe auto-run. - Some current tools may have read-only-mode logic embedded in handlers; after split, registration and handler validation must both enforce mode to prevent write leakage. +- `OperationSpec.Handler` can over-couple schema metadata and dispatch if introduced too early. Prefer enum/validation/test/doc generation first, then dispatch consolidation. - `mcp-go` default annotations are unsafe for compliance because title empty and destructive default true. ## Confirmed decisions - Fix all current mixed read/write surfaces, not only `kafka/topics.go` and `pulsar/namespace.go`. +- Implement operation-spec follow-up across all affected split builders, not only the two reference files. +- Start with shared spec + two reference migrations: `kafka/topics.go` and `pulsar/namespace.go`. +- Keep current read/write tool names unchanged. - Do not preserve old mixed tool names or old mixed builder/schema patterns. - Runtime-visible docs must be updated with read/write split and must avoid mixed read/write wording. +- For docs generation, start with generated operation table blocks only; do not generate whole Markdown documents. - Conservative safety annotations are acceptable for ambiguous side-effect tools unless implementation proves true read-only behavior. ## Recommended validation diff --git a/docs/tools/kafka_admin_connect.md b/docs/tools/kafka_admin_connect.md index 95f856cf..af4875f6 100644 --- a/docs/tools/kafka_admin_connect.md +++ b/docs/tools/kafka_admin_connect.md @@ -1,5 +1,13 @@ #### kafka-admin-connect + + +| Tool | Mode | Operations | +|---|---|---| +| `kafka_admin_connect_read` | read | `list`, `get` | +| `kafka_admin_connect_write` | write | `create`, `update`, `delete`, `restart`, `pause`, `resume` | + + **Claude connector safety:** Actual MCP tools are split into `kafka_admin_connect_read` and `kafka_admin_connect_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. ### `kafka_admin_connect_read` diff --git a/docs/tools/kafka_admin_groups.md b/docs/tools/kafka_admin_groups.md index 24e8c8a1..c33f70d0 100644 --- a/docs/tools/kafka_admin_groups.md +++ b/docs/tools/kafka_admin_groups.md @@ -1,5 +1,13 @@ #### kafka-admin-groups + + +| Tool | Mode | Operations | +|---|---|---| +| `kafka_admin_groups_read` | read | `list`, `describe`, `offsets` | +| `kafka_admin_groups_write` | write | `remove-members`, `delete-offset`, `set-offset` | + + **Claude connector safety:** Actual MCP tools are split into `kafka_admin_groups_read` and `kafka_admin_groups_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. ### `kafka_admin_groups_read` diff --git a/docs/tools/kafka_admin_schema_registry.md b/docs/tools/kafka_admin_schema_registry.md index 0d2c3700..f35f2283 100644 --- a/docs/tools/kafka_admin_schema_registry.md +++ b/docs/tools/kafka_admin_schema_registry.md @@ -1,5 +1,13 @@ #### kafka-admin-schema-registry + + +| Tool | Mode | Operations | +|---|---|---| +| `kafka_admin_sr_read` | read | `list`, `get` | +| `kafka_admin_sr_write` | write | `set`, `create`, `delete` | + + **Claude connector safety:** Actual MCP tools are split into `kafka_admin_sr_read` and `kafka_admin_sr_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. ### `kafka_admin_sr_read` diff --git a/docs/tools/kafka_admin_topics.md b/docs/tools/kafka_admin_topics.md index 89d82844..1a3ccd04 100644 --- a/docs/tools/kafka_admin_topics.md +++ b/docs/tools/kafka_admin_topics.md @@ -1,5 +1,13 @@ #### kafka-admin-topics + + +| Tool | Mode | Operations | +|---|---|---| +| `kafka_admin_topics_read` | read | `list`, `get`, `metadata` | +| `kafka_admin_topics_write` | write | `create`, `delete` | + + **Claude connector safety:** Actual MCP tools are split into `kafka_admin_topics_read` and `kafka_admin_topics_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. ### `kafka_admin_topics_read` diff --git a/docs/tools/pulsar_admin_brokers.md b/docs/tools/pulsar_admin_brokers.md index cb9cd5e6..ecf47d30 100644 --- a/docs/tools/pulsar_admin_brokers.md +++ b/docs/tools/pulsar_admin_brokers.md @@ -1,5 +1,13 @@ #### pulsar_admin_brokers_read / pulsar_admin_brokers_write + + +| Tool | Mode | Operations | +|---|---|---| +| `pulsar_admin_brokers_read` | read | `list`, `get` | +| `pulsar_admin_brokers_write` | write | `update`, `delete` | + + **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_brokers_read` and `pulsar_admin_brokers_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. ### `pulsar_admin_brokers_read` diff --git a/docs/tools/pulsar_admin_clusters.md b/docs/tools/pulsar_admin_clusters.md index f0fc9132..7a4670c3 100644 --- a/docs/tools/pulsar_admin_clusters.md +++ b/docs/tools/pulsar_admin_clusters.md @@ -1,5 +1,13 @@ #### pulsar_admin_cluster_read / pulsar_admin_cluster_write + + +| Tool | Mode | Operations | +|---|---|---| +| `pulsar_admin_cluster_read` | read | `list`, `get` | +| `pulsar_admin_cluster_write` | write | `create`, `update`, `delete` | + + **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_cluster_read` and `pulsar_admin_cluster_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. ### `pulsar_admin_cluster_read` diff --git a/docs/tools/pulsar_admin_functions.md b/docs/tools/pulsar_admin_functions.md index 33b6f50a..0ef8f0e9 100644 --- a/docs/tools/pulsar_admin_functions.md +++ b/docs/tools/pulsar_admin_functions.md @@ -1,5 +1,13 @@ #### pulsar_admin_functions_read / pulsar_admin_functions_write + + +| Tool | Mode | Operations | +|---|---|---| +| `pulsar_admin_functions_read` | read | `list`, `get`, `status`, `stats`, `querystate`, `download` | +| `pulsar_admin_functions_write` | write | `create`, `update`, `delete`, `start`, `stop`, `restart`, `putstate`, `trigger`, `upload` | + + **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_functions_read` and `pulsar_admin_functions_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. Pulsar Functions are lightweight compute processes that consume messages from Pulsar topics, apply user-defined logic, and optionally produce results to another topic. diff --git a/docs/tools/pulsar_admin_namespaces.md b/docs/tools/pulsar_admin_namespaces.md index d7377c02..ab49db9e 100644 --- a/docs/tools/pulsar_admin_namespaces.md +++ b/docs/tools/pulsar_admin_namespaces.md @@ -1,5 +1,13 @@ #### pulsar_admin_namespace_read / pulsar_admin_namespace_write + + +| Tool | Mode | Operations | +|---|---|---| +| `pulsar_admin_namespace_read` | read | `list`, `get_topics` | +| `pulsar_admin_namespace_write` | write | `create`, `delete`, `clear_backlog`, `unsubscribe`, `unload`, `split_bundle` | + + **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_namespace_read` and `pulsar_admin_namespace_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. ### `pulsar_admin_namespace_read` diff --git a/docs/tools/pulsar_admin_nsisolationpolicy.md b/docs/tools/pulsar_admin_nsisolationpolicy.md index ee6a9941..30a3f7c9 100644 --- a/docs/tools/pulsar_admin_nsisolationpolicy.md +++ b/docs/tools/pulsar_admin_nsisolationpolicy.md @@ -1,5 +1,13 @@ #### pulsar_admin_nsisolationpolicy_read / pulsar_admin_nsisolationpolicy_write + + +| Tool | Mode | Operations | +|---|---|---| +| `pulsar_admin_nsisolationpolicy_read` | read | `get`, `list` | +| `pulsar_admin_nsisolationpolicy_write` | write | `set`, `delete` | + + **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_nsisolationpolicy_read` and `pulsar_admin_nsisolationpolicy_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. Namespace isolation policies control which brokers specific namespaces can use. diff --git a/docs/tools/pulsar_admin_packages.md b/docs/tools/pulsar_admin_packages.md index c1a29b54..b8110afc 100644 --- a/docs/tools/pulsar_admin_packages.md +++ b/docs/tools/pulsar_admin_packages.md @@ -1,5 +1,13 @@ #### pulsar_admin_package_read / pulsar_admin_package_write + + +| Tool | Mode | Operations | +|---|---|---| +| `pulsar_admin_package_read` | read | `list`, `get`, `download` | +| `pulsar_admin_package_write` | write | `update`, `delete`, `upload` | + + **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_package_read` and `pulsar_admin_package_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. Supported package schemes include `function://`, `source://`, and `sink://`. diff --git a/docs/tools/pulsar_admin_resource_quotas.md b/docs/tools/pulsar_admin_resource_quotas.md index d616227e..59f9a574 100644 --- a/docs/tools/pulsar_admin_resource_quotas.md +++ b/docs/tools/pulsar_admin_resource_quotas.md @@ -1,5 +1,13 @@ #### pulsar_admin_resourcequota_read / pulsar_admin_resourcequota_write + + +| Tool | Mode | Operations | +|---|---|---| +| `pulsar_admin_resourcequota_read` | read | `get` | +| `pulsar_admin_resourcequota_write` | write | `set`, `reset` | + + **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_resourcequota_read` and `pulsar_admin_resourcequota_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. ### `pulsar_admin_resourcequota_read` diff --git a/docs/tools/pulsar_admin_schemas.md b/docs/tools/pulsar_admin_schemas.md index da700880..125e4cc5 100644 --- a/docs/tools/pulsar_admin_schemas.md +++ b/docs/tools/pulsar_admin_schemas.md @@ -1,5 +1,13 @@ #### pulsar_admin_schema_read / pulsar_admin_schema_write + + +| Tool | Mode | Operations | +|---|---|---| +| `pulsar_admin_schema_read` | read | `get` | +| `pulsar_admin_schema_write` | write | `upload`, `delete` | + + **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_schema_read` and `pulsar_admin_schema_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. ### `pulsar_admin_schema_read` diff --git a/docs/tools/pulsar_admin_sinks.md b/docs/tools/pulsar_admin_sinks.md index cdb77e43..9850930d 100644 --- a/docs/tools/pulsar_admin_sinks.md +++ b/docs/tools/pulsar_admin_sinks.md @@ -1,5 +1,13 @@ #### pulsar_admin_sinks_read / pulsar_admin_sinks_write + + +| Tool | Mode | Operations | +|---|---|---| +| `pulsar_admin_sinks_read` | read | `list`, `get`, `status`, `list-built-in` | +| `pulsar_admin_sinks_write` | write | `create`, `update`, `delete`, `start`, `stop`, `restart` | + + **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_sinks_read` and `pulsar_admin_sinks_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. Pulsar Sinks export data from Pulsar topics to external systems. diff --git a/docs/tools/pulsar_admin_sources.md b/docs/tools/pulsar_admin_sources.md index 27d8aa28..543cb1d9 100644 --- a/docs/tools/pulsar_admin_sources.md +++ b/docs/tools/pulsar_admin_sources.md @@ -1,5 +1,13 @@ #### pulsar_admin_sources_read / pulsar_admin_sources_write + + +| Tool | Mode | Operations | +|---|---|---| +| `pulsar_admin_sources_read` | read | `list`, `get`, `status`, `list-built-in` | +| `pulsar_admin_sources_write` | write | `create`, `update`, `delete`, `start`, `stop`, `restart` | + + **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_sources_read` and `pulsar_admin_sources_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. Pulsar Sources import data from external systems into Pulsar topics. diff --git a/docs/tools/pulsar_admin_subscriptions.md b/docs/tools/pulsar_admin_subscriptions.md index 65eef8eb..7c09c94f 100644 --- a/docs/tools/pulsar_admin_subscriptions.md +++ b/docs/tools/pulsar_admin_subscriptions.md @@ -1,5 +1,13 @@ #### pulsar_admin_subscription_read / pulsar_admin_subscription_write + + +| Tool | Mode | Operations | +|---|---|---| +| `pulsar_admin_subscription_read` | read | `list`, `peek`, `get-message-by-id` | +| `pulsar_admin_subscription_write` | write | `create`, `delete`, `skip`, `expire`, `reset-cursor` | + + **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_subscription_read` and `pulsar_admin_subscription_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. ### `pulsar_admin_subscription_read` diff --git a/docs/tools/pulsar_admin_tenants.md b/docs/tools/pulsar_admin_tenants.md index 6cdff348..d003578a 100644 --- a/docs/tools/pulsar_admin_tenants.md +++ b/docs/tools/pulsar_admin_tenants.md @@ -1,5 +1,13 @@ #### pulsar_admin_tenant_read / pulsar_admin_tenant_write + + +| Tool | Mode | Operations | +|---|---|---| +| `pulsar_admin_tenant_read` | read | `list`, `get` | +| `pulsar_admin_tenant_write` | write | `create`, `update`, `delete` | + + **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_tenant_read` and `pulsar_admin_tenant_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. ### `pulsar_admin_tenant_read` diff --git a/docs/tools/pulsar_admin_topic_policy.md b/docs/tools/pulsar_admin_topic_policy.md index 3afd47c8..949108d8 100644 --- a/docs/tools/pulsar_admin_topic_policy.md +++ b/docs/tools/pulsar_admin_topic_policy.md @@ -1,5 +1,13 @@ #### pulsar_admin_topic_policy_read / pulsar_admin_topic_policy_write + + +| Tool | Mode | Operations | +|---|---|---| +| `pulsar_admin_topic_policy_read` | read | `get-retention`, `get-message-ttl`, `get-max-producers`, `get-max-consumers`, `get-max-unacked-messages-per-consumer`, `get-max-unacked-messages-per-subscription`, `get-persistence`, `get-delayed-delivery`, `get-dispatch-rate`, `get-subscription-dispatch-rate`, `get-deduplication`, `get-backlog-quotas`, `get-compaction-threshold`, `get-publish-rate`, `get-inactive-topic-policies`, `get-subscription-types` | +| `pulsar_admin_topic_policy_write` | write | `set-retention`, `remove-retention`, `set-message-ttl`, `remove-message-ttl`, `set-max-producers`, `remove-max-producers`, `set-max-consumers`, `remove-max-consumers`, `set-max-unacked-messages-per-consumer`, `remove-max-unacked-messages-per-consumer`, `set-max-unacked-messages-per-subscription`, `remove-max-unacked-messages-per-subscription`, `set-persistence`, `remove-persistence`, `set-delayed-delivery`, `remove-delayed-delivery`, `set-dispatch-rate`, `remove-dispatch-rate`, `set-subscription-dispatch-rate`, `remove-subscription-dispatch-rate`, `set-deduplication`, `remove-deduplication`, `set-backlog-quota`, `remove-backlog-quota`, `set-compaction-threshold`, `remove-compaction-threshold`, `set-publish-rate`, `remove-publish-rate`, `set-inactive-topic-policies`, `remove-inactive-topic-policies`, `set-subscription-types`, `remove-subscription-types` | + + **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_topic_policy_read` and `pulsar_admin_topic_policy_write`. The read tool is read-only and only exposes get operations/parameters. The write tool is destructive and is not registered in read-only mode. ### `pulsar_admin_topic_policy_read` diff --git a/docs/tools/pulsar_admin_topics.md b/docs/tools/pulsar_admin_topics.md index c02e6913..e7d7a2a3 100644 --- a/docs/tools/pulsar_admin_topics.md +++ b/docs/tools/pulsar_admin_topics.md @@ -1,5 +1,13 @@ #### pulsar_admin_topic_read / pulsar_admin_topic_write + + +| Tool | Mode | Operations | +|---|---|---| +| `pulsar_admin_topic_read` | read | `list`, `get`, `get-permissions`, `stats`, `lookup`, `internal-stats`, `internal-info`, `bundle-range`, `last-message-id`, `compact-status`, `offload-status` | +| `pulsar_admin_topic_write` | write | `grant-permissions`, `revoke-permissions`, `create`, `delete`, `unload`, `terminate`, `compact`, `update`, `offload` | + + **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_topic_read` and `pulsar_admin_topic_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. ### `pulsar_admin_topic_read` diff --git a/pkg/mcp/builders/kafka/annotation_compliance_test.go b/pkg/mcp/builders/kafka/annotation_compliance_test.go index e1b4d420..f109983b 100644 --- a/pkg/mcp/builders/kafka/annotation_compliance_test.go +++ b/pkg/mcp/builders/kafka/annotation_compliance_test.go @@ -24,6 +24,15 @@ import ( "github.com/stretchr/testify/require" ) +func TestKafkaOperationSpecsAreValid(t *testing.T) { + for name, registry := range kafkaComplianceOperationSpecs() { + require.NotPanics(t, registry.MustValidate, name) + require.NotEmpty(t, registry.NamesForMode(builders.OperationModeRead), name) + require.NotEmpty(t, registry.NamesForMode(builders.OperationModeWrite), name) + require.ErrorContains(t, registry.ValidateModeOperation(builders.OperationModeRead, "__unknown__"), "unknown operation", name) + } +} + func TestKafkaToolAnnotationCompliance(t *testing.T) { builderList := []builders.ToolBuilder{ NewKafkaTopicsToolBuilder(), @@ -72,25 +81,43 @@ func toolPropertyNames(tool mcp.Tool) []string { func assertOperationEnumMode(t *testing.T, toolName string, operationSchema any) { t.Helper() - rawEnum, ok := stringEnum(operationSchema) + registry, ok := kafkaComplianceOperationSpecs()[toolName] if !ok { return } - writeOperations := map[string]struct{}{ - "create": {}, "delete": {}, "set": {}, "update": {}, "restart": {}, "pause": {}, "resume": {}, - "remove-members": {}, "delete-offset": {}, "set-offset": {}, + rawEnum, ok := stringEnum(operationSchema) + if !ok { + return } seenRead, seenWrite := false, false for _, op := range rawEnum { - if _, ok := writeOperations[op]; ok { - seenWrite = true - } else { + spec, ok := registry.SpecFor(op) + require.True(t, ok, "%s operation %q must be declared in OperationSpec", toolName, op) + switch spec.Mode { + case builders.OperationModeRead: seenRead = true + case builders.OperationModeWrite: + seenWrite = true + default: + require.Failf(t, "invalid operation mode", "%s operation %q has mode %q", toolName, op, spec.Mode) } } require.False(t, seenRead && seenWrite, toolName) } +func kafkaComplianceOperationSpecs() map[string]builders.OperationRegistry { + return map[string]builders.OperationRegistry{ + "kafka_admin_topics_read": kafkaTopicOperationSpecs, + "kafka_admin_topics_write": kafkaTopicOperationSpecs, + "kafka_admin_groups_read": kafkaGroupOperationSpecs, + "kafka_admin_groups_write": kafkaGroupOperationSpecs, + "kafka_admin_sr_read": kafkaSchemaRegistryOperationSpecs, + "kafka_admin_sr_write": kafkaSchemaRegistryOperationSpecs, + "kafka_admin_connect_read": kafkaConnectOperationSpecs, + "kafka_admin_connect_write": kafkaConnectOperationSpecs, + } +} + func requireStringEnum(t *testing.T, toolName string, propertySchema any, expected []string) { t.Helper() actual, ok := stringEnum(propertySchema) diff --git a/pkg/mcp/builders/kafka/connect.go b/pkg/mcp/builders/kafka/connect.go index d4e78268..f364630f 100644 --- a/pkg/mcp/builders/kafka/connect.go +++ b/pkg/mcp/builders/kafka/connect.go @@ -27,16 +27,17 @@ import ( "github.com/streamnative/streamnative-mcp-server/pkg/kafka" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" - "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) -var kafkaConnectWriteOperations = map[string]struct{}{ - "create": {}, - "update": {}, - "delete": {}, - "restart": {}, - "pause": {}, - "resume": {}, +var kafkaConnectOperationSpecs = builders.OperationRegistry{ + {Name: "list", Mode: builders.OperationModeRead}, + {Name: "get", Mode: builders.OperationModeRead}, + {Name: "create", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "update", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "delete", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "restart", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "pause", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "resume", Mode: builders.OperationModeWrite, Destructive: true}, } // KafkaConnectToolBuilder implements the ToolBuilder interface for Kafka Connect @@ -110,9 +111,9 @@ func (b *KafkaConnectToolBuilder) buildKafkaConnectTool(mode toolMode) mcp.Tool operationDesc := "Operation to perform. Available operations:\n" + "- list: List all connectors or connector plugins in a cluster.\n" + "- get: Retrieve detailed information about a Kafka Connect cluster or specific connector." - operationEnum := []string{"list", "get"} + operationEnum := kafkaConnectOperationSpecs.NamesForMode(mode) toolName := "kafka_admin_connect_read" - annotation := toolannotations.ReadOnly("Read Kafka Connect") + annotation := builders.ToolAnnotationForMode(mode, "Read Kafka Connect", "Manage Kafka Connect") if isToolModeWrite(mode) { resourceDesc = "Resource to operate on. Available resources:\n" + "- connector: A single Kafka Connect connector instance that moves data between Kafka and external systems." @@ -124,9 +125,8 @@ func (b *KafkaConnectToolBuilder) buildKafkaConnectTool(mode toolMode) mcp.Tool "- restart: Restart a running connector (useful after failures or configuration changes).\n" + "- pause: Temporarily stop a connector from processing data.\n" + "- resume: Continue processing with a previously paused connector." - operationEnum = []string{"create", "update", "delete", "restart", "pause", "resume"} + operationEnum = kafkaConnectOperationSpecs.NamesForMode(mode) toolName = "kafka_admin_connect_write" - annotation = toolannotations.Destructive("Manage Kafka Connect") } toolDesc := "Read Apache Kafka Connect cluster, connector, and plugin information.\n" + @@ -222,8 +222,8 @@ func (b *KafkaConnectToolBuilder) buildKafkaConnectHandler(mode toolMode) func(c resource = strings.ToLower(resource) operation = strings.ToLower(operation) - if !validateModeOperation(mode, operation, kafkaConnectWriteOperations) { - return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil + if err := validateModeOperation(mode, operation, kafkaConnectOperationSpecs); err != nil { + return mcp.NewToolResultError(err.Error()), nil } // Get Kafka Connect client diff --git a/pkg/mcp/builders/kafka/groups.go b/pkg/mcp/builders/kafka/groups.go index a5c2da83..b7c78714 100644 --- a/pkg/mcp/builders/kafka/groups.go +++ b/pkg/mcp/builders/kafka/groups.go @@ -24,14 +24,16 @@ import ( "github.com/mark3labs/mcp-go/server" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" - "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" "github.com/twmb/franz-go/pkg/kadm" ) -var kafkaGroupWriteOperations = map[string]struct{}{ - "remove-members": {}, - "delete-offset": {}, - "set-offset": {}, +var kafkaGroupOperationSpecs = builders.OperationRegistry{ + {Name: "list", Mode: builders.OperationModeRead}, + {Name: "describe", Mode: builders.OperationModeRead}, + {Name: "offsets", Mode: builders.OperationModeRead}, + {Name: "remove-members", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "delete-offset", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "set-offset", Mode: builders.OperationModeWrite, Destructive: true}, } // KafkaGroupsToolBuilder implements the ToolBuilder interface for Kafka Consumer Groups @@ -100,20 +102,19 @@ func (b *KafkaGroupsToolBuilder) buildKafkaGroupsTool(mode toolMode) mcp.Tool { "- list: List all Kafka Consumer Groups in the cluster\n" + "- describe: Get detailed information about a specific Consumer Group, including members, offsets, and lag\n" + "- offsets: Get offsets for a specific consumer group" - operationEnum := []string{"list", "describe", "offsets"} + operationEnum := kafkaGroupOperationSpecs.NamesForMode(mode) toolName := "kafka_admin_groups_read" - annotation := toolannotations.ReadOnly("Read Kafka Consumer Groups") + annotation := builders.ToolAnnotationForMode(mode, "Read Kafka Consumer Groups", "Manage Kafka Consumer Groups") if isToolModeWrite(mode) { operationDesc = "Operation to perform. Available operations:\n" + "- remove-members: Remove specific members from a Consumer Group to force rebalancing or troubleshoot issues\n" + "- delete-offset: Delete a specific offset for a consumer group of a topic\n" + "- set-offset: Set a specific offset for a consumer group's topic-partition" - operationEnum = []string{"remove-members", "delete-offset", "set-offset"} + operationEnum = kafkaGroupOperationSpecs.NamesForMode(mode) resourceDesc = "Resource to operate on. Available resources:\n" + "- group: A single Kafka Consumer Group for membership and offset changes" resourceEnum = []string{"group"} toolName = "kafka_admin_groups_write" - annotation = toolannotations.Destructive("Manage Kafka Consumer Groups") } toolDesc := "Read Apache Kafka Consumer Groups.\n" + @@ -207,8 +208,8 @@ func (b *KafkaGroupsToolBuilder) buildKafkaGroupsHandler(mode toolMode) func(con resource = strings.ToLower(resource) operation = strings.ToLower(operation) - if !validateModeOperation(mode, operation, kafkaGroupWriteOperations) { - return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil + if err := validateModeOperation(mode, operation, kafkaGroupOperationSpecs); err != nil { + return mcp.NewToolResultError(err.Error()), nil } // Get Kafka admin client diff --git a/pkg/mcp/builders/kafka/operation_docs_test.go b/pkg/mcp/builders/kafka/operation_docs_test.go new file mode 100644 index 00000000..55bb6c24 --- /dev/null +++ b/pkg/mcp/builders/kafka/operation_docs_test.go @@ -0,0 +1,83 @@ +// Copyright 2026 StreamNative +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package kafka + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" + "github.com/stretchr/testify/require" +) + +type kafkaOperationDocSpec struct { + File string + ReadTool string + WriteTool string + Registry builders.OperationRegistry +} + +func TestKafkaOperationDocsMatchSpecs(t *testing.T) { + for _, spec := range kafkaOperationDocSpecs() { + docPath := filepath.Join("..", "..", "..", "..", "docs", "tools", spec.File) + // #nosec G304 -- docPath is built from fixed test fixtures in kafkaOperationDocSpecs. + content, err := os.ReadFile(docPath) + require.NoError(t, err, spec.File) + require.Equal(t, expectedKafkaOperationBlock(spec), extractGeneratedOperationBlock(t, string(content)), spec.File) + } +} + +func kafkaOperationDocSpecs() []kafkaOperationDocSpec { + return []kafkaOperationDocSpec{ + {File: "kafka_admin_topics.md", ReadTool: "kafka_admin_topics_read", WriteTool: "kafka_admin_topics_write", Registry: kafkaTopicOperationSpecs}, + {File: "kafka_admin_groups.md", ReadTool: "kafka_admin_groups_read", WriteTool: "kafka_admin_groups_write", Registry: kafkaGroupOperationSpecs}, + {File: "kafka_admin_schema_registry.md", ReadTool: "kafka_admin_sr_read", WriteTool: "kafka_admin_sr_write", Registry: kafkaSchemaRegistryOperationSpecs}, + {File: "kafka_admin_connect.md", ReadTool: "kafka_admin_connect_read", WriteTool: "kafka_admin_connect_write", Registry: kafkaConnectOperationSpecs}, + } +} + +func expectedKafkaOperationBlock(spec kafkaOperationDocSpec) string { + return strings.Join([]string{ + "", + "| Tool | Mode | Operations |", + "|---|---|---|", + fmt.Sprintf("| `%s` | read | %s |", spec.ReadTool, formatOperationNames(spec.Registry.NamesForMode(builders.OperationModeRead))), + fmt.Sprintf("| `%s` | write | %s |", spec.WriteTool, formatOperationNames(spec.Registry.NamesForMode(builders.OperationModeWrite))), + "", + }, "\n") +} + +func extractGeneratedOperationBlock(t *testing.T, content string) string { + t.Helper() + startMarker := "" + endMarker := "" + start := strings.Index(content, startMarker) + require.NotEqual(t, -1, start, "missing generated operation start marker") + end := strings.Index(content[start:], endMarker) + require.NotEqual(t, -1, end, "missing generated operation end marker") + end += start + len(endMarker) + return strings.TrimSpace(content[start:end]) +} + +func formatOperationNames(operations []string) string { + quoted := make([]string, 0, len(operations)) + for _, operation := range operations { + quoted = append(quoted, "`"+operation+"`") + } + return strings.Join(quoted, ", ") +} diff --git a/pkg/mcp/builders/kafka/schema_registry.go b/pkg/mcp/builders/kafka/schema_registry.go index 7257ceea..25f3b7b4 100644 --- a/pkg/mcp/builders/kafka/schema_registry.go +++ b/pkg/mcp/builders/kafka/schema_registry.go @@ -25,14 +25,15 @@ import ( "github.com/mark3labs/mcp-go/server" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" - "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" "github.com/twmb/franz-go/pkg/sr" ) -var kafkaSchemaRegistryWriteOperations = map[string]struct{}{ - "set": {}, - "create": {}, - "delete": {}, +var kafkaSchemaRegistryOperationSpecs = builders.OperationRegistry{ + {Name: "list", Mode: builders.OperationModeRead}, + {Name: "get", Mode: builders.OperationModeRead}, + {Name: "set", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "create", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "delete", Mode: builders.OperationModeWrite, Destructive: true}, } // KafkaSchemaRegistryToolBuilder implements the ToolBuilder interface for Kafka Schema Registry @@ -105,9 +106,9 @@ func (b *KafkaSchemaRegistryToolBuilder) buildKafkaSchemaRegistryTool(mode toolM operationDesc := "Operation to perform. Available operations:\n" + "- list: List all subjects, versions for a subject, or supported schema types\n" + "- get: Get a subject's latest schema, a specific version, or compatibility level" - operationEnum := []string{"list", "get"} + operationEnum := kafkaSchemaRegistryOperationSpecs.NamesForMode(mode) toolName := "kafka_admin_sr_read" - annotation := toolannotations.ReadOnly("Read Kafka Schema Registry") + annotation := builders.ToolAnnotationForMode(mode, "Read Kafka Schema Registry", "Manage Kafka Schema Registry") if isToolModeWrite(mode) { resourceDesc = "Resource to operate on. Available resources:\n" + "- subject: A specific schema subject to register or delete\n" + @@ -118,9 +119,8 @@ func (b *KafkaSchemaRegistryToolBuilder) buildKafkaSchemaRegistryTool(mode toolM "- set: Set compatibility level for global or subject-specific schema evolution\n" + "- create: Register a new schema for a subject\n" + "- delete: Delete a schema subject or a specific version" - operationEnum = []string{"set", "create", "delete"} + operationEnum = kafkaSchemaRegistryOperationSpecs.NamesForMode(mode) toolName = "kafka_admin_sr_write" - annotation = toolannotations.Destructive("Manage Kafka Schema Registry") } toolDesc := "Read Apache Kafka Schema Registry metadata and schemas.\n" + @@ -214,8 +214,8 @@ func (b *KafkaSchemaRegistryToolBuilder) buildKafkaSchemaRegistryHandler(mode to resource = strings.ToLower(resource) operation = strings.ToLower(operation) - if !validateModeOperation(mode, operation, kafkaSchemaRegistryWriteOperations) { - return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil + if err := validateModeOperation(mode, operation, kafkaSchemaRegistryOperationSpecs); err != nil { + return mcp.NewToolResultError(err.Error()), nil } // Get Schema Registry client diff --git a/pkg/mcp/builders/kafka/tool_mode.go b/pkg/mcp/builders/kafka/tool_mode.go index 9cb9a953..c6277808 100644 --- a/pkg/mcp/builders/kafka/tool_mode.go +++ b/pkg/mcp/builders/kafka/tool_mode.go @@ -15,47 +15,25 @@ package kafka import ( - "strings" - "github.com/mark3labs/mcp-go/mcp" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" ) -type toolMode string +type toolMode = builders.OperationMode const ( - toolModeRead toolMode = "read" - toolModeWrite toolMode = "write" + toolModeRead = builders.OperationModeRead + toolModeWrite = builders.OperationModeWrite ) func isToolModeWrite(mode toolMode) bool { return mode == toolModeWrite } -func validateModeOperation(mode toolMode, operation string, writeOperations map[string]struct{}) bool { - _, isWrite := writeOperations[strings.ToLower(operation)] - return (mode == toolModeWrite) == isWrite +func validateModeOperation(mode toolMode, operation string, operations builders.OperationRegistry) error { + return operations.ValidateModeOperation(mode, operation) } func pruneToolInputSchema(tool *mcp.Tool, allowedProperties []string) { - allowed := make(map[string]struct{}, len(allowedProperties)) - for _, property := range allowedProperties { - allowed[property] = struct{}{} - } - - for property := range tool.InputSchema.Properties { - if _, ok := allowed[property]; !ok { - delete(tool.InputSchema.Properties, property) - } - } - - if len(tool.InputSchema.Required) == 0 { - return - } - required := tool.InputSchema.Required[:0] - for _, property := range tool.InputSchema.Required { - if _, ok := allowed[property]; ok { - required = append(required, property) - } - } - tool.InputSchema.Required = required + builders.PruneToolInputSchema(tool, allowedProperties) } diff --git a/pkg/mcp/builders/kafka/topics.go b/pkg/mcp/builders/kafka/topics.go index 493f488a..3472bd07 100644 --- a/pkg/mcp/builders/kafka/topics.go +++ b/pkg/mcp/builders/kafka/topics.go @@ -24,13 +24,15 @@ import ( "github.com/mark3labs/mcp-go/server" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" - "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" "github.com/twmb/franz-go/pkg/kadm" ) -var kafkaTopicWriteOperations = map[string]struct{}{ - "create": {}, - "delete": {}, +var kafkaTopicOperationSpecs = builders.OperationRegistry{ + {Name: "list", Mode: builders.OperationModeRead}, + {Name: "get", Mode: builders.OperationModeRead}, + {Name: "metadata", Mode: builders.OperationModeRead}, + {Name: "create", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "delete", Mode: builders.OperationModeWrite, Destructive: true}, } // KafkaTopicsToolBuilder implements the ToolBuilder interface for Kafka Topics @@ -99,19 +101,18 @@ func (b *KafkaTopicsToolBuilder) buildKafkaTopicsTool(mode toolMode) mcp.Tool { "- list: List all topics in the Kafka cluster, optionally including internal topics\n" + "- get: Get detailed configuration for a specific topic\n" + "- metadata: Get metadata for a specific topic\n" - operationEnum := []string{"list", "get", "metadata"} + operationEnum := kafkaTopicOperationSpecs.NamesForMode(mode) toolName := "kafka_admin_topics_read" - annotation := toolannotations.ReadOnly("Read Kafka Topics") + annotation := builders.ToolAnnotationForMode(mode, "Read Kafka Topics", "Manage Kafka Topics") if isToolModeWrite(mode) { operationDesc = "Operation to perform. Available operations:\n" + "- create: Create a new topic with specified partitions, replication factor, and optional configs\n" + "- delete: Delete an existing topic\n" - operationEnum = []string{"create", "delete"} + operationEnum = kafkaTopicOperationSpecs.NamesForMode(mode) resourceDesc = "Resource to operate on. Available resources:\n" + "- topic: A single Kafka topic for create or delete operations" resourceEnum = []string{"topic"} toolName = "kafka_admin_topics_write" - annotation = toolannotations.Destructive("Manage Kafka Topics") } toolDesc := "Read Apache Kafka topic metadata and lists.\n" + @@ -207,8 +208,8 @@ func (b *KafkaTopicsToolBuilder) buildKafkaTopicsHandler(mode toolMode) func(con resource = strings.ToLower(resource) operation = strings.ToLower(operation) - if !validateModeOperation(mode, operation, kafkaTopicWriteOperations) { - return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil + if err := validateModeOperation(mode, operation, kafkaTopicOperationSpecs); err != nil { + return mcp.NewToolResultError(err.Error()), nil } // Get Kafka admin client diff --git a/pkg/mcp/builders/operation_annotations.go b/pkg/mcp/builders/operation_annotations.go new file mode 100644 index 00000000..d6e0a875 --- /dev/null +++ b/pkg/mcp/builders/operation_annotations.go @@ -0,0 +1,30 @@ +// Copyright 2026 StreamNative +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package builders + +import ( + "github.com/mark3labs/mcp-go/mcp" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" +) + +// ToolAnnotationForMode selects Claude connector safety annotations from the +// operation mode so schemas, validation, and annotations share the same mode +// vocabulary. +func ToolAnnotationForMode(mode OperationMode, readTitle, writeTitle string) mcp.ToolOption { + if mode == OperationModeWrite { + return toolannotations.Destructive(writeTitle) + } + return toolannotations.ReadOnly(readTitle) +} diff --git a/pkg/mcp/builders/operations.go b/pkg/mcp/builders/operations.go new file mode 100644 index 00000000..bd5eae20 --- /dev/null +++ b/pkg/mcp/builders/operations.go @@ -0,0 +1,170 @@ +// Copyright 2026 StreamNative +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package builders + +import ( + "fmt" + "sort" + "strings" +) + +// OperationMode classifies whether an MCP operation is safe to expose through a +// read-only tool or must be exposed through a write/destructive tool. +type OperationMode string + +const ( + // OperationModeRead marks an operation as safe for read-only tools. + OperationModeRead OperationMode = "read" + // OperationModeWrite marks an operation as requiring a write/destructive tool. + OperationModeWrite OperationMode = "write" +) + +// ParamSpec describes an operation parameter. Builders can use this metadata for +// generated documentation and schema checks without coupling it to mcp-go types. +type ParamSpec struct { + Name string + Description string + Required bool +} + +// OperationHandler is a package-neutral handler placeholder for builders that +// later choose to dispatch directly from operation metadata. +type OperationHandler any + +// OperationSpec is the single source of truth for an operation's safety mode and +// documentation metadata. +type OperationSpec struct { + Name string + Mode OperationMode + Description string + Destructive bool + Idempotent bool + Resources []string + Params []ParamSpec + Handler OperationHandler +} + +// OperationRegistry stores a tool family's operation specs in schema order. +type OperationRegistry []OperationSpec + +// Names returns operation names in registry order. +func (r OperationRegistry) Names() []string { + names := make([]string, 0, len(r)) + for _, spec := range r { + names = append(names, spec.Name) + } + return names +} + +// NamesForMode returns operation names for one mode in registry order. +func (r OperationRegistry) NamesForMode(mode OperationMode) []string { + names := make([]string, 0, len(r)) + for _, spec := range r { + if spec.Mode == mode { + names = append(names, spec.Name) + } + } + return names +} + +// ReadNames returns read operation names in registry order. +func (r OperationRegistry) ReadNames() []string { + return r.NamesForMode(OperationModeRead) +} + +// WriteNames returns write operation names in registry order. +func (r OperationRegistry) WriteNames() []string { + return r.NamesForMode(OperationModeWrite) +} + +// WriteSet returns write operation names as a lookup set. +func (r OperationRegistry) WriteSet() map[string]struct{} { + set := make(map[string]struct{}) + for _, spec := range r { + if spec.Mode == OperationModeWrite { + set[strings.ToLower(spec.Name)] = struct{}{} + } + } + return set +} + +// SpecFor returns the metadata for operation. Matching is case-insensitive. +func (r OperationRegistry) SpecFor(operation string) (OperationSpec, bool) { + operation = strings.ToLower(operation) + for _, spec := range r { + if strings.ToLower(spec.Name) == operation { + return spec, true + } + } + return OperationSpec{}, false +} + +// ValidateModeOperation rejects unknown operations and operations exposed through +// the wrong read/write tool mode. +func (r OperationRegistry) ValidateModeOperation(mode OperationMode, operation string) error { + spec, ok := r.SpecFor(operation) + if !ok { + return fmt.Errorf("unknown operation %q. Supported operations: %s", operation, strings.Join(r.NamesForMode(mode), ", ")) + } + if spec.Mode != mode { + return fmt.Errorf("operation %q is not available in %s mode", operation, mode) + } + return nil +} + +// DescriptionsForMode returns markdown bullet rows for operations with +// descriptions. Specs without descriptions are rendered as just the operation name. +func (r OperationRegistry) DescriptionsForMode(mode OperationMode) []string { + rows := make([]string, 0, len(r)) + for _, spec := range r { + if spec.Mode != mode { + continue + } + if spec.Description == "" { + rows = append(rows, "- "+spec.Name) + continue + } + rows = append(rows, fmt.Sprintf("- %s: %s", spec.Name, spec.Description)) + } + return rows +} + +// MustValidate checks that the registry has unique names and valid modes. It is +// intended for tests and init-time assertions in generated registries. +func (r OperationRegistry) MustValidate() { + seen := make(map[string]struct{}, len(r)) + for _, spec := range r { + name := strings.ToLower(spec.Name) + if name == "" { + panic("operation spec has empty name") + } + if _, ok := seen[name]; ok { + panic(fmt.Sprintf("duplicate operation spec %q", spec.Name)) + } + seen[name] = struct{}{} + switch spec.Mode { + case OperationModeRead, OperationModeWrite: + default: + panic(fmt.Sprintf("operation %q has invalid mode %q", spec.Name, spec.Mode)) + } + } +} + +// SortedNames returns operation names sorted lexicographically for stable tests. +func (r OperationRegistry) SortedNames() []string { + names := r.Names() + sort.Strings(names) + return names +} diff --git a/pkg/mcp/builders/operations_test.go b/pkg/mcp/builders/operations_test.go new file mode 100644 index 00000000..c51691ec --- /dev/null +++ b/pkg/mcp/builders/operations_test.go @@ -0,0 +1,57 @@ +// Copyright 2026 StreamNative +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package builders + +import ( + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" +) + +func TestOperationRegistryModeEnumsAndValidation(t *testing.T) { + registry := OperationRegistry{ + {Name: "list", Mode: OperationModeRead}, + {Name: "get", Mode: OperationModeRead}, + {Name: "create", Mode: OperationModeWrite, Destructive: true}, + } + + require.Equal(t, []string{"list", "get"}, registry.NamesForMode(OperationModeRead)) + require.Equal(t, []string{"create"}, registry.NamesForMode(OperationModeWrite)) + require.NoError(t, registry.ValidateModeOperation(OperationModeRead, "LIST")) + require.NoError(t, registry.ValidateModeOperation(OperationModeWrite, "create")) + require.ErrorContains(t, registry.ValidateModeOperation(OperationModeRead, "create"), "not available in read mode") + require.ErrorContains(t, registry.ValidateModeOperation(OperationModeWrite, "unknown"), "unknown operation") +} + +func TestToolAnnotationForMode(t *testing.T) { + read := ToolAnnotationForMode(OperationModeRead, "Read Things", "Manage Things") + write := ToolAnnotationForMode(OperationModeWrite, "Read Things", "Manage Things") + + readTool := mcp.NewTool("read", read) + writeTool := mcp.NewTool("write", write) + + require.Equal(t, "Read Things", readTool.Annotations.Title) + require.NotNil(t, readTool.Annotations.ReadOnlyHint) + require.NotNil(t, readTool.Annotations.DestructiveHint) + require.True(t, *readTool.Annotations.ReadOnlyHint) + require.False(t, *readTool.Annotations.DestructiveHint) + + require.Equal(t, "Manage Things", writeTool.Annotations.Title) + require.NotNil(t, writeTool.Annotations.ReadOnlyHint) + require.NotNil(t, writeTool.Annotations.DestructiveHint) + require.False(t, *writeTool.Annotations.ReadOnlyHint) + require.True(t, *writeTool.Annotations.DestructiveHint) +} diff --git a/pkg/mcp/builders/pulsar/annotation_compliance_test.go b/pkg/mcp/builders/pulsar/annotation_compliance_test.go index e7556adb..eecaf375 100644 --- a/pkg/mcp/builders/pulsar/annotation_compliance_test.go +++ b/pkg/mcp/builders/pulsar/annotation_compliance_test.go @@ -24,6 +24,15 @@ import ( "github.com/stretchr/testify/require" ) +func TestPulsarOperationSpecsAreValid(t *testing.T) { + for name, registry := range pulsarComplianceOperationSpecs() { + require.NotPanics(t, registry.MustValidate, name) + require.NotEmpty(t, registry.NamesForMode(builders.OperationModeRead), name) + require.NotEmpty(t, registry.NamesForMode(builders.OperationModeWrite), name) + require.ErrorContains(t, registry.ValidateModeOperation(builders.OperationModeRead, "__unknown__"), "unknown operation", name) + } +} + func TestPulsarToolAnnotationCompliance(t *testing.T) { builderList := allPulsarComplianceBuilders() @@ -56,17 +65,25 @@ func TestPulsarToolAnnotationCompliance(t *testing.T) { func assertOperationEnumMode(t *testing.T, toolName string, operationSchema any) { t.Helper() + registry, ok := pulsarComplianceOperationSpecs()[toolName] + if !ok { + return + } rawEnum, ok := pulsarStringEnum(operationSchema) if !ok { return } - writeOperations := pulsarComplianceWriteOperations() seenRead, seenWrite := false, false for _, op := range rawEnum { - if _, isWrite := writeOperations[op]; isWrite { - seenWrite = true - } else { + spec, ok := registry.SpecFor(op) + require.True(t, ok, "%s operation %q must be declared in OperationSpec", toolName, op) + switch spec.Mode { + case builders.OperationModeRead: seenRead = true + case builders.OperationModeWrite: + seenWrite = true + default: + require.Failf(t, "invalid operation mode", "%s operation %q has mode %q", toolName, op, spec.Mode) } } require.False(t, seenRead && seenWrite, toolName) @@ -88,34 +105,37 @@ func pulsarStringEnum(propertySchema any) ([]string, bool) { return rawEnum, ok } -func pulsarComplianceWriteOperations() map[string]struct{} { - writeOperations := map[string]struct{}{ - "create": {}, - "update": {}, - "delete": {}, - "upload": {}, - } - for _, source := range []map[string]struct{}{ - readOnlyRestrictedTopicOperations, - readOnlyRestrictedSubscriptionOperations, - pulsarNamespaceWriteOperations, - readOnlyRestrictedTopicPolicyOperations, - pulsarBrokerWriteOperations, - pulsarClusterWriteOperations, - readOnlyRestrictedFunctionOperations, - pulsarSinkWriteOperations, - pulsarSourceWriteOperations, - pulsarPackageWriteOperations, - pulsarSchemaWriteOperations, - pulsarTenantWriteOperations, - pulsarNsIsolationPolicyWriteOperations, - pulsarResourceQuotaWriteOperations, - } { - for op := range source { - writeOperations[op] = struct{}{} - } +func pulsarComplianceOperationSpecs() map[string]builders.OperationRegistry { + return map[string]builders.OperationRegistry{ + "pulsar_admin_brokers_read": pulsarBrokerOperationSpecs, + "pulsar_admin_brokers_write": pulsarBrokerOperationSpecs, + "pulsar_admin_cluster_read": pulsarClusterOperationSpecs, + "pulsar_admin_cluster_write": pulsarClusterOperationSpecs, + "pulsar_admin_functions_read": pulsarFunctionOperationSpecs, + "pulsar_admin_functions_write": pulsarFunctionOperationSpecs, + "pulsar_admin_namespace_read": pulsarNamespaceOperationSpecs, + "pulsar_admin_namespace_write": pulsarNamespaceOperationSpecs, + "pulsar_admin_nsisolationpolicy_read": pulsarNsIsolationPolicyOperationSpecs, + "pulsar_admin_nsisolationpolicy_write": pulsarNsIsolationPolicyOperationSpecs, + "pulsar_admin_package_read": pulsarPackageOperationSpecs, + "pulsar_admin_package_write": pulsarPackageOperationSpecs, + "pulsar_admin_resourcequota_read": pulsarResourceQuotaOperationSpecs, + "pulsar_admin_resourcequota_write": pulsarResourceQuotaOperationSpecs, + "pulsar_admin_schema_read": pulsarSchemaOperationSpecs, + "pulsar_admin_schema_write": pulsarSchemaOperationSpecs, + "pulsar_admin_sinks_read": pulsarSinkOperationSpecs, + "pulsar_admin_sinks_write": pulsarSinkOperationSpecs, + "pulsar_admin_sources_read": pulsarSourceOperationSpecs, + "pulsar_admin_sources_write": pulsarSourceOperationSpecs, + "pulsar_admin_subscription_read": pulsarSubscriptionOperationSpecs, + "pulsar_admin_subscription_write": pulsarSubscriptionOperationSpecs, + "pulsar_admin_tenant_read": pulsarTenantOperationSpecs, + "pulsar_admin_tenant_write": pulsarTenantOperationSpecs, + "pulsar_admin_topic_policy_read": pulsarTopicPolicyOperationSpecs, + "pulsar_admin_topic_policy_write": pulsarTopicPolicyOperationSpecs, + "pulsar_admin_topic_read": pulsarTopicOperationSpecs, + "pulsar_admin_topic_write": pulsarTopicOperationSpecs, } - return writeOperations } func TestPulsarSplitToolsExposeModeSpecificParameters(t *testing.T) { diff --git a/pkg/mcp/builders/pulsar/brokers.go b/pkg/mcp/builders/pulsar/brokers.go index a4b5cdde..e6335001 100644 --- a/pkg/mcp/builders/pulsar/brokers.go +++ b/pkg/mcp/builders/pulsar/brokers.go @@ -25,12 +25,13 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" - "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) -var pulsarBrokerWriteOperations = map[string]struct{}{ - "update": {}, - "delete": {}, +var pulsarBrokerOperationSpecs = builders.OperationRegistry{ + {Name: "list", Mode: builders.OperationModeRead}, + {Name: "get", Mode: builders.OperationModeRead}, + {Name: "update", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "delete", Mode: builders.OperationModeWrite, Destructive: true}, } // PulsarAdminBrokersToolBuilder implements the ToolBuilder interface for Pulsar admin brokers @@ -97,24 +98,23 @@ func (b *PulsarAdminBrokersToolBuilder) buildPulsarAdminBrokersTool(mode toolMod "- config: Broker configurations\n" + "- namespaces: Namespaces owned by a broker" resourceEnum := []string{"brokers", "health", "config", "namespaces"} - operationEnum := []string{"list", "get"} + operationEnum := pulsarBrokerOperationSpecs.NamesForMode(mode) operationDesc := "Operation to perform, available options:\n" + "- list: List resources (used with brokers)\n" + "- get: Retrieve resource information (used with health, config, namespaces)" toolDesc := "Read Apache Pulsar broker resources. This tool lists active brokers, checks broker health, reads broker configurations, and views namespaces owned by a broker." toolName := "pulsar_admin_brokers_read" - annotation := toolannotations.ReadOnly("Read Pulsar Brokers") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Brokers", "Manage Pulsar Brokers") if isToolModeWrite(mode) { resourceDesc = "Type of broker resource to access, available options:\n" + "- config: Broker dynamic configuration values" resourceEnum = []string{"config"} - operationEnum = []string{"update", "delete"} + operationEnum = pulsarBrokerOperationSpecs.NamesForMode(mode) operationDesc = "Operation to perform, available options:\n" + "- update: Update a broker dynamic configuration value\n" + "- delete: Delete a broker dynamic configuration value" toolDesc = "Manage Apache Pulsar broker configurations. This write tool updates or deletes broker dynamic configuration values." toolName = "pulsar_admin_brokers_write" - annotation = toolannotations.Destructive("Manage Pulsar Brokers") } tool := mcp.NewTool(toolName, @@ -187,7 +187,7 @@ func (b *PulsarAdminBrokersToolBuilder) buildPulsarAdminBrokersHandler(mode tool operation, err := request.RequireString("operation") if err != nil { return mcp.NewToolResultError("Missing required operation parameter. " + - "Please specify one of: " + modeSupportedOperations(mode, []string{"list", "get"}, []string{"update", "delete"}) + " based on the resource type."), nil + "Please specify one of: " + modeSupportedOperations(mode, pulsarBrokerOperationSpecs) + " based on the resource type."), nil } // Validate if the parameter combination is valid @@ -196,8 +196,8 @@ func (b *PulsarAdminBrokersToolBuilder) buildPulsarAdminBrokersHandler(mode tool return mcp.NewToolResultError(errMsg), nil } - if !validateModeOperation(mode, operation, pulsarBrokerWriteOperations) { - return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil + if err := validateModeOperation(mode, operation, pulsarBrokerOperationSpecs); err != nil { + return mcp.NewToolResultError(err.Error()), nil } // Process request based on resource type diff --git a/pkg/mcp/builders/pulsar/cluster.go b/pkg/mcp/builders/pulsar/cluster.go index 44070a25..5cd2a330 100644 --- a/pkg/mcp/builders/pulsar/cluster.go +++ b/pkg/mcp/builders/pulsar/cluster.go @@ -25,13 +25,14 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" - "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) -var pulsarClusterWriteOperations = map[string]struct{}{ - "create": {}, - "update": {}, - "delete": {}, +var pulsarClusterOperationSpecs = builders.OperationRegistry{ + {Name: "list", Mode: builders.OperationModeRead}, + {Name: "get", Mode: builders.OperationModeRead}, + {Name: "create", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "update", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "delete", Mode: builders.OperationModeWrite, Destructive: true}, } // PulsarAdminClusterToolBuilder implements the ToolBuilder interface for Pulsar Admin Cluster tools @@ -113,9 +114,9 @@ func (b *PulsarAdminClusterToolBuilder) buildClusterTool(mode toolMode) mcp.Tool "- list: List resources (used with cluster, failure_domain)\n" + "- get: Retrieve resource information (used with cluster, peer_clusters, failure_domain)" - operationEnum := []string{"list", "get"} + operationEnum := pulsarClusterOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_cluster_read" - annotation := toolannotations.ReadOnly("Read Pulsar Clusters") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Clusters", "Manage Pulsar Clusters") if isToolModeWrite(mode) { toolDesc = "Manage Apache Pulsar clusters.\n" + "This write tool creates, updates, or deletes clusters and failure domains, and updates peer cluster settings." @@ -123,9 +124,8 @@ func (b *PulsarAdminClusterToolBuilder) buildClusterTool(mode toolMode) mcp.Tool "- create: Create a new resource (used with cluster, failure_domain)\n" + "- update: Update an existing resource (used with cluster, peer_clusters, failure_domain)\n" + "- delete: Delete a resource (used with cluster, failure_domain)" - operationEnum = []string{"create", "update", "delete"} + operationEnum = pulsarClusterOperationSpecs.NamesForMode(mode) toolName = "pulsar_admin_cluster_write" - annotation = toolannotations.Destructive("Manage Pulsar Clusters") } tool := mcp.NewTool(toolName, @@ -211,7 +211,7 @@ func (b *PulsarAdminClusterToolBuilder) buildClusterHandler(mode toolMode) func( operation, err := request.RequireString("operation") if err != nil { return mcp.NewToolResultError("Missing required operation parameter. " + - "Please specify one of: " + modeSupportedOperations(mode, []string{"list", "get"}, []string{"create", "update", "delete"}) + " based on the resource type."), nil + "Please specify one of: " + modeSupportedOperations(mode, pulsarClusterOperationSpecs) + " based on the resource type."), nil } // Validate if the parameter combination is valid @@ -220,8 +220,8 @@ func (b *PulsarAdminClusterToolBuilder) buildClusterHandler(mode toolMode) func( return mcp.NewToolResultError(errMsg), nil } - if !validateModeOperation(mode, operation, pulsarClusterWriteOperations) { - return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil + if err := validateModeOperation(mode, operation, pulsarClusterOperationSpecs); err != nil { + return mcp.NewToolResultError(err.Error()), nil } // Process request based on resource type diff --git a/pkg/mcp/builders/pulsar/functions.go b/pkg/mcp/builders/pulsar/functions.go index 3d3311b9..6b58dfbc 100644 --- a/pkg/mcp/builders/pulsar/functions.go +++ b/pkg/mcp/builders/pulsar/functions.go @@ -28,7 +28,6 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" - "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" "gopkg.in/yaml.v2" ) @@ -105,9 +104,9 @@ func (b *PulsarAdminFunctionsToolBuilder) buildPulsarAdminFunctionsTool(mode too "- querystate: Query state stored by a stateful function for a specific key\n" + "- download: Download function package data from Pulsar to a local file" - operationEnum := []string{"list", "get", "status", "stats", "querystate", "download"} + operationEnum := pulsarFunctionOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_functions_read" - annotation := toolannotations.ReadOnly("Read Pulsar Functions") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Functions", "Manage Pulsar Functions") if isToolModeWrite(mode) { toolDesc = "Manage Apache Pulsar Functions for stream processing. " + "This write tool deploys, updates, deletes, starts, stops, restarts, stores state for, triggers, or uploads packages for functions." @@ -121,9 +120,8 @@ func (b *PulsarAdminFunctionsToolBuilder) buildPulsarAdminFunctionsTool(mode too "- putstate: Store state in a function's state store\n" + "- trigger: Manually trigger a function with a specific value\n" + "- upload: Upload a local file into Pulsar function package storage" - operationEnum = []string{"create", "update", "delete", "start", "stop", "restart", "putstate", "trigger", "upload"} + operationEnum = pulsarFunctionOperationSpecs.NamesForMode(mode) toolName = "pulsar_admin_functions_write" - annotation = toolannotations.Destructive("Manage Pulsar Functions") } tool := mcp.NewTool(toolName, @@ -340,16 +338,9 @@ func (b *PulsarAdminFunctionsToolBuilder) buildPulsarAdminFunctionsHandler(mode return b.handleError("get operation", err), nil } - // Check if the operation is valid - if !isSupportedFunctionOperation(operation) { - return b.handleError("validate operation", fmt.Errorf("invalid operation: '%s'. Supported operations: %s", operation, - modeSupportedOperations(mode, - []string{"list", "get", "status", "stats", "querystate", "download"}, - []string{"create", "update", "delete", "start", "stop", "restart", "putstate", "trigger", "upload"}))), nil - } - - if !validateModeOperation(mode, operation, readOnlyRestrictedFunctionOperations) { - return b.handleError("check permissions", fmt.Errorf("operation %q is not available in %s mode", operation, mode)), nil + // Check if the operation is valid for this tool mode. + if err := validateModeOperation(mode, operation, pulsarFunctionOperationSpecs); err != nil { + return b.handleError("validate operation", err), nil } var identity functionIdentity @@ -793,44 +784,32 @@ const ( defaultNamespace = "default" ) -var supportedFunctionOperations = map[string]struct{}{ - "list": {}, - "get": {}, - "status": {}, - "stats": {}, - "querystate": {}, - "create": {}, - "update": {}, - "delete": {}, - "download": {}, - "start": {}, - "stop": {}, - "restart": {}, - "putstate": {}, - "trigger": {}, - "upload": {}, -} - -var readOnlyRestrictedFunctionOperations = map[string]struct{}{ - "create": {}, - "update": {}, - "delete": {}, - "start": {}, - "stop": {}, - "restart": {}, - "putstate": {}, - "trigger": {}, - "upload": {}, +var pulsarFunctionOperationSpecs = builders.OperationRegistry{ + {Name: "list", Mode: builders.OperationModeRead}, + {Name: "get", Mode: builders.OperationModeRead}, + {Name: "status", Mode: builders.OperationModeRead}, + {Name: "stats", Mode: builders.OperationModeRead}, + {Name: "querystate", Mode: builders.OperationModeRead}, + {Name: "download", Mode: builders.OperationModeRead}, + {Name: "create", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "update", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "delete", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "start", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "stop", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "restart", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "putstate", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "trigger", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "upload", Mode: builders.OperationModeWrite, Destructive: true}, } func isSupportedFunctionOperation(operation string) bool { - _, ok := supportedFunctionOperations[operation] + _, ok := pulsarFunctionOperationSpecs.SpecFor(operation) return ok } func isReadOnlyRestrictedFunctionOperation(operation string) bool { - _, ok := readOnlyRestrictedFunctionOperations[operation] - return ok + spec, ok := pulsarFunctionOperationSpecs.SpecFor(operation) + return ok && spec.Mode == builders.OperationModeWrite } func (b *PulsarAdminFunctionsToolBuilder) parseFunctionIdentity(request mcp.CallToolRequest, operation string) (functionIdentity, error) { diff --git a/pkg/mcp/builders/pulsar/namespace.go b/pkg/mcp/builders/pulsar/namespace.go index fbac718b..bfc94cd5 100644 --- a/pkg/mcp/builders/pulsar/namespace.go +++ b/pkg/mcp/builders/pulsar/namespace.go @@ -26,16 +26,17 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" - "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) -var pulsarNamespaceWriteOperations = map[string]struct{}{ - "create": {}, - "delete": {}, - "clear_backlog": {}, - "unsubscribe": {}, - "unload": {}, - "split_bundle": {}, +var pulsarNamespaceOperationSpecs = builders.OperationRegistry{ + {Name: "list", Mode: builders.OperationModeRead}, + {Name: "get_topics", Mode: builders.OperationModeRead}, + {Name: "create", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "delete", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "clear_backlog", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "unsubscribe", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "unload", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "split_bundle", Mode: builders.OperationModeWrite, Destructive: true}, } // PulsarAdminNamespaceToolBuilder implements the ToolBuilder interface for Pulsar Admin Namespace tools @@ -106,9 +107,9 @@ func (b *PulsarAdminNamespaceToolBuilder) buildNamespaceTool(mode toolMode) mcp. "- list: List all namespaces for a tenant\n" + "- get_topics: Get all topics within a namespace" - operationEnum := []string{"list", "get_topics"} + operationEnum := pulsarNamespaceOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_namespace_read" - annotation := toolannotations.ReadOnly("Read Pulsar Namespaces") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Namespaces", "Manage Pulsar Namespaces") if isToolModeWrite(mode) { toolDesc = "Manage Apache Pulsar namespaces. " + "This write tool creates, deletes, unloads, splits bundles, clears backlog, or unsubscribes namespace subscriptions." @@ -119,9 +120,8 @@ func (b *PulsarAdminNamespaceToolBuilder) buildNamespaceTool(mode toolMode) mcp. "- unsubscribe: Unsubscribe from a subscription for all topics in a namespace\n" + "- unload: Unload a namespace from the current serving broker\n" + "- split_bundle: Split a namespace bundle" - operationEnum = []string{"create", "delete", "clear_backlog", "unsubscribe", "unload", "split_bundle"} + operationEnum = pulsarNamespaceOperationSpecs.NamesForMode(mode) toolName = "pulsar_admin_namespace_write" - annotation = toolannotations.Destructive("Manage Pulsar Namespaces") } tool := mcp.NewTool(toolName, @@ -192,8 +192,8 @@ func (b *PulsarAdminNamespaceToolBuilder) buildNamespaceHandler(mode toolMode) f return mcp.NewToolResultError(fmt.Sprintf("Failed to get admin client: %v", err)), nil } - if !validateModeOperation(mode, operation, pulsarNamespaceWriteOperations) { - return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil + if err := validateModeOperation(mode, operation, pulsarNamespaceOperationSpecs); err != nil { + return mcp.NewToolResultError(err.Error()), nil } // Route to appropriate handler based on operation @@ -220,9 +220,7 @@ func (b *PulsarAdminNamespaceToolBuilder) buildNamespaceHandler(mode toolMode) f } default: return mcp.NewToolResultError(fmt.Sprintf("Unknown operation: %s. Supported operations: %s", operation, - modeSupportedOperations(mode, - []string{"list", "get_topics"}, - []string{"create", "delete", "clear_backlog", "unsubscribe", "unload", "split_bundle"}))), nil + modeSupportedOperations(mode, pulsarNamespaceOperationSpecs))), nil } // Should not reach here diff --git a/pkg/mcp/builders/pulsar/nsisolationpolicy.go b/pkg/mcp/builders/pulsar/nsisolationpolicy.go index 926cda3b..b0b87783 100644 --- a/pkg/mcp/builders/pulsar/nsisolationpolicy.go +++ b/pkg/mcp/builders/pulsar/nsisolationpolicy.go @@ -27,12 +27,13 @@ import ( "github.com/streamnative/streamnative-mcp-server/pkg/common" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" - "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) -var pulsarNsIsolationPolicyWriteOperations = map[string]struct{}{ - "set": {}, - "delete": {}, +var pulsarNsIsolationPolicyOperationSpecs = builders.OperationRegistry{ + {Name: "get", Mode: builders.OperationModeRead}, + {Name: "list", Mode: builders.OperationModeRead}, + {Name: "set", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "delete", Mode: builders.OperationModeWrite, Destructive: true}, } // PulsarAdminNsIsolationPolicyToolBuilder implements the ToolBuilder interface for Pulsar admin namespace isolation policies @@ -106,9 +107,9 @@ func (b *PulsarAdminNsIsolationPolicyToolBuilder) buildNsIsolationPolicyTool(mod "- get: Get resource details\n" + "- list: List all instances of the resource" - operationEnum := []string{"get", "list"} + operationEnum := pulsarNsIsolationPolicyOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_nsisolationpolicy_read" - annotation := toolannotations.ReadOnly("Read Pulsar Namespace Isolation Policies") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Namespace Isolation Policies", "Manage Pulsar Namespace Isolation Policies") if isToolModeWrite(mode) { toolDesc = "Manage namespace isolation policies in a Pulsar cluster. " + "This write tool creates, updates, or deletes namespace isolation policies." @@ -118,9 +119,8 @@ func (b *PulsarAdminNsIsolationPolicyToolBuilder) buildNsIsolationPolicyTool(mod operationDesc = "Operation to perform. Available operations:\n" + "- set: Create or update a namespace isolation policy (requires super-user permissions)\n" + "- delete: Delete a namespace isolation policy (requires super-user permissions)" - operationEnum = []string{"set", "delete"} + operationEnum = pulsarNsIsolationPolicyOperationSpecs.NamesForMode(mode) toolName = "pulsar_admin_nsisolationpolicy_write" - annotation = toolannotations.Destructive("Manage Pulsar Namespace Isolation Policies") } tool := mcp.NewTool(toolName, @@ -216,8 +216,8 @@ func (b *PulsarAdminNsIsolationPolicyToolBuilder) buildNsIsolationPolicyHandler( resource = strings.ToLower(resource) operation = strings.ToLower(operation) - if !validateModeOperation(mode, operation, pulsarNsIsolationPolicyWriteOperations) { - return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil + if err := validateModeOperation(mode, operation, pulsarNsIsolationPolicyOperationSpecs); err != nil { + return mcp.NewToolResultError(err.Error()), nil } // Dispatch based on resource type @@ -337,7 +337,7 @@ func (b *PulsarAdminNsIsolationPolicyToolBuilder) handlePolicyResource(client cm default: return mcp.NewToolResultError(fmt.Sprintf("Invalid operation for resource 'policy': %s. Available operations: %s", operation, - modeSupportedOperations(mode, []string{"get", "list"}, []string{"set", "delete"}))), nil + modeSupportedOperations(mode, pulsarNsIsolationPolicyOperationSpecs))), nil } } diff --git a/pkg/mcp/builders/pulsar/operation_docs_test.go b/pkg/mcp/builders/pulsar/operation_docs_test.go new file mode 100644 index 00000000..1efeb31f --- /dev/null +++ b/pkg/mcp/builders/pulsar/operation_docs_test.go @@ -0,0 +1,93 @@ +// Copyright 2026 StreamNative +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pulsar + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" + "github.com/stretchr/testify/require" +) + +type pulsarOperationDocSpec struct { + File string + ReadTool string + WriteTool string + Registry builders.OperationRegistry +} + +func TestPulsarOperationDocsMatchSpecs(t *testing.T) { + for _, spec := range pulsarOperationDocSpecs() { + docPath := filepath.Join("..", "..", "..", "..", "docs", "tools", spec.File) + // #nosec G304 -- docPath is built from fixed test fixtures in pulsarOperationDocSpecs. + content, err := os.ReadFile(docPath) + require.NoError(t, err, spec.File) + require.Equal(t, expectedPulsarOperationBlock(spec), extractGeneratedOperationBlock(t, string(content)), spec.File) + } +} + +func pulsarOperationDocSpecs() []pulsarOperationDocSpec { + return []pulsarOperationDocSpec{ + {File: "pulsar_admin_brokers.md", ReadTool: "pulsar_admin_brokers_read", WriteTool: "pulsar_admin_brokers_write", Registry: pulsarBrokerOperationSpecs}, + {File: "pulsar_admin_clusters.md", ReadTool: "pulsar_admin_cluster_read", WriteTool: "pulsar_admin_cluster_write", Registry: pulsarClusterOperationSpecs}, + {File: "pulsar_admin_functions.md", ReadTool: "pulsar_admin_functions_read", WriteTool: "pulsar_admin_functions_write", Registry: pulsarFunctionOperationSpecs}, + {File: "pulsar_admin_namespaces.md", ReadTool: "pulsar_admin_namespace_read", WriteTool: "pulsar_admin_namespace_write", Registry: pulsarNamespaceOperationSpecs}, + {File: "pulsar_admin_nsisolationpolicy.md", ReadTool: "pulsar_admin_nsisolationpolicy_read", WriteTool: "pulsar_admin_nsisolationpolicy_write", Registry: pulsarNsIsolationPolicyOperationSpecs}, + {File: "pulsar_admin_packages.md", ReadTool: "pulsar_admin_package_read", WriteTool: "pulsar_admin_package_write", Registry: pulsarPackageOperationSpecs}, + {File: "pulsar_admin_resource_quotas.md", ReadTool: "pulsar_admin_resourcequota_read", WriteTool: "pulsar_admin_resourcequota_write", Registry: pulsarResourceQuotaOperationSpecs}, + {File: "pulsar_admin_schemas.md", ReadTool: "pulsar_admin_schema_read", WriteTool: "pulsar_admin_schema_write", Registry: pulsarSchemaOperationSpecs}, + {File: "pulsar_admin_sinks.md", ReadTool: "pulsar_admin_sinks_read", WriteTool: "pulsar_admin_sinks_write", Registry: pulsarSinkOperationSpecs}, + {File: "pulsar_admin_sources.md", ReadTool: "pulsar_admin_sources_read", WriteTool: "pulsar_admin_sources_write", Registry: pulsarSourceOperationSpecs}, + {File: "pulsar_admin_subscriptions.md", ReadTool: "pulsar_admin_subscription_read", WriteTool: "pulsar_admin_subscription_write", Registry: pulsarSubscriptionOperationSpecs}, + {File: "pulsar_admin_tenants.md", ReadTool: "pulsar_admin_tenant_read", WriteTool: "pulsar_admin_tenant_write", Registry: pulsarTenantOperationSpecs}, + {File: "pulsar_admin_topic_policy.md", ReadTool: "pulsar_admin_topic_policy_read", WriteTool: "pulsar_admin_topic_policy_write", Registry: pulsarTopicPolicyOperationSpecs}, + {File: "pulsar_admin_topics.md", ReadTool: "pulsar_admin_topic_read", WriteTool: "pulsar_admin_topic_write", Registry: pulsarTopicOperationSpecs}, + } +} + +func expectedPulsarOperationBlock(spec pulsarOperationDocSpec) string { + return strings.Join([]string{ + "", + "| Tool | Mode | Operations |", + "|---|---|---|", + fmt.Sprintf("| `%s` | read | %s |", spec.ReadTool, formatOperationNames(spec.Registry.NamesForMode(builders.OperationModeRead))), + fmt.Sprintf("| `%s` | write | %s |", spec.WriteTool, formatOperationNames(spec.Registry.NamesForMode(builders.OperationModeWrite))), + "", + }, "\n") +} + +func extractGeneratedOperationBlock(t *testing.T, content string) string { + t.Helper() + startMarker := "" + endMarker := "" + start := strings.Index(content, startMarker) + require.NotEqual(t, -1, start, "missing generated operation start marker") + end := strings.Index(content[start:], endMarker) + require.NotEqual(t, -1, end, "missing generated operation end marker") + end += start + len(endMarker) + return strings.TrimSpace(content[start:end]) +} + +func formatOperationNames(operations []string) string { + quoted := make([]string, 0, len(operations)) + for _, operation := range operations { + quoted = append(quoted, "`"+operation+"`") + } + return strings.Join(quoted, ", ") +} diff --git a/pkg/mcp/builders/pulsar/packages.go b/pkg/mcp/builders/pulsar/packages.go index affd6145..cc1eff50 100644 --- a/pkg/mcp/builders/pulsar/packages.go +++ b/pkg/mcp/builders/pulsar/packages.go @@ -25,13 +25,15 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" - "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) -var pulsarPackageWriteOperations = map[string]struct{}{ - "update": {}, - "delete": {}, - "upload": {}, +var pulsarPackageOperationSpecs = builders.OperationRegistry{ + {Name: "list", Mode: builders.OperationModeRead}, + {Name: "get", Mode: builders.OperationModeRead}, + {Name: "download", Mode: builders.OperationModeRead}, + {Name: "update", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "delete", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "upload", Mode: builders.OperationModeWrite, Destructive: true}, } // PulsarAdminPackagesToolBuilder implements the ToolBuilder interface for Pulsar admin packages @@ -105,9 +107,9 @@ func (b *PulsarAdminPackagesToolBuilder) buildPackagesTool(mode toolMode) mcp.To "- get: Get metadata of a package\n" + "- download: Download a package" - operationEnum := []string{"list", "get", "download"} + operationEnum := pulsarPackageOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_package_read" - annotation := toolannotations.ReadOnly("Read Pulsar Packages") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Packages", "Manage Pulsar Packages") if isToolModeWrite(mode) { toolDesc = "Manage packages in Apache Pulsar. Support package schemes: `function://`, `source://`, `sink://`. " + "This write tool updates metadata, deletes packages, or uploads package contents." @@ -118,9 +120,8 @@ func (b *PulsarAdminPackagesToolBuilder) buildPackagesTool(mode toolMode) mcp.To "- update: Update metadata of a package (requires super-user permissions)\n" + "- delete: Delete a package (requires super-user permissions)\n" + "- upload: Upload a package (requires super-user permissions)" - operationEnum = []string{"update", "delete", "upload"} + operationEnum = pulsarPackageOperationSpecs.NamesForMode(mode) toolName = "pulsar_admin_package_write" - annotation = toolannotations.Destructive("Manage Pulsar Packages") } tool := mcp.NewTool(toolName, @@ -182,8 +183,8 @@ func (b *PulsarAdminPackagesToolBuilder) buildPackagesHandler(mode toolMode) fun resource = strings.ToLower(resource) operation = strings.ToLower(operation) - if !validateModeOperation(mode, operation, pulsarPackageWriteOperations) { - return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil + if err := validateModeOperation(mode, operation, pulsarPackageOperationSpecs); err != nil { + return mcp.NewToolResultError(err.Error()), nil } // Get Pulsar session from context @@ -317,7 +318,7 @@ func (b *PulsarAdminPackagesToolBuilder) handlePackageResource(client cmdutils.C default: return mcp.NewToolResultError(fmt.Sprintf("Invalid operation for resource 'package': %s. Available operations: %s", operation, - modeSupportedOperations(mode, []string{"list", "get", "download"}, []string{"update", "delete", "upload"}))), nil + modeSupportedOperations(mode, pulsarPackageOperationSpecs))), nil } } diff --git a/pkg/mcp/builders/pulsar/resourcequotas.go b/pkg/mcp/builders/pulsar/resourcequotas.go index 07929fe8..167194de 100644 --- a/pkg/mcp/builders/pulsar/resourcequotas.go +++ b/pkg/mcp/builders/pulsar/resourcequotas.go @@ -26,12 +26,12 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" - "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) -var pulsarResourceQuotaWriteOperations = map[string]struct{}{ - "set": {}, - "reset": {}, +var pulsarResourceQuotaOperationSpecs = builders.OperationRegistry{ + {Name: "get", Mode: builders.OperationModeRead}, + {Name: "set", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "reset", Mode: builders.OperationModeWrite, Destructive: true}, } // PulsarAdminResourceQuotasToolBuilder implements the ToolBuilder interface for Pulsar admin resource quotas @@ -102,18 +102,17 @@ func (b *PulsarAdminResourceQuotasToolBuilder) buildResourceQuotasTool(mode tool operationDesc := "Operation to perform. Available operations:\n" + "- get: Get the resource quota for a specified namespace bundle or default quota" - operationEnum := []string{"get"} + operationEnum := pulsarResourceQuotaOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_resourcequota_read" - annotation := toolannotations.ReadOnly("Read Pulsar Resource Quotas") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Resource Quotas", "Manage Pulsar Resource Quotas") if isToolModeWrite(mode) { toolDesc = "Manage Apache Pulsar resource quotas for brokers, namespaces and bundles. " + "This write tool sets or resets quota configuration." operationDesc = "Operation to perform. Available operations:\n" + "- set: Set the resource quota for a specified namespace bundle or default quota (requires super-user permissions)\n" + "- reset: Reset a namespace bundle's resource quota to default value (requires super-user permissions)" - operationEnum = []string{"set", "reset"} + operationEnum = pulsarResourceQuotaOperationSpecs.NamesForMode(mode) toolName = "pulsar_admin_resourcequota_write" - annotation = toolannotations.Destructive("Manage Pulsar Resource Quotas") } tool := mcp.NewTool(toolName, @@ -184,8 +183,8 @@ func (b *PulsarAdminResourceQuotasToolBuilder) buildResourceQuotasHandler(mode t resource = strings.ToLower(resource) operation = strings.ToLower(operation) - if !validateModeOperation(mode, operation, pulsarResourceQuotaWriteOperations) { - return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil + if err := validateModeOperation(mode, operation, pulsarResourceQuotaOperationSpecs); err != nil { + return mcp.NewToolResultError(err.Error()), nil } // Verify resource type @@ -214,7 +213,7 @@ func (b *PulsarAdminResourceQuotasToolBuilder) buildResourceQuotasHandler(mode t return b.handleQuotaReset(admin, request) default: return mcp.NewToolResultError(fmt.Sprintf("Invalid operation: %s. Available operations: %s", operation, - modeSupportedOperations(mode, []string{"get"}, []string{"set", "reset"}))), nil + modeSupportedOperations(mode, pulsarResourceQuotaOperationSpecs))), nil } } } diff --git a/pkg/mcp/builders/pulsar/schema.go b/pkg/mcp/builders/pulsar/schema.go index 5282d8a5..b29db6ec 100644 --- a/pkg/mcp/builders/pulsar/schema.go +++ b/pkg/mcp/builders/pulsar/schema.go @@ -29,12 +29,12 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" - "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) -var pulsarSchemaWriteOperations = map[string]struct{}{ - "upload": {}, - "delete": {}, +var pulsarSchemaOperationSpecs = builders.OperationRegistry{ + {Name: "get", Mode: builders.OperationModeRead}, + {Name: "upload", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "delete", Mode: builders.OperationModeWrite, Destructive: true}, } // PulsarAdminSchemaToolBuilder implements the ToolBuilder interface for Pulsar Admin Schema tools @@ -108,18 +108,17 @@ func (b *PulsarAdminSchemaToolBuilder) buildSchemaTool(mode toolMode) mcp.Tool { operationDesc := "Operation to perform. Available operations:\n" + "- get: Get the schema for a topic (optionally by version)" - operationEnum := []string{"get"} + operationEnum := pulsarSchemaOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_schema_read" - annotation := toolannotations.ReadOnly("Read Pulsar Schemas") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Schemas", "Manage Pulsar Schemas") if isToolModeWrite(mode) { toolDesc = "Manage Apache Pulsar schemas for topics. " + "This write tool uploads or deletes topic schemas." operationDesc = "Operation to perform. Available operations:\n" + "- upload: Upload a new schema for a topic (requires namespace admin permissions)\n" + "- delete: Delete the schema for a topic (requires namespace admin permissions)" - operationEnum = []string{"upload", "delete"} + operationEnum = pulsarSchemaOperationSpecs.NamesForMode(mode) toolName = "pulsar_admin_schema_write" - annotation = toolannotations.Destructive("Manage Pulsar Schemas") } tool := mcp.NewTool(toolName, @@ -180,8 +179,8 @@ func (b *PulsarAdminSchemaToolBuilder) buildSchemaHandler(mode toolMode) func(co resource = strings.ToLower(resource) operation = strings.ToLower(operation) - if !validateModeOperation(mode, operation, pulsarSchemaWriteOperations) { - return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil + if err := validateModeOperation(mode, operation, pulsarSchemaOperationSpecs); err != nil { + return mcp.NewToolResultError(err.Error()), nil } // Verify resource type diff --git a/pkg/mcp/builders/pulsar/sinks.go b/pkg/mcp/builders/pulsar/sinks.go index e42af864..a4221e3a 100644 --- a/pkg/mcp/builders/pulsar/sinks.go +++ b/pkg/mcp/builders/pulsar/sinks.go @@ -28,17 +28,20 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" - "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" "gopkg.in/yaml.v2" ) -var pulsarSinkWriteOperations = map[string]struct{}{ - "create": {}, - "update": {}, - "delete": {}, - "start": {}, - "stop": {}, - "restart": {}, +var pulsarSinkOperationSpecs = builders.OperationRegistry{ + {Name: "list", Mode: builders.OperationModeRead}, + {Name: "get", Mode: builders.OperationModeRead}, + {Name: "status", Mode: builders.OperationModeRead}, + {Name: "list-built-in", Mode: builders.OperationModeRead}, + {Name: "create", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "update", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "delete", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "start", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "stop", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "restart", Mode: builders.OperationModeWrite, Destructive: true}, } // PulsarAdminSinksToolBuilder implements the ToolBuilder interface for Pulsar admin sinks @@ -109,9 +112,9 @@ func (b *PulsarAdminSinksToolBuilder) buildSinksTool(mode toolMode) mcp.Tool { "- status: Get the runtime status of a sink (instances, metrics)\n" + "- list-built-in: List all built-in sink connectors available in the system" - operationEnum := []string{"list", "get", "status", "list-built-in"} + operationEnum := pulsarSinkOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_sinks_read" - annotation := toolannotations.ReadOnly("Read Pulsar Sinks") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Sinks", "Manage Pulsar Sinks") if isToolModeWrite(mode) { toolDesc = "Manage Apache Pulsar Sinks for data movement and integration. " + "This write tool deploys, updates, deletes, starts, stops, or restarts sinks." @@ -122,9 +125,8 @@ func (b *PulsarAdminSinksToolBuilder) buildSinksTool(mode toolMode) mcp.Tool { "- start: Start a stopped sink\n" + "- stop: Stop a running sink\n" + "- restart: Restart a sink" - operationEnum = []string{"create", "update", "delete", "start", "stop", "restart"} + operationEnum = pulsarSinkOperationSpecs.NamesForMode(mode) toolName = "pulsar_admin_sinks_write" - annotation = toolannotations.Destructive("Manage Pulsar Sinks") } tool := mcp.NewTool(toolName, @@ -276,13 +278,11 @@ func (b *PulsarAdminSinksToolBuilder) buildSinksHandler(mode toolMode) func(cont if !validOperations[operation] { return mcp.NewToolResultError(fmt.Sprintf("Invalid operation: '%s'. Supported operations: %s", operation, - modeSupportedOperations(mode, - []string{"list", "get", "status", "list-built-in"}, - []string{"create", "update", "delete", "start", "stop", "restart"}))), nil + modeSupportedOperations(mode, pulsarSinkOperationSpecs))), nil } - if !validateModeOperation(mode, operation, pulsarSinkWriteOperations) { - return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil + if err := validateModeOperation(mode, operation, pulsarSinkOperationSpecs); err != nil { + return mcp.NewToolResultError(err.Error()), nil } // Get Pulsar session from context diff --git a/pkg/mcp/builders/pulsar/sources.go b/pkg/mcp/builders/pulsar/sources.go index 05bb6f40..9fe24876 100644 --- a/pkg/mcp/builders/pulsar/sources.go +++ b/pkg/mcp/builders/pulsar/sources.go @@ -28,17 +28,20 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" - "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" "gopkg.in/yaml.v2" ) -var pulsarSourceWriteOperations = map[string]struct{}{ - "create": {}, - "update": {}, - "delete": {}, - "start": {}, - "stop": {}, - "restart": {}, +var pulsarSourceOperationSpecs = builders.OperationRegistry{ + {Name: "list", Mode: builders.OperationModeRead}, + {Name: "get", Mode: builders.OperationModeRead}, + {Name: "status", Mode: builders.OperationModeRead}, + {Name: "list-built-in", Mode: builders.OperationModeRead}, + {Name: "create", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "update", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "delete", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "start", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "stop", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "restart", Mode: builders.OperationModeWrite, Destructive: true}, } // PulsarAdminSourcesToolBuilder implements the ToolBuilder interface for Pulsar admin sources @@ -109,9 +112,9 @@ func (b *PulsarAdminSourcesToolBuilder) buildSourcesTool(mode toolMode) mcp.Tool "- status: Get the runtime status of a source (instances, metrics)\n" + "- list-built-in: List all built-in source connectors available in the system" - operationEnum := []string{"list", "get", "status", "list-built-in"} + operationEnum := pulsarSourceOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_sources_read" - annotation := toolannotations.ReadOnly("Read Pulsar Sources") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Sources", "Manage Pulsar Sources") if isToolModeWrite(mode) { toolDesc = "Manage Apache Pulsar Sources for data ingestion and integration. " + "This write tool deploys, updates, deletes, starts, stops, or restarts sources." @@ -122,9 +125,8 @@ func (b *PulsarAdminSourcesToolBuilder) buildSourcesTool(mode toolMode) mcp.Tool "- start: Start a stopped source\n" + "- stop: Stop a running source\n" + "- restart: Restart a source" - operationEnum = []string{"create", "update", "delete", "start", "stop", "restart"} + operationEnum = pulsarSourceOperationSpecs.NamesForMode(mode) toolName = "pulsar_admin_sources_write" - annotation = toolannotations.Destructive("Manage Pulsar Sources") } tool := mcp.NewTool(toolName, @@ -244,13 +246,11 @@ func (b *PulsarAdminSourcesToolBuilder) buildSourcesHandler(mode toolMode) func( if !validOperations[operation] { return mcp.NewToolResultError(fmt.Sprintf("Invalid operation: '%s'. Supported operations: %s", operation, - modeSupportedOperations(mode, - []string{"list", "get", "status", "list-built-in"}, - []string{"create", "update", "delete", "start", "stop", "restart"}))), nil + modeSupportedOperations(mode, pulsarSourceOperationSpecs))), nil } - if !validateModeOperation(mode, operation, pulsarSourceWriteOperations) { - return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil + if err := validateModeOperation(mode, operation, pulsarSourceOperationSpecs); err != nil { + return mcp.NewToolResultError(err.Error()), nil } // Get Pulsar session from context diff --git a/pkg/mcp/builders/pulsar/subscription.go b/pkg/mcp/builders/pulsar/subscription.go index 78605d9a..fc7fa1cc 100644 --- a/pkg/mcp/builders/pulsar/subscription.go +++ b/pkg/mcp/builders/pulsar/subscription.go @@ -29,26 +29,17 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" - "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) -var supportedSubscriptionOperations = map[string]struct{}{ - "list": {}, - "create": {}, - "delete": {}, - "skip": {}, - "expire": {}, - "reset-cursor": {}, - "peek": {}, - "get-message-by-id": {}, -} - -var readOnlyRestrictedSubscriptionOperations = map[string]struct{}{ - "create": {}, - "delete": {}, - "skip": {}, - "expire": {}, - "reset-cursor": {}, +var pulsarSubscriptionOperationSpecs = builders.OperationRegistry{ + {Name: "list", Mode: builders.OperationModeRead}, + {Name: "peek", Mode: builders.OperationModeRead}, + {Name: "get-message-by-id", Mode: builders.OperationModeRead}, + {Name: "create", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "delete", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "skip", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "expire", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "reset-cursor", Mode: builders.OperationModeWrite, Destructive: true}, } const maxSubscriptionPeekCount int64 = 100 @@ -136,9 +127,9 @@ func (b *PulsarAdminSubscriptionToolBuilder) buildSubscriptionTool(mode toolMode "- peek: Peek one or more messages for a subscription without advancing the cursor\n" + "- get-message-by-id: Read a message by ledger ID and entry ID for topic-level debugging" - operationEnum := []string{"list", "peek", "get-message-by-id"} + operationEnum := pulsarSubscriptionOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_subscription_read" - annotation := toolannotations.ReadOnly("Read Pulsar Subscriptions") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Subscriptions", "Manage Pulsar Subscriptions") if isToolModeWrite(mode) { toolDesc = "Manage Apache Pulsar subscriptions on topics. " + "This write tool creates or deletes subscriptions and changes subscription cursor positions." @@ -148,9 +139,8 @@ func (b *PulsarAdminSubscriptionToolBuilder) buildSubscriptionTool(mode toolMode "- skip: Skip a specified number of messages for a subscription\n" + "- expire: Expire messages older than specified time for a subscription\n" + "- reset-cursor: Reset the cursor position for a subscription to a specific message ID" - operationEnum = []string{"create", "delete", "skip", "expire", "reset-cursor"} + operationEnum = pulsarSubscriptionOperationSpecs.NamesForMode(mode) toolName = "pulsar_admin_subscription_write" - annotation = toolannotations.Destructive("Manage Pulsar Subscriptions") } tool := mcp.NewTool(toolName, @@ -235,8 +225,8 @@ func (b *PulsarAdminSubscriptionToolBuilder) buildSubscriptionHandler(mode toolM return mcp.NewToolResultError(fmt.Sprintf("Unknown operation: %s", operation)), nil } - if !validateModeOperation(mode, operation, readOnlyRestrictedSubscriptionOperations) { - return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil + if err := validateModeOperation(mode, operation, pulsarSubscriptionOperationSpecs); err != nil { + return mcp.NewToolResultError(err.Error()), nil } // Verify resource type @@ -542,13 +532,13 @@ func (b *PulsarAdminSubscriptionToolBuilder) handleSubsGetMessageByID(admin cmdu } func isSupportedSubscriptionOperation(operation string) bool { - _, ok := supportedSubscriptionOperations[strings.ToLower(operation)] + _, ok := pulsarSubscriptionOperationSpecs.SpecFor(operation) return ok } func isReadOnlyRestrictedSubscriptionOperation(operation string) bool { - _, ok := readOnlyRestrictedSubscriptionOperations[strings.ToLower(operation)] - return ok + spec, ok := pulsarSubscriptionOperationSpecs.SpecFor(operation) + return ok && spec.Mode == builders.OperationModeWrite } func parseSubscriptionMessageID(messageID string) (utils.MessageID, error) { diff --git a/pkg/mcp/builders/pulsar/tenant.go b/pkg/mcp/builders/pulsar/tenant.go index ec57df6b..69470150 100644 --- a/pkg/mcp/builders/pulsar/tenant.go +++ b/pkg/mcp/builders/pulsar/tenant.go @@ -26,13 +26,14 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" - "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) -var pulsarTenantWriteOperations = map[string]struct{}{ - "create": {}, - "update": {}, - "delete": {}, +var pulsarTenantOperationSpecs = builders.OperationRegistry{ + {Name: "list", Mode: builders.OperationModeRead}, + {Name: "get", Mode: builders.OperationModeRead}, + {Name: "create", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "update", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "delete", Mode: builders.OperationModeWrite, Destructive: true}, } // PulsarAdminTenantToolBuilder implements the ToolBuilder interface for Pulsar Admin Tenant tools @@ -112,9 +113,9 @@ func (b *PulsarAdminTenantToolBuilder) buildTenantTool(mode toolMode) mcp.Tool { "- list: List all tenants in the Pulsar instance\n" + "- get: Get configuration details for a specific tenant" - operationEnum := []string{"list", "get"} + operationEnum := pulsarTenantOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_tenant_read" - annotation := toolannotations.ReadOnly("Read Pulsar Tenants") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Tenants", "Manage Pulsar Tenants") if isToolModeWrite(mode) { toolDesc = "Manage Apache Pulsar tenants. " + "This write tool creates, updates, or deletes tenant configuration and may change cluster state." @@ -122,9 +123,8 @@ func (b *PulsarAdminTenantToolBuilder) buildTenantTool(mode toolMode) mcp.Tool { "- create: Create a new tenant with specified configuration\n" + "- update: Update configuration for an existing tenant\n" + "- delete: Delete an existing tenant (must not have any active namespaces)" - operationEnum = []string{"create", "update", "delete"} + operationEnum = pulsarTenantOperationSpecs.NamesForMode(mode) toolName = "pulsar_admin_tenant_write" - annotation = toolannotations.Destructive("Manage Pulsar Tenants") } tool := mcp.NewTool(toolName, @@ -201,8 +201,8 @@ func (b *PulsarAdminTenantToolBuilder) buildTenantHandler(mode toolMode) func(co return mcp.NewToolResultError(fmt.Sprintf("Invalid resource: %s. Only 'tenant' is supported.", resource)), nil } - if !validateModeOperation(mode, operation, pulsarTenantWriteOperations) { - return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil + if err := validateModeOperation(mode, operation, pulsarTenantOperationSpecs); err != nil { + return mcp.NewToolResultError(err.Error()), nil } // Get Pulsar session from context diff --git a/pkg/mcp/builders/pulsar/tool_mode.go b/pkg/mcp/builders/pulsar/tool_mode.go index aa7ce712..3211246e 100644 --- a/pkg/mcp/builders/pulsar/tool_mode.go +++ b/pkg/mcp/builders/pulsar/tool_mode.go @@ -18,78 +18,32 @@ import ( "strings" "github.com/mark3labs/mcp-go/mcp" + "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" ) -type toolMode string +type toolMode = builders.OperationMode const ( - toolModeRead toolMode = "read" - toolModeWrite toolMode = "write" + toolModeRead = builders.OperationModeRead + toolModeWrite = builders.OperationModeWrite ) func isToolModeWrite(mode toolMode) bool { return mode == toolModeWrite } -func isWriteOperation(operation string, writeOperations map[string]struct{}) bool { - _, ok := writeOperations[strings.ToLower(operation)] - return ok +func validateModeOperation(mode toolMode, operation string, operations builders.OperationRegistry) error { + return operations.ValidateModeOperation(mode, operation) } -func validateModeOperation(mode toolMode, operation string, writeOperations map[string]struct{}) bool { - return (mode == toolModeWrite) == isWriteOperation(operation, writeOperations) -} - -func modeSupportedOperations(mode toolMode, readOperations, writeOperations []string) string { - if isToolModeWrite(mode) { - return strings.Join(writeOperations, ", ") - } - return strings.Join(readOperations, ", ") +func modeSupportedOperations(mode toolMode, operations builders.OperationRegistry) string { + return strings.Join(operations.NamesForMode(mode), ", ") } func pruneToolInputSchema(tool *mcp.Tool, allowedProperties []string) { - allowed := make(map[string]struct{}, len(allowedProperties)) - for _, property := range allowedProperties { - allowed[property] = struct{}{} - } - - for property := range tool.InputSchema.Properties { - if _, ok := allowed[property]; !ok { - delete(tool.InputSchema.Properties, property) - } - } - - filterRequiredProperties(tool, allowed) + builders.PruneToolInputSchema(tool, allowedProperties) } func removeToolInputSchemaProperties(tool *mcp.Tool, properties []string) { - removed := make(map[string]struct{}, len(properties)) - for _, property := range properties { - removed[property] = struct{}{} - delete(tool.InputSchema.Properties, property) - } - - if len(tool.InputSchema.Required) == 0 { - return - } - required := tool.InputSchema.Required[:0] - for _, property := range tool.InputSchema.Required { - if _, ok := removed[property]; !ok { - required = append(required, property) - } - } - tool.InputSchema.Required = required -} - -func filterRequiredProperties(tool *mcp.Tool, allowed map[string]struct{}) { - if len(tool.InputSchema.Required) == 0 { - return - } - required := tool.InputSchema.Required[:0] - for _, property := range tool.InputSchema.Required { - if _, ok := allowed[property]; ok { - required = append(required, property) - } - } - tool.InputSchema.Required = required + builders.RemoveToolInputSchemaProperties(tool, properties) } diff --git a/pkg/mcp/builders/pulsar/topic.go b/pkg/mcp/builders/pulsar/topic.go index 08b2fec8..1f3727b4 100644 --- a/pkg/mcp/builders/pulsar/topic.go +++ b/pkg/mcp/builders/pulsar/topic.go @@ -29,19 +29,29 @@ import ( "github.com/streamnative/pulsarctl/pkg/cmdutils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" - "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) -var readOnlyRestrictedTopicOperations = map[string]struct{}{ - "create": {}, - "delete": {}, - "unload": {}, - "terminate": {}, - "compact": {}, - "update": {}, - "offload": {}, - "grant-permissions": {}, - "revoke-permissions": {}, +var pulsarTopicOperationSpecs = builders.OperationRegistry{ + {Name: "list", Mode: builders.OperationModeRead}, + {Name: "get", Mode: builders.OperationModeRead}, + {Name: "get-permissions", Mode: builders.OperationModeRead}, + {Name: "stats", Mode: builders.OperationModeRead}, + {Name: "lookup", Mode: builders.OperationModeRead}, + {Name: "internal-stats", Mode: builders.OperationModeRead}, + {Name: "internal-info", Mode: builders.OperationModeRead}, + {Name: "bundle-range", Mode: builders.OperationModeRead}, + {Name: "last-message-id", Mode: builders.OperationModeRead}, + {Name: "compact-status", Mode: builders.OperationModeRead}, + {Name: "offload-status", Mode: builders.OperationModeRead}, + {Name: "grant-permissions", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "revoke-permissions", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "create", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "delete", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "unload", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "terminate", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "compact", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "update", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "offload", Mode: builders.OperationModeWrite, Destructive: true}, } var topicOperationAliases = map[string]string{ @@ -135,9 +145,9 @@ func (b *PulsarAdminTopicToolBuilder) buildTopicTool(mode toolMode) mcp.Tool { "- compact-status: Get compaction status for a topic (legacy alias: status)\n" + "- offload-status: Check the status of data offloading for a topic" - operationEnum := []string{"list", "get", "get-permissions", "stats", "lookup", "internal-stats", "internal-info", "bundle-range", "last-message-id", "compact-status", "offload-status"} + operationEnum := pulsarTopicOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_topic_read" - annotation := toolannotations.ReadOnly("Read Pulsar Topics") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Topics", "Manage Pulsar Topics") if isToolModeWrite(mode) { toolDesc = "Manage Apache Pulsar topics. " + "This write tool changes topic lifecycle, permissions, partitioning, compaction, or offload state. " + @@ -155,9 +165,8 @@ func (b *PulsarAdminTopicToolBuilder) buildTopicTool(mode toolMode) mcp.Tool { "- compact: Trigger compaction on a topic\n" + "- update: Update topic partitions\n" + "- offload: Offload data from a topic to long-term storage" - operationEnum = []string{"grant-permissions", "revoke-permissions", "create", "delete", "unload", "terminate", "compact", "update", "offload"} + operationEnum = pulsarTopicOperationSpecs.NamesForMode(mode) toolName = "pulsar_admin_topic_write" - annotation = toolannotations.Destructive("Manage Pulsar Topics") } tool := mcp.NewTool(toolName, @@ -261,8 +270,8 @@ func (b *PulsarAdminTopicToolBuilder) buildTopicHandler(mode toolMode) func(cont resource = strings.ToLower(resource) operation = normalizeTopicOperation(operation) - if !validateModeOperation(mode, operation, readOnlyRestrictedTopicOperations) { - return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil + if err := validateModeOperation(mode, operation, pulsarTopicOperationSpecs); err != nil { + return mcp.NewToolResultError(err.Error()), nil } // Get Pulsar session from context @@ -336,8 +345,8 @@ func (b *PulsarAdminTopicToolBuilder) buildTopicHandler(mode toolMode) func(cont } func isReadOnlyRestrictedTopicOperation(operation string) bool { - _, ok := readOnlyRestrictedTopicOperations[strings.ToLower(operation)] - return ok + spec, ok := pulsarTopicOperationSpecs.SpecFor(operation) + return ok && spec.Mode == builders.OperationModeWrite } func normalizeTopicOperation(operation string) string { diff --git a/pkg/mcp/builders/pulsar/topic_policy.go b/pkg/mcp/builders/pulsar/topic_policy.go index 2bc46d41..ac2d95f7 100644 --- a/pkg/mcp/builders/pulsar/topic_policy.go +++ b/pkg/mcp/builders/pulsar/topic_policy.go @@ -29,97 +29,62 @@ import ( ctlutil "github.com/streamnative/pulsarctl/pkg/ctl/utils" "github.com/streamnative/streamnative-mcp-server/pkg/mcp/builders" mcpCtx "github.com/streamnative/streamnative-mcp-server/pkg/mcp/internal/context" - "github.com/streamnative/streamnative-mcp-server/pkg/mcp/toolannotations" ) -var readOnlyRestrictedTopicPolicyOperations = map[string]struct{}{ - "set-retention": {}, - "remove-retention": {}, - "set-message-ttl": {}, - "remove-message-ttl": {}, - "set-max-producers": {}, - "remove-max-producers": {}, - "set-max-consumers": {}, - "remove-max-consumers": {}, - "set-max-unacked-messages-per-consumer": {}, - "remove-max-unacked-messages-per-consumer": {}, - "set-max-unacked-messages-per-subscription": {}, - "remove-max-unacked-messages-per-subscription": {}, - "set-persistence": {}, - "remove-persistence": {}, - "set-delayed-delivery": {}, - "remove-delayed-delivery": {}, - "set-dispatch-rate": {}, - "remove-dispatch-rate": {}, - "set-subscription-dispatch-rate": {}, - "remove-subscription-dispatch-rate": {}, - "set-deduplication": {}, - "remove-deduplication": {}, - "set-backlog-quota": {}, - "remove-backlog-quota": {}, - "set-compaction-threshold": {}, - "remove-compaction-threshold": {}, - "set-publish-rate": {}, - "remove-publish-rate": {}, - "set-inactive-topic-policies": {}, - "remove-inactive-topic-policies": {}, - "set-subscription-types": {}, - "remove-subscription-types": {}, -} - -var readOnlyTopicPolicyOperations = []string{ - "get-retention", - "get-message-ttl", - "get-max-producers", - "get-max-consumers", - "get-max-unacked-messages-per-consumer", - "get-max-unacked-messages-per-subscription", - "get-persistence", - "get-delayed-delivery", - "get-dispatch-rate", - "get-subscription-dispatch-rate", - "get-deduplication", - "get-backlog-quotas", - "get-compaction-threshold", - "get-publish-rate", - "get-inactive-topic-policies", - "get-subscription-types", -} - -var writeTopicPolicyOperations = []string{ - "set-retention", - "remove-retention", - "set-message-ttl", - "remove-message-ttl", - "set-max-producers", - "remove-max-producers", - "set-max-consumers", - "remove-max-consumers", - "set-max-unacked-messages-per-consumer", - "remove-max-unacked-messages-per-consumer", - "set-max-unacked-messages-per-subscription", - "remove-max-unacked-messages-per-subscription", - "set-persistence", - "remove-persistence", - "set-delayed-delivery", - "remove-delayed-delivery", - "set-dispatch-rate", - "remove-dispatch-rate", - "set-subscription-dispatch-rate", - "remove-subscription-dispatch-rate", - "set-deduplication", - "remove-deduplication", - "set-backlog-quota", - "remove-backlog-quota", - "set-compaction-threshold", - "remove-compaction-threshold", - "set-publish-rate", - "remove-publish-rate", - "set-inactive-topic-policies", - "remove-inactive-topic-policies", - "set-subscription-types", - "remove-subscription-types", -} +var pulsarTopicPolicyOperationSpecs = builders.OperationRegistry{ + {Name: "get-retention", Mode: builders.OperationModeRead}, + {Name: "get-message-ttl", Mode: builders.OperationModeRead}, + {Name: "get-max-producers", Mode: builders.OperationModeRead}, + {Name: "get-max-consumers", Mode: builders.OperationModeRead}, + {Name: "get-max-unacked-messages-per-consumer", Mode: builders.OperationModeRead}, + {Name: "get-max-unacked-messages-per-subscription", Mode: builders.OperationModeRead}, + {Name: "get-persistence", Mode: builders.OperationModeRead}, + {Name: "get-delayed-delivery", Mode: builders.OperationModeRead}, + {Name: "get-dispatch-rate", Mode: builders.OperationModeRead}, + {Name: "get-subscription-dispatch-rate", Mode: builders.OperationModeRead}, + {Name: "get-deduplication", Mode: builders.OperationModeRead}, + {Name: "get-backlog-quotas", Mode: builders.OperationModeRead}, + {Name: "get-compaction-threshold", Mode: builders.OperationModeRead}, + {Name: "get-publish-rate", Mode: builders.OperationModeRead}, + {Name: "get-inactive-topic-policies", Mode: builders.OperationModeRead}, + {Name: "get-subscription-types", Mode: builders.OperationModeRead}, + {Name: "set-retention", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "remove-retention", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "set-message-ttl", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "remove-message-ttl", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "set-max-producers", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "remove-max-producers", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "set-max-consumers", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "remove-max-consumers", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "set-max-unacked-messages-per-consumer", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "remove-max-unacked-messages-per-consumer", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "set-max-unacked-messages-per-subscription", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "remove-max-unacked-messages-per-subscription", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "set-persistence", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "remove-persistence", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "set-delayed-delivery", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "remove-delayed-delivery", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "set-dispatch-rate", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "remove-dispatch-rate", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "set-subscription-dispatch-rate", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "remove-subscription-dispatch-rate", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "set-deduplication", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "remove-deduplication", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "set-backlog-quota", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "remove-backlog-quota", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "set-compaction-threshold", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "remove-compaction-threshold", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "set-publish-rate", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "remove-publish-rate", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "set-inactive-topic-policies", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "remove-inactive-topic-policies", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "set-subscription-types", Mode: builders.OperationModeWrite, Destructive: true}, + {Name: "remove-subscription-types", Mode: builders.OperationModeWrite, Destructive: true}, +} + +var readOnlyTopicPolicyOperations = pulsarTopicPolicyOperationSpecs.ReadNames() + +var writeTopicPolicyOperations = pulsarTopicPolicyOperationSpecs.WriteNames() var topicPolicyOperationAliases = map[string]string{ "get_ttl": "get-message-ttl", @@ -220,7 +185,7 @@ func (b *PulsarAdminTopicPolicyToolBuilder) buildTopicPolicyTool(mode toolMode) operationEnum := readOnlyTopicPolicyOperations toolName := "pulsar_admin_topic_policy_read" - annotation := toolannotations.ReadOnly("Read Pulsar Topic Policies") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Topic Policies", "Manage Pulsar Topic Policies") if isToolModeWrite(mode) { toolDesc = "Manage Pulsar topic-level policies with operation names aligned to pulsarctl topic policy commands. " + "This write tool sets or removes topic-level policies. Legacy underscore operation aliases from the older MCP implementation remain supported." @@ -242,7 +207,6 @@ func (b *PulsarAdminTopicPolicyToolBuilder) buildTopicPolicyTool(mode toolMode) }, "\n") operationEnum = writeTopicPolicyOperations toolName = "pulsar_admin_topic_policy_write" - annotation = toolannotations.Destructive("Manage Pulsar Topic Policies") } tool := mcp.NewTool(toolName, @@ -359,8 +323,8 @@ func (b *PulsarAdminTopicPolicyToolBuilder) buildTopicPolicyHandler(mode toolMod return mcp.NewToolResultError("Missing required parameter 'topic'"), nil } - if !validateModeOperation(mode, operation, readOnlyRestrictedTopicPolicyOperations) { - return mcp.NewToolResultError(fmt.Sprintf("Operation %q is not available in %s mode", operation, mode)), nil + if err := validateModeOperation(mode, operation, pulsarTopicPolicyOperationSpecs); err != nil { + return mcp.NewToolResultError(err.Error()), nil } session := mcpCtx.GetPulsarSession(ctx) @@ -491,8 +455,8 @@ func normalizeTopicPolicyOperation(operation string) string { } func isReadOnlyRestrictedTopicPolicyOperation(operation string) bool { - _, ok := readOnlyRestrictedTopicPolicyOperations[normalizeTopicPolicyOperation(operation)] - return ok + spec, ok := pulsarTopicPolicyOperationSpecs.SpecFor(normalizeTopicPolicyOperation(operation)) + return ok && spec.Mode == builders.OperationModeWrite } func (b *PulsarAdminTopicPolicyToolBuilder) handleError(operation string, err error) *mcp.CallToolResult { diff --git a/pkg/mcp/builders/tool_schema.go b/pkg/mcp/builders/tool_schema.go new file mode 100644 index 00000000..94e4e280 --- /dev/null +++ b/pkg/mcp/builders/tool_schema.go @@ -0,0 +1,69 @@ +// Copyright 2026 StreamNative +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package builders + +import "github.com/mark3labs/mcp-go/mcp" + +// PruneToolInputSchema keeps only allowed properties in a tool schema and drops +// removed properties from the required list. +func PruneToolInputSchema(tool *mcp.Tool, allowedProperties []string) { + allowed := make(map[string]struct{}, len(allowedProperties)) + for _, property := range allowedProperties { + allowed[property] = struct{}{} + } + + for property := range tool.InputSchema.Properties { + if _, ok := allowed[property]; !ok { + delete(tool.InputSchema.Properties, property) + } + } + + FilterRequiredProperties(tool, allowed) +} + +// RemoveToolInputSchemaProperties removes the given properties from a tool schema +// and drops them from the required list. +func RemoveToolInputSchemaProperties(tool *mcp.Tool, properties []string) { + removed := make(map[string]struct{}, len(properties)) + for _, property := range properties { + removed[property] = struct{}{} + delete(tool.InputSchema.Properties, property) + } + + if len(tool.InputSchema.Required) == 0 { + return + } + required := tool.InputSchema.Required[:0] + for _, property := range tool.InputSchema.Required { + if _, ok := removed[property]; !ok { + required = append(required, property) + } + } + tool.InputSchema.Required = required +} + +// FilterRequiredProperties keeps required entries that are present in allowed. +func FilterRequiredProperties(tool *mcp.Tool, allowed map[string]struct{}) { + if len(tool.InputSchema.Required) == 0 { + return + } + required := tool.InputSchema.Required[:0] + for _, property := range tool.InputSchema.Required { + if _, ok := allowed[property]; ok { + required = append(required, property) + } + } + tool.InputSchema.Required = required +} From 7dd1a52940aaf623b952bc7ebd5953d17e836a4d Mon Sep 17 00:00:00 2001 From: Rui Fu Date: Tue, 19 May 2026 18:16:51 +0800 Subject: [PATCH 06/10] fix: refine MCP tool annotations --- PLAN.md | 33 +++++++ .../kafka/annotation_compliance_test.go | 6 ++ pkg/mcp/builders/kafka/connect.go | 2 +- pkg/mcp/builders/kafka/groups.go | 2 +- pkg/mcp/builders/kafka/schema_registry.go | 2 +- pkg/mcp/builders/kafka/topics.go | 2 +- pkg/mcp/builders/operation_annotations.go | 36 ++++++- pkg/mcp/builders/operations.go | 18 +++- pkg/mcp/builders/operations_test.go | 19 +++- .../pulsar/annotation_compliance_test.go | 6 ++ pkg/mcp/builders/pulsar/brokers.go | 2 +- pkg/mcp/builders/pulsar/cluster.go | 2 +- pkg/mcp/builders/pulsar/functions.go | 2 +- pkg/mcp/builders/pulsar/namespace.go | 2 +- pkg/mcp/builders/pulsar/nsisolationpolicy.go | 2 +- pkg/mcp/builders/pulsar/packages.go | 2 +- pkg/mcp/builders/pulsar/resourcequotas.go | 2 +- pkg/mcp/builders/pulsar/schema.go | 2 +- pkg/mcp/builders/pulsar/sinks.go | 2 +- pkg/mcp/builders/pulsar/sources.go | 2 +- pkg/mcp/builders/pulsar/subscription.go | 2 +- pkg/mcp/builders/pulsar/tenant.go | 2 +- pkg/mcp/builders/pulsar/topic.go | 2 +- pkg/mcp/builders/pulsar/topic_policy.go | 2 +- pkg/mcp/pftools/manager_annotation_test.go | 4 + pkg/mcp/sncontext_tools.go | 4 +- pkg/mcp/static_tool_annotations_test.go | 43 +++++---- pkg/mcp/toolannotations/annotations.go | 62 +++++++++--- pkg/mcp/toolannotations/annotations_test.go | 95 +++++++++++++++++++ 29 files changed, 299 insertions(+), 63 deletions(-) create mode 100644 pkg/mcp/toolannotations/annotations_test.go diff --git a/PLAN.md b/PLAN.md index 87ddb31f..6cbd1600 100644 --- a/PLAN.md +++ b/PLAN.md @@ -4,6 +4,39 @@ Prepare StreamNative MCP Server for Claude connector submission and review. +## Current review follow-up: annotation helper granularity + +Reviewer feedback is valid: `toolannotations.ReadOnly` and `toolannotations.Destructive` currently call `mcp.WithToolAnnotation(...)`, replacing the whole annotation struct and dropping mcp-go defaults for `idempotentHint` and `openWorldHint`. The helper also collapses all side effects into `Destructive`, so local session mutations and reversible lifecycle operations look equivalent to delete/apply operations. + +Implementation status: implemented in current work. + +1. Replaced whole-annotation assignment helpers with composed field setters so unspecified hints keep mcp-go defaults unless explicitly changed. +2. Exposed helper categories: + - `ReadOnly(title)` for non-mutating external reads: read-only true, destructive false, idempotent true, open-world true. + - `ExternalRead(title)` as explicit alias for external read tools when caller intent should be obvious. + - `LocalSessionMutation(title)` for session/context state changes: read-only false, destructive false, idempotent false by default, open-world false. + - `Mutating(title, destructive, idempotent)` for external writes/side effects: read-only false, caller-controlled destructive/idempotent, open-world true. +3. Kept `Destructive(title)` as compatibility wrapper over `Mutating(title, true, false)` while migrating call sites to precise helpers. +4. Updated context tools to use `LocalSessionMutation`; updated operation-mode helper to derive destructive/idempotent from `OperationSpec` where available instead of treating all writes as destructive. +5. Extended annotation tests to assert `idempotentHint` and `openWorldHint` for static/context/builder tools, not only read/destructive. + +Risks: + +- Tool annotation surface changes are runtime-visible to MCP clients and Claude review. +- Incorrect idempotency classification can mislead clients; delete/remove/reset may be idempotent only depending backend behavior. +- Broad migration across all builders risks noisy diff; recommended first patch helper + high-signal call sites, then operation registry cleanup separately. + +Recommended validation: + +```bash +go test -race ./pkg/mcp/... ./pkg/mcp/builders/... ./pkg/mcp/pftools/... +go test -race ./... +go fmt ./... +go mod tidy +golangci-lint run --timeout=3m +make build +``` + ## Current review follow-up: operation spec registry Reviewer feedback is valid: current branch split read/write tool names, but duplicated `toolMode` helpers and per-builder write-operation maps still create scatter-shot maintenance. Adding one operation can require enum updates, write map updates, handler switch updates, docs, and compliance-test classification. `validateModeOperation` also classifies any operation missing from the write map as read, so an unclassified future write can pass read-mode validation until the handler switch rejects or mishandles it. diff --git a/pkg/mcp/builders/kafka/annotation_compliance_test.go b/pkg/mcp/builders/kafka/annotation_compliance_test.go index f109983b..a5a13566 100644 --- a/pkg/mcp/builders/kafka/annotation_compliance_test.go +++ b/pkg/mcp/builders/kafka/annotation_compliance_test.go @@ -54,6 +54,8 @@ func TestKafkaToolAnnotationCompliance(t *testing.T) { require.NotEmpty(t, tool.Annotations.Title, tool.Name) require.NotNil(t, tool.Annotations.ReadOnlyHint, tool.Name) require.NotNil(t, tool.Annotations.DestructiveHint, tool.Name) + require.NotNil(t, tool.Annotations.IdempotentHint, tool.Name) + require.NotNil(t, tool.Annotations.OpenWorldHint, tool.Name) require.LessOrEqual(t, len(tool.Name), 64, tool.Name) isRead := strings.HasSuffix(tool.Name, "_read") @@ -61,10 +63,14 @@ func TestKafkaToolAnnotationCompliance(t *testing.T) { if isRead { require.True(t, *tool.Annotations.ReadOnlyHint, tool.Name) require.False(t, *tool.Annotations.DestructiveHint, tool.Name) + require.True(t, *tool.Annotations.IdempotentHint, tool.Name) + require.True(t, *tool.Annotations.OpenWorldHint, tool.Name) } if isWrite { require.False(t, *tool.Annotations.ReadOnlyHint, tool.Name) require.True(t, *tool.Annotations.DestructiveHint, tool.Name) + require.False(t, *tool.Annotations.IdempotentHint, tool.Name) + require.True(t, *tool.Annotations.OpenWorldHint, tool.Name) } assertOperationEnumMode(t, tool.Name, tool.InputSchema.Properties["operation"]) } diff --git a/pkg/mcp/builders/kafka/connect.go b/pkg/mcp/builders/kafka/connect.go index f364630f..31dd69a8 100644 --- a/pkg/mcp/builders/kafka/connect.go +++ b/pkg/mcp/builders/kafka/connect.go @@ -113,7 +113,7 @@ func (b *KafkaConnectToolBuilder) buildKafkaConnectTool(mode toolMode) mcp.Tool "- get: Retrieve detailed information about a Kafka Connect cluster or specific connector." operationEnum := kafkaConnectOperationSpecs.NamesForMode(mode) toolName := "kafka_admin_connect_read" - annotation := builders.ToolAnnotationForMode(mode, "Read Kafka Connect", "Manage Kafka Connect") + annotation := builders.ToolAnnotationForMode(mode, "Read Kafka Connect", "Manage Kafka Connect", kafkaConnectOperationSpecs) if isToolModeWrite(mode) { resourceDesc = "Resource to operate on. Available resources:\n" + "- connector: A single Kafka Connect connector instance that moves data between Kafka and external systems." diff --git a/pkg/mcp/builders/kafka/groups.go b/pkg/mcp/builders/kafka/groups.go index b7c78714..61f1e280 100644 --- a/pkg/mcp/builders/kafka/groups.go +++ b/pkg/mcp/builders/kafka/groups.go @@ -104,7 +104,7 @@ func (b *KafkaGroupsToolBuilder) buildKafkaGroupsTool(mode toolMode) mcp.Tool { "- offsets: Get offsets for a specific consumer group" operationEnum := kafkaGroupOperationSpecs.NamesForMode(mode) toolName := "kafka_admin_groups_read" - annotation := builders.ToolAnnotationForMode(mode, "Read Kafka Consumer Groups", "Manage Kafka Consumer Groups") + annotation := builders.ToolAnnotationForMode(mode, "Read Kafka Consumer Groups", "Manage Kafka Consumer Groups", kafkaGroupOperationSpecs) if isToolModeWrite(mode) { operationDesc = "Operation to perform. Available operations:\n" + "- remove-members: Remove specific members from a Consumer Group to force rebalancing or troubleshoot issues\n" + diff --git a/pkg/mcp/builders/kafka/schema_registry.go b/pkg/mcp/builders/kafka/schema_registry.go index 25f3b7b4..5a890869 100644 --- a/pkg/mcp/builders/kafka/schema_registry.go +++ b/pkg/mcp/builders/kafka/schema_registry.go @@ -108,7 +108,7 @@ func (b *KafkaSchemaRegistryToolBuilder) buildKafkaSchemaRegistryTool(mode toolM "- get: Get a subject's latest schema, a specific version, or compatibility level" operationEnum := kafkaSchemaRegistryOperationSpecs.NamesForMode(mode) toolName := "kafka_admin_sr_read" - annotation := builders.ToolAnnotationForMode(mode, "Read Kafka Schema Registry", "Manage Kafka Schema Registry") + annotation := builders.ToolAnnotationForMode(mode, "Read Kafka Schema Registry", "Manage Kafka Schema Registry", kafkaSchemaRegistryOperationSpecs) if isToolModeWrite(mode) { resourceDesc = "Resource to operate on. Available resources:\n" + "- subject: A specific schema subject to register or delete\n" + diff --git a/pkg/mcp/builders/kafka/topics.go b/pkg/mcp/builders/kafka/topics.go index 3472bd07..42a50993 100644 --- a/pkg/mcp/builders/kafka/topics.go +++ b/pkg/mcp/builders/kafka/topics.go @@ -103,7 +103,7 @@ func (b *KafkaTopicsToolBuilder) buildKafkaTopicsTool(mode toolMode) mcp.Tool { "- metadata: Get metadata for a specific topic\n" operationEnum := kafkaTopicOperationSpecs.NamesForMode(mode) toolName := "kafka_admin_topics_read" - annotation := builders.ToolAnnotationForMode(mode, "Read Kafka Topics", "Manage Kafka Topics") + annotation := builders.ToolAnnotationForMode(mode, "Read Kafka Topics", "Manage Kafka Topics", kafkaTopicOperationSpecs) if isToolModeWrite(mode) { operationDesc = "Operation to perform. Available operations:\n" + "- create: Create a new topic with specified partitions, replication factor, and optional configs\n" + diff --git a/pkg/mcp/builders/operation_annotations.go b/pkg/mcp/builders/operation_annotations.go index d6e0a875..381cb784 100644 --- a/pkg/mcp/builders/operation_annotations.go +++ b/pkg/mcp/builders/operation_annotations.go @@ -21,10 +21,36 @@ import ( // ToolAnnotationForMode selects Claude connector safety annotations from the // operation mode so schemas, validation, and annotations share the same mode -// vocabulary. -func ToolAnnotationForMode(mode OperationMode, readTitle, writeTitle string) mcp.ToolOption { - if mode == OperationModeWrite { - return toolannotations.Destructive(writeTitle) +// vocabulary. If operation metadata is supplied, write-tool destructive and +// idempotent hints are derived from the write specs instead of from mode alone. +func ToolAnnotationForMode(mode OperationMode, readTitle, writeTitle string, registries ...OperationRegistry) mcp.ToolOption { + if mode != OperationModeWrite { + return toolannotations.ExternalRead(readTitle) } - return toolannotations.ReadOnly(readTitle) + + destructive, idempotent := writeAnnotationHints(registries...) + return toolannotations.Mutating(writeTitle, destructive, idempotent) +} + +func writeAnnotationHints(registries ...OperationRegistry) (destructive bool, idempotent bool) { + if len(registries) == 0 { + return true, false + } + + foundWriteSpec := false + allIdempotent := true + for _, registry := range registries { + for _, spec := range registry.SpecsForMode(OperationModeWrite) { + foundWriteSpec = true + destructive = destructive || spec.Destructive + allIdempotent = allIdempotent && spec.Idempotent + } + } + if !foundWriteSpec { + return true, false + } + + // A multi-operation tool is idempotent only when every exposed write + // operation is idempotent. + return destructive, allIdempotent } diff --git a/pkg/mcp/builders/operations.go b/pkg/mcp/builders/operations.go index bd5eae20..2461bcb4 100644 --- a/pkg/mcp/builders/operations.go +++ b/pkg/mcp/builders/operations.go @@ -68,14 +68,24 @@ func (r OperationRegistry) Names() []string { return names } -// NamesForMode returns operation names for one mode in registry order. -func (r OperationRegistry) NamesForMode(mode OperationMode) []string { - names := make([]string, 0, len(r)) +// SpecsForMode returns operation specs for one mode in registry order. +func (r OperationRegistry) SpecsForMode(mode OperationMode) []OperationSpec { + specs := make([]OperationSpec, 0, len(r)) for _, spec := range r { if spec.Mode == mode { - names = append(names, spec.Name) + specs = append(specs, spec) } } + return specs +} + +// NamesForMode returns operation names for one mode in registry order. +func (r OperationRegistry) NamesForMode(mode OperationMode) []string { + specs := r.SpecsForMode(mode) + names := make([]string, 0, len(specs)) + for _, spec := range specs { + names = append(names, spec.Name) + } return names } diff --git a/pkg/mcp/builders/operations_test.go b/pkg/mcp/builders/operations_test.go index c51691ec..e8be26e1 100644 --- a/pkg/mcp/builders/operations_test.go +++ b/pkg/mcp/builders/operations_test.go @@ -37,8 +37,13 @@ func TestOperationRegistryModeEnumsAndValidation(t *testing.T) { } func TestToolAnnotationForMode(t *testing.T) { - read := ToolAnnotationForMode(OperationModeRead, "Read Things", "Manage Things") - write := ToolAnnotationForMode(OperationModeWrite, "Read Things", "Manage Things") + registry := OperationRegistry{ + {Name: "list", Mode: OperationModeRead}, + {Name: "update", Mode: OperationModeWrite, Destructive: false, Idempotent: true}, + } + + read := ToolAnnotationForMode(OperationModeRead, "Read Things", "Manage Things", registry) + write := ToolAnnotationForMode(OperationModeWrite, "Read Things", "Manage Things", registry) readTool := mcp.NewTool("read", read) writeTool := mcp.NewTool("write", write) @@ -46,12 +51,20 @@ func TestToolAnnotationForMode(t *testing.T) { require.Equal(t, "Read Things", readTool.Annotations.Title) require.NotNil(t, readTool.Annotations.ReadOnlyHint) require.NotNil(t, readTool.Annotations.DestructiveHint) + require.NotNil(t, readTool.Annotations.IdempotentHint) + require.NotNil(t, readTool.Annotations.OpenWorldHint) require.True(t, *readTool.Annotations.ReadOnlyHint) require.False(t, *readTool.Annotations.DestructiveHint) + require.True(t, *readTool.Annotations.IdempotentHint) + require.True(t, *readTool.Annotations.OpenWorldHint) require.Equal(t, "Manage Things", writeTool.Annotations.Title) require.NotNil(t, writeTool.Annotations.ReadOnlyHint) require.NotNil(t, writeTool.Annotations.DestructiveHint) + require.NotNil(t, writeTool.Annotations.IdempotentHint) + require.NotNil(t, writeTool.Annotations.OpenWorldHint) require.False(t, *writeTool.Annotations.ReadOnlyHint) - require.True(t, *writeTool.Annotations.DestructiveHint) + require.False(t, *writeTool.Annotations.DestructiveHint) + require.True(t, *writeTool.Annotations.IdempotentHint) + require.True(t, *writeTool.Annotations.OpenWorldHint) } diff --git a/pkg/mcp/builders/pulsar/annotation_compliance_test.go b/pkg/mcp/builders/pulsar/annotation_compliance_test.go index eecaf375..fac2808b 100644 --- a/pkg/mcp/builders/pulsar/annotation_compliance_test.go +++ b/pkg/mcp/builders/pulsar/annotation_compliance_test.go @@ -46,6 +46,8 @@ func TestPulsarToolAnnotationCompliance(t *testing.T) { require.NotEmpty(t, tool.Annotations.Title, tool.Name) require.NotNil(t, tool.Annotations.ReadOnlyHint, tool.Name) require.NotNil(t, tool.Annotations.DestructiveHint, tool.Name) + require.NotNil(t, tool.Annotations.IdempotentHint, tool.Name) + require.NotNil(t, tool.Annotations.OpenWorldHint, tool.Name) require.LessOrEqual(t, len(tool.Name), 64, tool.Name) isRead := strings.HasSuffix(tool.Name, "_read") || strings.HasPrefix(tool.Name, "pulsar_admin_namespace_policy_get") || tool.Name == "pulsar_admin_status" || tool.Name == "pulsar_admin_broker_stats" || tool.Name == "pulsar_admin_functions_worker" @@ -53,10 +55,14 @@ func TestPulsarToolAnnotationCompliance(t *testing.T) { if isRead { require.True(t, *tool.Annotations.ReadOnlyHint, tool.Name) require.False(t, *tool.Annotations.DestructiveHint, tool.Name) + require.True(t, *tool.Annotations.IdempotentHint, tool.Name) + require.True(t, *tool.Annotations.OpenWorldHint, tool.Name) } if isWrite { require.False(t, *tool.Annotations.ReadOnlyHint, tool.Name) require.True(t, *tool.Annotations.DestructiveHint, tool.Name) + require.False(t, *tool.Annotations.IdempotentHint, tool.Name) + require.True(t, *tool.Annotations.OpenWorldHint, tool.Name) } assertOperationEnumMode(t, tool.Name, tool.InputSchema.Properties["operation"]) } diff --git a/pkg/mcp/builders/pulsar/brokers.go b/pkg/mcp/builders/pulsar/brokers.go index e6335001..7c085e29 100644 --- a/pkg/mcp/builders/pulsar/brokers.go +++ b/pkg/mcp/builders/pulsar/brokers.go @@ -104,7 +104,7 @@ func (b *PulsarAdminBrokersToolBuilder) buildPulsarAdminBrokersTool(mode toolMod "- get: Retrieve resource information (used with health, config, namespaces)" toolDesc := "Read Apache Pulsar broker resources. This tool lists active brokers, checks broker health, reads broker configurations, and views namespaces owned by a broker." toolName := "pulsar_admin_brokers_read" - annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Brokers", "Manage Pulsar Brokers") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Brokers", "Manage Pulsar Brokers", pulsarBrokerOperationSpecs) if isToolModeWrite(mode) { resourceDesc = "Type of broker resource to access, available options:\n" + "- config: Broker dynamic configuration values" diff --git a/pkg/mcp/builders/pulsar/cluster.go b/pkg/mcp/builders/pulsar/cluster.go index 5cd2a330..9bff3a51 100644 --- a/pkg/mcp/builders/pulsar/cluster.go +++ b/pkg/mcp/builders/pulsar/cluster.go @@ -116,7 +116,7 @@ func (b *PulsarAdminClusterToolBuilder) buildClusterTool(mode toolMode) mcp.Tool operationEnum := pulsarClusterOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_cluster_read" - annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Clusters", "Manage Pulsar Clusters") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Clusters", "Manage Pulsar Clusters", pulsarClusterOperationSpecs) if isToolModeWrite(mode) { toolDesc = "Manage Apache Pulsar clusters.\n" + "This write tool creates, updates, or deletes clusters and failure domains, and updates peer cluster settings." diff --git a/pkg/mcp/builders/pulsar/functions.go b/pkg/mcp/builders/pulsar/functions.go index 6b58dfbc..a77c9974 100644 --- a/pkg/mcp/builders/pulsar/functions.go +++ b/pkg/mcp/builders/pulsar/functions.go @@ -106,7 +106,7 @@ func (b *PulsarAdminFunctionsToolBuilder) buildPulsarAdminFunctionsTool(mode too operationEnum := pulsarFunctionOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_functions_read" - annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Functions", "Manage Pulsar Functions") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Functions", "Manage Pulsar Functions", pulsarFunctionOperationSpecs) if isToolModeWrite(mode) { toolDesc = "Manage Apache Pulsar Functions for stream processing. " + "This write tool deploys, updates, deletes, starts, stops, restarts, stores state for, triggers, or uploads packages for functions." diff --git a/pkg/mcp/builders/pulsar/namespace.go b/pkg/mcp/builders/pulsar/namespace.go index bfc94cd5..2f856a46 100644 --- a/pkg/mcp/builders/pulsar/namespace.go +++ b/pkg/mcp/builders/pulsar/namespace.go @@ -109,7 +109,7 @@ func (b *PulsarAdminNamespaceToolBuilder) buildNamespaceTool(mode toolMode) mcp. operationEnum := pulsarNamespaceOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_namespace_read" - annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Namespaces", "Manage Pulsar Namespaces") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Namespaces", "Manage Pulsar Namespaces", pulsarNamespaceOperationSpecs) if isToolModeWrite(mode) { toolDesc = "Manage Apache Pulsar namespaces. " + "This write tool creates, deletes, unloads, splits bundles, clears backlog, or unsubscribes namespace subscriptions." diff --git a/pkg/mcp/builders/pulsar/nsisolationpolicy.go b/pkg/mcp/builders/pulsar/nsisolationpolicy.go index b0b87783..cd8920b2 100644 --- a/pkg/mcp/builders/pulsar/nsisolationpolicy.go +++ b/pkg/mcp/builders/pulsar/nsisolationpolicy.go @@ -109,7 +109,7 @@ func (b *PulsarAdminNsIsolationPolicyToolBuilder) buildNsIsolationPolicyTool(mod operationEnum := pulsarNsIsolationPolicyOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_nsisolationpolicy_read" - annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Namespace Isolation Policies", "Manage Pulsar Namespace Isolation Policies") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Namespace Isolation Policies", "Manage Pulsar Namespace Isolation Policies", pulsarNsIsolationPolicyOperationSpecs) if isToolModeWrite(mode) { toolDesc = "Manage namespace isolation policies in a Pulsar cluster. " + "This write tool creates, updates, or deletes namespace isolation policies." diff --git a/pkg/mcp/builders/pulsar/packages.go b/pkg/mcp/builders/pulsar/packages.go index cc1eff50..b7c80f54 100644 --- a/pkg/mcp/builders/pulsar/packages.go +++ b/pkg/mcp/builders/pulsar/packages.go @@ -109,7 +109,7 @@ func (b *PulsarAdminPackagesToolBuilder) buildPackagesTool(mode toolMode) mcp.To operationEnum := pulsarPackageOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_package_read" - annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Packages", "Manage Pulsar Packages") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Packages", "Manage Pulsar Packages", pulsarPackageOperationSpecs) if isToolModeWrite(mode) { toolDesc = "Manage packages in Apache Pulsar. Support package schemes: `function://`, `source://`, `sink://`. " + "This write tool updates metadata, deletes packages, or uploads package contents." diff --git a/pkg/mcp/builders/pulsar/resourcequotas.go b/pkg/mcp/builders/pulsar/resourcequotas.go index 167194de..a9b34329 100644 --- a/pkg/mcp/builders/pulsar/resourcequotas.go +++ b/pkg/mcp/builders/pulsar/resourcequotas.go @@ -104,7 +104,7 @@ func (b *PulsarAdminResourceQuotasToolBuilder) buildResourceQuotasTool(mode tool operationEnum := pulsarResourceQuotaOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_resourcequota_read" - annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Resource Quotas", "Manage Pulsar Resource Quotas") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Resource Quotas", "Manage Pulsar Resource Quotas", pulsarResourceQuotaOperationSpecs) if isToolModeWrite(mode) { toolDesc = "Manage Apache Pulsar resource quotas for brokers, namespaces and bundles. " + "This write tool sets or resets quota configuration." diff --git a/pkg/mcp/builders/pulsar/schema.go b/pkg/mcp/builders/pulsar/schema.go index b29db6ec..f322ba2c 100644 --- a/pkg/mcp/builders/pulsar/schema.go +++ b/pkg/mcp/builders/pulsar/schema.go @@ -110,7 +110,7 @@ func (b *PulsarAdminSchemaToolBuilder) buildSchemaTool(mode toolMode) mcp.Tool { operationEnum := pulsarSchemaOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_schema_read" - annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Schemas", "Manage Pulsar Schemas") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Schemas", "Manage Pulsar Schemas", pulsarSchemaOperationSpecs) if isToolModeWrite(mode) { toolDesc = "Manage Apache Pulsar schemas for topics. " + "This write tool uploads or deletes topic schemas." diff --git a/pkg/mcp/builders/pulsar/sinks.go b/pkg/mcp/builders/pulsar/sinks.go index a4221e3a..2bd50104 100644 --- a/pkg/mcp/builders/pulsar/sinks.go +++ b/pkg/mcp/builders/pulsar/sinks.go @@ -114,7 +114,7 @@ func (b *PulsarAdminSinksToolBuilder) buildSinksTool(mode toolMode) mcp.Tool { operationEnum := pulsarSinkOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_sinks_read" - annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Sinks", "Manage Pulsar Sinks") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Sinks", "Manage Pulsar Sinks", pulsarSinkOperationSpecs) if isToolModeWrite(mode) { toolDesc = "Manage Apache Pulsar Sinks for data movement and integration. " + "This write tool deploys, updates, deletes, starts, stops, or restarts sinks." diff --git a/pkg/mcp/builders/pulsar/sources.go b/pkg/mcp/builders/pulsar/sources.go index 9fe24876..5fcdf86f 100644 --- a/pkg/mcp/builders/pulsar/sources.go +++ b/pkg/mcp/builders/pulsar/sources.go @@ -114,7 +114,7 @@ func (b *PulsarAdminSourcesToolBuilder) buildSourcesTool(mode toolMode) mcp.Tool operationEnum := pulsarSourceOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_sources_read" - annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Sources", "Manage Pulsar Sources") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Sources", "Manage Pulsar Sources", pulsarSourceOperationSpecs) if isToolModeWrite(mode) { toolDesc = "Manage Apache Pulsar Sources for data ingestion and integration. " + "This write tool deploys, updates, deletes, starts, stops, or restarts sources." diff --git a/pkg/mcp/builders/pulsar/subscription.go b/pkg/mcp/builders/pulsar/subscription.go index fc7fa1cc..36d9a9ea 100644 --- a/pkg/mcp/builders/pulsar/subscription.go +++ b/pkg/mcp/builders/pulsar/subscription.go @@ -129,7 +129,7 @@ func (b *PulsarAdminSubscriptionToolBuilder) buildSubscriptionTool(mode toolMode operationEnum := pulsarSubscriptionOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_subscription_read" - annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Subscriptions", "Manage Pulsar Subscriptions") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Subscriptions", "Manage Pulsar Subscriptions", pulsarSubscriptionOperationSpecs) if isToolModeWrite(mode) { toolDesc = "Manage Apache Pulsar subscriptions on topics. " + "This write tool creates or deletes subscriptions and changes subscription cursor positions." diff --git a/pkg/mcp/builders/pulsar/tenant.go b/pkg/mcp/builders/pulsar/tenant.go index 69470150..e67cf075 100644 --- a/pkg/mcp/builders/pulsar/tenant.go +++ b/pkg/mcp/builders/pulsar/tenant.go @@ -115,7 +115,7 @@ func (b *PulsarAdminTenantToolBuilder) buildTenantTool(mode toolMode) mcp.Tool { operationEnum := pulsarTenantOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_tenant_read" - annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Tenants", "Manage Pulsar Tenants") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Tenants", "Manage Pulsar Tenants", pulsarTenantOperationSpecs) if isToolModeWrite(mode) { toolDesc = "Manage Apache Pulsar tenants. " + "This write tool creates, updates, or deletes tenant configuration and may change cluster state." diff --git a/pkg/mcp/builders/pulsar/topic.go b/pkg/mcp/builders/pulsar/topic.go index 1f3727b4..275c0fb3 100644 --- a/pkg/mcp/builders/pulsar/topic.go +++ b/pkg/mcp/builders/pulsar/topic.go @@ -147,7 +147,7 @@ func (b *PulsarAdminTopicToolBuilder) buildTopicTool(mode toolMode) mcp.Tool { operationEnum := pulsarTopicOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_topic_read" - annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Topics", "Manage Pulsar Topics") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Topics", "Manage Pulsar Topics", pulsarTopicOperationSpecs) if isToolModeWrite(mode) { toolDesc = "Manage Apache Pulsar topics. " + "This write tool changes topic lifecycle, permissions, partitioning, compaction, or offload state. " + diff --git a/pkg/mcp/builders/pulsar/topic_policy.go b/pkg/mcp/builders/pulsar/topic_policy.go index ac2d95f7..9150431c 100644 --- a/pkg/mcp/builders/pulsar/topic_policy.go +++ b/pkg/mcp/builders/pulsar/topic_policy.go @@ -185,7 +185,7 @@ func (b *PulsarAdminTopicPolicyToolBuilder) buildTopicPolicyTool(mode toolMode) operationEnum := readOnlyTopicPolicyOperations toolName := "pulsar_admin_topic_policy_read" - annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Topic Policies", "Manage Pulsar Topic Policies") + annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Topic Policies", "Manage Pulsar Topic Policies", pulsarTopicPolicyOperationSpecs) if isToolModeWrite(mode) { toolDesc = "Manage Pulsar topic-level policies with operation names aligned to pulsarctl topic policy commands. " + "This write tool sets or removes topic-level policies. Legacy underscore operation aliases from the older MCP implementation remain supported." diff --git a/pkg/mcp/pftools/manager_annotation_test.go b/pkg/mcp/pftools/manager_annotation_test.go index 5e03e1ed..84515d94 100644 --- a/pkg/mcp/pftools/manager_annotation_test.go +++ b/pkg/mcp/pftools/manager_annotation_test.go @@ -44,6 +44,10 @@ func TestConvertFunctionToToolSetsDestructiveAnnotation(t *testing.T) { require.NotEmpty(t, annotations.Title) require.NotNil(t, annotations.ReadOnlyHint) require.NotNil(t, annotations.DestructiveHint) + require.NotNil(t, annotations.IdempotentHint) + require.NotNil(t, annotations.OpenWorldHint) require.False(t, *annotations.ReadOnlyHint) require.True(t, *annotations.DestructiveHint) + require.False(t, *annotations.IdempotentHint) + require.True(t, *annotations.OpenWorldHint) } diff --git a/pkg/mcp/sncontext_tools.go b/pkg/mcp/sncontext_tools.go index 60a92885..d3897e90 100644 --- a/pkg/mcp/sncontext_tools.go +++ b/pkg/mcp/sncontext_tools.go @@ -50,11 +50,11 @@ func RegisterContextTools(s *server.MCPServer, features []string, readOnly bool, mcp.WithString("clusterName", mcp.Required(), mcp.Description("The name of the pulsar cluster to use"), ), - toolannotations.Destructive("Use StreamNative Cloud Cluster Context"), + toolannotations.LocalSessionMutation("Use StreamNative Cloud Cluster Context"), ) resetContextTool := mcp.NewTool("sncloud_context_reset", mcp.WithDescription("Reset the current StreamNative Cloud cluster context. After reset, the session has no bound Pulsar or Kafka cluster connection; use `sncloud_context_use_cluster` before calling cluster-specific tools again."), - toolannotations.Destructive("Reset StreamNative Cloud Cluster Context"), + toolannotations.LocalSessionMutation("Reset StreamNative Cloud Cluster Context"), ) // Skip registering context mutation tools when context is already provided or the server is read-only. if !skipContextTools && !readOnly { diff --git a/pkg/mcp/static_tool_annotations_test.go b/pkg/mcp/static_tool_annotations_test.go index 47e7a157..be000700 100644 --- a/pkg/mcp/static_tool_annotations_test.go +++ b/pkg/mcp/static_tool_annotations_test.go @@ -28,18 +28,16 @@ func TestStreamNativeStaticToolAnnotations(t *testing.T) { tool mcpgotypes.Tool readOnly bool destructive bool + idempotent bool + openWorld bool }{ - {name: "sncloud_logs", tool: NewSNCloudLogsTool(), readOnly: true}, - {name: "sncloud_resources_apply", tool: NewSNCloudResourcesApplyTool(), destructive: true}, - {name: "sncloud_resources_delete", tool: NewSNCloudResourcesDeleteTool(), destructive: true}, + {name: "sncloud_logs", tool: NewSNCloudLogsTool(), readOnly: true, idempotent: true, openWorld: true}, + {name: "sncloud_resources_apply", tool: NewSNCloudResourcesApplyTool(), destructive: true, openWorld: true}, + {name: "sncloud_resources_delete", tool: NewSNCloudResourcesDeleteTool(), destructive: true, openWorld: true}, } for _, tt := range tools { - require.NotEmpty(t, tt.tool.Annotations.Title, tt.name) - require.NotNil(t, tt.tool.Annotations.ReadOnlyHint, tt.name) - require.NotNil(t, tt.tool.Annotations.DestructiveHint, tt.name) - require.Equal(t, tt.readOnly, *tt.tool.Annotations.ReadOnlyHint, tt.name) - require.Equal(t, tt.destructive, *tt.tool.Annotations.DestructiveHint, tt.name) + requireToolAnnotation(t, tt.name, tt.tool, tt.readOnly, tt.destructive, tt.idempotent, tt.openWorld) } } @@ -50,21 +48,32 @@ func TestStreamNativeContextToolAnnotations(t *testing.T) { expectations := map[string]struct { readOnly bool destructive bool + idempotent bool + openWorld bool }{ - "sncloud_context_whoami": {readOnly: true}, - "sncloud_context_available_clusters": {readOnly: true}, - "sncloud_context_use_cluster": {destructive: true}, - "sncloud_context_reset": {destructive: true}, + "sncloud_context_whoami": {readOnly: true, idempotent: true, openWorld: true}, + "sncloud_context_available_clusters": {readOnly: true, idempotent: true, openWorld: true}, + "sncloud_context_use_cluster": {}, + "sncloud_context_reset": {}, } for name, expected := range expectations { serverTool := server.GetTool(name) require.NotNil(t, serverTool, name) tool := serverTool.Tool - require.NotEmpty(t, tool.Annotations.Title, name) - require.NotNil(t, tool.Annotations.ReadOnlyHint, name) - require.NotNil(t, tool.Annotations.DestructiveHint, name) - require.Equal(t, expected.readOnly, *tool.Annotations.ReadOnlyHint, name) - require.Equal(t, expected.destructive, *tool.Annotations.DestructiveHint, name) + requireToolAnnotation(t, name, tool, expected.readOnly, expected.destructive, expected.idempotent, expected.openWorld) } } + +func requireToolAnnotation(t *testing.T, name string, tool mcpgotypes.Tool, readOnly, destructive, idempotent, openWorld bool) { + t.Helper() + require.NotEmpty(t, tool.Annotations.Title, name) + require.NotNil(t, tool.Annotations.ReadOnlyHint, name) + require.NotNil(t, tool.Annotations.DestructiveHint, name) + require.NotNil(t, tool.Annotations.IdempotentHint, name) + require.NotNil(t, tool.Annotations.OpenWorldHint, name) + require.Equal(t, readOnly, *tool.Annotations.ReadOnlyHint, name) + require.Equal(t, destructive, *tool.Annotations.DestructiveHint, name) + require.Equal(t, idempotent, *tool.Annotations.IdempotentHint, name) + require.Equal(t, openWorld, *tool.Annotations.OpenWorldHint, name) +} diff --git a/pkg/mcp/toolannotations/annotations.go b/pkg/mcp/toolannotations/annotations.go index 520a1e33..e420e921 100644 --- a/pkg/mcp/toolannotations/annotations.go +++ b/pkg/mcp/toolannotations/annotations.go @@ -17,24 +17,58 @@ package toolannotations import "github.com/mark3labs/mcp-go/mcp" -// ReadOnly annotates a tool that does not modify external or session state. +// ReadOnly annotates a non-mutating read tool. It is safe to call repeatedly and +// may interact with external services to retrieve data. func ReadOnly(title string) mcp.ToolOption { - return mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: title, - ReadOnlyHint: boolPtr(true), - DestructiveHint: boolPtr(false), - }) + return compose( + mcp.WithTitleAnnotation(title), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(true), + ) } -// Destructive annotates a tool that may modify external resources or session state. +// ExternalRead is an explicit alias for read tools whose purpose is to retrieve +// data from external systems. +func ExternalRead(title string) mcp.ToolOption { + return ReadOnly(title) +} + +// LocalSessionMutation annotates a tool that mutates only MCP server/session +// state, not external StreamNative, Kafka, or Pulsar resources. +func LocalSessionMutation(title string) mcp.ToolOption { + return compose( + mcp.WithTitleAnnotation(title), + mcp.WithReadOnlyHintAnnotation(false), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(false), + mcp.WithOpenWorldHintAnnotation(false), + ) +} + +// Mutating annotates a tool that may perform external side effects. Callers must +// classify whether the operation may be destructive and whether repeated calls +// with identical arguments are idempotent. +func Mutating(title string, destructive bool, idempotent bool) mcp.ToolOption { + return compose( + mcp.WithTitleAnnotation(title), + mcp.WithReadOnlyHintAnnotation(false), + mcp.WithDestructiveHintAnnotation(destructive), + mcp.WithIdempotentHintAnnotation(idempotent), + mcp.WithOpenWorldHintAnnotation(true), + ) +} + +// Destructive annotates a non-idempotent tool that may modify external resources. func Destructive(title string) mcp.ToolOption { - return mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: title, - ReadOnlyHint: boolPtr(false), - DestructiveHint: boolPtr(true), - }) + return Mutating(title, true, false) } -func boolPtr(v bool) *bool { - return &v +func compose(opts ...mcp.ToolOption) mcp.ToolOption { + return func(t *mcp.Tool) { + for _, opt := range opts { + opt(t) + } + } } diff --git a/pkg/mcp/toolannotations/annotations_test.go b/pkg/mcp/toolannotations/annotations_test.go new file mode 100644 index 00000000..a37c0f95 --- /dev/null +++ b/pkg/mcp/toolannotations/annotations_test.go @@ -0,0 +1,95 @@ +// Copyright 2026 StreamNative +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package toolannotations + +import ( + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" +) + +func TestAnnotationHelpersSetGranularHints(t *testing.T) { + tests := []struct { + name string + option mcp.ToolOption + title string + readOnly bool + destructive bool + idempotent bool + openWorld bool + }{ + { + name: "read only", + option: ReadOnly("Read Things"), + title: "Read Things", + readOnly: true, + destructive: false, + idempotent: true, + openWorld: true, + }, + { + name: "external read", + option: ExternalRead("Read External Things"), + title: "Read External Things", + readOnly: true, + destructive: false, + idempotent: true, + openWorld: true, + }, + { + name: "local session mutation", + option: LocalSessionMutation("Use Context"), + title: "Use Context", + readOnly: false, + destructive: false, + idempotent: false, + openWorld: false, + }, + { + name: "non destructive idempotent mutation", + option: Mutating("Update Setting", false, true), + title: "Update Setting", + readOnly: false, + destructive: false, + idempotent: true, + openWorld: true, + }, + { + name: "destructive compatibility", + option: Destructive("Delete Things"), + title: "Delete Things", + readOnly: false, + destructive: true, + idempotent: false, + openWorld: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tool := mcp.NewTool("test", tt.option) + require.Equal(t, tt.title, tool.Annotations.Title) + require.NotNil(t, tool.Annotations.ReadOnlyHint) + require.NotNil(t, tool.Annotations.DestructiveHint) + require.NotNil(t, tool.Annotations.IdempotentHint) + require.NotNil(t, tool.Annotations.OpenWorldHint) + require.Equal(t, tt.readOnly, *tool.Annotations.ReadOnlyHint) + require.Equal(t, tt.destructive, *tool.Annotations.DestructiveHint) + require.Equal(t, tt.idempotent, *tool.Annotations.IdempotentHint) + require.Equal(t, tt.openWorld, *tool.Annotations.OpenWorldHint) + }) + } +} From dc4cf0026c79e9b81aca616772369b2b965b5302 Mon Sep 17 00:00:00 2001 From: Rui Fu Date: Tue, 19 May 2026 18:26:57 +0800 Subject: [PATCH 07/10] chore: remove the PLAN.md file --- PLAN.md | 683 -------------------------------------------------------- 1 file changed, 683 deletions(-) delete mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 6cbd1600..00000000 --- a/PLAN.md +++ /dev/null @@ -1,683 +0,0 @@ -# Plan: Claude connector tool split + annotations - -## Goal - -Prepare StreamNative MCP Server for Claude connector submission and review. - -## Current review follow-up: annotation helper granularity - -Reviewer feedback is valid: `toolannotations.ReadOnly` and `toolannotations.Destructive` currently call `mcp.WithToolAnnotation(...)`, replacing the whole annotation struct and dropping mcp-go defaults for `idempotentHint` and `openWorldHint`. The helper also collapses all side effects into `Destructive`, so local session mutations and reversible lifecycle operations look equivalent to delete/apply operations. - -Implementation status: implemented in current work. - -1. Replaced whole-annotation assignment helpers with composed field setters so unspecified hints keep mcp-go defaults unless explicitly changed. -2. Exposed helper categories: - - `ReadOnly(title)` for non-mutating external reads: read-only true, destructive false, idempotent true, open-world true. - - `ExternalRead(title)` as explicit alias for external read tools when caller intent should be obvious. - - `LocalSessionMutation(title)` for session/context state changes: read-only false, destructive false, idempotent false by default, open-world false. - - `Mutating(title, destructive, idempotent)` for external writes/side effects: read-only false, caller-controlled destructive/idempotent, open-world true. -3. Kept `Destructive(title)` as compatibility wrapper over `Mutating(title, true, false)` while migrating call sites to precise helpers. -4. Updated context tools to use `LocalSessionMutation`; updated operation-mode helper to derive destructive/idempotent from `OperationSpec` where available instead of treating all writes as destructive. -5. Extended annotation tests to assert `idempotentHint` and `openWorldHint` for static/context/builder tools, not only read/destructive. - -Risks: - -- Tool annotation surface changes are runtime-visible to MCP clients and Claude review. -- Incorrect idempotency classification can mislead clients; delete/remove/reset may be idempotent only depending backend behavior. -- Broad migration across all builders risks noisy diff; recommended first patch helper + high-signal call sites, then operation registry cleanup separately. - -Recommended validation: - -```bash -go test -race ./pkg/mcp/... ./pkg/mcp/builders/... ./pkg/mcp/pftools/... -go test -race ./... -go fmt ./... -go mod tidy -golangci-lint run --timeout=3m -make build -``` - -## Current review follow-up: operation spec registry - -Reviewer feedback is valid: current branch split read/write tool names, but duplicated `toolMode` helpers and per-builder write-operation maps still create scatter-shot maintenance. Adding one operation can require enum updates, write map updates, handler switch updates, docs, and compliance-test classification. `validateModeOperation` also classifies any operation missing from the write map as read, so an unclassified future write can pass read-mode validation until the handler switch rejects or mishandles it. - -Recommended next design: make each tool family declare operation metadata once, then derive mode-specific tool schemas, annotations, validation, and tests from that registry. - -```go -type OperationMode string - -const ( - OperationModeRead OperationMode = "read" - OperationModeWrite OperationMode = "write" -) - -type OperationSpec struct { - Name string - Mode OperationMode - Destructive bool - Idempotent bool - Resources []string - Params []ParamSpec - Handler OperationHandler -} -``` - -Scope is incremental but complete: add shared spec helpers, migrate `kafka/topics.go` and `pulsar/namespace.go` as reference implementations, then migrate every remaining split builder in batches. Keep current read/write tool names unchanged. Use docs generated operation-table blocks first; do not rewrite full Markdown documents. - -Hard requirements from Claude docs: - -- every MCP tool has non-empty `annotations.title` -- every MCP tool has explicit applicable `annotations.readOnlyHint` or `annotations.destructiveHint` -- read and write operations must be separate tools; no mixed `operation` catch-all that contains both safe and unsafe operations - -Source docs checked: - -- https://claude.com/docs/connectors/building/submission -- https://claude.com/docs/connectors/building/review-criteria - -Reference implementation checked: - -- `/Users/rui/playground/sn/mcp-auth0-proxy/internal/hooks/org_session_tools.go` -- Existing pattern there: - - separate tool names: e.g. `sncloud_byoc_read` and `sncloud_byoc_write` - - shared builder with mode enum: `controlPlaneToolModeRead` / `controlPlaneToolModeWrite` - - read tool operation enum: `list`, `get` - - write tool operation enum: `apply`, `delete` - - annotation set from mode: - - read: `readOnlyHint=true`, `destructiveHint=false` - - write: `readOnlyHint=false`, `destructiveHint=true` - - shared handler still validates operation against mode - -## Current findings - -Current follow-up focus: previous read/write split only separated tool names and operation enums in some builders. It still leaves mixed mode descriptions and write-only schema fields visible on read tools. Examples: `pkg/mcp/builders/kafka/topics.go` and `pkg/mcp/builders/pulsar/namespace.go`; same class can exist in other split builders. Connector review can still treat this as a mixed surface because `tools/list` exposes write verbs/examples/parameters through read tools. - -Static `mcp.NewTool(...)` definitions found under `pkg/`: 36 tool definitions plus dynamic Pulsar Functions-as-Tools. - -Current gaps: - -- Most static tools have no explicit title. -- Most static tools rely on `mcp-go` defaults (`readOnlyHint=false`, `destructiveHint=true`, `openWorldHint=true`), which marks read tools as destructive. -- Only `sncloud_resources_apply` and `sncloud_resources_delete` currently set `WithToolAnnotation`; `apply` sets title only. -- Dynamic Pulsar Functions-as-Tools in `pkg/mcp/pftools/manager.go` create tools without title/read-only/destructive annotations. -- Many admin tools multiplex read and write operations through one `operation` parameter. Claude review criteria says mixed read/write catch-all tools can be rejected even if description documents safe/unsafe operations. -- Some already-split tools still have mixed descriptions and schemas. Mode-specific enum is not enough; read tools must not expose write operations, examples, or write-only parameters. - -## Proposed design - -### Design principle - -Follow `mcp-auth0-proxy` pattern: - -- split mixed tools into separate read and write tool names -- keep shared internal implementation where practical -- make operation enum, description, examples, and parameters mode-specific, so tool schema itself prevents mixed use -- keep read-only runtime mode simple: register only read tools -- in read-write runtime mode: register read tools and write tools as separate entries -- do not expose legacy mixed tools in Claude-submitted surface - -### Naming convention - -For mixed tools, replace one tool with two tools: - -- `_read` -- `_write` - -Examples: - -- `kafka_admin_topics` -> `kafka_admin_topics_read`, `kafka_admin_topics_write` -- `pulsar_admin_topic` -> `pulsar_admin_topic_read`, `pulsar_admin_topic_write` -- `pulsar_admin_namespace_policy` already has partial split; align names and annotations instead of forcing one exact pattern when current names are already narrow. - -Pure read tools can keep current names if no write side effects exist. -Pure write/side-effect tools can keep current names if description and annotation are clear. - -Compatibility policy: - -- Remove mixed legacy tool registration from default surface. -- Do not add opt-in legacy aliases or compatibility flags for old mixed tool names. -- Do not keep mixed legacy tools visible in submitted connector, even with destructive annotation. - -### Shared helper APIs - -Add a small helper package, likely `pkg/mcp/toolannotations`, to avoid duplicated pointer boilerplate and import cycles: - -- `ReadOnly(title string) mcp.ToolOption` -> title, `readOnlyHint=true`, `destructiveHint=false` -- `Destructive(title string) mcp.ToolOption` -> title, `readOnlyHint=false`, `destructiveHint=true` -- optional `NonDestructiveWrite(title string)` only if a tool changes local session state without modifying external service; use sparingly because Claude requirement names `destructiveHint` for modifying/deleting tools. - -Add builder-local mode types where useful: - -```go -type toolMode string - -const ( - toolModeRead toolMode = "read" - toolModeWrite toolMode = "write" -) -``` - -Build functions should accept mode: - -- `buildTool(mode toolMode)` -- `buildHandler(mode toolMode, readOnly bool)` -- `validateOperation(mode, operation)` -- `isWriteOperation(operation)` - -Mode-specific tool builders should also split: - -- `toolDesc` -- `resourceDesc` when resource meanings differ by mode -- `operationDesc` -- `operationEnum` -- parameter set (`mcp.WithString`, `mcp.WithNumber`, `mcp.WithObject`, etc.) - -Read tools must not expose write-only fields. Write tools should not expose read-only-only fields unless a write operation genuinely needs them. - -## Split inventory - -### Kafka builders - -#### `kafka_admin_topics` - -Split: - -- `kafka_admin_topics_read` - - operations: `list`, `get`, `metadata` - - annotation: read-only -- `kafka_admin_topics_write` - - operations: `create`, `delete` - - annotation: destructive - -Read-only runtime: register read only. -Read-write runtime: register both. - -#### `kafka_admin_groups` - -Split: - -- `kafka_admin_groups_read` - - operations: `list`, `describe`, `offsets` - - annotation: read-only -- `kafka_admin_groups_write` - - operations: `remove-members`, `delete-offset`, `set-offset` - - annotation: destructive - -#### `kafka_admin_sr` - -Split: - -- `kafka_admin_sr_read` - - operations: `list`, `get`, plus schema type/capability read operations - - annotation: read-only -- `kafka_admin_sr_write` - - operations: `set`, `create`, `delete` - - annotation: destructive - -#### `kafka_admin_connect` - -Split: - -- `kafka_admin_connect_read` - - read operations: cluster info, connector list/get/status/config, connector plugins, transforms - - annotation: read-only -- `kafka_admin_connect_write` - - write operations: `create`, `update`, `delete`, `restart`, `pause`, `resume` - - annotation: destructive - -#### `kafka_admin_partitions` - -Current tool appears write-only (`update`). Options: - -- keep `kafka_admin_partitions` as destructive write-only; or -- rename to `kafka_admin_partitions_write` for consistency. - -Recommendation: rename to `kafka_admin_partitions_write` if no read operations exist, and update docs. If preserving name matters, keep current name but annotate destructive. - -#### `kafka_client_produce` - -Write/side-effect tool. Keep current name, annotate destructive. - -#### `kafka_client_consume` - -Ambiguous: - -- description says no offset commit unless `group` parameter is explicitly specified -- with `group`, consumer group state may change - -Recommendation for review safety: - -- split into `kafka_client_consume_read` without `group` / no offset commit -- optional `kafka_client_consume_group` or `kafka_client_consume_write` for group-based consumption that may affect offsets/state, annotated destructive - -If implementation never commits offsets, keep single read tool after code verification and adjust description/schema to remove side-effect ambiguity. - -### Pulsar builders - -#### `pulsar_admin_topic` - -Split: - -- `pulsar_admin_topic_read` - - operations: `list`, `get`, `get-permissions`, `stats`, `lookup`, `internal-stats`, `internal-info`, `bundle-range`, `last-message-id`, `compact-status`, `offload-status` - - annotation: read-only -- `pulsar_admin_topic_write` - - operations: `grant-permissions`, `revoke-permissions`, `create`, `delete`, `unload`, `terminate`, `compact`, `update`, `offload` - - annotation: destructive - -#### `pulsar_admin_subscription` - -Split: - -- `pulsar_admin_subscription_read` - - operations: `list`, `peek`, `get-message-by-id` - - annotation: read-only -- `pulsar_admin_subscription_write` - - operations: `create`, `delete`, `skip`, `expire`, `reset-cursor` - - annotation: destructive - -#### `pulsar_admin_namespace` - -Split: - -- `pulsar_admin_namespace_read` - - operations: `list`, `get_topics` - - annotation: read-only -- `pulsar_admin_namespace_write` - - operations: `create`, `delete`, `clear_backlog`, `unsubscribe`, `unload`, `split_bundle` - - annotation: destructive - -#### `pulsar_admin_namespace_policy*` - -Already partly separated: - -- `pulsar_admin_namespace_policy_get` -> read-only -- `pulsar_admin_namespace_policy_get_anti_affinity_namespaces` -> read-only -- `pulsar_admin_namespace_policy_set` -> destructive -- `pulsar_admin_namespace_policy_remove` -> destructive - -Keep split; add titles/annotations and ensure no tool mixes set/remove/get. - -#### `pulsar_admin_topic_policy` - -Likely mixed get/set/remove operations. Split: - -- `pulsar_admin_topic_policy_read` -- `pulsar_admin_topic_policy_write` - -Use same operation partitioning as handler supports. - -#### `pulsar_admin_brokers` - -Split: - -- `pulsar_admin_brokers_read` - - list/get/health/config/namespaces/runtime/internal/all_dynamic reads - - annotation: read-only -- `pulsar_admin_brokers_write` - - dynamic config update/delete or any mutable broker operation - - annotation: destructive - -#### `pulsar_admin_cluster` - -Split: - -- `pulsar_admin_cluster_read` - - `list`, `get`, read peer/failure-domain operations - - annotation: read-only -- `pulsar_admin_cluster_write` - - `create`, `update`, `delete`, write peer/failure-domain operations - - annotation: destructive - -#### `pulsar_admin_functions` - -Split: - -- `pulsar_admin_functions_read` - - `list`, `get`, `status`, `stats`, `querystate`, `download` - - annotation: read-only -- `pulsar_admin_functions_write` - - `create`, `update`, `delete`, `start`, `stop`, `restart`, `putstate`, `trigger`, `upload` - - annotation: destructive - -#### `pulsar_admin_sinks` / `pulsar_admin_sources` - -Split each: - -- `*_read` - - `list`, `get`, `status`, `list-built-in` - - annotation: read-only -- `*_write` - - `create`, `update`, `delete`, `start`, `stop`, `restart` - - annotation: destructive - -#### `pulsar_admin_packages` - -Split: - -- `pulsar_admin_package_read` - - `list`, `get`, `download` - - annotation: read-only -- `pulsar_admin_package_write` - - `update`, `delete`, `upload` - - annotation: destructive - -#### `pulsar_admin_schema` - -Split: - -- `pulsar_admin_schema_read` - - `get` - - annotation: read-only -- `pulsar_admin_schema_write` - - `upload`, `delete` - - annotation: destructive - -#### `pulsar_admin_tenant` - -Split: - -- `pulsar_admin_tenant_read` - - `list`, `get` - - annotation: read-only -- `pulsar_admin_tenant_write` - - `create`, `update`, `delete` - - annotation: destructive - -#### `pulsar_admin_nsisolationpolicy` - -Split: - -- `pulsar_admin_nsisolationpolicy_read` - - `get`, `list`, broker read operations - - annotation: read-only -- `pulsar_admin_nsisolationpolicy_write` - - `set`, `delete` - - annotation: destructive - -#### `pulsar_admin_resourcequota` - -Split: - -- `pulsar_admin_resourcequota_read` - - `get` - - annotation: read-only -- `pulsar_admin_resourcequota_write` - - `set`, `reset` - - annotation: destructive - -#### Pure read tools - -Keep current names, add read-only annotation: - -- `pulsar_admin_status` -- `pulsar_admin_broker_stats` -- `pulsar_admin_functions_worker` -- any MCP resources/templates that are not tools stay out of tool annotation scope - -#### Pulsar client tools - -- `pulsar_client_produce`: keep current name, destructive annotation. -- `pulsar_client_consume`: likely side-effectful because subscriptions/cursors can be created/advanced. Recommendation: annotate destructive unless implementation is changed to provide a non-mutating peek/read variant. - -Possible future split: - -- `pulsar_client_peek_read` for non-destructive peeking if supported by admin APIs -- `pulsar_client_consume` remains destructive - -### StreamNative Cloud tools - -#### Existing resource tools - -Already split by action: - -- `sncloud_resources_apply`: destructive; include title and `destructiveHint=true` -- `sncloud_resources_delete`: destructive; title already present, ensure readOnlyHint false too - -No read counterpart currently. If resource list/get is added, use `sncloud_resources_read` rather than adding list/get to apply/delete tools. - -#### Context tools - -- `sncloud_context_whoami`: read-only -- `sncloud_context_available_clusters`: read-only -- `sncloud_context_use_cluster`: session/context mutation; annotate destructive or non-read-only. For Claude safety, use destructive unless we explicitly add `NonDestructiveWrite` and verify review accepts it. -- `sncloud_context_reset`: session/context mutation; annotate destructive or non-read-only. For Claude safety, use destructive. - -#### Logs - -- `sncloud_logs`: read-only - -### Dynamic Functions-as-Tools - -`pkg/mcp/pftools/manager.go` dynamic tools invoke deployed Pulsar Functions and can produce messages / trigger external effects. - -Plan: - -- keep dynamic tool name from function metadata -- add human-readable title from function metadata/tool name -- annotate `destructiveHint=true` -- if read-only mode should not expose dynamic invocation tools, verify registration path and add test -- do not mark dynamic function tools read-only unless function metadata explicitly supports safe read-only classification in future - -## Implementation phases - -### Phase 1: shared annotation + mode helpers - -Status: implemented on current branch. - -- Add `pkg/mcp/toolannotations` helper. -- Add local read/write mode helpers in builders with mixed operations. -- Add reusable operation validation helpers where a builder already has operation maps. - -### Phase 2: split Kafka tools completely - -Status: implemented on current branch; follow-up refactor still needed to remove duplicated operation classification. - -- Update all Kafka builders to build mode-specific tools. -- Ensure read/write tools have mode-specific descriptions, examples, operation enums, and parameter schemas. -- Read-only config returns only read tools. -- Read-write config returns read + write tools, except pure write tools remain write-only. -- Remove old mixed tool surface; no compatibility alias. -- Update wrapper tests/docs. - -### Phase 3: split Pulsar tools completely - -Status: implemented on current branch; follow-up refactor still needed to remove duplicated operation classification. - -- Update all Pulsar builders to build mode-specific tools. -- Ensure read/write tools have mode-specific descriptions, examples, operation enums, and parameter schemas. -- Preserve existing read-only behavior by not registering write tools in read-only config. -- Ensure mode-specific operation enums and validation errors. -- Remove old mixed tool surface; no compatibility alias. -- Add/extend parity tests for operation coverage. - -### Phase 4: StreamNative Cloud/static tool annotations - -Status: implemented on current branch. - -- Add annotations to context/log/resource tools. -- Keep already split apply/delete tools. -- Ensure no new mixed resource tool appears. - -### Phase 5: dynamic tools - -Status: implemented on current branch. - -- Add annotations to Functions-as-Tools. -- Validate read-only exposure behavior. - -### Phase 6: runtime-visible docs - -Status: implemented on current branch, with generated operation-table guard tests for migrated split builders. - -Update runtime-visible docs together with schema changes: - -- Keep current read/write tool names unchanged. -- `README.md` feature/tool examples only if behavior changes. -- `docs/tools/*.md` matching current split tools. -- Split docs into explicit read/write sections where a family has both tool modes. -- Ensure read docs do not mention write-only operations or parameters. -- Ensure write docs do not rely on old mixed tool names. -- Any design notes under `agents/` if tool surface changes are architectural. - -### Phase 7: shared operation spec registry follow-up - -Status: implemented on current branch. - -Goal: make operation metadata single-source-of-truth and remove copy-paste `toolMode` + `writeOperations` maps. - -1. Add shared operation metadata API, likely under `pkg/mcp/builders/operations.go`: - - `OperationModeRead` / `OperationModeWrite` - - `OperationSpec` - - `ParamSpec` - - `OperationHandler` type alias or adapter - - `OperationRegistry` or helper functions over `[]OperationSpec` -2. Shared helpers must derive: - - read operation enum - - write operation enum - - operation description fragments/table rows - - mode-specific validation - - unknown-operation rejection - - read/write annotation selection - - compliance-test classification -3. Validation semantics: - - operation absent from spec => reject, never default to read - - operation present with wrong mode => reject with mode-specific error - - operation present with matching mode => dispatch allowed -4. Migrate two reference builders first: - - `pkg/mcp/builders/kafka/topics.go` - - `pkg/mcp/builders/pulsar/namespace.go` -5. Reference builder acceptance criteria: - - no local `xxxWriteOperations` map - - operation enum generated from specs - - handler validation uses specs - - unknown operation test added - - read-only build still excludes write tools - - tool names unchanged - - docs operation table generated or checked from specs -6. Migrate remaining Kafka split builders: - - `connect.go` - - `groups.go` - - `schema_registry.go` - - `topics.go` -7. Migrate remaining Pulsar split builders: - - `brokers.go` - - `cluster.go` - - `functions.go` - - `namespace.go` - - `nsisolationpolicy.go` - - `packages.go` - - `resourcequotas.go` - - `schema.go` - - `sinks.go` - - `sources.go` - - `subscription.go` - - `tenant.go` - - `topic.go` - - `topic_policy.go` -8. Keep pure read/write client tools out of forced registry migration unless helper reuse is cheap: - - `kafka_client_produce` - - `kafka_client_consume` - - `pulsar_client_produce` - - `pulsar_client_consume` - - pure read Pulsar admin status/stats/worker tools -9. Replace compliance-test manual classification: - - derive write/read operation sets from specs - - assert no enum mixes read/write specs - - assert every enum value exists in specs - - assert specs cover every handler switch operation -10. Docs generation/check: - - do not rewrite whole Markdown documents - - add generated operation table blocks only: - `` / `` - - add `go generate` or test helper to refresh/check blocks -11. Cleanup after migration: - - delete or shrink duplicated Kafka/Pulsar `tool_mode.go` - - move shared `pruneToolInputSchema`/required filtering helper if still duplicated - - remove obsolete per-builder write maps - - remove obsolete compliance-test write maps - -## Tests / compliance guard - -Add focused tests: - -- For every builder under `pkg/mcp/builders/kafka` and `pkg/mcp/builders/pulsar`: - - no returned tool mixes read and write operations - - tool name length <= 64 - - title non-empty - - read-only or destructive hint explicit - - read tools: `ReadOnlyHint=true`, `DestructiveHint=false` - - write tools: `DestructiveHint=true`, `ReadOnlyHint=false` - - read-only config returns no write tools - - read tools do not expose known write-only parameters - - read tool descriptions do not mention known write-only operations, examples, or destructive verbs for that family - - write tool schemas do not expose read-only-only parameters unless genuinely shared -- StreamNative Cloud/context/log/resource tools have valid annotations. -- PFTools dynamic tool creation has valid annotation. -- Operation validation rejects read operations on write tools and write operations on read tools. -- Operation validation rejects unknown operations instead of treating them as read. -- Operation enum values are derived from or checked against `OperationSpec`. -- Compliance tests derive read/write classification from `OperationSpec`, not hand-maintained write maps. -- Docs generated operation table blocks match `OperationSpec`. - -Static guard: - -- Build all feature sets and assert no `operation` enum contains both read and write verbs in one tool. -- For split tool families, assert mode-specific schema/description purity with family-specific allow/deny lists. -- Assert every handler switch operation has a matching `OperationSpec` entry. - -## Risks - -- Tool split is runtime-visible and likely breaking for clients/prompts that call old names. -- Operation spec refactor can accidentally change schema ordering, descriptions, or enum content even when tool names stay unchanged. -- Docs under `docs/tools/` can drift if generated operation blocks are not checked in tests or `go generate`. -- Some operations are ambiguous (`consume`, `trigger`, cursor operations, context reset). Conservative destructive annotation may add confirmations but avoids unsafe auto-run. -- Some current tools may have read-only-mode logic embedded in handlers; after split, registration and handler validation must both enforce mode to prevent write leakage. -- `OperationSpec.Handler` can over-couple schema metadata and dispatch if introduced too early. Prefer enum/validation/test/doc generation first, then dispatch consolidation. -- `mcp-go` default annotations are unsafe for compliance because title empty and destructive default true. - -## Confirmed decisions - -- Fix all current mixed read/write surfaces, not only `kafka/topics.go` and `pulsar/namespace.go`. -- Implement operation-spec follow-up across all affected split builders, not only the two reference files. -- Start with shared spec + two reference migrations: `kafka/topics.go` and `pulsar/namespace.go`. -- Keep current read/write tool names unchanged. -- Do not preserve old mixed tool names or old mixed builder/schema patterns. -- Runtime-visible docs must be updated with read/write split and must avoid mixed read/write wording. -- For docs generation, start with generated operation table blocks only; do not generate whole Markdown documents. -- Conservative safety annotations are acceptable for ambiguous side-effect tools unless implementation proves true read-only behavior. - -## Recommended validation - -Fast local: - -```bash -go test ./pkg/mcp/... ./pkg/schema/... -go test -race ./pkg/mcp/builders/... -go fmt ./... -go mod tidy -``` - -Full repo before PR: - -```bash -go mod verify -go mod download -golangci-lint run --timeout=3m -go test -race ./... -make build -make license-check -``` - -Connector-specific manual check: - -```bash -bin/snmcp stdio --use-external-pulsar --pulsar-web-service-url http://localhost:8080 -# then inspect tools/list with MCP Inspector and verify every tool annotation and no mixed read/write operation enum -``` - -Chart/E2E only needed if chart, SSE auth, or e2e harness touched: - -```bash -./scripts/e2e-test.sh all -``` From ab88ad047ed8887f15dc7aac97e363e799064f38 Mon Sep 17 00:00:00 2001 From: Rui Fu Date: Tue, 19 May 2026 18:44:09 +0800 Subject: [PATCH 08/10] chore: add PLAN.md to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3dc98ef2..a90954a7 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ scripts/update-sdk-apiserver.sh ralph/ scripts/ralph* scripts/__pycache__/ralph*.pyc +/PLAN.md From 1b639d619f2a2303c10457176ac69a6e4556eefa Mon Sep 17 00:00:00 2001 From: Rui Fu Date: Tue, 19 May 2026 23:37:58 +0800 Subject: [PATCH 09/10] fix: address PR review comments --- .licenserc.yaml | 1 + charts/snmcp/e2e/test-tokens.env | 5 +- docs/tools/pulsar_admin_packages.md | 13 ++-- hack/common.sh | 14 ----- .../pulsar/annotation_compliance_test.go | 2 +- pkg/mcp/builders/pulsar/packages.go | 12 ++-- scripts/e2e-test.sh | 63 +++++++++++++++++-- 7 files changed, 74 insertions(+), 36 deletions(-) diff --git a/.licenserc.yaml b/.licenserc.yaml index a2a38c28..97f9f8f5 100644 --- a/.licenserc.yaml +++ b/.licenserc.yaml @@ -38,6 +38,7 @@ header: - '.gitignore' - 'Dockerfile.goreleaser' - '**/.gitkeep' + - 'hack/common.sh' - 'charts/snmcp/e2e/test-secret.key' - 'charts/snmcp/templates/NOTES.txt' diff --git a/charts/snmcp/e2e/test-tokens.env b/charts/snmcp/e2e/test-tokens.env index c5656934..7f912015 100644 --- a/charts/snmcp/e2e/test-tokens.env +++ b/charts/snmcp/e2e/test-tokens.env @@ -12,5 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -ADMIN_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjQxMDI0NDQ4MDAsImlhdCI6MTcwMDAwMDAwMCwic3ViIjoiYWRtaW4ifQ.fvMIzcCv16QvecEd8rJS6GZaJP_FeFw-XndtfRMfZyc -TEST_USER_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjQxMDI0NDQ4MDAsImlhdCI6MTcwMDAwMDAwMCwic3ViIjoidGVzdC11c2VyIn0.gv49qzkZrtc-6aXMGxSGFpRLk_C3pnFI4SprgewhN54 +# Optional local E2E JWT overrides. By default, scripts generate tokens from test-secret.key. +# ADMIN_TOKEN= +# TEST_USER_TOKEN= diff --git a/docs/tools/pulsar_admin_packages.md b/docs/tools/pulsar_admin_packages.md index b8110afc..e1bd1064 100644 --- a/docs/tools/pulsar_admin_packages.md +++ b/docs/tools/pulsar_admin_packages.md @@ -4,8 +4,8 @@ | Tool | Mode | Operations | |---|---|---| -| `pulsar_admin_package_read` | read | `list`, `get`, `download` | -| `pulsar_admin_package_write` | write | `update`, `delete`, `upload` | +| `pulsar_admin_package_read` | read | `list`, `get` | +| `pulsar_admin_package_write` | write | `download`, `update`, `delete`, `upload` | **Claude connector safety:** Actual MCP tools are split into `pulsar_admin_package_read` and `pulsar_admin_package_write`. The read tool is read-only and only exposes read operations/parameters. The write tool is destructive and is not registered in read-only mode. @@ -14,17 +14,13 @@ Supported package schemes include `function://`, `source://`, and `sink://`. ### `pulsar_admin_package_read` -Read package metadata, versions, package lists, and package contents. +Read package metadata, versions, and package lists. - **package** - **list**: List versions of a package - `packageName` (string, required): Package name - **get**: Get package metadata - `packageName` (string, required): Package name - - **download**: Download package contents to local storage - - `packageName` (string, required): Package name - - `path` (string, required): Local destination path - - **packages** - **list**: List packages of a type in a namespace - `type` (string, required): Package type: `function`, `source`, or `sink` @@ -35,6 +31,9 @@ Read package metadata, versions, package lists, and package contents. Manage package metadata and package contents. - **package** + - **download**: Download package contents to local storage + - `packageName` (string, required): Package name + - `path` (string, required): Local destination path - **update**: Update package metadata - `packageName` (string, required): Package name - `description` (string, required): Package description diff --git a/hack/common.sh b/hack/common.sh index 412e27e4..4662131f 100644 --- a/hack/common.sh +++ b/hack/common.sh @@ -1,18 +1,4 @@ #!/usr/bin/env bash -# Copyright 2026 StreamNative -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - # # Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file diff --git a/pkg/mcp/builders/pulsar/annotation_compliance_test.go b/pkg/mcp/builders/pulsar/annotation_compliance_test.go index fac2808b..b12ab4cb 100644 --- a/pkg/mcp/builders/pulsar/annotation_compliance_test.go +++ b/pkg/mcp/builders/pulsar/annotation_compliance_test.go @@ -154,7 +154,7 @@ func TestPulsarSplitToolsExposeModeSpecificParameters(t *testing.T) { "pulsar_admin_namespace_read": {"operation", "tenant", "namespace"}, "pulsar_admin_nsisolationpolicy_read": {"resource", "operation", "cluster", "name"}, "pulsar_admin_nsisolationpolicy_write": {"resource", "operation", "cluster", "name", "namespaces", "primary", "secondary", "autoFailoverPolicyType", "autoFailoverPolicyParams"}, - "pulsar_admin_package_read": {"resource", "operation", "packageName", "namespace", "type", "path"}, + "pulsar_admin_package_read": {"resource", "operation", "packageName", "namespace", "type"}, "pulsar_admin_package_write": {"resource", "operation", "packageName", "description", "contact", "path", "properties"}, "pulsar_admin_resourcequota_read": {"resource", "operation", "namespace", "bundle"}, "pulsar_admin_schema_read": {"resource", "operation", "topic", "version"}, diff --git a/pkg/mcp/builders/pulsar/packages.go b/pkg/mcp/builders/pulsar/packages.go index b7c80f54..0d2cd9cd 100644 --- a/pkg/mcp/builders/pulsar/packages.go +++ b/pkg/mcp/builders/pulsar/packages.go @@ -30,7 +30,7 @@ import ( var pulsarPackageOperationSpecs = builders.OperationRegistry{ {Name: "list", Mode: builders.OperationModeRead}, {Name: "get", Mode: builders.OperationModeRead}, - {Name: "download", Mode: builders.OperationModeRead}, + {Name: "download", Mode: builders.OperationModeWrite, Destructive: true}, {Name: "update", Mode: builders.OperationModeWrite, Destructive: true}, {Name: "delete", Mode: builders.OperationModeWrite, Destructive: true}, {Name: "upload", Mode: builders.OperationModeWrite, Destructive: true}, @@ -95,7 +95,7 @@ func (b *PulsarAdminPackagesToolBuilder) BuildTools(_ context.Context, config bu // buildPackagesTool builds the Pulsar admin packages MCP tool definition func (b *PulsarAdminPackagesToolBuilder) buildPackagesTool(mode toolMode) mcp.Tool { toolDesc := "Read packages in Apache Pulsar. Support package schemes: `function://`, `source://`, `sink://`. " + - "Allows listing, viewing, and downloading packages." + "Allows listing packages and viewing package metadata." resourceDesc := "Resource to operate on. Available resources:\n" + "- package: A specific package\n" + @@ -104,19 +104,19 @@ func (b *PulsarAdminPackagesToolBuilder) buildPackagesTool(mode toolMode) mcp.To operationDesc := "Operation to perform. Available operations:\n" + "- list: List all packages of a specific type or versions of a package\n" + - "- get: Get metadata of a package\n" + - "- download: Download a package" + "- get: Get metadata of a package" operationEnum := pulsarPackageOperationSpecs.NamesForMode(mode) toolName := "pulsar_admin_package_read" annotation := builders.ToolAnnotationForMode(mode, "Read Pulsar Packages", "Manage Pulsar Packages", pulsarPackageOperationSpecs) if isToolModeWrite(mode) { toolDesc = "Manage packages in Apache Pulsar. Support package schemes: `function://`, `source://`, `sink://`. " + - "This write tool updates metadata, deletes packages, or uploads package contents." + "This write tool downloads package contents, updates metadata, deletes packages, or uploads package contents." resourceDesc = "Resource to operate on. Available resources:\n" + "- package: A specific package" resourceEnum = []string{"package"} operationDesc = "Operation to perform. Available operations:\n" + + "- download: Download a package to a local filesystem path\n" + "- update: Update metadata of a package (requires super-user permissions)\n" + "- delete: Delete a package (requires super-user permissions)\n" + "- upload: Upload a package (requires super-user permissions)" @@ -160,7 +160,7 @@ func (b *PulsarAdminPackagesToolBuilder) buildPackagesTool(mode toolMode) mcp.To if isToolModeWrite(mode) { pruneToolInputSchema(&tool, []string{"resource", "operation", "packageName", "description", "contact", "path", "properties"}) } else { - pruneToolInputSchema(&tool, []string{"resource", "operation", "packageName", "namespace", "type", "path"}) + pruneToolInputSchema(&tool, []string{"resource", "operation", "packageName", "namespace", "type"}) } return tool } diff --git a/scripts/e2e-test.sh b/scripts/e2e-test.sh index 62603ac6..b9132352 100755 --- a/scripts/e2e-test.sh +++ b/scripts/e2e-test.sh @@ -76,12 +76,63 @@ require_cmd() { } load_tokens() { - [[ -f "$TOKEN_ENV_FILE" ]] || die "missing token env file: $TOKEN_ENV_FILE" - set -a - # shellcheck disable=SC1090 - source "$TOKEN_ENV_FILE" - set +a - [[ -n "${ADMIN_TOKEN:-}" ]] || die "ADMIN_TOKEN not set in $TOKEN_ENV_FILE" + if [[ -f "$TOKEN_ENV_FILE" ]]; then + set -a + # shellcheck disable=SC1090 + source "$TOKEN_ENV_FILE" + set +a + fi + + if [[ -z "${ADMIN_TOKEN:-}" || -z "${TEST_USER_TOKEN:-}" ]]; then + require_cmd python3 + [[ -f "$TOKEN_SECRET_FILE" ]] || die "missing token secret file: $TOKEN_SECRET_FILE" + generate_test_tokens "$TOKEN_SECRET_FILE" + fi + + [[ -n "${ADMIN_TOKEN:-}" ]] || die "ADMIN_TOKEN is not set and could not be generated" + [[ -n "${TEST_USER_TOKEN:-}" ]] || die "TEST_USER_TOKEN is not set and could not be generated" +} + +generate_test_tokens() { + local secret_file="$1" + local generated + if ! generated="$(python3 - "$secret_file" <<'PY' +import base64 +import hashlib +import hmac +import json +import sys + +secret_path = sys.argv[1] +with open(secret_path, "rb") as secret_file: + secret = secret_file.read() + + +def b64url(value): + return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") + + +def token(subject): + header = {"alg": "HS256", "typ": "JWT"} + payload = {"exp": 4102444800, "iat": 1700000000, "sub": subject} + signing_input = ".".join( + [ + b64url(json.dumps(header, separators=(",", ":")).encode("utf-8")), + b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8")), + ] + ).encode("ascii") + signature = hmac.new(secret, signing_input, hashlib.sha256).digest() + return signing_input.decode("ascii") + "." + b64url(signature) + +print("ADMIN_TOKEN=" + token("admin")) +print("TEST_USER_TOKEN=" + token("test-user")) +PY + )"; then + die "failed to generate e2e JWT tokens" + fi + ADMIN_TOKEN="$(printf '%s\n' "$generated" | awk -F= '$1 == "ADMIN_TOKEN" {print substr($0, index($0, "=") + 1)}')" + TEST_USER_TOKEN="$(printf '%s\n' "$generated" | awk -F= '$1 == "TEST_USER_TOKEN" {print substr($0, index($0, "=") + 1)}')" + export ADMIN_TOKEN TEST_USER_TOKEN } ensure_kind_network() { From 5e4f077280975d4398ce499d56af2bd7180e3ba1 Mon Sep 17 00:00:00 2001 From: Rui Fu Date: Wed, 20 May 2026 16:06:31 +0800 Subject: [PATCH 10/10] fix(operation-docs): normalize line endings in test extractors --- pkg/mcp/builders/kafka/operation_docs_test.go | 3 ++- pkg/mcp/builders/pulsar/operation_docs_test.go | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/mcp/builders/kafka/operation_docs_test.go b/pkg/mcp/builders/kafka/operation_docs_test.go index 55bb6c24..23c85883 100644 --- a/pkg/mcp/builders/kafka/operation_docs_test.go +++ b/pkg/mcp/builders/kafka/operation_docs_test.go @@ -71,7 +71,8 @@ func extractGeneratedOperationBlock(t *testing.T, content string) string { end := strings.Index(content[start:], endMarker) require.NotEqual(t, -1, end, "missing generated operation end marker") end += start + len(endMarker) - return strings.TrimSpace(content[start:end]) + block := strings.TrimSpace(content[start:end]) + return strings.ReplaceAll(block, "\r\n", "\n") } func formatOperationNames(operations []string) string { diff --git a/pkg/mcp/builders/pulsar/operation_docs_test.go b/pkg/mcp/builders/pulsar/operation_docs_test.go index 1efeb31f..347c6a87 100644 --- a/pkg/mcp/builders/pulsar/operation_docs_test.go +++ b/pkg/mcp/builders/pulsar/operation_docs_test.go @@ -81,7 +81,8 @@ func extractGeneratedOperationBlock(t *testing.T, content string) string { end := strings.Index(content[start:], endMarker) require.NotEqual(t, -1, end, "missing generated operation end marker") end += start + len(endMarker) - return strings.TrimSpace(content[start:end]) + block := strings.TrimSpace(content[start:end]) + return strings.ReplaceAll(block, "\r\n", "\n") } func formatOperationNames(operations []string) string {