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/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 diff --git a/docs/features/intent-declaration.md b/docs/features/intent-declaration.md index 9c0f4977..60e8eabb 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. Intent metadata is provided as **flat string parameters** (not nested objects) for maximum compatibility with AI models: ```json { @@ -55,26 +52,19 @@ Agents must declare intent in **two places** that must match: "arguments": { "name": "github:delete_repo", "args_json": "{\"repo\": \"test-repo\"}", - "intent": { - "operation_type": "destructive", - "data_sensitivity": "private", - "reason": "User requested repository cleanup" - } + "intent_data_sensitivity": "private", + "intent_reason": "User requested repository cleanup" } } ``` -**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 (`intent_data_sensitivity`, `intent_reason`) are validated if provided +3. Server annotation check → validate against `destructiveHint`/`readOnlyHint` ## Tool Variants @@ -82,18 +72,24 @@ 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" - } + "intent_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 @@ -104,15 +100,12 @@ 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" - } + "intent_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,46 +117,43 @@ 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" - } + "intent_data_sensitivity": "private", + "intent_reason": "User confirmed deletion of test repository" } ``` **Validation:** -- `intent.operation_type` MUST be "destructive" +- `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 **required** on all tool calls: +| 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` | Yes | `read`, `write`, `destructive` | Must match tool variant | -| `data_sensitivity` | No | `public`, `internal`, `private`, `unknown` | Data classification | -| `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 -**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 { - "intent": { - "operation_type": "write", - "data_sensitivity": "private", - "reason": "Creating user profile with personal information" - } + "name": "dataserver:write_data", + "args_json": "{\"id\": \"123\", \"value\": \"new\"}", + "intent_data_sensitivity": "private", + "intent_reason": "Updating user profile with personal information" } ``` @@ -284,22 +274,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:** ``` -intent.operation_type is required. -Provide intent: {operation_type: "read"|"write"|"destructive"} +Invalid intent.data_sensitivity 'secret': must be public, internal, private, or unknown +``` + +**Reason too long:** +``` +intent.reason exceeds maximum length of 1000 characters ``` ## IDE Configuration Examples @@ -362,14 +350,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": "{...}" } } ``` +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/contracts/intent.go b/internal/contracts/intent.go index 095564f3..6e2107f8 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 @@ -257,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 { 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..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) } }) } @@ -135,65 +152,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..c8c4393b 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -320,8 +320,9 @@ 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. 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", @@ -331,16 +332,18 @@ 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.Required(), - mcp.Description("Intent declaration (required). Must include: operation_type='read'. Optional: data_sensitivity (public|internal|private|unknown), reason (explanation for operation)."), + mcp.WithString("intent_data_sensitivity", + mcp.Description("Classify data being accessed: public, internal, private, or unknown. Helps track sensitive data access patterns."), + ), + mcp.WithString("intent_reason", + 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", @@ -350,16 +353,18 @@ 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.Required(), - mcp.Description("Intent declaration (required). Must include: operation_type='write'. Optional: data_sensitivity (public|internal|private|unknown), reason (explanation for operation)."), + mcp.WithString("intent_data_sensitivity", + mcp.Description("Classify data being modified: public, internal, private, or unknown. Helps track sensitive data changes."), + ), + mcp.WithString("intent_reason", + 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", @@ -369,9 +374,11 @@ 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.Required(), - mcp.Description("Intent declaration (required). Must include: operation_type='destructive'. Optional: data_sensitivity (public|internal|private|unknown), reason (explanation for operation)."), + mcp.WithString("intent_data_sensitivity", + 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("Why is this deletion needed? Provide justification like 'User confirmed cleanup' or 'Removing obsolete data'."), ), ) p.server.AddTool(callToolDestructiveTool, p.handleCallToolDestructive) @@ -875,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. " + - "INTENT PARAMETER: Always include 'intent' object with 'operation_type' matching your tool choice (read/write/destructive). Optional fields: data_sensitivity, reason.", + "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 @@ -1030,7 +1037,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 +1054,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 +1066,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 } @@ -3887,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 } @@ -3897,33 +3899,40 @@ 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. +// 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.