From 94c76b97bd7f1dab0b5ea485e23cc5c5beaa9b99 Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 1 Oct 2025 13:57:11 -0800 Subject: [PATCH] feat: add list_apps MCP tool (#19952) --- codersdk/toolsdk/toolsdk.go | 53 ++++++++++++ codersdk/toolsdk/toolsdk_test.go | 140 +++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index 63e17c2f03..c8228e5104 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -50,6 +50,7 @@ const ( ToolNameWorkspaceEditFile = "coder_workspace_edit_file" ToolNameWorkspaceEditFiles = "coder_workspace_edit_files" ToolNameWorkspacePortForward = "coder_workspace_port_forward" + ToolNameWorkspaceListApps = "coder_workspace_list_apps" ToolNameCreateTask = "coder_create_task" ToolNameDeleteTask = "coder_delete_task" ToolNameListTasks = "coder_list_tasks" @@ -227,6 +228,7 @@ var All = []GenericTool{ WorkspaceEditFile.Generic(), WorkspaceEditFiles.Generic(), WorkspacePortForward.Generic(), + WorkspaceListApps.Generic(), CreateTask.Generic(), DeleteTask.Generic(), ListTasks.Generic(), @@ -1756,6 +1758,57 @@ var WorkspacePortForward = Tool[WorkspacePortForwardArgs, WorkspacePortForwardRe }, } +type WorkspaceListAppsArgs struct { + Workspace string `json:"workspace"` +} + +type WorkspaceListApp struct { + Name string `json:"name"` + URL string `json:"url"` +} + +type WorkspaceListAppsResponse struct { + Apps []WorkspaceListApp `json:"apps"` +} + +var WorkspaceListApps = Tool[WorkspaceListAppsArgs, WorkspaceListAppsResponse]{ + Tool: aisdk.Tool{ + Name: ToolNameWorkspaceListApps, + Description: `List the URLs of Coder apps running in a workspace for a single agent.`, + Schema: aisdk.Schema{ + Properties: map[string]any{ + "workspace": map[string]any{ + "type": "string", + "description": workspaceDescription, + }, + }, + Required: []string{"workspace"}, + }, + }, + UserClientOptional: true, + Handler: func(ctx context.Context, deps Deps, args WorkspaceListAppsArgs) (WorkspaceListAppsResponse, error) { + workspaceName := NormalizeWorkspaceInput(args.Workspace) + _, workspaceAgent, err := findWorkspaceAndAgent(ctx, deps.coderClient, workspaceName) + if err != nil { + return WorkspaceListAppsResponse{}, xerrors.Errorf("failed to find workspace: %w", err) + } + + var res WorkspaceListAppsResponse + for _, app := range workspaceAgent.Apps { + name := app.DisplayName + if name == "" { + name = app.Slug + } + res.Apps = append(res.Apps, WorkspaceListApp{ + Name: name, + URL: app.URL, + }) + } + + return res, nil + }, +} + type CreateTaskArgs struct { Input string `json:"input"` TemplateVersionID string `json:"template_version_id"` diff --git a/codersdk/toolsdk/toolsdk_test.go b/codersdk/toolsdk/toolsdk_test.go index 8e0cc09fff..1bc17fabb3 100644 --- a/codersdk/toolsdk/toolsdk_test.go +++ b/codersdk/toolsdk/toolsdk_test.go @@ -1147,6 +1147,146 @@ func TestTools(t *testing.T) { }) } }) + + t.Run("WorkspaceListApps", func(t *testing.T) { + t.Parallel() + + // nolint:gocritic // This is in a test package and does not end up in the build + _ = dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + Name: "list-app-workspace-one-agent", + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + }).WithAgent(func(agents []*proto.Agent) []*proto.Agent { + agents[0].Apps = []*proto.App{ + { + Slug: "zero", + Url: "http://zero.dev.coder.com", + }, + } + return agents + }).Do() + + // nolint:gocritic // This is in a test package and does not end up in the build + _ = dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + Name: "list-app-workspace-multi-agent", + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + }).WithAgent(func(agents []*proto.Agent) []*proto.Agent { + agents[0].Apps = []*proto.App{ + { + Slug: "one", + Url: "http://one.dev.coder.com", + }, + { + Slug: "two", + Url: "http://two.dev.coder.com", + }, + { + Slug: "three", + Url: "http://three.dev.coder.com", + }, + } + agents = append(agents, &proto.Agent{ + Id: uuid.NewString(), + Name: "dev2", + Auth: &proto.Agent_Token{ + Token: uuid.NewString(), + }, + Env: map[string]string{}, + Apps: []*proto.App{ + { + Slug: "four", + Url: "http://four.dev.coder.com", + }, + }, + }) + return agents + }).Do() + + tests := []struct { + name string + args toolsdk.WorkspaceListAppsArgs + expected []toolsdk.WorkspaceListApp + error string + }{ + { + name: "NonExistentWorkspace", + args: toolsdk.WorkspaceListAppsArgs{ + Workspace: "list-appp-workspace-does-not-exist", + }, + error: "failed to find workspace", + }, + { + name: "OneAgentOneApp", + args: toolsdk.WorkspaceListAppsArgs{ + Workspace: "list-app-workspace-one-agent", + }, + expected: []toolsdk.WorkspaceListApp{ + { + Name: "zero", + URL: "http://zero.dev.coder.com", + }, + }, + }, + { + name: "MultiAgent", + args: toolsdk.WorkspaceListAppsArgs{ + Workspace: "list-app-workspace-multi-agent", + }, + error: "multiple agents found, please specify the agent name", + }, + { + name: "MultiAgentOneApp", + args: toolsdk.WorkspaceListAppsArgs{ + Workspace: "list-app-workspace-multi-agent.dev2", + }, + expected: []toolsdk.WorkspaceListApp{ + { + Name: "four", + URL: "http://four.dev.coder.com", + }, + }, + }, + { + name: "MultiAgentMultiApp", + args: toolsdk.WorkspaceListAppsArgs{ + Workspace: "list-app-workspace-multi-agent.dev", + }, + expected: []toolsdk.WorkspaceListApp{ + { + Name: "one", + URL: "http://one.dev.coder.com", + }, + { + Name: "three", + URL: "http://three.dev.coder.com", + }, + { + Name: "two", + URL: "http://two.dev.coder.com", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tb, err := toolsdk.NewDeps(memberClient) + require.NoError(t, err) + + res, err := testTool(t, toolsdk.WorkspaceListApps, tb, tt.args) + if tt.error != "" { + require.Error(t, err) + require.ErrorContains(t, err, tt.error) + } else { + require.NoError(t, err) + require.Equal(t, tt.expected, res.Apps) + } + }) + } + }) } // TestedTools keeps track of which tools have been tested.