From 41debfea45ceca162e3be22a2f5f1f4f4732cbbb Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 27 Jan 2026 14:06:08 +0200 Subject: [PATCH 1/7] fix(intent): infer operation_type from tool variant instead of requiring it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This fixes #278 where Gemini 3 Pro via Antigravity crashed when generating tool calls with nested JSON objects in the intent parameter. Changes: - Remove operation_type requirement from intent parameter - Infer operation_type automatically from tool variant: - call_tool_read → "read" - call_tool_write → "write" - call_tool_destructive → "destructive" - Make intent parameter optional (empty {} now works) - Update validateIntentForVariant to return (intent, error) and create default intent if nil - Simplify Validate() to only check optional fields (data_sensitivity, reason) - Update tests to reflect new behavior - Update documentation The two-key security model was redundant - the tool variant already declares intent. This simplification enables compatibility with models that have issues generating nested JSON objects. Closes #278 Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 3 +- docs/features/intent-declaration.md | 81 ++++++------ internal/contracts/intent.go | 62 +++------- internal/contracts/intent_test.go | 144 +++++++--------------- internal/server/e2e_test.go | 44 +++---- internal/server/intent_validation_test.go | 86 ++++++++----- internal/server/mcp.go | 48 ++++---- 7 files changed, 189 insertions(+), 279 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f4a525f9..b810d38a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -173,11 +173,10 @@ See [docs/configuration.md](docs/configuration.md) for complete reference. **Tool Format**: `:` (e.g., `github:create_issue`) -**Intent Declaration (Spec 018)**: Tool variants enable granular IDE permission control. The `intent` parameter provides two-key security: +**Intent Declaration (Spec 018)**: Tool variants enable granular IDE permission control. The `operation_type` is automatically inferred from the tool variant (`call_tool_read` → "read", etc.). Optional `intent` fields for audit: ```json { "intent": { - "operation_type": "read", "data_sensitivity": "public", "reason": "User requested list of repositories" } diff --git a/docs/features/intent-declaration.md b/docs/features/intent-declaration.md index 9c0f4977..059581e5 100644 --- a/docs/features/intent-declaration.md +++ b/docs/features/intent-declaration.md @@ -42,12 +42,9 @@ MCPProxy Tools: [ ] call_tool_destructive → Always ask + confirm ``` -## Two-Key Security Model +## How It Works -Agents must declare intent in **two places** that must match: - -1. **Tool Selection**: Which variant to call (`call_tool_read` / `write` / `destructive`) -2. **Intent Parameter**: `intent.operation_type` must match the tool variant +The tool variant (`call_tool_read` / `write` / `destructive`) **automatically determines** the operation type: ```json { @@ -56,7 +53,6 @@ Agents must declare intent in **two places** that must match: "name": "github:delete_repo", "args_json": "{\"repo\": \"test-repo\"}", "intent": { - "operation_type": "destructive", "data_sensitivity": "private", "reason": "User requested repository cleanup" } @@ -64,17 +60,13 @@ Agents must declare intent in **two places** that must match: } ``` -**Why two keys?** This prevents: -- Accidental misclassification (agent confusion) -- Intentional misclassification (attack attempts) -- Sneaking destructive operations through auto-approved read channel +The `operation_type` is inferred from the tool variant - agents don't need to specify it explicitly. ### Validation Chain -1. Tool variant declares expected intent (`call_tool_destructive` expects "destructive") -2. `intent.operation_type` is validated (MUST be "destructive") -3. Mismatch → **REJECT** with clear error message -4. Server annotation check → validate against `destructiveHint`/`readOnlyHint` +1. Tool variant determines operation type (`call_tool_destructive` → "destructive") +2. Optional intent fields (`data_sensitivity`, `reason`) are validated if provided +3. Server annotation check → validate against `destructiveHint`/`readOnlyHint` ## Tool Variants @@ -82,18 +74,26 @@ Agents must declare intent in **two places** that must match: Execute read-only operations that don't modify state. +```json +{ + "name": "github:list_repos", + "args_json": "{\"org\": \"myorg\"}" +} +``` + +Or with optional metadata: ```json { "name": "github:list_repos", "args_json": "{\"org\": \"myorg\"}", "intent": { - "operation_type": "read" + "reason": "Listing repositories for project analysis" } } ``` **Validation:** -- `intent.operation_type` MUST be "read" +- `operation_type` automatically inferred as "read" - Rejected if server marks tool as `destructiveHint: true` ### call_tool_write @@ -105,14 +105,13 @@ Execute state-modifying operations that create or update resources. "name": "github:create_issue", "args_json": "{\"title\": \"Bug report\", \"body\": \"Details...\"}", "intent": { - "operation_type": "write", "reason": "Creating bug report per user request" } } ``` **Validation:** -- `intent.operation_type` MUST be "write" +- `operation_type` automatically inferred as "write" - Rejected if server marks tool as `destructiveHint: true` ### call_tool_destructive @@ -124,7 +123,6 @@ Execute destructive or irreversible operations. "name": "github:delete_repo", "args_json": "{\"repo\": \"test-repo\"}", "intent": { - "operation_type": "destructive", "data_sensitivity": "private", "reason": "User confirmed deletion of test repository" } @@ -132,37 +130,37 @@ Execute destructive or irreversible operations. ``` **Validation:** -- `intent.operation_type` MUST be "destructive" +- `operation_type` automatically inferred as "destructive" - Most permissive - allowed regardless of server annotations ## Intent Parameter -The `intent` object is **required** on all tool calls: +The `intent` object is **optional** - operation type is automatically inferred from the tool variant: | Field | Required | Values | Description | |-------|----------|--------|-------------| -| `operation_type` | Yes | `read`, `write`, `destructive` | Must match tool variant | -| `data_sensitivity` | No | `public`, `internal`, `private`, `unknown` | Data classification | +| `operation_type` | No | `read`, `write`, `destructive` | Automatically inferred from tool variant | +| `data_sensitivity` | No | `public`, `internal`, `private`, `unknown` | Data classification for audit | | `reason` | No | String (max 1000 chars) | Explanation for audit trail | ### Examples -**Minimal (required only):** +**Minimal (no intent needed):** ```json { - "intent": { - "operation_type": "read" - } + "name": "dataserver:read_data", + "args_json": "{\"id\": \"123\"}" } ``` -**Full intent:** +**With optional metadata:** ```json { + "name": "dataserver:write_data", + "args_json": "{\"id\": \"123\", \"value\": \"new\"}", "intent": { - "operation_type": "write", "data_sensitivity": "private", - "reason": "Creating user profile with personal information" + "reason": "Updating user profile with personal information" } } ``` @@ -284,22 +282,20 @@ curl -H "X-API-Key: $KEY" "http://127.0.0.1:8080/api/v1/activity?intent_type=des Clear error messages help agents self-correct: -**Intent mismatch:** -``` -Intent mismatch: tool is call_tool_read but intent declares write. -Use call_tool_write for write operations. -``` - **Server annotation conflict:** ``` Tool 'github:delete_repo' is marked destructive by server. Use call_tool_destructive instead of call_tool_read. ``` -**Missing intent:** +**Invalid data sensitivity:** +``` +Invalid intent.data_sensitivity 'secret': must be public, internal, private, or unknown +``` + +**Reason too long:** ``` -intent.operation_type is required. -Provide intent: {operation_type: "read"|"write"|"destructive"} +intent.reason exceeds maximum length of 1000 characters ``` ## IDE Configuration Examples @@ -362,14 +358,13 @@ The legacy `call_tool` has been removed. Update your integrations: "name": "call_tool_write", "arguments": { "name": "github:create_issue", - "args_json": "{...}", - "intent": { - "operation_type": "write" - } + "args_json": "{...}" } } ``` +The `intent` parameter is optional - `operation_type` is automatically inferred from the tool variant. You can add `data_sensitivity` and `reason` for audit purposes. + :::tip Choosing the Right Variant When unsure, use `call_tool_destructive` - it's the most permissive and will always succeed validation. Then refine based on `retrieve_tools` guidance. ::: diff --git a/internal/contracts/intent.go b/internal/contracts/intent.go index 095564f3..8295d491 100644 --- a/internal/contracts/intent.go +++ b/internal/contracts/intent.go @@ -55,11 +55,13 @@ var ToolVariantToOperationType = map[string]string{ const MaxReasonLength = 1000 // IntentDeclaration represents the agent's declared intent for a tool call. -// This enables the two-key security model where intent must be declared both -// in tool selection (call_tool_read/write/destructive) and in this parameter. +// The operation_type is automatically inferred from the tool variant used +// (call_tool_read/write/destructive), so agents only need to provide optional +// metadata fields for audit and compliance purposes. type IntentDeclaration struct { - // OperationType is REQUIRED and must match the tool variant used. + // OperationType is automatically inferred from the tool variant. // Valid values: "read", "write", "destructive" + // This field is populated by the server based on which tool variant is called. OperationType string `json:"operation_type"` // DataSensitivity is optional classification of data being accessed/modified. @@ -104,29 +106,9 @@ func NewIntentValidationError(code, message string, details map[string]interface } } -// Validate validates the IntentDeclaration fields +// Validate validates the IntentDeclaration optional fields. +// Note: operation_type is not validated here as it's inferred from tool variant. func (i *IntentDeclaration) Validate() *IntentValidationError { - // Check operation_type is present - if i.OperationType == "" { - return NewIntentValidationError( - IntentErrorCodeMissingOperationType, - "intent.operation_type is required", - nil, - ) - } - - // Check operation_type is valid - if !isValidOperationType(i.OperationType) { - return NewIntentValidationError( - IntentErrorCodeInvalidOperationType, - fmt.Sprintf("Invalid intent.operation_type '%s': must be read, write, or destructive", i.OperationType), - map[string]interface{}{ - "provided": i.OperationType, - "valid_values": ValidOperationTypes, - }, - ) - } - // Check data_sensitivity if provided if i.DataSensitivity != "" && !isValidDataSensitivity(i.DataSensitivity) { return NewIntentValidationError( @@ -154,15 +136,12 @@ func (i *IntentDeclaration) Validate() *IntentValidationError { return nil } -// ValidateForToolVariant validates that the intent matches the tool variant +// ValidateForToolVariant validates the intent and sets operation_type from tool variant. +// The operation_type is automatically inferred from the tool variant, so agents +// don't need to provide it explicitly. func (i *IntentDeclaration) ValidateForToolVariant(toolVariant string) *IntentValidationError { - // First validate the intent itself - if err := i.Validate(); err != nil { - return err - } - - // Get expected operation type for this tool variant - expectedOpType, ok := ToolVariantToOperationType[toolVariant] + // Get operation type for this tool variant + opType, ok := ToolVariantToOperationType[toolVariant] if !ok { return NewIntentValidationError( IntentErrorCodeMismatch, @@ -173,20 +152,11 @@ func (i *IntentDeclaration) ValidateForToolVariant(toolVariant string) *IntentVa ) } - // Check two-key match: intent.operation_type must match tool variant - if i.OperationType != expectedOpType { - return NewIntentValidationError( - IntentErrorCodeMismatch, - fmt.Sprintf("Intent mismatch: tool is %s but intent declares %s", toolVariant, i.OperationType), - map[string]interface{}{ - "tool_variant": toolVariant, - "expected_operation": expectedOpType, - "declared_operation": i.OperationType, - }, - ) - } + // Set operation_type from tool variant (inferring it) + i.OperationType = opType - return nil + // Validate the optional fields + return i.Validate() } // ValidateAgainstServerAnnotations validates intent against server-provided annotations diff --git a/internal/contracts/intent_test.go b/internal/contracts/intent_test.go index 9d02548a..e2297d3f 100644 --- a/internal/contracts/intent_test.go +++ b/internal/contracts/intent_test.go @@ -7,6 +7,8 @@ import ( ) func TestIntentDeclaration_Validate(t *testing.T) { + // Note: Validate() only checks optional fields (data_sensitivity, reason) + // operation_type is inferred from tool variant, not validated here tests := []struct { name string intent IntentDeclaration @@ -14,63 +16,21 @@ func TestIntentDeclaration_Validate(t *testing.T) { wantErrCode string }{ { - name: "valid read intent", - intent: IntentDeclaration{ - OperationType: OperationTypeRead, - }, - wantErr: false, - }, - { - name: "valid write intent", - intent: IntentDeclaration{ - OperationType: OperationTypeWrite, - }, - wantErr: false, - }, - { - name: "valid destructive intent", - intent: IntentDeclaration{ - OperationType: OperationTypeDestructive, - }, + name: "empty intent - valid (operation_type inferred elsewhere)", + intent: IntentDeclaration{}, wantErr: false, }, { - name: "valid intent with all fields", + name: "intent with only optional fields", intent: IntentDeclaration{ - OperationType: OperationTypeWrite, DataSensitivity: DataSensitivityPrivate, Reason: "User requested update", }, wantErr: false, }, - { - name: "missing operation_type", - intent: IntentDeclaration{ - OperationType: "", - }, - wantErr: true, - wantErrCode: IntentErrorCodeMissingOperationType, - }, - { - name: "invalid operation_type", - intent: IntentDeclaration{ - OperationType: "unknown", - }, - wantErr: true, - wantErrCode: IntentErrorCodeInvalidOperationType, - }, - { - name: "case sensitive operation_type - uppercase fails", - intent: IntentDeclaration{ - OperationType: "READ", - }, - wantErr: true, - wantErrCode: IntentErrorCodeInvalidOperationType, - }, { name: "invalid data_sensitivity", intent: IntentDeclaration{ - OperationType: OperationTypeRead, DataSensitivity: "secret", }, wantErr: true, @@ -79,7 +39,6 @@ func TestIntentDeclaration_Validate(t *testing.T) { { name: "valid data_sensitivity - public", intent: IntentDeclaration{ - OperationType: OperationTypeRead, DataSensitivity: DataSensitivityPublic, }, wantErr: false, @@ -87,15 +46,20 @@ func TestIntentDeclaration_Validate(t *testing.T) { { name: "valid data_sensitivity - internal", intent: IntentDeclaration{ - OperationType: OperationTypeRead, DataSensitivity: DataSensitivityInternal, }, wantErr: false, }, + { + name: "valid data_sensitivity - private", + intent: IntentDeclaration{ + DataSensitivity: DataSensitivityPrivate, + }, + wantErr: false, + }, { name: "valid data_sensitivity - unknown", intent: IntentDeclaration{ - OperationType: OperationTypeRead, DataSensitivity: DataSensitivityUnknown, }, wantErr: false, @@ -103,16 +67,14 @@ func TestIntentDeclaration_Validate(t *testing.T) { { name: "reason at max length", intent: IntentDeclaration{ - OperationType: OperationTypeRead, - Reason: string(make([]byte, MaxReasonLength)), + Reason: string(make([]byte, MaxReasonLength)), }, wantErr: false, }, { name: "reason exceeds max length", intent: IntentDeclaration{ - OperationType: OperationTypeRead, - Reason: string(make([]byte, MaxReasonLength+1)), + Reason: string(make([]byte, MaxReasonLength+1)), }, wantErr: true, wantErrCode: IntentErrorCodeReasonTooLong, @@ -134,82 +96,63 @@ func TestIntentDeclaration_Validate(t *testing.T) { } func TestIntentDeclaration_ValidateForToolVariant(t *testing.T) { + // Note: ValidateForToolVariant now SETS operation_type from tool variant (inference) + // It no longer validates that operation_type matches - it always overwrites with inferred value tests := []struct { - name string - intent IntentDeclaration - toolVariant string - wantErr bool - wantErrCode string + name string + intent IntentDeclaration + toolVariant string + wantErr bool + wantErrCode string + wantOpType string // expected operation_type after call }{ { - name: "read intent with call_tool_read - matches", - intent: IntentDeclaration{ - OperationType: OperationTypeRead, - }, + name: "empty intent with call_tool_read - sets operation_type", + intent: IntentDeclaration{}, toolVariant: ToolVariantRead, wantErr: false, + wantOpType: OperationTypeRead, }, { - name: "write intent with call_tool_write - matches", - intent: IntentDeclaration{ - OperationType: OperationTypeWrite, - }, + name: "empty intent with call_tool_write - sets operation_type", + intent: IntentDeclaration{}, toolVariant: ToolVariantWrite, wantErr: false, + wantOpType: OperationTypeWrite, }, { - name: "destructive intent with call_tool_destructive - matches", - intent: IntentDeclaration{ - OperationType: OperationTypeDestructive, - }, + name: "empty intent with call_tool_destructive - sets operation_type", + intent: IntentDeclaration{}, toolVariant: ToolVariantDestructive, wantErr: false, + wantOpType: OperationTypeDestructive, }, { - name: "read intent with call_tool_write - mismatch", + name: "intent with optional fields - sets operation_type", intent: IntentDeclaration{ - OperationType: OperationTypeRead, + DataSensitivity: DataSensitivityPrivate, + Reason: "test reason", }, toolVariant: ToolVariantWrite, - wantErr: true, - wantErrCode: IntentErrorCodeMismatch, + wantErr: false, + wantOpType: OperationTypeWrite, }, { - name: "write intent with call_tool_read - mismatch", + name: "intent with invalid data_sensitivity - error", intent: IntentDeclaration{ - OperationType: OperationTypeWrite, + DataSensitivity: "invalid", }, toolVariant: ToolVariantRead, wantErr: true, - wantErrCode: IntentErrorCodeMismatch, - }, - { - name: "read intent with call_tool_destructive - mismatch", - intent: IntentDeclaration{ - OperationType: OperationTypeRead, - }, - toolVariant: ToolVariantDestructive, - wantErr: true, - wantErrCode: IntentErrorCodeMismatch, + wantErrCode: IntentErrorCodeInvalidSensitivity, }, { - name: "destructive intent with call_tool_read - mismatch", - intent: IntentDeclaration{ - OperationType: OperationTypeDestructive, - }, - toolVariant: ToolVariantRead, + name: "unknown tool variant - error", + intent: IntentDeclaration{}, + toolVariant: "unknown_variant", wantErr: true, wantErrCode: IntentErrorCodeMismatch, }, - { - name: "missing operation_type - validation fails first", - intent: IntentDeclaration{ - OperationType: "", - }, - toolVariant: ToolVariantRead, - wantErr: true, - wantErrCode: IntentErrorCodeMissingOperationType, - }, } for _, tt := range tests { @@ -222,6 +165,9 @@ func TestIntentDeclaration_ValidateForToolVariant(t *testing.T) { if tt.wantErr && err != nil && err.Code != tt.wantErrCode { t.Errorf("ValidateForToolVariant() error code = %v, wantErrCode %v", err.Code, tt.wantErrCode) } + if !tt.wantErr && tt.intent.OperationType != tt.wantOpType { + t.Errorf("ValidateForToolVariant() operation_type = %v, want %v", tt.intent.OperationType, tt.wantOpType) + } }) } } diff --git a/internal/server/e2e_test.go b/internal/server/e2e_test.go index afb0a2c5..cfb7ef3d 100644 --- a/internal/server/e2e_test.go +++ b/internal/server/e2e_test.go @@ -1566,35 +1566,30 @@ func TestE2E_IntentDeclarationToolVariants(t *testing.T) { t.Log("✅ call_tool_destructive with matching intent succeeded") }) - // Test 4: Intent mismatch should fail (call_tool_read with write intent) - t.Run("call_tool_read with write intent fails", func(t *testing.T) { - mismatchRequest := mcp.CallToolRequest{} - mismatchRequest.Params.Name = contracts.ToolVariantRead - mismatchRequest.Params.Arguments = map[string]interface{}{ + // Test 4: Intent operation_type is inferred from tool variant (any provided value is ignored) + t.Run("call_tool_read infers operation_type from tool variant", func(t *testing.T) { + // Even if operation_type is provided, it will be overwritten by the tool variant + requestWithAnyIntent := mcp.CallToolRequest{} + requestWithAnyIntent.Params.Name = contracts.ToolVariantRead + requestWithAnyIntent.Params.Arguments = map[string]interface{}{ "name": "dataserver:read_data", "args": map[string]interface{}{ "id": "test-123", }, "intent": map[string]interface{}{ - "operation_type": contracts.OperationTypeWrite, // Mismatch! + "operation_type": contracts.OperationTypeWrite, // Will be ignored, inferred as "read" + "reason": "Testing inference", }, } - result, err := mcpClient.CallTool(ctx, mismatchRequest) + result, err := mcpClient.CallTool(ctx, requestWithAnyIntent) require.NoError(t, err) - assert.True(t, result.IsError, "call_tool_read with write intent should fail") - - // Verify error message mentions mismatch - if len(result.Content) > 0 { - contentBytes, _ := json.Marshal(result.Content[0]) - contentStr := string(contentBytes) - assert.Contains(t, strings.ToLower(contentStr), "mismatch", "Error should mention intent mismatch") - } - t.Log("✅ call_tool_read with write intent correctly rejected") + assert.False(t, result.IsError, "call_tool_read should succeed - operation_type is inferred from tool variant") + t.Log("✅ call_tool_read correctly infers operation_type from tool variant") }) - // Test 5: Missing intent should fail - t.Run("call_tool_write without intent fails", func(t *testing.T) { + // Test 5: Missing intent should succeed (operation_type is inferred from tool variant) + t.Run("call_tool_write without intent succeeds", func(t *testing.T) { noIntentRequest := mcp.CallToolRequest{} noIntentRequest.Params.Name = contracts.ToolVariantWrite noIntentRequest.Params.Arguments = map[string]interface{}{ @@ -1603,20 +1598,13 @@ func TestE2E_IntentDeclarationToolVariants(t *testing.T) { "id": "test-456", "value": "new value", }, - // No intent provided + // No intent provided - operation_type will be inferred from tool variant } result, err := mcpClient.CallTool(ctx, noIntentRequest) require.NoError(t, err) - assert.True(t, result.IsError, "call_tool_write without intent should fail") - - // Verify error message mentions missing intent - if len(result.Content) > 0 { - contentBytes, _ := json.Marshal(result.Content[0]) - contentStr := string(contentBytes) - assert.Contains(t, strings.ToLower(contentStr), "intent", "Error should mention intent requirement") - } - t.Log("✅ call_tool_write without intent correctly rejected") + assert.False(t, result.IsError, "call_tool_write without intent should succeed - operation_type is inferred") + t.Log("✅ call_tool_write without intent succeeds with inferred operation_type") }) t.Log("✅ All Intent Declaration tool variant tests passed") diff --git a/internal/server/intent_validation_test.go b/internal/server/intent_validation_test.go index 620fe8ba..f8712761 100644 --- a/internal/server/intent_validation_test.go +++ b/internal/server/intent_validation_test.go @@ -135,65 +135,83 @@ func TestMCPProxyServer_validateIntentForVariant(t *testing.T) { } tests := []struct { - name string - intent *contracts.IntentDeclaration - toolVariant string - wantErr bool + name string + intent *contracts.IntentDeclaration + toolVariant string + wantErr bool + wantOpType string // expected operation_type after inference }{ { - name: "nil intent - error", - intent: nil, - toolVariant: contracts.ToolVariantRead, - wantErr: true, + name: "nil intent - creates default with inferred operation_type", + intent: nil, + toolVariant: contracts.ToolVariantRead, + wantErr: false, + wantOpType: contracts.OperationTypeRead, }, { - name: "matching read intent", - intent: &contracts.IntentDeclaration{ - OperationType: contracts.OperationTypeRead, - }, - toolVariant: contracts.ToolVariantRead, - wantErr: false, + name: "empty intent - infers operation_type from read variant", + intent: &contracts.IntentDeclaration{}, + toolVariant: contracts.ToolVariantRead, + wantErr: false, + wantOpType: contracts.OperationTypeRead, }, { - name: "matching write intent", - intent: &contracts.IntentDeclaration{ - OperationType: contracts.OperationTypeWrite, - }, - toolVariant: contracts.ToolVariantWrite, - wantErr: false, + name: "empty intent - infers operation_type from write variant", + intent: &contracts.IntentDeclaration{}, + toolVariant: contracts.ToolVariantWrite, + wantErr: false, + wantOpType: contracts.OperationTypeWrite, }, { - name: "matching destructive intent", - intent: &contracts.IntentDeclaration{ - OperationType: contracts.OperationTypeDestructive, - }, - toolVariant: contracts.ToolVariantDestructive, - wantErr: false, + name: "empty intent - infers operation_type from destructive variant", + intent: &contracts.IntentDeclaration{}, + toolVariant: contracts.ToolVariantDestructive, + wantErr: false, + wantOpType: contracts.OperationTypeDestructive, }, { - name: "mismatched intent - read declared, write variant", + name: "intent with optional fields - operation_type inferred", intent: &contracts.IntentDeclaration{ - OperationType: contracts.OperationTypeRead, + DataSensitivity: "private", + Reason: "test reason", }, toolVariant: contracts.ToolVariantWrite, - wantErr: true, + wantErr: false, + wantOpType: contracts.OperationTypeWrite, }, { - name: "mismatched intent - write declared, read variant", + name: "intent with invalid data_sensitivity - error", intent: &contracts.IntentDeclaration{ - OperationType: contracts.OperationTypeWrite, + DataSensitivity: "invalid", }, toolVariant: contracts.ToolVariantRead, wantErr: true, }, + { + name: "unknown tool variant - error", + intent: &contracts.IntentDeclaration{}, + toolVariant: "unknown_variant", + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := proxy.validateIntentForVariant(tt.intent, tt.toolVariant) + intent, errResult := proxy.validateIntentForVariant(tt.intent, tt.toolVariant) - if (result != nil) != tt.wantErr { - t.Errorf("validateIntentForVariant() error = %v, wantErr %v", result != nil, tt.wantErr) + if (errResult != nil) != tt.wantErr { + t.Errorf("validateIntentForVariant() error = %v, wantErr %v", errResult != nil, tt.wantErr) + return + } + + if !tt.wantErr { + if intent == nil { + t.Errorf("validateIntentForVariant() returned nil intent, want non-nil") + return + } + if intent.OperationType != tt.wantOpType { + t.Errorf("validateIntentForVariant() operation_type = %v, want %v", intent.OperationType, tt.wantOpType) + } } }) } diff --git a/internal/server/mcp.go b/internal/server/mcp.go index 56a7f71d..a15a1252 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -321,7 +321,7 @@ func (p *MCPProxyServer) registerTools(_ bool) { // call_tool_read - Read-only operations callToolReadTool := mcp.NewTool(contracts.ToolVariantRead, - mcp.WithDescription("Execute a READ-ONLY tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results. DECISION RULE: Use this when the tool name contains: search, query, list, get, fetch, find, check, view, read, show, describe, lookup, retrieve, browse, explore, discover, scan, inspect, analyze, examine, validate, verify. Examples: search_files, get_user, list_repositories, query_database, find_issues, check_status. This is the DEFAULT choice when unsure - most tools are read-only. Requires intent.operation_type='read'."), + mcp.WithDescription("Execute a READ-ONLY tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results. DECISION RULE: Use this when the tool name contains: search, query, list, get, fetch, find, check, view, read, show, describe, lookup, retrieve, browse, explore, discover, scan, inspect, analyze, examine, validate, verify. Examples: search_files, get_user, list_repositories, query_database, find_issues, check_status. This is the DEFAULT choice when unsure - most tools are read-only."), mcp.WithTitleAnnotation("Call Tool (Read)"), mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("name", @@ -332,15 +332,14 @@ func (p *MCPProxyServer) registerTools(_ bool) { mcp.Description("Arguments to pass to the tool as JSON string. Refer to the tool's inputSchema from retrieve_tools for required parameters."), ), mcp.WithObject("intent", - mcp.Required(), - mcp.Description("Intent declaration (required). Must include: operation_type='read'. Optional: data_sensitivity (public|internal|private|unknown), reason (explanation for operation)."), + mcp.Description("Intent declaration (optional). operation_type is automatically inferred from tool variant. Optional fields: data_sensitivity (public|internal|private|unknown), reason (explanation for operation)."), ), ) p.server.AddTool(callToolReadTool, p.handleCallToolRead) // call_tool_write - State-modifying operations callToolWriteTool := mcp.NewTool(contracts.ToolVariantWrite, - mcp.WithDescription("Execute a STATE-MODIFYING tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results. DECISION RULE: Use this when the tool name contains: create, update, modify, add, set, send, edit, change, write, post, put, patch, insert, upload, submit, assign, configure, enable, register, subscribe, publish, move, copy, rename, merge. Examples: create_issue, update_file, send_message, add_comment, set_status, edit_page. Use only when explicitly modifying state. Requires intent.operation_type='write'."), + mcp.WithDescription("Execute a STATE-MODIFYING tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results. DECISION RULE: Use this when the tool name contains: create, update, modify, add, set, send, edit, change, write, post, put, patch, insert, upload, submit, assign, configure, enable, register, subscribe, publish, move, copy, rename, merge. Examples: create_issue, update_file, send_message, add_comment, set_status, edit_page. Use only when explicitly modifying state."), mcp.WithTitleAnnotation("Call Tool (Write)"), mcp.WithDestructiveHintAnnotation(false), mcp.WithString("name", @@ -351,15 +350,14 @@ func (p *MCPProxyServer) registerTools(_ bool) { mcp.Description("Arguments to pass to the tool as JSON string. Refer to the tool's inputSchema from retrieve_tools for required parameters."), ), mcp.WithObject("intent", - mcp.Required(), - mcp.Description("Intent declaration (required). Must include: operation_type='write'. Optional: data_sensitivity (public|internal|private|unknown), reason (explanation for operation)."), + mcp.Description("Intent declaration (optional). operation_type is automatically inferred from tool variant. Optional fields: data_sensitivity (public|internal|private|unknown), reason (explanation for operation)."), ), ) p.server.AddTool(callToolWriteTool, p.handleCallToolWrite) // call_tool_destructive - Irreversible operations callToolDestructiveTool := mcp.NewTool(contracts.ToolVariantDestructive, - mcp.WithDescription("Execute a DESTRUCTIVE tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results. DECISION RULE: Use this when the tool name contains: delete, remove, drop, revoke, disable, destroy, purge, reset, clear, unsubscribe, cancel, terminate, close, archive, ban, block, disconnect, kill, wipe, truncate, force, hard. Examples: delete_repo, remove_user, drop_table, revoke_access, clear_cache, terminate_session. Use for irreversible or high-impact operations. Requires intent.operation_type='destructive'."), + mcp.WithDescription("Execute a DESTRUCTIVE tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results. DECISION RULE: Use this when the tool name contains: delete, remove, drop, revoke, disable, destroy, purge, reset, clear, unsubscribe, cancel, terminate, close, archive, ban, block, disconnect, kill, wipe, truncate, force, hard. Examples: delete_repo, remove_user, drop_table, revoke_access, clear_cache, terminate_session. Use for irreversible or high-impact operations."), mcp.WithTitleAnnotation("Call Tool (Destructive)"), mcp.WithDestructiveHintAnnotation(true), mcp.WithString("name", @@ -370,8 +368,7 @@ func (p *MCPProxyServer) registerTools(_ bool) { mcp.Description("Arguments to pass to the tool as JSON string. Refer to the tool's inputSchema from retrieve_tools for required parameters."), ), mcp.WithObject("intent", - mcp.Required(), - mcp.Description("Intent declaration (required). Must include: operation_type='destructive'. Optional: data_sensitivity (public|internal|private|unknown), reason (explanation for operation)."), + mcp.Description("Intent declaration (optional). operation_type is automatically inferred from tool variant. Optional fields: data_sensitivity (public|internal|private|unknown), reason (explanation for operation)."), ), ) p.server.AddTool(callToolDestructiveTool, p.handleCallToolDestructive) @@ -875,7 +872,7 @@ func (p *MCPProxyServer) handleRetrieveTools(ctx context.Context, request mcp.Ca "(1) READ (call_tool_read): search, query, list, get, fetch, find, check, view, read, show, describe, lookup, retrieve, browse, explore, discover, scan, inspect, analyze, examine, validate, verify. DEFAULT choice when unsure. " + "(2) WRITE (call_tool_write): create, update, modify, add, set, send, edit, change, write, post, put, patch, insert, upload, submit, assign, configure, enable, register, subscribe, publish, move, copy, rename, merge. " + "(3) DESTRUCTIVE (call_tool_destructive): delete, remove, drop, revoke, disable, destroy, purge, reset, clear, unsubscribe, cancel, terminate, close, archive, ban, block, disconnect, kill, wipe, truncate, force, hard. " + - "INTENT PARAMETER: Always include 'intent' object with 'operation_type' matching your tool choice (read/write/destructive). Optional fields: data_sensitivity, reason.", + "INTENT PARAMETER: The 'intent' object is optional. Operation type is automatically inferred from the tool variant you choose. Optional fields: data_sensitivity (public|internal|private|unknown), reason (for audit trail).", } // Add debug information if requested @@ -1030,7 +1027,7 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp. return "" } - // Extract intent (required for all call_tool_* variants) + // Extract intent (optional - operation_type is inferred from tool variant) intent, err := p.extractIntent(request) if err != nil { errMsg := fmt.Sprintf("Invalid intent parameter: %v", err) @@ -1047,15 +1044,10 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp. return mcp.NewToolResultError(errMsg), nil } - // Validate intent matches tool variant (two-key security model) - if errResult := p.validateIntentForVariant(intent, toolVariant); errResult != nil { - // Record activity error for intent validation failure (use "unknown" if server name not parsed yet) - var reason string - if intent == nil { - reason = fmt.Sprintf("Intent validation failed: intent parameter is required for %s", toolVariant) - } else { - reason = fmt.Sprintf("Intent validation failed: operation_type '%s' does not match tool variant '%s'", intent.OperationType, toolVariant) - } + // Validate intent and infer operation_type from tool variant + intent, errResult := p.validateIntentForVariant(intent, toolVariant) + if errResult != nil { + // Record activity error for intent validation failure logServer := serverName if logServer == "" { logServer = "unknown" @@ -1064,7 +1056,7 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp. if logTool == "" { logTool = toolName } - p.emitActivityPolicyDecision(logServer, logTool, getSessionID(), "blocked", reason) + p.emitActivityPolicyDecision(logServer, logTool, getSessionID(), "blocked", "Intent validation failed") return errResult, nil } @@ -3911,19 +3903,21 @@ func (p *MCPProxyServer) extractIntent(request mcp.CallToolRequest) (*contracts. } // validateIntentForVariant validates intent for a specific tool variant. +// If intent is nil, creates a default intent with operation_type inferred from tool variant. // Returns an error response if validation fails, nil if validation passes. -func (p *MCPProxyServer) validateIntentForVariant(intent *contracts.IntentDeclaration, toolVariant string) *mcp.CallToolResult { - // Check intent is present +// Also returns the intent (possibly created) for use by caller. +func (p *MCPProxyServer) validateIntentForVariant(intent *contracts.IntentDeclaration, toolVariant string) (*contracts.IntentDeclaration, *mcp.CallToolResult) { + // Create default intent if not provided if intent == nil { - return mcp.NewToolResultError(fmt.Sprintf("intent parameter is required for %s", toolVariant)) + intent = &contracts.IntentDeclaration{} } - // Validate two-key match: intent.operation_type must match tool variant + // Validate and set operation_type from tool variant if err := intent.ValidateForToolVariant(toolVariant); err != nil { - return mcp.NewToolResultError(err.Message) + return nil, mcp.NewToolResultError(err.Message) } - return nil + return intent, nil } // validateIntentAgainstServer validates intent against server-provided annotations. From fa3389d148978b14bfbd280ff507b16c92c6196f Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 27 Jan 2026 14:37:56 +0200 Subject: [PATCH 2/7] fix(intent): remove intent object from schema for Gemini compatibility Gemini 3 Pro has strict limitations with nested objects in tool schemas: - Maximum 4-level nesting depth - Cannot handle untyped/schema-less objects - Strict schema validation Remove the `intent` object parameter entirely from call_tool_* schemas. The operation_type is already inferred from the tool variant, and the optional audit fields (data_sensitivity, reason) can be added back later if needed via flat string parameters. This should resolve the "improper format stop reason" errors when Gemini tries to generate tool calls. Co-Authored-By: Claude Opus 4.5 --- internal/server/mcp.go | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/internal/server/mcp.go b/internal/server/mcp.go index a15a1252..e6adbd1b 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -320,6 +320,7 @@ func (p *MCPProxyServer) registerTools(_ bool) { // that enable granular IDE permission control and require explicit intent declaration. // call_tool_read - Read-only operations + // NOTE: Intent parameters are flattened (not nested objects) for Gemini 3 Pro compatibility callToolReadTool := mcp.NewTool(contracts.ToolVariantRead, mcp.WithDescription("Execute a READ-ONLY tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results. DECISION RULE: Use this when the tool name contains: search, query, list, get, fetch, find, check, view, read, show, describe, lookup, retrieve, browse, explore, discover, scan, inspect, analyze, examine, validate, verify. Examples: search_files, get_user, list_repositories, query_database, find_issues, check_status. This is the DEFAULT choice when unsure - most tools are read-only."), mcp.WithTitleAnnotation("Call Tool (Read)"), @@ -331,9 +332,6 @@ func (p *MCPProxyServer) registerTools(_ bool) { mcp.WithString("args_json", mcp.Description("Arguments to pass to the tool as JSON string. Refer to the tool's inputSchema from retrieve_tools for required parameters."), ), - mcp.WithObject("intent", - mcp.Description("Intent declaration (optional). operation_type is automatically inferred from tool variant. Optional fields: data_sensitivity (public|internal|private|unknown), reason (explanation for operation)."), - ), ) p.server.AddTool(callToolReadTool, p.handleCallToolRead) @@ -349,9 +347,6 @@ func (p *MCPProxyServer) registerTools(_ bool) { mcp.WithString("args_json", mcp.Description("Arguments to pass to the tool as JSON string. Refer to the tool's inputSchema from retrieve_tools for required parameters."), ), - mcp.WithObject("intent", - mcp.Description("Intent declaration (optional). operation_type is automatically inferred from tool variant. Optional fields: data_sensitivity (public|internal|private|unknown), reason (explanation for operation)."), - ), ) p.server.AddTool(callToolWriteTool, p.handleCallToolWrite) @@ -367,9 +362,6 @@ func (p *MCPProxyServer) registerTools(_ bool) { mcp.WithString("args_json", mcp.Description("Arguments to pass to the tool as JSON string. Refer to the tool's inputSchema from retrieve_tools for required parameters."), ), - mcp.WithObject("intent", - mcp.Description("Intent declaration (optional). operation_type is automatically inferred from tool variant. Optional fields: data_sensitivity (public|internal|private|unknown), reason (explanation for operation)."), - ), ) p.server.AddTool(callToolDestructiveTool, p.handleCallToolDestructive) @@ -872,7 +864,7 @@ func (p *MCPProxyServer) handleRetrieveTools(ctx context.Context, request mcp.Ca "(1) READ (call_tool_read): search, query, list, get, fetch, find, check, view, read, show, describe, lookup, retrieve, browse, explore, discover, scan, inspect, analyze, examine, validate, verify. DEFAULT choice when unsure. " + "(2) WRITE (call_tool_write): create, update, modify, add, set, send, edit, change, write, post, put, patch, insert, upload, submit, assign, configure, enable, register, subscribe, publish, move, copy, rename, merge. " + "(3) DESTRUCTIVE (call_tool_destructive): delete, remove, drop, revoke, disable, destroy, purge, reset, clear, unsubscribe, cancel, terminate, close, archive, ban, block, disconnect, kill, wipe, truncate, force, hard. " + - "INTENT PARAMETER: The 'intent' object is optional. Operation type is automatically inferred from the tool variant you choose. Optional fields: data_sensitivity (public|internal|private|unknown), reason (for audit trail).", + "OPERATION TYPE: Automatically inferred from tool variant - no need to specify. Just provide name and args_json.", } // Add debug information if requested From 502e12c89550da7da119cb98d592c2386f5b9286 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 27 Jan 2026 14:46:48 +0200 Subject: [PATCH 3/7] refactor(intent): flatten intent params for Gemini 3 Pro compatibility Replace nested intent object with flat string parameters to avoid Gemini 3 Pro's strict limitations with nested JSON objects: - Replace `intent: { data_sensitivity, reason }` with flat params: - `intent_data_sensitivity` - optional data classification - `intent_reason` - optional explanation for audit trail - Update extractIntent() to read from flat parameters - operation_type still inferred from tool variant (call_tool_read/write/destructive) - Activity log features preserved - intent metadata recorded in activity records - Update tests and documentation Fixes #278 Co-Authored-By: Claude Opus 4.5 --- docs/features/intent-declaration.md | 46 +++++++-------- internal/server/intent_validation_test.go | 69 ++++++++++++++--------- internal/server/mcp.go | 37 +++++++++--- 3 files changed, 92 insertions(+), 60 deletions(-) diff --git a/docs/features/intent-declaration.md b/docs/features/intent-declaration.md index 059581e5..60e8eabb 100644 --- a/docs/features/intent-declaration.md +++ b/docs/features/intent-declaration.md @@ -44,7 +44,7 @@ MCPProxy Tools: ## How It Works -The tool variant (`call_tool_read` / `write` / `destructive`) **automatically determines** the operation type: +The tool variant (`call_tool_read` / `write` / `destructive`) **automatically determines** the operation type. Intent metadata is provided as **flat string parameters** (not nested objects) for maximum compatibility with AI models: ```json { @@ -52,10 +52,8 @@ The tool variant (`call_tool_read` / `write` / `destructive`) **automatically de "arguments": { "name": "github:delete_repo", "args_json": "{\"repo\": \"test-repo\"}", - "intent": { - "data_sensitivity": "private", - "reason": "User requested repository cleanup" - } + "intent_data_sensitivity": "private", + "intent_reason": "User requested repository cleanup" } } ``` @@ -65,7 +63,7 @@ The `operation_type` is inferred from the tool variant - agents don't need to sp ### Validation Chain 1. Tool variant determines operation type (`call_tool_destructive` → "destructive") -2. Optional intent fields (`data_sensitivity`, `reason`) are validated if provided +2. Optional intent fields (`intent_data_sensitivity`, `intent_reason`) are validated if provided 3. Server annotation check → validate against `destructiveHint`/`readOnlyHint` ## Tool Variants @@ -86,9 +84,7 @@ Or with optional metadata: { "name": "github:list_repos", "args_json": "{\"org\": \"myorg\"}", - "intent": { - "reason": "Listing repositories for project analysis" - } + "intent_reason": "Listing repositories for project analysis" } ``` @@ -104,9 +100,7 @@ Execute state-modifying operations that create or update resources. { "name": "github:create_issue", "args_json": "{\"title\": \"Bug report\", \"body\": \"Details...\"}", - "intent": { - "reason": "Creating bug report per user request" - } + "intent_reason": "Creating bug report per user request" } ``` @@ -123,9 +117,8 @@ Execute destructive or irreversible operations. "name": "github:delete_repo", "args_json": "{\"repo\": \"test-repo\"}", "intent": { - "data_sensitivity": "private", - "reason": "User confirmed deletion of test repository" - } + "intent_data_sensitivity": "private", + "intent_reason": "User confirmed deletion of test repository" } ``` @@ -133,15 +126,16 @@ Execute destructive or irreversible operations. - `operation_type` automatically inferred as "destructive" - Most permissive - allowed regardless of server annotations -## Intent Parameter +## Intent Parameters + +Intent metadata is provided as **flat string parameters** for maximum compatibility with AI models (e.g., Gemini): -The `intent` object is **optional** - operation type is automatically inferred from the tool variant: +| Parameter | Required | Values | Description | +|-----------|----------|--------|-------------| +| `intent_data_sensitivity` | No | `public`, `internal`, `private`, `unknown` | Data classification for audit | +| `intent_reason` | No | String (max 1000 chars) | Explanation for audit trail | -| Field | Required | Values | Description | -|-------|----------|--------|-------------| -| `operation_type` | No | `read`, `write`, `destructive` | Automatically inferred from tool variant | -| `data_sensitivity` | No | `public`, `internal`, `private`, `unknown` | Data classification for audit | -| `reason` | No | String (max 1000 chars) | Explanation for audit trail | +The `operation_type` is automatically inferred from the tool variant and cannot be overridden. ### Examples @@ -158,10 +152,8 @@ The `intent` object is **optional** - operation type is automatically inferred f { "name": "dataserver:write_data", "args_json": "{\"id\": \"123\", \"value\": \"new\"}", - "intent": { - "data_sensitivity": "private", - "reason": "Updating user profile with personal information" - } + "intent_data_sensitivity": "private", + "intent_reason": "Updating user profile with personal information" } ``` @@ -363,7 +355,7 @@ The legacy `call_tool` has been removed. Update your integrations: } ``` -The `intent` parameter is optional - `operation_type` is automatically inferred from the tool variant. You can add `data_sensitivity` and `reason` for audit purposes. +Intent parameters are optional - `operation_type` is automatically inferred from the tool variant. You can add `intent_data_sensitivity` and `intent_reason` for audit purposes. :::tip Choosing the Right Variant When unsure, use `call_tool_destructive` - it's the most permissive and will always succeed validation. Then refine based on `retrieve_tools` guidance. diff --git a/internal/server/intent_validation_test.go b/internal/server/intent_validation_test.go index f8712761..07488326 100644 --- a/internal/server/intent_validation_test.go +++ b/internal/server/intent_validation_test.go @@ -20,45 +20,58 @@ func TestMCPProxyServer_extractIntent(t *testing.T) { } tests := []struct { - name string - request mcp.CallToolRequest - wantNil bool - wantOpTyp string - wantErr bool + name string + request mcp.CallToolRequest + wantNil bool + wantSensitivity string + wantReason string + wantErr bool }{ { - name: "valid intent object", + name: "flat intent with data_sensitivity only", request: mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "test", Arguments: map[string]interface{}{ - "intent": map[string]interface{}{ - "operation_type": "read", - }, + "intent_data_sensitivity": "private", }, }, }, - wantNil: false, - wantOpTyp: "read", - wantErr: false, + wantNil: false, + wantSensitivity: "private", + wantReason: "", + wantErr: false, }, { - name: "intent with all fields", + name: "flat intent with reason only", request: mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "test", Arguments: map[string]interface{}{ - "intent": map[string]interface{}{ - "operation_type": "write", - "data_sensitivity": "private", - "reason": "test reason", - }, + "intent_reason": "test reason", }, }, }, - wantNil: false, - wantOpTyp: "write", - wantErr: false, + wantNil: false, + wantSensitivity: "", + wantReason: "test reason", + wantErr: false, + }, + { + name: "flat intent with all fields", + request: mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "test", + Arguments: map[string]interface{}{ + "intent_data_sensitivity": "internal", + "intent_reason": "user requested update", + }, + }, + }, + wantNil: false, + wantSensitivity: "internal", + wantReason: "user requested update", + wantErr: false, }, { name: "no intent - nil arguments", @@ -83,17 +96,18 @@ func TestMCPProxyServer_extractIntent(t *testing.T) { wantErr: false, }, { - name: "intent not an object - error", + name: "no intent - only other args present", request: mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "test", Arguments: map[string]interface{}{ - "intent": "not an object", + "name": "github:list_repos", + "args_json": "{}", }, }, }, wantNil: true, - wantErr: true, + wantErr: false, }, } @@ -118,8 +132,11 @@ func TestMCPProxyServer_extractIntent(t *testing.T) { return } - if intent.OperationType != tt.wantOpTyp { - t.Errorf("extractIntent().OperationType = %v, want %v", intent.OperationType, tt.wantOpTyp) + if intent.DataSensitivity != tt.wantSensitivity { + t.Errorf("extractIntent().DataSensitivity = %v, want %v", intent.DataSensitivity, tt.wantSensitivity) + } + if intent.Reason != tt.wantReason { + t.Errorf("extractIntent().Reason = %v, want %v", intent.Reason, tt.wantReason) } }) } diff --git a/internal/server/mcp.go b/internal/server/mcp.go index e6adbd1b..d6ce9894 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -332,6 +332,12 @@ func (p *MCPProxyServer) registerTools(_ bool) { mcp.WithString("args_json", mcp.Description("Arguments to pass to the tool as JSON string. Refer to the tool's inputSchema from retrieve_tools for required parameters."), ), + mcp.WithString("intent_data_sensitivity", + mcp.Description("Optional data sensitivity classification: public, internal, private, or unknown. For audit trail."), + ), + mcp.WithString("intent_reason", + mcp.Description("Optional explanation for the operation. For audit trail. Max 1000 chars."), + ), ) p.server.AddTool(callToolReadTool, p.handleCallToolRead) @@ -347,6 +353,12 @@ func (p *MCPProxyServer) registerTools(_ bool) { mcp.WithString("args_json", mcp.Description("Arguments to pass to the tool as JSON string. Refer to the tool's inputSchema from retrieve_tools for required parameters."), ), + mcp.WithString("intent_data_sensitivity", + mcp.Description("Optional data sensitivity classification: public, internal, private, or unknown. For audit trail."), + ), + mcp.WithString("intent_reason", + mcp.Description("Optional explanation for the operation. For audit trail. Max 1000 chars."), + ), ) p.server.AddTool(callToolWriteTool, p.handleCallToolWrite) @@ -362,6 +374,12 @@ func (p *MCPProxyServer) registerTools(_ bool) { mcp.WithString("args_json", mcp.Description("Arguments to pass to the tool as JSON string. Refer to the tool's inputSchema from retrieve_tools for required parameters."), ), + mcp.WithString("intent_data_sensitivity", + mcp.Description("Optional data sensitivity classification: public, internal, private, or unknown. For audit trail."), + ), + mcp.WithString("intent_reason", + mcp.Description("Optional explanation for the operation. For audit trail. Max 1000 chars."), + ), ) p.server.AddTool(callToolDestructiveTool, p.handleCallToolDestructive) @@ -3871,7 +3889,7 @@ func (p *MCPProxyServer) CallToolDirect(ctx context.Context, request mcp.CallToo // extractIntent extracts the IntentDeclaration from MCP request parameters. // Returns nil if intent is not present (caller should handle missing intent error). func (p *MCPProxyServer) extractIntent(request mcp.CallToolRequest) (*contracts.IntentDeclaration, error) { - // Get intent from request parameters + // Get intent from flat request parameters (intent_data_sensitivity, intent_reason) if request.Params.Arguments == nil { return nil, nil } @@ -3881,17 +3899,22 @@ func (p *MCPProxyServer) extractIntent(request mcp.CallToolRequest) (*contracts. return nil, nil } - intentRaw, exists := argumentsMap["intent"] - if !exists { + // Extract flat intent parameters + dataSensitivity, _ := argumentsMap["intent_data_sensitivity"].(string) + reason, _ := argumentsMap["intent_reason"].(string) + + // If neither field is provided, return nil (intent is optional) + if dataSensitivity == "" && reason == "" { return nil, nil } - intentMap, ok := intentRaw.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("intent must be an object") + // Build intent from flat parameters + intent := &contracts.IntentDeclaration{ + DataSensitivity: dataSensitivity, + Reason: reason, } - return contracts.IntentFromMap(intentMap), nil + return intent, nil } // validateIntentForVariant validates intent for a specific tool variant. From 88fee9906b56b09c453185fe1c8606149bf4cc70 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 27 Jan 2026 14:57:56 +0200 Subject: [PATCH 4/7] fix(cli): use flat intent params in call commands The CLI was still building a nested intent object which the server's extractIntent() no longer reads. Updated to use flat intent_data_sensitivity and intent_reason parameters. Co-Authored-By: Claude Opus 4.5 --- cmd/mcpproxy/call_cmd.go | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/cmd/mcpproxy/call_cmd.go b/cmd/mcpproxy/call_cmd.go index 80445290..d75f2c05 100644 --- a/cmd/mcpproxy/call_cmd.go +++ b/cmd/mcpproxy/call_cmd.go @@ -350,22 +350,17 @@ func runCallToolVariant(toolVariant, operationType string) error { return fmt.Errorf("invalid JSON arguments: %w", err) } - // Build intent declaration - intent := map[string]interface{}{ - "operation_type": operationType, + // Build arguments for the tool variant with flat intent params + variantArgs := map[string]interface{}{ + "name": callToolName, + "args": toolArgs, } + // Add flat intent params (operation_type is inferred from tool variant) if callIntentSensitivity != "" { - intent["data_sensitivity"] = callIntentSensitivity + variantArgs["intent_data_sensitivity"] = callIntentSensitivity } if callIntentReason != "" { - intent["reason"] = callIntentReason - } - - // Build arguments for the tool variant - variantArgs := map[string]interface{}{ - "name": callToolName, - "args": toolArgs, - "intent": intent, + variantArgs["intent_reason"] = callIntentReason } // Load configuration From bf040b97bd4960247aa771c0a04ec7ef54fb2f62 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 27 Jan 2026 15:00:04 +0200 Subject: [PATCH 5/7] docs(intent): improve intent param descriptions to encourage usage - Remove "Optional" from descriptions (models skip optional fields) - Change "For audit trail" to "Recommended for compliance/accountability" - Add "Requires intent.operation_type" to main tool descriptions Co-Authored-By: Claude Opus 4.5 --- internal/server/mcp.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/server/mcp.go b/internal/server/mcp.go index d6ce9894..05b1cbf8 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -322,7 +322,7 @@ func (p *MCPProxyServer) registerTools(_ bool) { // call_tool_read - Read-only operations // NOTE: Intent parameters are flattened (not nested objects) for Gemini 3 Pro compatibility callToolReadTool := mcp.NewTool(contracts.ToolVariantRead, - mcp.WithDescription("Execute a READ-ONLY tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results. DECISION RULE: Use this when the tool name contains: search, query, list, get, fetch, find, check, view, read, show, describe, lookup, retrieve, browse, explore, discover, scan, inspect, analyze, examine, validate, verify. Examples: search_files, get_user, list_repositories, query_database, find_issues, check_status. This is the DEFAULT choice when unsure - most tools are read-only."), + mcp.WithDescription("Execute a READ-ONLY tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results. DECISION RULE: Use this when the tool name contains: search, query, list, get, fetch, find, check, view, read, show, describe, lookup, retrieve, browse, explore, discover, scan, inspect, analyze, examine, validate, verify. Examples: search_files, get_user, list_repositories, query_database, find_issues, check_status. This is the DEFAULT choice when unsure - most tools are read-only. Requires intent.operation_type='read'."), mcp.WithTitleAnnotation("Call Tool (Read)"), mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("name", @@ -333,17 +333,17 @@ func (p *MCPProxyServer) registerTools(_ bool) { mcp.Description("Arguments to pass to the tool as JSON string. Refer to the tool's inputSchema from retrieve_tools for required parameters."), ), mcp.WithString("intent_data_sensitivity", - mcp.Description("Optional data sensitivity classification: public, internal, private, or unknown. For audit trail."), + mcp.Description("Data sensitivity: public, internal, private, or unknown. Recommended for compliance tracking."), ), mcp.WithString("intent_reason", - mcp.Description("Optional explanation for the operation. For audit trail. Max 1000 chars."), + mcp.Description("Brief explanation of why this operation is needed. Recommended for audit accountability."), ), ) p.server.AddTool(callToolReadTool, p.handleCallToolRead) // call_tool_write - State-modifying operations callToolWriteTool := mcp.NewTool(contracts.ToolVariantWrite, - mcp.WithDescription("Execute a STATE-MODIFYING tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results. DECISION RULE: Use this when the tool name contains: create, update, modify, add, set, send, edit, change, write, post, put, patch, insert, upload, submit, assign, configure, enable, register, subscribe, publish, move, copy, rename, merge. Examples: create_issue, update_file, send_message, add_comment, set_status, edit_page. Use only when explicitly modifying state."), + mcp.WithDescription("Execute a STATE-MODIFYING tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results. DECISION RULE: Use this when the tool name contains: create, update, modify, add, set, send, edit, change, write, post, put, patch, insert, upload, submit, assign, configure, enable, register, subscribe, publish, move, copy, rename, merge. Examples: create_issue, update_file, send_message, add_comment, set_status, edit_page. Use only when explicitly modifying state. Requires intent.operation_type='write'."), mcp.WithTitleAnnotation("Call Tool (Write)"), mcp.WithDestructiveHintAnnotation(false), mcp.WithString("name", @@ -354,17 +354,17 @@ func (p *MCPProxyServer) registerTools(_ bool) { mcp.Description("Arguments to pass to the tool as JSON string. Refer to the tool's inputSchema from retrieve_tools for required parameters."), ), mcp.WithString("intent_data_sensitivity", - mcp.Description("Optional data sensitivity classification: public, internal, private, or unknown. For audit trail."), + mcp.Description("Data sensitivity: public, internal, private, or unknown. Recommended for compliance tracking."), ), mcp.WithString("intent_reason", - mcp.Description("Optional explanation for the operation. For audit trail. Max 1000 chars."), + mcp.Description("Brief explanation of why this operation is needed. Recommended for audit accountability."), ), ) p.server.AddTool(callToolWriteTool, p.handleCallToolWrite) // call_tool_destructive - Irreversible operations callToolDestructiveTool := mcp.NewTool(contracts.ToolVariantDestructive, - mcp.WithDescription("Execute a DESTRUCTIVE tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results. DECISION RULE: Use this when the tool name contains: delete, remove, drop, revoke, disable, destroy, purge, reset, clear, unsubscribe, cancel, terminate, close, archive, ban, block, disconnect, kill, wipe, truncate, force, hard. Examples: delete_repo, remove_user, drop_table, revoke_access, clear_cache, terminate_session. Use for irreversible or high-impact operations."), + mcp.WithDescription("Execute a DESTRUCTIVE tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results. DECISION RULE: Use this when the tool name contains: delete, remove, drop, revoke, disable, destroy, purge, reset, clear, unsubscribe, cancel, terminate, close, archive, ban, block, disconnect, kill, wipe, truncate, force, hard. Examples: delete_repo, remove_user, drop_table, revoke_access, clear_cache, terminate_session. Use for irreversible or high-impact operations. Requires intent.operation_type='destructive'."), mcp.WithTitleAnnotation("Call Tool (Destructive)"), mcp.WithDestructiveHintAnnotation(true), mcp.WithString("name", @@ -375,10 +375,10 @@ func (p *MCPProxyServer) registerTools(_ bool) { mcp.Description("Arguments to pass to the tool as JSON string. Refer to the tool's inputSchema from retrieve_tools for required parameters."), ), mcp.WithString("intent_data_sensitivity", - mcp.Description("Optional data sensitivity classification: public, internal, private, or unknown. For audit trail."), + mcp.Description("Data sensitivity: public, internal, private, or unknown. Recommended for compliance tracking."), ), mcp.WithString("intent_reason", - mcp.Description("Optional explanation for the operation. For audit trail. Max 1000 chars."), + mcp.Description("Brief explanation of why this operation is needed. Recommended for audit accountability."), ), ) p.server.AddTool(callToolDestructiveTool, p.handleCallToolDestructive) From 4ee053d5e1b8211d629371b0e16823c1098efb64 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 27 Jan 2026 15:06:55 +0200 Subject: [PATCH 6/7] fix(intent): improve descriptions to encourage LLMs to provide intent fields - Remove operation_type mentions (inferred from tool name) - Make intent_data_sensitivity action-oriented: "Classify data being accessed/modified/deleted" - Make intent_reason a question with examples: "Why is this tool being called?" - Update retrieve_tools: "Always provide intent_reason and intent_data_sensitivity" Co-Authored-By: Claude Opus 4.5 --- internal/server/mcp.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/server/mcp.go b/internal/server/mcp.go index 05b1cbf8..c8c4393b 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -322,7 +322,7 @@ func (p *MCPProxyServer) registerTools(_ bool) { // call_tool_read - Read-only operations // NOTE: Intent parameters are flattened (not nested objects) for Gemini 3 Pro compatibility callToolReadTool := mcp.NewTool(contracts.ToolVariantRead, - mcp.WithDescription("Execute a READ-ONLY tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results. DECISION RULE: Use this when the tool name contains: search, query, list, get, fetch, find, check, view, read, show, describe, lookup, retrieve, browse, explore, discover, scan, inspect, analyze, examine, validate, verify. Examples: search_files, get_user, list_repositories, query_database, find_issues, check_status. This is the DEFAULT choice when unsure - most tools are read-only. Requires intent.operation_type='read'."), + mcp.WithDescription("Execute a READ-ONLY tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results. DECISION RULE: Use this when the tool name contains: search, query, list, get, fetch, find, check, view, read, show, describe, lookup, retrieve, browse, explore, discover, scan, inspect, analyze, examine, validate, verify. Examples: search_files, get_user, list_repositories, query_database, find_issues, check_status. This is the DEFAULT choice when unsure - most tools are read-only."), mcp.WithTitleAnnotation("Call Tool (Read)"), mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("name", @@ -333,17 +333,17 @@ func (p *MCPProxyServer) registerTools(_ bool) { mcp.Description("Arguments to pass to the tool as JSON string. Refer to the tool's inputSchema from retrieve_tools for required parameters."), ), mcp.WithString("intent_data_sensitivity", - mcp.Description("Data sensitivity: public, internal, private, or unknown. Recommended for compliance tracking."), + mcp.Description("Classify data being accessed: public, internal, private, or unknown. Helps track sensitive data access patterns."), ), mcp.WithString("intent_reason", - mcp.Description("Brief explanation of why this operation is needed. Recommended for audit accountability."), + mcp.Description("Why is this tool being called? Provide context like 'User asked to check status' or 'Gathering data for report'."), ), ) p.server.AddTool(callToolReadTool, p.handleCallToolRead) // call_tool_write - State-modifying operations callToolWriteTool := mcp.NewTool(contracts.ToolVariantWrite, - mcp.WithDescription("Execute a STATE-MODIFYING tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results. DECISION RULE: Use this when the tool name contains: create, update, modify, add, set, send, edit, change, write, post, put, patch, insert, upload, submit, assign, configure, enable, register, subscribe, publish, move, copy, rename, merge. Examples: create_issue, update_file, send_message, add_comment, set_status, edit_page. Use only when explicitly modifying state. Requires intent.operation_type='write'."), + mcp.WithDescription("Execute a STATE-MODIFYING tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results. DECISION RULE: Use this when the tool name contains: create, update, modify, add, set, send, edit, change, write, post, put, patch, insert, upload, submit, assign, configure, enable, register, subscribe, publish, move, copy, rename, merge. Examples: create_issue, update_file, send_message, add_comment, set_status, edit_page. Use only when explicitly modifying state."), mcp.WithTitleAnnotation("Call Tool (Write)"), mcp.WithDestructiveHintAnnotation(false), mcp.WithString("name", @@ -354,17 +354,17 @@ func (p *MCPProxyServer) registerTools(_ bool) { mcp.Description("Arguments to pass to the tool as JSON string. Refer to the tool's inputSchema from retrieve_tools for required parameters."), ), mcp.WithString("intent_data_sensitivity", - mcp.Description("Data sensitivity: public, internal, private, or unknown. Recommended for compliance tracking."), + mcp.Description("Classify data being modified: public, internal, private, or unknown. Helps track sensitive data changes."), ), mcp.WithString("intent_reason", - mcp.Description("Brief explanation of why this operation is needed. Recommended for audit accountability."), + mcp.Description("Why is this modification needed? Provide context like 'User requested update' or 'Fixing reported issue'."), ), ) p.server.AddTool(callToolWriteTool, p.handleCallToolWrite) // call_tool_destructive - Irreversible operations callToolDestructiveTool := mcp.NewTool(contracts.ToolVariantDestructive, - mcp.WithDescription("Execute a DESTRUCTIVE tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results. DECISION RULE: Use this when the tool name contains: delete, remove, drop, revoke, disable, destroy, purge, reset, clear, unsubscribe, cancel, terminate, close, archive, ban, block, disconnect, kill, wipe, truncate, force, hard. Examples: delete_repo, remove_user, drop_table, revoke_access, clear_cache, terminate_session. Use for irreversible or high-impact operations. Requires intent.operation_type='destructive'."), + mcp.WithDescription("Execute a DESTRUCTIVE tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results. DECISION RULE: Use this when the tool name contains: delete, remove, drop, revoke, disable, destroy, purge, reset, clear, unsubscribe, cancel, terminate, close, archive, ban, block, disconnect, kill, wipe, truncate, force, hard. Examples: delete_repo, remove_user, drop_table, revoke_access, clear_cache, terminate_session. Use for irreversible or high-impact operations."), mcp.WithTitleAnnotation("Call Tool (Destructive)"), mcp.WithDestructiveHintAnnotation(true), mcp.WithString("name", @@ -375,10 +375,10 @@ func (p *MCPProxyServer) registerTools(_ bool) { mcp.Description("Arguments to pass to the tool as JSON string. Refer to the tool's inputSchema from retrieve_tools for required parameters."), ), mcp.WithString("intent_data_sensitivity", - mcp.Description("Data sensitivity: public, internal, private, or unknown. Recommended for compliance tracking."), + mcp.Description("Classify data being deleted: public, internal, private, or unknown. Important for tracking destructive operations on sensitive data."), ), mcp.WithString("intent_reason", - mcp.Description("Brief explanation of why this operation is needed. Recommended for audit accountability."), + mcp.Description("Why is this deletion needed? Provide justification like 'User confirmed cleanup' or 'Removing obsolete data'."), ), ) p.server.AddTool(callToolDestructiveTool, p.handleCallToolDestructive) @@ -882,7 +882,7 @@ func (p *MCPProxyServer) handleRetrieveTools(ctx context.Context, request mcp.Ca "(1) READ (call_tool_read): search, query, list, get, fetch, find, check, view, read, show, describe, lookup, retrieve, browse, explore, discover, scan, inspect, analyze, examine, validate, verify. DEFAULT choice when unsure. " + "(2) WRITE (call_tool_write): create, update, modify, add, set, send, edit, change, write, post, put, patch, insert, upload, submit, assign, configure, enable, register, subscribe, publish, move, copy, rename, merge. " + "(3) DESTRUCTIVE (call_tool_destructive): delete, remove, drop, revoke, disable, destroy, purge, reset, clear, unsubscribe, cancel, terminate, close, archive, ban, block, disconnect, kill, wipe, truncate, force, hard. " + - "OPERATION TYPE: Automatically inferred from tool variant - no need to specify. Just provide name and args_json.", + "INTENT TRACKING: Always provide intent_reason (why you're calling this tool) and intent_data_sensitivity (public/internal/private/unknown) to enable activity auditing.", } // Add debug information if requested From 5d2a9f98fa0e82153b9c178c45d97c182512432c Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 27 Jan 2026 15:15:05 +0200 Subject: [PATCH 7/7] fix(lint): remove unused isValidOperationType function No longer needed since operation_type is inferred from tool variant. Co-Authored-By: Claude Opus 4.5 --- internal/contracts/intent.go | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/internal/contracts/intent.go b/internal/contracts/intent.go index 8295d491..6e2107f8 100644 --- a/internal/contracts/intent.go +++ b/internal/contracts/intent.go @@ -227,16 +227,6 @@ func DeriveCallWith(annotations *config.ToolAnnotations) string { return ToolVariantRead } -// isValidOperationType checks if the operation type is valid -func isValidOperationType(opType string) bool { - for _, valid := range ValidOperationTypes { - if strings.EqualFold(opType, valid) { - return opType == valid // Case-sensitive match required - } - } - return false -} - // isValidDataSensitivity checks if the data sensitivity is valid func isValidDataSensitivity(sensitivity string) bool { for _, valid := range ValidDataSensitivities {