fix: add preset support to MCP tools (#24694)

The chat tools (`read_template`, `create_workspace`) did not surface or
respect template version presets. Presets were invisible to the LLM and
preset parameter defaults were never applied at workspace creation. The
`toolsdk` MCP surface had the same gap (ref #24695, now subsumed here).

## What this changes

- **`read_template`** returns presets with `id`, `name`, `default`,
`description`, `icon`, `parameters`, and `desired_prebuild_instances`
(when set), so the LLM can pick the right preset and prefer
prebuilt-backed ones.
- **`create_workspace`** accepts a `preset_id`. The wsbuilder applies
preset parameter defaults and may claim a prebuilt workspace.
- **`start_workspace`** does *not* accept a preset. Presets are a
creation-time choice; subsequent starts use the workspace's existing
version and parameters. Users who need a specific preset or version on
an existing chat can create the workspace out-of-band (CLI / UI / API)
with the desired configuration and attach the chat to it.
- **`toolsdk`** gains `GetTemplate` (with presets including
`desired_prebuild_instances`), preset support on `CreateWorkspace`, and
preset + `rich_parameters` support on `CreateWorkspaceBuild`. The
`template_version_preset_id` description warns about preset/version
affinity.


> 🤖 Generated with [Coder Agents](https://coder.com/agents) and reviewed by a human.

Co-authored-by: Max schwenk <maschwenk@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cian Johnston
2026-05-01 10:57:52 +01:00
committed by GitHub
co-authored by Max schwenk Claude Opus 4.7
parent 90bee3aaef
commit 04cc983833
10 changed files with 1290 additions and 48 deletions
+486 -12
View File
@@ -132,6 +132,14 @@ func TestGenericToolMCPAnnotations(t *testing.T) {
idempotentHint: true,
openWorldHint: false,
},
{
name: "GetTemplateIsReadOnly",
toolName: toolsdk.ToolNameGetTemplate,
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
}
for _, tt := range tests {
@@ -178,6 +186,12 @@ func TestTools(t *testing.T) {
}
return agents
}).Do()
preset := dbgen.Preset(t, store, database.InsertPresetParams{
TemplateVersionID: r.TemplateVersion.ID,
Name: testutil.GetRandomNameHyphenated(t),
CreatedAt: r.TemplateVersion.CreatedAt,
Description: "Preset for agent tool tests.",
})
// Given: a client configured with the agent token.
agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(r.AgentToken))
@@ -404,6 +418,169 @@ func TestTools(t *testing.T) {
// Cancel the build so it doesn't remain in the 'pending' state indefinitely.
require.NoError(t, client.CancelWorkspaceBuild(ctx, rollbackBuild.ID, codersdk.CancelWorkspaceBuildParams{}))
})
t.Run("Start_WithPreset", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
tb, err := toolsdk.NewDeps(memberClient)
require.NoError(t, err)
result, err := testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{
WorkspaceID: r.Workspace.ID.String(),
Transition: "start",
TemplateVersionPresetID: preset.ID.String(),
})
require.NoError(t, err)
require.Equal(t, codersdk.WorkspaceTransitionStart, result.Transition)
require.Equal(t, r.Workspace.ID, result.WorkspaceID)
require.NotNil(t, result.TemplateVersionPresetID,
"build must record the preset ID supplied to create_workspace_build")
require.Equal(t, preset.ID, *result.TemplateVersionPresetID)
require.NoError(t, client.CancelWorkspaceBuild(ctx, result.ID, codersdk.CancelWorkspaceBuildParams{}))
})
t.Run("Start_WithRichParameters", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
// Isolated fixture: a template version with one rich
// parameter, so rich_parameters has something to bind
// to. The shared `r` fixture has no parameters.
rpBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
}).Do()
dbgen.TemplateVersionParameter(t, store, database.TemplateVersionParameter{
TemplateVersionID: rpBuild.TemplateVersion.ID,
Name: "region",
Description: "Region to deploy in.",
Type: "string",
DefaultValue: "us-east-1",
Required: false,
Mutable: true,
})
tb, err := toolsdk.NewDeps(memberClient)
require.NoError(t, err)
result, err := testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{
WorkspaceID: rpBuild.Workspace.ID.String(),
Transition: "start",
RichParameters: map[string]string{"region": "us-west-2"},
})
require.NoError(t, err)
require.Equal(t, codersdk.WorkspaceTransitionStart, result.Transition)
params, err := memberClient.WorkspaceBuildParameters(ctx, result.ID)
require.NoError(t, err)
require.Len(t, params, 1)
require.Equal(t, "region", params[0].Name)
require.Equal(t, "us-west-2", params[0].Value)
require.NoError(t, client.CancelWorkspaceBuild(ctx, result.ID, codersdk.CancelWorkspaceBuildParams{}))
})
t.Run("Start_WithPresetAndParams", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
// Isolated fixture: a template version with a parameter
// and a preset that sets it. Asserts the documented
// override direction: when preset and rich_parameters
// conflict, the preset value wins. Mirrors the
// CreateWorkspace/WithPresetAndParams contract.
ovBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
}).Do()
dbgen.TemplateVersionParameter(t, store, database.TemplateVersionParameter{
TemplateVersionID: ovBuild.TemplateVersion.ID,
Name: "region",
Description: "Region to deploy in.",
Type: "string",
DefaultValue: "us-east-1",
Required: false,
Mutable: true,
})
ovPreset := dbgen.Preset(t, store, database.InsertPresetParams{
TemplateVersionID: ovBuild.TemplateVersion.ID,
Name: testutil.GetRandomNameHyphenated(t),
CreatedAt: ovBuild.TemplateVersion.CreatedAt,
Description: "Preset for build override test.",
})
dbgen.PresetParameter(t, store, database.InsertPresetParametersParams{
TemplateVersionPresetID: ovPreset.ID,
Names: []string{"region"},
Values: []string{"us-west-2"},
})
tb, err := toolsdk.NewDeps(memberClient)
require.NoError(t, err)
result, err := testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{
WorkspaceID: ovBuild.Workspace.ID.String(),
Transition: "start",
TemplateVersionPresetID: ovPreset.ID.String(),
RichParameters: map[string]string{"region": "us-east-1"},
})
require.NoError(t, err)
require.NotNil(t, result.TemplateVersionPresetID)
require.Equal(t, ovPreset.ID, *result.TemplateVersionPresetID)
params, err := memberClient.WorkspaceBuildParameters(ctx, result.ID)
require.NoError(t, err)
require.Len(t, params, 1)
require.Equal(t, "region", params[0].Name)
require.Equal(t, "us-west-2", params[0].Value,
"preset parameter value must override conflicting rich_parameters entry")
require.NoError(t, client.CancelWorkspaceBuild(ctx, result.ID, codersdk.CancelWorkspaceBuildParams{}))
})
t.Run("RejectsPresetOnStop", func(t *testing.T) {
tb, err := toolsdk.NewDeps(memberClient)
require.NoError(t, err)
_, err = testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{
WorkspaceID: r.Workspace.ID.String(),
Transition: "stop",
TemplateVersionPresetID: preset.ID.String(),
})
require.ErrorContains(t, err, "template_version_preset_id is only valid for start")
})
t.Run("RejectsParamsOnDelete", func(t *testing.T) {
tb, err := toolsdk.NewDeps(memberClient)
require.NoError(t, err)
_, err = testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{
WorkspaceID: r.Workspace.ID.String(),
Transition: "delete",
RichParameters: map[string]string{"region": "us-west-2"},
})
require.ErrorContains(t, err, "rich_parameters is only valid for start")
})
t.Run("RejectsBothOnStop", func(t *testing.T) {
// Both fields set on a non-start transition. The
// handler must surface both violations via errors.Join
// so agents fix both in one round-trip rather than
// fix-one, retry, hit-the-next.
tb, err := toolsdk.NewDeps(memberClient)
require.NoError(t, err)
_, err = testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{
WorkspaceID: r.Workspace.ID.String(),
Transition: "stop",
TemplateVersionPresetID: preset.ID.String(),
RichParameters: map[string]string{"region": "us-west-2"},
})
require.Error(t, err)
require.ErrorContains(t, err, "template_version_preset_id is only valid for start")
require.ErrorContains(t, err, "rich_parameters is only valid for start")
})
t.Run("InvalidPresetID", func(t *testing.T) {
tb, err := toolsdk.NewDeps(memberClient)
require.NoError(t, err)
_, err = testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{
WorkspaceID: r.Workspace.ID.String(),
Transition: "start",
TemplateVersionPresetID: "not-a-uuid",
})
require.ErrorContains(t, err, "template_version_preset_id must be a valid UUID")
})
})
t.Run("ListTemplateVersionParameters", func(t *testing.T) {
@@ -417,6 +594,129 @@ func TestTools(t *testing.T) {
require.Empty(t, params)
})
t.Run("GetTemplate", func(t *testing.T) {
// Build an isolated fixture so the existing fixture's
// assertions (no parameters, single preset with no
// preset parameters) stay intact.
gtBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
}).Do()
// Add a rich parameter to the active version so
// `parameters` is non-empty in the response.
dbgen.TemplateVersionParameter(t, store, database.TemplateVersionParameter{
TemplateVersionID: gtBuild.TemplateVersion.ID,
Name: "region",
DisplayName: "Region",
Description: "Region to deploy in.",
Type: "string",
DefaultValue: "us-east-1",
Required: false,
Mutable: true,
})
// Attach a preset with one parameter so we can assert
// PresetParameters round-trip end-to-end.
const gtPresetDesiredPrebuildInstances = 3
gtPreset := dbgen.Preset(t, store, database.InsertPresetParams{
TemplateVersionID: gtBuild.TemplateVersion.ID,
Name: testutil.GetRandomNameHyphenated(t),
CreatedAt: gtBuild.TemplateVersion.CreatedAt,
Description: "Preset for GetTemplate tests.",
DesiredInstances: sql.NullInt32{
Int32: gtPresetDesiredPrebuildInstances,
Valid: true,
},
})
dbgen.PresetParameter(t, store, database.InsertPresetParametersParams{
TemplateVersionPresetID: gtPreset.ID,
Names: []string{"region"},
Values: []string{"us-west-2"},
})
// A second template with no presets, used to assert
// the omit-when-empty behavior of the `presets` field.
gtNoPresetBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
}).Do()
t.Run("WithPresets", func(t *testing.T) {
tb, err := toolsdk.NewDeps(memberClient)
require.NoError(t, err)
result, err := testTool(t, toolsdk.GetTemplate, tb, toolsdk.GetTemplateArgs{
TemplateID: gtBuild.Template.ID.String(),
})
require.NoError(t, err)
// MinimalTemplate fields populated.
require.Equal(t, gtBuild.Template.ID.String(), result.ID)
require.Equal(t, gtBuild.Template.Name, result.Name)
require.Equal(t, gtBuild.Template.ActiveVersionID, result.ActiveVersionID)
// Parameters round-trip from the active version.
require.Len(t, result.Parameters, 1)
require.Equal(t, "region", result.Parameters[0].Name)
require.Equal(t, "us-east-1", result.Parameters[0].DefaultValue)
// Presets and their parameters round-trip.
require.Len(t, result.Presets, 1)
require.Equal(t, gtPreset.ID, result.Presets[0].ID)
require.Equal(t, gtPreset.Name, result.Presets[0].Name)
require.Equal(t, "Preset for GetTemplate tests.", result.Presets[0].Description)
require.Len(t, result.Presets[0].Parameters, 1)
require.Equal(t, "region", result.Presets[0].Parameters[0].Name)
require.Equal(t, "us-west-2", result.Presets[0].Parameters[0].Value)
// DesiredPrebuildInstances round-trips through toPresetView.
// The tool description tells the LLM to prefer presets with
// desired_prebuild_instances > 0; if this field stops
// flowing, that hint silently breaks.
require.NotNil(t, result.Presets[0].DesiredPrebuildInstances,
"desired_prebuild_instances should be populated when the preset has DesiredInstances")
require.EqualValues(t, gtPresetDesiredPrebuildInstances, *result.Presets[0].DesiredPrebuildInstances)
})
t.Run("WithoutPresets", func(t *testing.T) {
tb, err := toolsdk.NewDeps(memberClient)
require.NoError(t, err)
result, err := testTool(t, toolsdk.GetTemplate, tb, toolsdk.GetTemplateArgs{
TemplateID: gtNoPresetBuild.Template.ID.String(),
})
require.NoError(t, err)
require.Equal(t, gtNoPresetBuild.Template.ID.String(), result.ID)
require.Empty(t, result.Presets, "presets should be empty when the template has none")
// The `presets` field should be absent from the
// JSON entirely when the template has no presets.
b, err := json.Marshal(result)
require.NoError(t, err)
require.NotContains(t, string(b), `"presets"`)
})
t.Run("InvalidID", func(t *testing.T) {
tb, err := toolsdk.NewDeps(memberClient)
require.NoError(t, err)
_, err = testTool(t, toolsdk.GetTemplate, tb, toolsdk.GetTemplateArgs{
TemplateID: "not-a-uuid",
})
require.ErrorContains(t, err, "template_id must be a valid UUID")
})
t.Run("NotFound", func(t *testing.T) {
tb, err := toolsdk.NewDeps(memberClient)
require.NoError(t, err)
_, err = testTool(t, toolsdk.GetTemplate, tb, toolsdk.GetTemplateArgs{
TemplateID: uuid.New().String(),
})
require.ErrorContains(t, err, "get template")
})
})
t.Run("GetWorkspaceAgentLogs", func(t *testing.T) {
tb, err := toolsdk.NewDeps(memberClient)
require.NoError(t, err)
@@ -533,18 +833,193 @@ func TestTools(t *testing.T) {
t.Run("CreateWorkspace", func(t *testing.T) {
tb, err := toolsdk.NewDeps(client)
require.NoError(t, err)
// We need a template version ID to create a workspace
res, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{
User: "me",
TemplateVersionID: r.TemplateVersion.ID.String(),
Name: testutil.GetRandomNameHyphenated(t),
RichParameters: map[string]string{},
t.Run("WithoutPreset", func(t *testing.T) {
res, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{
User: "me",
TemplateVersionID: r.TemplateVersion.ID.String(),
Name: testutil.GetRandomNameHyphenated(t),
RichParameters: map[string]string{},
})
require.NoError(t, err)
require.NotEmpty(t, res.ID, "expected a workspace ID")
})
// The creation might fail for various reasons, but the important thing is
// to mark it as tested
require.NoError(t, err)
require.NotEmpty(t, res.ID, "expected a workspace ID")
t.Run("WithPreset", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
res, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{
User: "me",
TemplateVersionID: r.TemplateVersion.ID.String(),
TemplateVersionPresetID: preset.ID.String(),
Name: testutil.GetRandomNameHyphenated(t),
RichParameters: map[string]string{},
})
require.NoError(t, err)
require.NotEmpty(t, res.ID, "expected a workspace ID")
build, err := client.WorkspaceBuild(ctx, res.LatestBuild.ID)
require.NoError(t, err)
require.NotNil(t, build.TemplateVersionPresetID)
require.Equal(t, preset.ID, *build.TemplateVersionPresetID)
})
t.Run("WithTemplateID", func(t *testing.T) {
// Exercises the template_id path on create_workspace,
// which lets the server resolve the active version
// atomically with the build. Mirrors how the chattool
// surface keys this tool.
res, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{
User: "me",
TemplateID: r.Template.ID.String(),
Name: testutil.GetRandomNameHyphenated(t),
RichParameters: map[string]string{},
})
require.NoError(t, err)
require.NotEmpty(t, res.ID, "expected a workspace ID")
})
t.Run("WithRichParameters", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
// Isolated fixture: a template version with a single
// rich parameter, no preset. Confirms that
// rich_parameters round-trip on their own without
// being shadowed or overridden by preset auto-binding
// when no preset matches.
rpBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
}).Do()
dbgen.TemplateVersionParameter(t, store, database.TemplateVersionParameter{
TemplateVersionID: rpBuild.TemplateVersion.ID,
Name: "region",
Description: "Region to deploy in.",
Type: "string",
DefaultValue: "us-east-1",
Required: false,
Mutable: true,
})
res, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{
User: "me",
TemplateVersionID: rpBuild.TemplateVersion.ID.String(),
Name: testutil.GetRandomNameHyphenated(t),
RichParameters: map[string]string{"region": "us-west-2"},
})
require.NoError(t, err)
require.NotEmpty(t, res.ID, "expected a workspace ID")
params, err := client.WorkspaceBuildParameters(ctx, res.LatestBuild.ID)
require.NoError(t, err)
require.Len(t, params, 1)
require.Equal(t, "region", params[0].Name)
require.Equal(t, "us-west-2", params[0].Value)
})
t.Run("RejectsBothIDs", func(t *testing.T) {
_, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{
User: "me",
TemplateID: r.Template.ID.String(),
TemplateVersionID: r.TemplateVersion.ID.String(),
Name: testutil.GetRandomNameHyphenated(t),
RichParameters: map[string]string{},
})
require.ErrorContains(t, err, "exactly one of template_id or template_version_id")
})
t.Run("RejectsNeitherID", func(t *testing.T) {
_, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{
User: "me",
Name: testutil.GetRandomNameHyphenated(t),
RichParameters: map[string]string{},
})
require.ErrorContains(t, err, "exactly one of template_id or template_version_id")
})
t.Run("WithPresetAndParams", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
// Build an isolated fixture: a template version with one
// rich parameter and a preset that sets it. The shared
// fixture's preset has no parameters and would not exercise
// the override path.
ovBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
}).Do()
dbgen.TemplateVersionParameter(t, store, database.TemplateVersionParameter{
TemplateVersionID: ovBuild.TemplateVersion.ID,
Name: "region",
Description: "Region to deploy in.",
Type: "string",
DefaultValue: "us-east-1",
Required: false,
Mutable: true,
})
ovPreset := dbgen.Preset(t, store, database.InsertPresetParams{
TemplateVersionID: ovBuild.TemplateVersion.ID,
Name: testutil.GetRandomNameHyphenated(t),
CreatedAt: ovBuild.TemplateVersion.CreatedAt,
Description: "Preset for override test.",
})
dbgen.PresetParameter(t, store, database.InsertPresetParametersParams{
TemplateVersionPresetID: ovPreset.ID,
Names: []string{"region"},
Values: []string{"us-west-2"},
})
// Send conflicting rich_parameters; the preset value
// should win, per the contract advertised in the
// template_version_preset_id schema description.
res, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{
User: "me",
TemplateVersionID: ovBuild.TemplateVersion.ID.String(),
TemplateVersionPresetID: ovPreset.ID.String(),
Name: testutil.GetRandomNameHyphenated(t),
RichParameters: map[string]string{"region": "us-east-1"},
})
require.NoError(t, err)
require.NotEmpty(t, res.ID, "expected a workspace ID")
// wsbuilder persists resolved parameters during the
// build transaction, before provisioning, so the values
// are readable immediately without waiting for the
// build job to complete.
params, err := client.WorkspaceBuildParameters(ctx, res.LatestBuild.ID)
require.NoError(t, err)
require.Len(t, params, 1)
require.Equal(t, "region", params[0].Name)
require.Equal(t, "us-west-2", params[0].Value,
"preset parameter value must override conflicting rich_parameters entry")
})
t.Run("RejectsInvalidTemplateID", func(t *testing.T) {
_, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{
User: "me",
Name: testutil.GetRandomNameHyphenated(t),
TemplateID: "not-a-uuid",
})
require.ErrorContains(t, err, "template_id must be a valid UUID")
})
t.Run("RejectsInvalidTemplateVersionID", func(t *testing.T) {
_, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{
User: "me",
Name: testutil.GetRandomNameHyphenated(t),
TemplateVersionID: "not-a-uuid",
})
require.ErrorContains(t, err, "template_version_id must be a valid UUID")
})
t.Run("RejectsInvalidTemplateVersionPresetID", func(t *testing.T) {
_, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{
User: "me",
Name: testutil.GetRandomNameHyphenated(t),
TemplateVersionID: uuid.NewString(),
TemplateVersionPresetID: "not-a-uuid",
})
require.ErrorContains(t, err, "template_version_preset_id must be a valid UUID")
})
})
t.Run("WorkspaceSSHExec", func(t *testing.T) {
@@ -1123,11 +1598,10 @@ func TestTools(t *testing.T) {
{
name: "WithPreset",
args: toolsdk.CreateTaskArgs{
TemplateVersionID: r.TemplateVersion.ID.String(),
TemplateVersionID: aiTV.TemplateVersion.ID.String(),
TemplateVersionPresetID: presetID.String(),
Input: "not enough barrel rolls",
},
error: "Template does not have a valid \"coder_ai_task\" resource.",
},
}