Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3da5372
Phase 1 server: carry tool Title/Description on both paths, drop Tool…
cursoragent Jul 13, 2026
11073fe
Phase 1 webapp: plumb tool title/mcp_bare_name/server_origin; add too…
cursoragent Jul 13, 2026
6e1ecd3
Phase 1 tests: drift-guard parity, GetTools title/annotations, tool_i…
cursoragent Jul 13, 2026
0b74618
Phase 1: fix no-undefined lint in tool_identity.test.ts
cursoragent Jul 13, 2026
822945b
Rich tool call rendering (2/3): generic arguments field list + view raw
cursoragent Jul 13, 2026
ff8b2c1
Phase 3: extract ToolCardShell and host View raw on the shell
cursoragent Jul 13, 2026
8501826
Phase 3: renderer registry + rich cards + entity chips
cursoragent Jul 13, 2026
5d51cc1
Phase 3 tests: parsers, entity chips, registry routing, view-raw inhe…
cursoragent Jul 13, 2026
e122ca4
Phase 3 e2e: rich create_post card spec (mock LLM) + shard assignment
cursoragent Jul 13, 2026
6934cbc
Phase 1: restore webapp/package-lock.json to master (no dependency ch…
cursoragent Jul 13, 2026
b5bef92
Phase 2: restore webapp/package-lock.json to master (no dependency ch…
cursoragent Jul 13, 2026
c8eaf4a
Phase 3: restore webapp/package-lock.json to master (no dependency ch…
cursoragent Jul 13, 2026
765acff
Phase 1: table-driven parity test for the toolUseBlocks writer
cursoragent Jul 13, 2026
8b2b906
Phase 1: table-driven parity test for the toolUseBlocks writer (stacked)
cursoragent Jul 13, 2026
ac3412d
Phase 1: table-driven parity test for the toolUseBlocks writer (stacked)
cursoragent Jul 13, 2026
e2d3078
Merge branch 'cursor/rich-tool-call-rendering-f0d9' into cursor/rich-…
cursoragent Jul 13, 2026
6279537
Merge branch 'cursor/rich-tool-call-rendering-phase-2-f0d9' into curs…
cursoragent Jul 13, 2026
62ca6c1
Trim verbose comments; remove phase references
cursoragent Jul 20, 2026
95b8992
Simplify from review: single-parse registry, drop dead code
cursoragent Jul 20, 2026
c341dc8
Address PR review feedback
cursoragent Jul 21, 2026
49734fa
Restyle tool cards to match the card designs
cursoragent Jul 21, 2026
b8dad58
e2e: disambiguate channel-name assertions on the rich create_post card
cursoragent Jul 21, 2026
7961ce8
Align card styling with Mattermost conventions
cursoragent Jul 21, 2026
a61444f
ci: retrigger after unrelated container-startup flake in e2e-shard-2
cursoragent Jul 21, 2026
be53be4
Generic response rendering, post preview card, prune future-use code
cursoragent Jul 22, 2026
901608a
e2e: allow seeded content to match both the post preview and the resp…
cursoragent Jul 22, 2026
dbf98a3
Scope the read_post preview to undecided calls
cursoragent Jul 22, 2026
6fb783c
create_post: preview the post-to-be before approval
cursoragent Jul 22, 2026
9d0fbc9
Make the create_post preview non-interactive
cursoragent Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 32 additions & 8 deletions conversation/content_block.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ type ContentBlock struct {
Status string `json:"status,omitempty"`
Shared *bool `json:"shared,omitempty"` // pointer to distinguish unset from false

// Title is the resolved human-readable tool display name (see
// llm.ToolCall.Title). Persisted so a reloaded conversation renders the
// same tool name that the live websocket event showed. Left visible to
// non-requesters (name-equivalent).
Title string `json:"title,omitempty"`

// Description is the tool description (see llm.ToolCall.Description).
// Plumbing-only in this pass — persisted for live/persisted parity and
// future display, but not rendered anywhere yet. Left visible to
// non-requesters (name-equivalent).
Description string `json:"description,omitempty"`

// UserInteraction is the persisted form of llm.Tool.UserInteraction.
UserInteraction string `json:"user_interaction,omitempty"`

Expand Down Expand Up @@ -105,10 +117,13 @@ func Int64Ptr(v int64) *int64 { return &v }

// FilterForNonRequester returns a new slice of content blocks with private
// tool data redacted. Tool use blocks with shared != true have their Input
// field set to nil. Tool result blocks with shared != true have their Content
// field set to empty string. All other block types pass through unchanged.
// The original slice and its elements are never mutated.
// Returns nil if the input is nil.
// and MCPBareName fields cleared. Tool result blocks with shared != true have
// their Content field set to empty string. Name, Title, ServerOrigin, and
// Description are intentionally left visible to non-requesters (they are
// tool-identity metadata, not user/model data — mirroring redactToolCalls on
// the live path so the two paths render identically). All other block types
// pass through unchanged. The original slice and its elements are never
// mutated. Returns nil if the input is nil.
func FilterForNonRequester(blocks []ContentBlock) []ContentBlock {
if blocks == nil {
return nil
Expand All @@ -133,10 +148,13 @@ func FilterForNonRequester(blocks []ContentBlock) []ContentBlock {
}

// SanitizeForDisplay returns a new slice of content blocks with LLM-generated
// string fields sanitized against Unicode bidi/spoofing attacks. Tool use
// blocks have their Input field sanitized, and tool result blocks have their
// Content field sanitized. The original slice is never mutated.
// Returns nil if the input is nil.
// and MCP-server-supplied string fields sanitized against Unicode bidi/spoofing
// attacks. Tool use blocks have their Input, Title, and Description fields
// sanitized, and tool result blocks have their Content field sanitized. The
// Title/Description sanitization is defense in depth: MCP metadata is already
// sanitized at capture (mcp/user_clients.go GetTools), but old persisted turns
// predate that and this path also covers any non-MCP writer. The original slice
// is never mutated. Returns nil if the input is nil.
func SanitizeForDisplay(blocks []ContentBlock) []ContentBlock {
if blocks == nil {
return nil
Expand All @@ -150,6 +168,12 @@ func SanitizeForDisplay(blocks []ContentBlock) []ContentBlock {
if len(block.Input) > 0 {
result[i].Input = json.RawMessage(llm.SanitizeNonPrintableChars(string(block.Input)))
}
if block.Title != "" {
result[i].Title = llm.SanitizeNonPrintableChars(block.Title)
}
if block.Description != "" {
result[i].Description = llm.SanitizeNonPrintableChars(block.Description)
}
case BlockTypeToolResult:
if block.Content != "" {
result[i].Content = llm.SanitizeNonPrintableChars(block.Content)
Expand Down
64 changes: 64 additions & 0 deletions conversation/content_block_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,22 @@ func TestContentBlockMarshalUnmarshal(t *testing.T) {
},
expected: `{"type":"tool_use","id":"tc_02","name":"read_file","input":{"path":"/etc/passwd"},"status":"pending","shared":false}`,
},
{
name: "tool_use block with title/description/mcp_bare_name",
block: ContentBlock{
Type: BlockTypeToolUse,
ID: "tc_03",
Name: "mattermost__create_post",
ServerOrigin: "embedded://mattermost",
MCPBareName: "create_post",
Title: "Create Post",
Description: "Create a new post in Mattermost.",
Input: json.RawMessage(`{"channel_id":"c1"}`),
Status: StatusPending,
Shared: BoolPtr(true),
},
expected: `{"type":"tool_use","id":"tc_03","name":"mattermost__create_post","server_origin":"embedded://mattermost","input":{"channel_id":"c1"},"mcp_bare_name":"create_post","status":"pending","shared":true,"title":"Create Post","description":"Create a new post in Mattermost."}`,
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -194,6 +210,8 @@ func TestFilterForNonRequesterRedactsApprovalMetadata(t *testing.T) {
Type: BlockTypeToolUse,
ID: "tc_private",
Name: "jira__get_issue",
Title: "Get Issue",
Description: "Get a Jira issue",
Input: json.RawMessage(`{"key":"MM-1"}`),
MCPBareName: "get_issue",
Status: StatusPending,
Expand All @@ -205,6 +223,12 @@ func TestFilterForNonRequesterRedactsApprovalMetadata(t *testing.T) {
require.Len(t, result, 1)
assert.Nil(t, result[0].Input)
assert.Empty(t, result[0].MCPBareName)
// Title/Description are tool-identity metadata (name-equivalent): they stay
// visible to non-requesters, matching redactToolCalls on the live path so
// the call renders identically live and after reload.
assert.Equal(t, "Get Issue", result[0].Title)
assert.Equal(t, "Get a Jira issue", result[0].Description)
assert.Equal(t, "jira__get_issue", result[0].Name)
assert.NotNil(t, blocks[0].Input, "original block must not be mutated")
assert.Equal(t, "get_issue", blocks[0].MCPBareName, "original block must not be mutated")
}
Expand Down Expand Up @@ -331,3 +355,43 @@ func TestFilterForNonRequesterDoesNotMutateOriginal(t *testing.T) {
assert.Equal(t, originalInputCopy, original[0].Input)
assert.Equal(t, originalContentCopy, original[1].Content)
}

func TestSanitizeForDisplaySanitizesTitleAndDescription(t *testing.T) {
// U+202E (right-to-left override) is a classic bidi spoofing character.
blocks := []ContentBlock{{
Type: BlockTypeToolUse,
ID: "tc1",
Name: "jira__get_issue",
Title: "Get\u202eIssue",
Description: "Get\u202ean issue",
Input: json.RawMessage("{\"key\":\"MM\u202e-1\"}"),
Status: StatusPending,
}}

result := SanitizeForDisplay(blocks)

require.Len(t, result, 1)
assert.Equal(t, "Get[U+202E]Issue", result[0].Title)
assert.Equal(t, "Get[U+202E]an issue", result[0].Description)
assert.Contains(t, string(result[0].Input), "[U+202E]")

// Original is not mutated.
assert.Equal(t, "Get\u202eIssue", blocks[0].Title)
assert.Equal(t, "Get\u202ean issue", blocks[0].Description)
}

func TestSanitizeForDisplayLeavesCleanTitleAndDescription(t *testing.T) {
blocks := []ContentBlock{{
Type: BlockTypeToolUse,
ID: "tc1",
Name: "jira__get_issue",
Title: "Get Issue",
Description: "Get a Jira issue",
}}

result := SanitizeForDisplay(blocks)

require.Len(t, result, 1)
assert.Equal(t, "Get Issue", result[0].Title)
assert.Equal(t, "Get a Jira issue", result[0].Description)
}
4 changes: 4 additions & 0 deletions conversation/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ func BlocksToPost(
Arguments: arguments,
MCPBareName: block.MCPBareName,
Status: StatusFromString(block.Status),
Title: block.Title,
Description: block.Description,
}
if redactToolUse {
toolCall.MCPBareName = ""
Expand Down Expand Up @@ -268,6 +270,8 @@ func PostToBlocks(post llm.Post, shared bool) []ContentBlock {
MCPBareName: tc.MCPBareName,
Status: StatusToString(tc.Status),
Shared: BoolPtr(shared),
Title: tc.Title,
Description: tc.Description,
})

if tc.Result != "" {
Expand Down
20 changes: 8 additions & 12 deletions conversation/convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,6 @@ func TestBlocksToPost_RedactUnshared(t *testing.T) {
assert.JSONEq(t, `{}`, args["t-nilshared"])
for _, tc := range got.ToolUse {
if tc.ID == "t-private" {
assert.Nil(t, tc.Schema)
assert.Empty(t, tc.MCPBareName)
assert.Empty(t, tc.Description)
}
Expand All @@ -246,16 +245,11 @@ func TestPostToBlocksPreservesToolIdentityMetadata(t *testing.T) {
ID: "tc1",
Name: "jira__get_issue",
Description: "Get a Jira issue",
Title: "Get Issue",
ServerOrigin: "https://jira.example.com",
Arguments: json.RawMessage(`{"key":"MM-1"}`),
Schema: map[string]any{
"type": "object",
"properties": map[string]any{
"key": map[string]any{"type": "string"},
},
},
MCPBareName: "get_issue",
Status: llm.ToolCallStatusPending,
MCPBareName: "get_issue",
Status: llm.ToolCallStatusPending,
}},
}

Expand All @@ -266,11 +260,13 @@ func TestPostToBlocksPreservesToolIdentityMetadata(t *testing.T) {
assert.Equal(t, "jira__get_issue", blocks[0].Name)
assert.Equal(t, "https://jira.example.com", blocks[0].ServerOrigin)
assert.Equal(t, "get_issue", blocks[0].MCPBareName)
assert.Equal(t, "Get Issue", blocks[0].Title)
assert.Equal(t, "Get a Jira issue", blocks[0].Description)

data, err := json.Marshal(blocks[0])
require.NoError(t, err)
assert.NotContains(t, string(data), "input_schema")
assert.NotContains(t, string(data), "tool_description")
assert.NotContains(t, string(data), "\"schema\"")
}

func TestBlocksToPostRehydratesToolCatalogMetadata(t *testing.T) {
Expand All @@ -290,6 +286,7 @@ func TestBlocksToPostRehydratesToolCatalogMetadata(t *testing.T) {
toolStore.AddTools([]llm.Tool{{
Name: "jira__get_issue",
Description: "Get a Jira issue",
Title: "Get Issue",
Schema: schema,
ServerOrigin: "https://jira.example.com",
}})
Expand All @@ -303,8 +300,7 @@ func TestBlocksToPostRehydratesToolCatalogMetadata(t *testing.T) {
assert.Equal(t, "https://jira.example.com", toolCall.ServerOrigin)
assert.Equal(t, "get_issue", toolCall.MCPBareName)
assert.Equal(t, "Get a Jira issue", toolCall.Description)
require.IsType(t, json.RawMessage{}, toolCall.Schema)
assert.JSONEq(t, `{"type":"object","properties":{"key":{"type":"string"}}}`, string(toolCall.Schema.(json.RawMessage)))
assert.Equal(t, "Get Issue", toolCall.Title)
}

func TestPostToBlocks(t *testing.T) {
Expand Down
2 changes: 2 additions & 0 deletions conversation/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ func toolUseBlocks(
Status: StatusToString(tc.Status),
Shared: BoolPtr(shared),
UserInteraction: tc.UserInteraction,
Title: tc.Title,
Description: tc.Description,
})
}

Expand Down
4 changes: 3 additions & 1 deletion conversation/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,9 @@ func TestToolUseBlocksPreservesApprovalMetadata(t *testing.T) {
ID: "tc1",
Name: "jira__get_issue",
Description: "Get a Jira issue",
Title: "Get Issue",
ServerOrigin: "https://jira.example.com",
Arguments: json.RawMessage(`{"key":"MM-1"}`),
Schema: json.RawMessage(`{"type":"object"}`),
MCPBareName: "get_issue",
Status: llm.ToolCallStatusPending,
}}, false)
Expand All @@ -77,6 +77,8 @@ func TestToolUseBlocksPreservesApprovalMetadata(t *testing.T) {
assert.Equal(t, "jira__get_issue", blocks[0].Name)
assert.Equal(t, "https://jira.example.com", blocks[0].ServerOrigin)
assert.Equal(t, "get_issue", blocks[0].MCPBareName)
assert.Equal(t, "Get Issue", blocks[0].Title)
assert.Equal(t, "Get a Jira issue", blocks[0].Description)
}

func TestUnmarshalBlocks(t *testing.T) {
Expand Down
48 changes: 44 additions & 4 deletions llm/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ type Tool struct {
Schema any
Resolver ToolResolver

// Title is an optional human-readable display name supplied by an MCP
// server (see the MCP spec's display-name precedence: title >
// annotations.title > name). Empty for built-in tools and for MCP tools
// that do not declare one. Captured from MCP metadata in
// mcp/user_clients.go GetTools and Unicode-sanitized at capture.
Title string

// Annotations carries optional MCP tool annotations. Captured from MCP
// metadata but not consumed yet: the safety hints are display-only trust
// signals that are intentionally not surfaced in this pass (the MCP spec
// warns clients not to trust annotations from untrusted servers). Nil for
// built-in tools and MCP tools that declare none.
Annotations *ToolAnnotations

// ServerOrigin identifies the MCP server this tool came from (the BaseURL).
// Empty for built-in (non-MCP) tools. Used for auto-approval decisions.
ServerOrigin string
Expand All @@ -50,6 +64,21 @@ type Tool struct {
CallMetadata map[string]any
}

// ToolAnnotations mirrors the subset of the MCP SDK's mcp.ToolAnnotations that
// this plugin captures. All fields are hints supplied by the MCP server and are
// NOT trusted for security decisions (per the MCP spec). Title participates in
// display-name resolution; the safety hints are captured for future use but not
// displayed in this pass.
type ToolAnnotations struct {
// Title is a human-readable display name (lower precedence than Tool.Title).
Title string
// ReadOnlyHint indicates the tool does not modify its environment.
ReadOnlyHint bool
// DestructiveHint indicates the tool may perform destructive updates.
// A nil pointer means the server did not declare the hint.
DestructiveHint *bool
}

// UserInteractionSelect identifies tools answered by the user picking from a
// set of options presented in the Mattermost UI.
const UserInteractionSelect = "select"
Expand Down Expand Up @@ -244,11 +273,18 @@ type ToolCall struct {
Name string `json:"name"`
Description string `json:"description"`
Arguments json.RawMessage `json:"arguments"`
Schema any `json:"schema,omitempty"`
Result string `json:"result"`
Status ToolCallStatus `json:"status"`
MCPBareName string `json:"mcp_bare_name,omitempty"`

// Title is the resolved human-readable display name for MCP tools that
// declare one (precedence: Tool.Title > Tool.Annotations.Title >
// prettified bare name, resolved at capture). Empty for built-in tools and
// MCP tools without a declared title; the webapp then prettifies the bare
// name. Carried on both the live (websocket) and persisted paths, and left
// visible to non-requesters (name-equivalent).
Title string `json:"title,omitempty"`

// UserInteraction mirrors Tool.UserInteraction so the webapp can render
// the matching interaction UI (e.g. a question card) for pending calls.
UserInteraction string `json:"user_interaction,omitempty"`
Expand Down Expand Up @@ -437,9 +473,11 @@ type EnrichToolCallOptions struct {
BareNameFallback bool
}

// EnrichToolCall fills a tool call's Description, Schema, ServerOrigin, and
// EnrichToolCall fills a tool call's Description, Title, ServerOrigin, and
// MCPBareName from the resolved store entry. MCPBareName is only set for MCP
// tools (those with a server origin); builtins are left untouched.
// tools (those with a server origin); builtins are left untouched. Title and
// Description follow the same overwrite semantics: rehydration trusts the store
// (OverwriteDescription), approval preserves any value already present.
func EnrichToolCall(tc *ToolCall, store *ToolStore, opts EnrichToolCallOptions) {
if tc == nil || store == nil {
return
Expand All @@ -455,7 +493,9 @@ func EnrichToolCall(tc *ToolCall, store *ToolStore, opts EnrichToolCallOptions)
if opts.OverwriteDescription || tc.Description == "" {
tc.Description = tool.Description
}
tc.Schema = tool.Schema
if opts.OverwriteDescription || tc.Title == "" {
tc.Title = tool.Title
}
tc.UserInteraction = tool.UserInteraction
if tc.ServerOrigin == "" {
tc.ServerOrigin = lookup.ServerOrigin
Expand Down
Loading
Loading