From bd5b62c97645c00597bed343dc512e4915f07312 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 18 Mar 2026 10:21:45 +0000 Subject: [PATCH] feat: expose MCP tool annotations for tool grouping (#23195) ## Summary - add shared MCP annotation metadata to toolsdk tools - emit MCP tool annotations from both coderd and CLI MCP servers - cover annotation serialization in toolsdk, coderd MCP e2e, and CLI MCP tests ## Why - Coder already exposed MCP tools, but it did not populate MCP tool annotation hints (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`). - Hosts such as Claude Desktop use those hints to classify and group tools, so without them Coder tools can get lumped together. - This change adds a shared annotation source in `toolsdk` and has both MCP servers emit those hints through `mcp.Tool.Annotations`, avoiding drift between local and remote MCP implementations. ## Testing - Tested locally on Cladue Desktop and the tools are categorized correctly.
Before After
image image
Note: Done using Coder Agents, reviewed and tested by human locally --- cli/exp_mcp.go | 6 +++ cli/exp_mcp_test.go | 17 +++++++- coderd/mcp/mcp.go | 6 +++ coderd/mcp/mcp_e2e_test.go | 38 +++++++++++++----- codersdk/toolsdk/bash.go | 1 + codersdk/toolsdk/chatgpt.go | 2 + codersdk/toolsdk/toolsdk.go | 68 +++++++++++++++++++++++++++++++ codersdk/toolsdk/toolsdk_test.go | 69 ++++++++++++++++++++++++++++++++ 8 files changed, 197 insertions(+), 10 deletions(-) diff --git a/cli/exp_mcp.go b/cli/exp_mcp.go index 41a5bea255..f0013afb52 100644 --- a/cli/exp_mcp.go +++ b/cli/exp_mcp.go @@ -1000,6 +1000,12 @@ func mcpFromSDK(sdkTool toolsdk.GenericTool, tb toolsdk.Deps) server.ServerTool Properties: sdkTool.Schema.Properties, Required: sdkTool.Schema.Required, }, + Annotations: mcp.ToolAnnotation{ + ReadOnlyHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.ReadOnlyHint), + DestructiveHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.DestructiveHint), + IdempotentHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.IdempotentHint), + OpenWorldHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.OpenWorldHint), + }, }, Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { var buf bytes.Buffer diff --git a/cli/exp_mcp_test.go b/cli/exp_mcp_test.go index 3c94816a59..50b7ff1372 100644 --- a/cli/exp_mcp_test.go +++ b/cli/exp_mcp_test.go @@ -81,7 +81,13 @@ func TestExpMcpServer(t *testing.T) { var toolsResponse struct { Result struct { Tools []struct { - Name string `json:"name"` + Name string `json:"name"` + Annotations struct { + ReadOnlyHint *bool `json:"readOnlyHint"` + DestructiveHint *bool `json:"destructiveHint"` + IdempotentHint *bool `json:"idempotentHint"` + OpenWorldHint *bool `json:"openWorldHint"` + } `json:"annotations"` } `json:"tools"` } `json:"result"` } @@ -94,6 +100,15 @@ func TestExpMcpServer(t *testing.T) { } slices.Sort(foundTools) require.Equal(t, []string{"coder_get_authenticated_user"}, foundTools) + annotations := toolsResponse.Result.Tools[0].Annotations + require.NotNil(t, annotations.ReadOnlyHint) + require.NotNil(t, annotations.DestructiveHint) + require.NotNil(t, annotations.IdempotentHint) + require.NotNil(t, annotations.OpenWorldHint) + assert.True(t, *annotations.ReadOnlyHint) + assert.False(t, *annotations.DestructiveHint) + assert.True(t, *annotations.IdempotentHint) + assert.False(t, *annotations.OpenWorldHint) // Call the tool and ensure it works. toolPayload := `{"jsonrpc":"2.0","id":3,"method":"tools/call", "params": {"name": "coder_get_authenticated_user", "arguments": {}}}` diff --git a/coderd/mcp/mcp.go b/coderd/mcp/mcp.go index 1400b1e61b..3ce17867c4 100644 --- a/coderd/mcp/mcp.go +++ b/coderd/mcp/mcp.go @@ -136,6 +136,12 @@ func mcpFromSDK(sdkTool toolsdk.GenericTool, tb toolsdk.Deps) server.ServerTool Properties: sdkTool.Schema.Properties, Required: sdkTool.Schema.Required, }, + Annotations: mcp.ToolAnnotation{ + ReadOnlyHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.ReadOnlyHint), + DestructiveHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.DestructiveHint), + IdempotentHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.IdempotentHint), + OpenWorldHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.OpenWorldHint), + }, }, Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { var buf bytes.Buffer diff --git a/coderd/mcp/mcp_e2e_test.go b/coderd/mcp/mcp_e2e_test.go index c1386363cc..b713fd8155 100644 --- a/coderd/mcp/mcp_e2e_test.go +++ b/coderd/mcp/mcp_e2e_test.go @@ -91,21 +91,41 @@ func TestMCPHTTP_E2E_ClientIntegration(t *testing.T) { // Verify we have some expected Coder tools var foundTools []string - for _, tool := range tools.Tools { + var userTool *mcp.Tool + var writeFileTool *mcp.Tool + for i := range tools.Tools { + tool := tools.Tools[i] foundTools = append(foundTools, tool.Name) + switch tool.Name { + case toolsdk.ToolNameGetAuthenticatedUser: + userTool = &tools.Tools[i] + case toolsdk.ToolNameWorkspaceWriteFile: + writeFileTool = &tools.Tools[i] + } } // Check for some basic tools that should be available assert.Contains(t, foundTools, toolsdk.ToolNameGetAuthenticatedUser, "Should have authenticated user tool") + require.NotNil(t, userTool) + require.NotNil(t, writeFileTool) + require.NotNil(t, userTool.Annotations.ReadOnlyHint) + require.NotNil(t, userTool.Annotations.DestructiveHint) + require.NotNil(t, userTool.Annotations.IdempotentHint) + require.NotNil(t, userTool.Annotations.OpenWorldHint) + assert.True(t, *userTool.Annotations.ReadOnlyHint) + assert.False(t, *userTool.Annotations.DestructiveHint) + assert.True(t, *userTool.Annotations.IdempotentHint) + assert.False(t, *userTool.Annotations.OpenWorldHint) + require.NotNil(t, writeFileTool.Annotations.ReadOnlyHint) + require.NotNil(t, writeFileTool.Annotations.DestructiveHint) + require.NotNil(t, writeFileTool.Annotations.IdempotentHint) + require.NotNil(t, writeFileTool.Annotations.OpenWorldHint) + assert.False(t, *writeFileTool.Annotations.ReadOnlyHint) + assert.True(t, *writeFileTool.Annotations.DestructiveHint) + assert.False(t, *writeFileTool.Annotations.IdempotentHint) + assert.False(t, *writeFileTool.Annotations.OpenWorldHint) - // Find and execute the authenticated user tool - var userTool *mcp.Tool - for _, tool := range tools.Tools { - if tool.Name == toolsdk.ToolNameGetAuthenticatedUser { - userTool = &tool - break - } - } + // Execute the authenticated user tool. require.NotNil(t, userTool, "Expected to find "+toolsdk.ToolNameGetAuthenticatedUser+" tool") // Execute the tool diff --git a/codersdk/toolsdk/bash.go b/codersdk/toolsdk/bash.go index 4248d20a0e..78a102fbc1 100644 --- a/codersdk/toolsdk/bash.go +++ b/codersdk/toolsdk/bash.go @@ -89,6 +89,7 @@ Examples: Required: []string{"workspace", "command"}, }, }, + MCPAnnotations: mcpDestructiveAnnotations, Handler: func(ctx context.Context, deps Deps, args WorkspaceBashArgs) (res WorkspaceBashResult, err error) { if args.Workspace == "" { return WorkspaceBashResult{}, xerrors.New("workspace name cannot be empty") diff --git a/codersdk/toolsdk/chatgpt.go b/codersdk/toolsdk/chatgpt.go index 119715fdfd..4761bb7b1f 100644 --- a/codersdk/toolsdk/chatgpt.go +++ b/codersdk/toolsdk/chatgpt.go @@ -299,6 +299,7 @@ List workspaces with multiple filters - running workspaces owned by "alice". Required: []string{"query"}, }, }, + MCPAnnotations: mcpReadOnlyAnnotations, Handler: func(ctx context.Context, deps Deps, args SearchArgs) (SearchResult, error) { query, err := parseSearchQuery(args.Query) if err != nil { @@ -419,6 +420,7 @@ var ChatGPTFetch = Tool[FetchArgs, FetchResult]{ Required: []string{"id"}, }, }, + MCPAnnotations: mcpReadOnlyAnnotations, Handler: func(ctx context.Context, deps Deps, args FetchArgs) (FetchResult, error) { objectID, err := parseObjectID(args.ID) if err != nil { diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index 9d78d45fa5..75b8df27b3 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -97,12 +97,47 @@ type Tool[Arg, Ret any] struct { aisdk.Tool Handler HandlerFunc[Arg, Ret] + // MCPAnnotations is the shared source of truth for MCP tool + // classification. Both the coderd-hosted MCP server and the CLI MCP + // server translate these hints into mcp.Tool.Annotations so hosts can + // consistently group tools. + MCPAnnotations MCPToolAnnotations + // UserClientOptional indicates whether this tool can function without a valid // user authentication token. If true, the tool will be available even when // running in an unauthenticated mode with just an agent token. UserClientOptional bool } +// MCPToolAnnotations describes how an MCP host should classify a tool. +type MCPToolAnnotations struct { + ReadOnlyHint bool + DestructiveHint bool + IdempotentHint bool + OpenWorldHint bool +} + +var ( + mcpReadOnlyAnnotations = MCPToolAnnotations{ + ReadOnlyHint: true, + DestructiveHint: false, + IdempotentHint: true, + OpenWorldHint: false, + } + mcpMutationAnnotations = MCPToolAnnotations{ + ReadOnlyHint: false, + DestructiveHint: false, + IdempotentHint: false, + OpenWorldHint: false, + } + mcpDestructiveAnnotations = MCPToolAnnotations{ + ReadOnlyHint: false, + DestructiveHint: true, + IdempotentHint: false, + OpenWorldHint: false, + } +) + // Generic returns a type-erased version of a TypedTool where the arguments and // return values are converted to/from json.RawMessage. // This allows the tool to be referenced without knowing the concrete arguments @@ -111,6 +146,7 @@ type Tool[Arg, Ret any] struct { func (t Tool[Arg, Ret]) Generic() GenericTool { return GenericTool{ Tool: t.Tool, + MCPAnnotations: t.MCPAnnotations, UserClientOptional: t.UserClientOptional, Handler: wrap(func(ctx context.Context, deps Deps, args json.RawMessage) (json.RawMessage, error) { var typedArgs Arg @@ -134,6 +170,9 @@ type GenericTool struct { aisdk.Tool Handler GenericHandlerFunc + // MCPAnnotations are host hints used when this tool is exposed over MCP. + MCPAnnotations MCPToolAnnotations + // UserClientOptional indicates whether this tool can function without a valid // user authentication token. If true, the tool will be available even when // running in an unauthenticated mode with just an agent token. @@ -291,6 +330,7 @@ ONLY report a "complete", "idle", or "failure" state if you have FULLY completed Required: []string{"summary", "link", "state"}, }, }, + MCPAnnotations: mcpMutationAnnotations, UserClientOptional: true, Handler: func(_ context.Context, deps Deps, args ReportTaskArgs) (codersdk.Response, error) { if len(args.Summary) > 160 { @@ -330,6 +370,7 @@ This returns more data than list_workspaces to reduce token usage.`, Required: []string{"workspace_id"}, }, }, + MCPAnnotations: mcpReadOnlyAnnotations, Handler: func(ctx context.Context, deps Deps, args GetWorkspaceArgs) (codersdk.Workspace, error) { wsID, err := uuid.Parse(args.WorkspaceID) if err != nil { @@ -389,6 +430,7 @@ be ready before trying to use or connect to the workspace. Required: []string{"user", "template_version_id", "name", "rich_parameters"}, }, }, + MCPAnnotations: mcpMutationAnnotations, Handler: func(ctx context.Context, deps Deps, args CreateWorkspaceArgs) (codersdk.Workspace, error) { tvID, err := uuid.Parse(args.TemplateVersionID) if err != nil { @@ -434,6 +476,7 @@ var ListWorkspaces = Tool[ListWorkspacesArgs, []MinimalWorkspace]{ Required: []string{}, }, }, + MCPAnnotations: mcpReadOnlyAnnotations, Handler: func(ctx context.Context, deps Deps, args ListWorkspacesArgs) ([]MinimalWorkspace, error) { owner := args.Owner if owner == "" { @@ -471,6 +514,7 @@ var ListTemplates = Tool[NoArgs, []MinimalTemplate]{ Required: []string{}, }, }, + MCPAnnotations: mcpReadOnlyAnnotations, Handler: func(ctx context.Context, deps Deps, _ NoArgs) ([]MinimalTemplate, error) { templates, err := deps.coderClient.Templates(ctx, codersdk.TemplateFilter{}) if err != nil { @@ -508,6 +552,7 @@ var ListTemplateVersionParameters = Tool[ListTemplateVersionParametersArgs, []co Required: []string{"template_version_id"}, }, }, + MCPAnnotations: mcpReadOnlyAnnotations, Handler: func(ctx context.Context, deps Deps, args ListTemplateVersionParametersArgs) ([]codersdk.TemplateVersionParameter, error) { templateVersionID, err := uuid.Parse(args.TemplateVersionID) if err != nil { @@ -530,6 +575,7 @@ var GetAuthenticatedUser = Tool[NoArgs, codersdk.User]{ Required: []string{}, }, }, + MCPAnnotations: mcpReadOnlyAnnotations, Handler: func(ctx context.Context, deps Deps, _ NoArgs) (codersdk.User, error) { return deps.coderClient.User(ctx, "me") }, @@ -568,6 +614,7 @@ connect to the workspace. Required: []string{"workspace_id", "transition"}, }, }, + MCPAnnotations: mcpDestructiveAnnotations, Handler: func(ctx context.Context, deps Deps, args CreateWorkspaceBuildArgs) (codersdk.WorkspaceBuild, error) { workspaceID, err := uuid.Parse(args.WorkspaceID) if err != nil { @@ -1061,6 +1108,7 @@ The file_id provided is a reference to a tar file you have uploaded containing t Required: []string{"file_id"}, }, }, + MCPAnnotations: mcpMutationAnnotations, Handler: func(ctx context.Context, deps Deps, args CreateTemplateVersionArgs) (codersdk.TemplateVersion, error) { me, err := deps.coderClient.User(ctx, "me") if err != nil { @@ -1111,6 +1159,7 @@ var GetWorkspaceAgentLogs = Tool[GetWorkspaceAgentLogsArgs, []string]{ Required: []string{"workspace_agent_id"}, }, }, + MCPAnnotations: mcpReadOnlyAnnotations, Handler: func(ctx context.Context, deps Deps, args GetWorkspaceAgentLogsArgs) ([]string, error) { workspaceAgentID, err := uuid.Parse(args.WorkspaceAgentID) if err != nil { @@ -1150,6 +1199,7 @@ var GetWorkspaceBuildLogs = Tool[GetWorkspaceBuildLogsArgs, []string]{ Required: []string{"workspace_build_id"}, }, }, + MCPAnnotations: mcpReadOnlyAnnotations, Handler: func(ctx context.Context, deps Deps, args GetWorkspaceBuildLogsArgs) ([]string, error) { workspaceBuildID, err := uuid.Parse(args.WorkspaceBuildID) if err != nil { @@ -1185,6 +1235,7 @@ var GetTemplateVersionLogs = Tool[GetTemplateVersionLogsArgs, []string]{ Required: []string{"template_version_id"}, }, }, + MCPAnnotations: mcpReadOnlyAnnotations, Handler: func(ctx context.Context, deps Deps, args GetTemplateVersionLogsArgs) ([]string, error) { templateVersionID, err := uuid.Parse(args.TemplateVersionID) if err != nil { @@ -1225,6 +1276,7 @@ var UpdateTemplateActiveVersion = Tool[UpdateTemplateActiveVersionArgs, string]{ Required: []string{"template_id", "template_version_id"}, }, }, + MCPAnnotations: mcpMutationAnnotations, Handler: func(ctx context.Context, deps Deps, args UpdateTemplateActiveVersionArgs) (string, error) { templateID, err := uuid.Parse(args.TemplateID) if err != nil { @@ -1262,6 +1314,7 @@ var UploadTarFile = Tool[UploadTarFileArgs, codersdk.UploadResponse]{ Required: []string{"files"}, }, }, + MCPAnnotations: mcpMutationAnnotations, Handler: func(ctx context.Context, deps Deps, args UploadTarFileArgs) (codersdk.UploadResponse, error) { pipeReader, pipeWriter := io.Pipe() done := make(chan struct{}) @@ -1337,6 +1390,7 @@ var CreateTemplate = Tool[CreateTemplateArgs, codersdk.Template]{ Required: []string{"name", "display_name", "description", "version_id"}, }, }, + MCPAnnotations: mcpMutationAnnotations, Handler: func(ctx context.Context, deps Deps, args CreateTemplateArgs) (codersdk.Template, error) { me, err := deps.coderClient.User(ctx, "me") if err != nil { @@ -1376,6 +1430,7 @@ var DeleteTemplate = Tool[DeleteTemplateArgs, codersdk.Response]{ Required: []string{"template_id"}, }, }, + MCPAnnotations: mcpDestructiveAnnotations, Handler: func(ctx context.Context, deps Deps, args DeleteTemplateArgs) (codersdk.Response, error) { templateID, err := uuid.Parse(args.TemplateID) if err != nil { @@ -1443,6 +1498,7 @@ var WorkspaceLS = Tool[WorkspaceLSArgs, WorkspaceLSResponse]{ Required: []string{"path", "workspace"}, }, }, + MCPAnnotations: mcpReadOnlyAnnotations, UserClientOptional: true, Handler: func(ctx context.Context, deps Deps, args WorkspaceLSArgs) (WorkspaceLSResponse, error) { conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace) @@ -1508,6 +1564,7 @@ var WorkspaceReadFile = Tool[WorkspaceReadFileArgs, WorkspaceReadFileResponse]{ Required: []string{"path", "workspace"}, }, }, + MCPAnnotations: mcpReadOnlyAnnotations, UserClientOptional: true, Handler: func(ctx context.Context, deps Deps, args WorkspaceReadFileArgs) (WorkspaceReadFileResponse, error) { conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace) @@ -1581,6 +1638,7 @@ content you are trying to write, then re-encode it properly. Required: []string{"path", "workspace", "content"}, }, }, + MCPAnnotations: mcpDestructiveAnnotations, UserClientOptional: true, Handler: func(ctx context.Context, deps Deps, args WorkspaceWriteFileArgs) (codersdk.Response, error) { conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace) @@ -1643,6 +1701,7 @@ var WorkspaceEditFile = Tool[WorkspaceEditFileArgs, codersdk.Response]{ Required: []string{"path", "workspace", "edits"}, }, }, + MCPAnnotations: mcpDestructiveAnnotations, UserClientOptional: true, Handler: func(ctx context.Context, deps Deps, args WorkspaceEditFileArgs) (codersdk.Response, error) { conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace) @@ -1724,6 +1783,7 @@ var WorkspaceEditFiles = Tool[WorkspaceEditFilesArgs, codersdk.Response]{ Required: []string{"workspace", "files"}, }, }, + MCPAnnotations: mcpDestructiveAnnotations, UserClientOptional: true, Handler: func(ctx context.Context, deps Deps, args WorkspaceEditFilesArgs) (codersdk.Response, error) { conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace) @@ -1770,6 +1830,7 @@ var WorkspacePortForward = Tool[WorkspacePortForwardArgs, WorkspacePortForwardRe Required: []string{"workspace", "port"}, }, }, + MCPAnnotations: mcpReadOnlyAnnotations, UserClientOptional: true, Handler: func(ctx context.Context, deps Deps, args WorkspacePortForwardArgs) (WorkspacePortForwardResponse, error) { workspaceName := NormalizeWorkspaceInput(args.Workspace) @@ -1823,6 +1884,7 @@ var WorkspaceListApps = Tool[WorkspaceListAppsArgs, WorkspaceListAppsResponse]{ Required: []string{"workspace"}, }, }, + MCPAnnotations: mcpReadOnlyAnnotations, UserClientOptional: true, Handler: func(ctx context.Context, deps Deps, args WorkspaceListAppsArgs) (WorkspaceListAppsResponse, error) { workspaceName := NormalizeWorkspaceInput(args.Workspace) @@ -1880,6 +1942,7 @@ var CreateTask = Tool[CreateTaskArgs, codersdk.Task]{ Required: []string{"input", "template_version_id"}, }, }, + MCPAnnotations: mcpMutationAnnotations, UserClientOptional: true, Handler: func(ctx context.Context, deps Deps, args CreateTaskArgs) (codersdk.Task, error) { if args.Input == "" { @@ -1934,6 +1997,7 @@ var DeleteTask = Tool[DeleteTaskArgs, codersdk.Response]{ Required: []string{"task_id"}, }, }, + MCPAnnotations: mcpDestructiveAnnotations, UserClientOptional: true, Handler: func(ctx context.Context, deps Deps, args DeleteTaskArgs) (codersdk.Response, error) { if args.TaskID == "" { @@ -1983,6 +2047,7 @@ var ListTasks = Tool[ListTasksArgs, ListTasksResponse]{ Required: []string{}, }, }, + MCPAnnotations: mcpReadOnlyAnnotations, UserClientOptional: true, Handler: func(ctx context.Context, deps Deps, args ListTasksArgs) (ListTasksResponse, error) { if args.User == "" { @@ -2026,6 +2091,7 @@ var GetTaskStatus = Tool[GetTaskStatusArgs, GetTaskStatusResponse]{ Required: []string{"task_id"}, }, }, + MCPAnnotations: mcpReadOnlyAnnotations, UserClientOptional: true, Handler: func(ctx context.Context, deps Deps, args GetTaskStatusArgs) (GetTaskStatusResponse, error) { if args.TaskID == "" { @@ -2067,6 +2133,7 @@ var SendTaskInput = Tool[SendTaskInputArgs, codersdk.Response]{ Required: []string{"task_id", "input"}, }, }, + MCPAnnotations: mcpMutationAnnotations, UserClientOptional: true, Handler: func(ctx context.Context, deps Deps, args SendTaskInputArgs) (codersdk.Response, error) { if args.TaskID == "" { @@ -2113,6 +2180,7 @@ var GetTaskLogs = Tool[GetTaskLogsArgs, codersdk.TaskLogsResponse]{ Required: []string{"task_id"}, }, }, + MCPAnnotations: mcpReadOnlyAnnotations, UserClientOptional: true, Handler: func(ctx context.Context, deps Deps, args GetTaskLogsArgs) (codersdk.TaskLogsResponse, error) { if args.TaskID == "" { diff --git a/codersdk/toolsdk/toolsdk_test.go b/codersdk/toolsdk/toolsdk_test.go index df20276998..ec5567c4b2 100644 --- a/codersdk/toolsdk/toolsdk_test.go +++ b/codersdk/toolsdk/toolsdk_test.go @@ -61,6 +61,75 @@ func setupWorkspaceForAgent(t *testing.T, opts *coderdtest.Options) (*codersdk.C return userClient, r.Workspace, r.AgentToken } +// These tests are dependent on the state of the coder server. +// Running them in parallel is prone to racy behavior. +// nolint:tparallel,paralleltest +func TestGenericToolMCPAnnotations(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + toolName string + readOnlyHint bool + destructiveHint bool + idempotentHint bool + openWorldHint bool + }{ + { + name: "ReadOnlyTool", + toolName: toolsdk.ToolNameGetAuthenticatedUser, + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + { + name: "DestructiveTool", + toolName: toolsdk.ToolNameWorkspaceWriteFile, + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, + { + name: "MutatingTool", + toolName: toolsdk.ToolNameCreateWorkspace, + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + { + name: "PortForwardIsReadOnly", + toolName: toolsdk.ToolNameWorkspacePortForward, + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var found *toolsdk.GenericTool + for i := range toolsdk.All { + if toolsdk.All[i].Name == tc.toolName { + found = &toolsdk.All[i] + break + } + } + require.NotNil(t, found) + assert.Equal(t, tc.readOnlyHint, found.MCPAnnotations.ReadOnlyHint) + assert.Equal(t, tc.destructiveHint, found.MCPAnnotations.DestructiveHint) + assert.Equal(t, tc.idempotentHint, found.MCPAnnotations.IdempotentHint) + assert.Equal(t, tc.openWorldHint, found.MCPAnnotations.OpenWorldHint) + }) + } +} + // These tests are dependent on the state of the coder server. // Running them in parallel is prone to racy behavior. // nolint:tparallel,paralleltest