| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "time" |
| 8 | |
| 9 | "reasonix/internal/agent" |
| 10 | "reasonix/internal/event" |
| 11 | "reasonix/internal/mcpinteraction" |
| 12 | "reasonix/internal/provider" |
| 13 | ) |
| 14 | |
| 15 | // MCPAppCallTool executes one App-initiated tools/call. Security model: |
| 16 | // plugin.AppInstanceTool enforces same-server, app visibility, and catalog |
| 17 | // generation; the controller then applies the session permission policy and |
| 18 | // hooks around the dispatch, records nested ToolDispatch/ToolResult events |
| 19 | // under the instance's parent id, and stores a LocalOnly transcript message — |
| 20 | // visible in the UI, never added to model context. |
| 21 | func (c *Controller) MCPAppCallTool(instanceToken, toolName string, args json.RawMessage) (string, error) { |
| 22 | if c == nil { |
| 23 | return "", fmt.Errorf("no controller") |
| 24 | } |
| 25 | host := c.mcp.hostRef() |
| 26 | if host == nil { |
| 27 | return "", fmt.Errorf("no MCP runtime") |
| 28 | } |
| 29 | ref, ok := host.AppInstanceTool(instanceToken, toolName) |
| 30 | if !ok { |
| 31 | return "", fmt.Errorf("app tool call refused: unknown instance, cross-server target, non-app tool, or stale catalog") |
| 32 | } |
| 33 | inst, _ := host.LookupAppInstance(instanceToken) |
| 34 | callCtx, ok := host.AppInstanceContext(instanceToken) |
| 35 | if !ok || callCtx.Err() != nil { |
| 36 | return "", fmt.Errorf("app tool call refused: instance is closed") |
| 37 | } |
| 38 | target := ref.UITool() |
| 39 | callID := fmt.Sprintf("mcp-app-%d", time.Now().UnixNano()) |
| 40 | parentID := "" |
| 41 | if inst != nil { |
| 42 | parentID = inst.CallID |
| 43 | } |
| 44 | |
| 45 | argsJSON := args |
| 46 | if len(argsJSON) == 0 { |
| 47 | argsJSON = json.RawMessage(`{}`) |
| 48 | } |
| 49 | toolEvent := event.Tool{ |
| 50 | ID: callID, ParentID: parentID, |
| 51 | Name: target.Name(), |
| 52 | Args: string(argsJSON), |
| 53 | ReadOnly: target.ReadOnly(), |
| 54 | } |
| 55 | if err := event.EmitChecked(c.sink, event.Event{Kind: event.ToolDispatch, Tool: toolEvent}); err != nil { |
| 56 | return "", fmt.Errorf("persist app dispatch: %w", err) |
| 57 | } |
| 58 | |
| 59 | ctx := agent.WithToolCallContext(callCtx, callID, c.sink, c, false) |
| 60 | ctx = mcpinteraction.WithBroker(ctx, c) |
| 61 | |
| 62 | if c.hooks != nil { |
| 63 | c.hooks.PreToolUse(ctx, target.Name(), argsJSON) |
| 64 | } |
| 65 | rawResult, output, reportedError, err := target.ExecuteForApp(ctx, argsJSON) |
| 66 | hookErr := err |
| 67 | if hookErr == nil && reportedError { |
| 68 | hookErr = errors.New("MCP tool returned isError") |
| 69 | } |
| 70 | if c.hooks != nil { |
| 71 | if hookErr != nil { |
| 72 | c.hooks.PostToolUseFailure(ctx, target.Name(), argsJSON, output, hookErr) |
| 73 | } else { |
| 74 | c.hooks.PostToolUse(ctx, target.Name(), argsJSON, output) |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | toolEvent.Output = output |
| 79 | if hookErr != nil { |
| 80 | toolEvent.Err = hookErr.Error() |
| 81 | } |
| 82 | committedMessage := provider.Message{ |
| 83 | ID: agent.NewMessageID(), |
| 84 | Role: provider.RoleTool, LocalOnly: true, |
| 85 | ToolCallID: callID, Name: target.Name(), |
| 86 | Content: output, |
| 87 | } |
| 88 | if emitErr := event.EmitChecked(c.sink, event.Event{Kind: event.ToolResult, Tool: toolEvent, CommittedMessage: &committedMessage}); emitErr != nil { |
| 89 | return string(rawResult), fmt.Errorf("persist app result: %w", emitErr) |
| 90 | } |
| 91 | if c.executor != nil && c.executor.Session() != nil { |
| 92 | c.executor.Session().Add(committedMessage) |
| 93 | } |
| 94 | return string(rawResult), err |
| 95 | } |
| 96 |