diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 0f4a0bc011..4f2bad4f45 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1706,6 +1706,7 @@ CREATE TABLE mcp_server_configs ( updated_by uuid, created_at timestamp with time zone DEFAULT now() NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL, + model_intent boolean DEFAULT false NOT NULL, CONSTRAINT mcp_server_configs_auth_type_check CHECK ((auth_type = ANY (ARRAY['none'::text, 'oauth2'::text, 'api_key'::text, 'custom_headers'::text]))), CONSTRAINT mcp_server_configs_availability_check CHECK ((availability = ANY (ARRAY['force_on'::text, 'default_on'::text, 'default_off'::text]))), CONSTRAINT mcp_server_configs_transport_check CHECK ((transport = ANY (ARRAY['streamable_http'::text, 'sse'::text]))) diff --git a/coderd/database/migrations/000454_mcp_server_model_intent.down.sql b/coderd/database/migrations/000454_mcp_server_model_intent.down.sql new file mode 100644 index 0000000000..2a3deb3db3 --- /dev/null +++ b/coderd/database/migrations/000454_mcp_server_model_intent.down.sql @@ -0,0 +1 @@ +ALTER TABLE mcp_server_configs DROP COLUMN model_intent; diff --git a/coderd/database/migrations/000454_mcp_server_model_intent.up.sql b/coderd/database/migrations/000454_mcp_server_model_intent.up.sql new file mode 100644 index 0000000000..fc2b0dad15 --- /dev/null +++ b/coderd/database/migrations/000454_mcp_server_model_intent.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE mcp_server_configs + ADD COLUMN model_intent BOOLEAN NOT NULL DEFAULT false; diff --git a/coderd/database/models.go b/coderd/database/models.go index acf41bfb73..fb168304eb 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -4488,6 +4488,7 @@ type MCPServerConfig struct { UpdatedBy uuid.NullUUID `db:"updated_by" json:"updated_by"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ModelIntent bool `db:"model_intent" json:"model_intent"` } type MCPServerUserToken struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 5649048776..ac3596f3e4 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -10786,7 +10786,7 @@ func (q *sqlQuerier) DeleteMCPServerUserToken(ctx context.Context, arg DeleteMCP const getEnabledMCPServerConfigs = `-- name: GetEnabledMCPServerConfigs :many SELECT - id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at + id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent FROM mcp_server_configs WHERE @@ -10832,6 +10832,7 @@ func (q *sqlQuerier) GetEnabledMCPServerConfigs(ctx context.Context) ([]MCPServe &i.UpdatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.ModelIntent, ); err != nil { return nil, err } @@ -10848,7 +10849,7 @@ func (q *sqlQuerier) GetEnabledMCPServerConfigs(ctx context.Context) ([]MCPServe const getForcedMCPServerConfigs = `-- name: GetForcedMCPServerConfigs :many SELECT - id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at + id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent FROM mcp_server_configs WHERE @@ -10895,6 +10896,7 @@ func (q *sqlQuerier) GetForcedMCPServerConfigs(ctx context.Context) ([]MCPServer &i.UpdatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.ModelIntent, ); err != nil { return nil, err } @@ -10911,7 +10913,7 @@ func (q *sqlQuerier) GetForcedMCPServerConfigs(ctx context.Context) ([]MCPServer const getMCPServerConfigByID = `-- name: GetMCPServerConfigByID :one SELECT - id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at + id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent FROM mcp_server_configs WHERE @@ -10949,13 +10951,14 @@ func (q *sqlQuerier) GetMCPServerConfigByID(ctx context.Context, id uuid.UUID) ( &i.UpdatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.ModelIntent, ) return i, err } const getMCPServerConfigBySlug = `-- name: GetMCPServerConfigBySlug :one SELECT - id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at + id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent FROM mcp_server_configs WHERE @@ -10993,13 +10996,14 @@ func (q *sqlQuerier) GetMCPServerConfigBySlug(ctx context.Context, slug string) &i.UpdatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.ModelIntent, ) return i, err } const getMCPServerConfigs = `-- name: GetMCPServerConfigs :many SELECT - id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at + id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent FROM mcp_server_configs ORDER BY @@ -11043,6 +11047,7 @@ func (q *sqlQuerier) GetMCPServerConfigs(ctx context.Context) ([]MCPServerConfig &i.UpdatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.ModelIntent, ); err != nil { return nil, err } @@ -11059,7 +11064,7 @@ func (q *sqlQuerier) GetMCPServerConfigs(ctx context.Context) ([]MCPServerConfig const getMCPServerConfigsByIDs = `-- name: GetMCPServerConfigsByIDs :many SELECT - id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at + id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent FROM mcp_server_configs WHERE @@ -11105,6 +11110,7 @@ func (q *sqlQuerier) GetMCPServerConfigsByIDs(ctx context.Context, ids []uuid.UU &i.UpdatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.ModelIntent, ); err != nil { return nil, err } @@ -11221,6 +11227,7 @@ INSERT INTO mcp_server_configs ( tool_deny_list, availability, enabled, + model_intent, created_by, updated_by ) VALUES ( @@ -11246,11 +11253,12 @@ INSERT INTO mcp_server_configs ( $20::text[], $21::text, $22::boolean, - $23::uuid, - $24::uuid + $23::boolean, + $24::uuid, + $25::uuid ) RETURNING - id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at + id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent ` type InsertMCPServerConfigParams struct { @@ -11276,6 +11284,7 @@ type InsertMCPServerConfigParams struct { ToolDenyList []string `db:"tool_deny_list" json:"tool_deny_list"` Availability string `db:"availability" json:"availability"` Enabled bool `db:"enabled" json:"enabled"` + ModelIntent bool `db:"model_intent" json:"model_intent"` CreatedBy uuid.UUID `db:"created_by" json:"created_by"` UpdatedBy uuid.UUID `db:"updated_by" json:"updated_by"` } @@ -11304,6 +11313,7 @@ func (q *sqlQuerier) InsertMCPServerConfig(ctx context.Context, arg InsertMCPSer pq.Array(arg.ToolDenyList), arg.Availability, arg.Enabled, + arg.ModelIntent, arg.CreatedBy, arg.UpdatedBy, ) @@ -11336,6 +11346,7 @@ func (q *sqlQuerier) InsertMCPServerConfig(ctx context.Context, arg InsertMCPSer &i.UpdatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.ModelIntent, ) return i, err } @@ -11366,12 +11377,13 @@ SET tool_deny_list = $20::text[], availability = $21::text, enabled = $22::boolean, - updated_by = $23::uuid, + model_intent = $23::boolean, + updated_by = $24::uuid, updated_at = NOW() WHERE - id = $24::uuid + id = $25::uuid RETURNING - id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at + id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent ` type UpdateMCPServerConfigParams struct { @@ -11397,6 +11409,7 @@ type UpdateMCPServerConfigParams struct { ToolDenyList []string `db:"tool_deny_list" json:"tool_deny_list"` Availability string `db:"availability" json:"availability"` Enabled bool `db:"enabled" json:"enabled"` + ModelIntent bool `db:"model_intent" json:"model_intent"` UpdatedBy uuid.UUID `db:"updated_by" json:"updated_by"` ID uuid.UUID `db:"id" json:"id"` } @@ -11425,6 +11438,7 @@ func (q *sqlQuerier) UpdateMCPServerConfig(ctx context.Context, arg UpdateMCPSer pq.Array(arg.ToolDenyList), arg.Availability, arg.Enabled, + arg.ModelIntent, arg.UpdatedBy, arg.ID, ) @@ -11457,6 +11471,7 @@ func (q *sqlQuerier) UpdateMCPServerConfig(ctx context.Context, arg UpdateMCPSer &i.UpdatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.ModelIntent, ) return i, err } diff --git a/coderd/database/queries/mcpserverconfigs.sql b/coderd/database/queries/mcpserverconfigs.sql index 28cf83b22a..d4b22cc280 100644 --- a/coderd/database/queries/mcpserverconfigs.sql +++ b/coderd/database/queries/mcpserverconfigs.sql @@ -77,6 +77,7 @@ INSERT INTO mcp_server_configs ( tool_deny_list, availability, enabled, + model_intent, created_by, updated_by ) VALUES ( @@ -102,6 +103,7 @@ INSERT INTO mcp_server_configs ( @tool_deny_list::text[], @availability::text, @enabled::boolean, + @model_intent::boolean, @created_by::uuid, @updated_by::uuid ) @@ -134,6 +136,7 @@ SET tool_deny_list = @tool_deny_list::text[], availability = @availability::text, enabled = @enabled::boolean, + model_intent = @model_intent::boolean, updated_by = @updated_by::uuid, updated_at = NOW() WHERE diff --git a/coderd/mcp.go b/coderd/mcp.go index 52ada2591b..c4de049f67 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -170,6 +170,7 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { ToolDenyList: coalesceStringSlice(trimStringSlice(req.ToolDenyList)), Availability: strings.TrimSpace(req.Availability), Enabled: req.Enabled, + ModelIntent: req.ModelIntent, CreatedBy: apiKey.UserID, UpdatedBy: apiKey.UserID, }) @@ -256,6 +257,7 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { ToolDenyList: inserted.ToolDenyList, Availability: inserted.Availability, Enabled: inserted.Enabled, + ModelIntent: inserted.ModelIntent, UpdatedBy: apiKey.UserID, }) if err != nil { @@ -323,6 +325,7 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { ToolDenyList: coalesceStringSlice(trimStringSlice(req.ToolDenyList)), Availability: strings.TrimSpace(req.Availability), Enabled: req.Enabled, + ModelIntent: req.ModelIntent, CreatedBy: apiKey.UserID, UpdatedBy: apiKey.UserID, }) @@ -572,6 +575,11 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) { enabled = *req.Enabled } + modelIntent := existing.ModelIntent + if req.ModelIntent != nil { + modelIntent = *req.ModelIntent + } + // When auth_type changes, clear fields belonging to the // previous auth type so stale secrets don't persist. if authType != existing.AuthType { @@ -639,6 +647,7 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) { ToolDenyList: toolDenyList, Availability: availability, Enabled: enabled, + ModelIntent: modelIntent, UpdatedBy: apiKey.UserID, ID: existing.ID, }) @@ -1120,9 +1129,10 @@ func convertMCPServerConfig(config database.MCPServerConfig) codersdk.MCPServerC Availability: config.Availability, - Enabled: config.Enabled, - CreatedAt: config.CreatedAt, - UpdatedAt: config.UpdatedAt, + Enabled: config.Enabled, + ModelIntent: config.ModelIntent, + CreatedAt: config.CreatedAt, + UpdatedAt: config.UpdatedAt, } } diff --git a/coderd/x/chatd/mcpclient/mcpclient.go b/coderd/x/chatd/mcpclient/mcpclient.go index 5c38432cec..57b46fbb4c 100644 --- a/coderd/x/chatd/mcpclient/mcpclient.go +++ b/coderd/x/chatd/mcpclient/mcpclient.go @@ -197,7 +197,7 @@ func connectOne( } tools = append( - tools, newMCPTool(cfg.ID, cfg.Slug, mcpTool, mcpClient), + tools, newMCPTool(cfg.ID, cfg.Slug, mcpTool, mcpClient, cfg.ModelIntent), ) } @@ -401,6 +401,7 @@ type mcpToolWrapper struct { description string parameters map[string]any required []string + modelIntent bool client *client.Client providerOptions fantasy.ProviderOptions } @@ -418,6 +419,7 @@ func newMCPTool( serverSlug string, tool mcp.Tool, mcpClient *client.Client, + modelIntent bool, ) *mcpToolWrapper { return &mcpToolWrapper{ configID: configID, @@ -426,22 +428,53 @@ func newMCPTool( description: tool.Description, parameters: tool.InputSchema.Properties, required: tool.InputSchema.Required, + modelIntent: modelIntent, client: mcpClient, } } func (t *mcpToolWrapper) Info() fantasy.ToolInfo { - // Ensure Required is never nil so that it serializes to [] instead - // of null. OpenAI rejects null for the JSON Schema "required" field. required := t.required if required == nil { required = []string{} } + + if !t.modelIntent { + return fantasy.ToolInfo{ + Name: t.prefixedName, + Description: t.description, + Parameters: t.parameters, + Required: required, + Parallel: true, + } + } + + // Wrap original parameters under "properties" and add + // "model_intent" so the LLM provides a human-readable + // description of each tool call. + wrapped := map[string]any{ + "model_intent": map[string]any{ + "type": "string", + "description": "A short, natural-language, present-participle " + + "phrase describing why you are calling this tool. " + + "This is shown to the user as a status label while " + + "the tool runs. Use plain English with no underscores " + + "or technical jargon. Keep it under 100 characters. " + + "Good examples: \"Reading the authentication module\", " + + "\"Searching for configuration files\", " + + "\"Creating a new workspace\".", + }, + "properties": map[string]any{ + "type": "object", + "properties": t.parameters, + "required": required, + }, + } return fantasy.ToolInfo{ Name: t.prefixedName, Description: t.description, - Parameters: t.parameters, - Required: required, + Parameters: wrapped, + Required: []string{"model_intent", "properties"}, Parallel: true, } } @@ -450,10 +483,15 @@ func (t *mcpToolWrapper) Run( ctx context.Context, params fantasy.ToolCall, ) (fantasy.ToolResponse, error) { + input := params.Input + if t.modelIntent { + input = unwrapModelIntent(input) + } + var args map[string]any - if params.Input != "" { + if input != "" { if err := json.Unmarshal( - []byte(params.Input), &args, + []byte(input), &args, ); err != nil { return fantasy.NewTextErrorResponse( "invalid JSON input: " + err.Error(), @@ -490,6 +528,36 @@ func (t *mcpToolWrapper) SetProviderOptions( t.providerOptions = opts } +// unwrapModelIntent strips the model_intent wrapper from tool +// call input so the remote MCP server receives only the original +// arguments. It handles three shapes the model may produce: +// +// 1. { model_intent, properties: {...} } — correct format +// 2. { model_intent, key: val, ... } — flat, no properties wrapper +// 3. Anything else — returned as-is +func unwrapModelIntent(input string) string { + var parsed map[string]any + if err := json.Unmarshal([]byte(input), &parsed); err != nil { + return input + } + + delete(parsed, "model_intent") + + // Case 1: correct { model_intent, properties: {...} } format. + if props, ok := parsed["properties"]; ok { + if b, err := json.Marshal(props); err == nil { + return string(b) + } + } + + // Case 2: flat { model_intent, key: val, ... } without wrapper. + if b, err := json.Marshal(parsed); err == nil { + return string(b) + } + + return input +} + // convertCallResult translates an MCP CallToolResult into a // fantasy.ToolResponse. The fantasy response model supports a // single content type per response, so we prioritize text. All diff --git a/coderd/x/chatd/mcpclient/mcpclient_test.go b/coderd/x/chatd/mcpclient/mcpclient_test.go index 95b40e4408..8dc25a738d 100644 --- a/coderd/x/chatd/mcpclient/mcpclient_test.go +++ b/coderd/x/chatd/mcpclient/mcpclient_test.go @@ -986,3 +986,162 @@ func TestConnectAll_CallToolError(t *testing.T) { assert.True(t, resp.IsError, "response should be flagged as error") assert.Contains(t, resp.Content, "something broke") } + +func TestModelIntent_Info_WrapsSchema(t *testing.T) { + t.Parallel() + ctx := context.Background() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + ts := newTestMCPServer(t, echoTool()) + + cfg := makeConfig("intent-srv", ts.URL) + cfg.ModelIntent = true + + tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil) + t.Cleanup(cleanup) + require.Len(t, tools, 1) + + info := tools[0].Info() + + // Top-level schema should have model_intent and properties. + _, hasModelIntent := info.Parameters["model_intent"] + _, hasProperties := info.Parameters["properties"] + assert.True(t, hasModelIntent, "schema should contain model_intent") + assert.True(t, hasProperties, "schema should contain properties") + + // Required should include both. + assert.Contains(t, info.Required, "model_intent") + assert.Contains(t, info.Required, "properties") + + // The original "input" parameter should be nested under + // properties.properties. + propsObj, ok := info.Parameters["properties"].(map[string]any) + require.True(t, ok) + innerProps, ok := propsObj["properties"].(map[string]any) + require.True(t, ok) + _, hasInput := innerProps["input"] + assert.True(t, hasInput, "original 'input' param should be nested") +} + +func TestModelIntent_Info_NoWrapWhenDisabled(t *testing.T) { + t.Parallel() + ctx := context.Background() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + ts := newTestMCPServer(t, echoTool()) + + cfg := makeConfig("no-intent", ts.URL) + cfg.ModelIntent = false + + tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil) + t.Cleanup(cleanup) + require.Len(t, tools, 1) + + info := tools[0].Info() + + // Original schema should be flat — no model_intent wrapper. + _, hasModelIntent := info.Parameters["model_intent"] + assert.False(t, hasModelIntent, "schema should NOT contain model_intent") + _, hasInput := info.Parameters["input"] + assert.True(t, hasInput, "original 'input' param should be at top level") +} + +func TestModelIntent_Run_UnwrapsProperties(t *testing.T) { + t.Parallel() + ctx := context.Background() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + ts := newTestMCPServer(t, echoTool()) + + cfg := makeConfig("unwrap-srv", ts.URL) + cfg.ModelIntent = true + + tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil) + t.Cleanup(cleanup) + require.Len(t, tools, 1) + + // Correct format: model_intent + properties wrapper. + resp, err := tools[0].Run(ctx, fantasy.ToolCall{ + ID: "call-1", + Name: "unwrap-srv__echo", + Input: `{"model_intent":"Testing echo","properties":{"input":"hello"}}`, + }) + require.NoError(t, err) + assert.False(t, resp.IsError) + assert.Equal(t, "echo: hello", resp.Content) +} + +func TestModelIntent_Run_UnwrapsFlat(t *testing.T) { + t.Parallel() + ctx := context.Background() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + ts := newTestMCPServer(t, echoTool()) + + cfg := makeConfig("flat-srv", ts.URL) + cfg.ModelIntent = true + + tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil) + t.Cleanup(cleanup) + require.Len(t, tools, 1) + + // Flat format: model_intent at top level, no properties wrapper. + resp, err := tools[0].Run(ctx, fantasy.ToolCall{ + ID: "call-2", + Name: "flat-srv__echo", + Input: `{"model_intent":"Testing flat","input":"world"}`, + }) + require.NoError(t, err) + assert.False(t, resp.IsError) + assert.Equal(t, "echo: world", resp.Content) +} + +func TestModelIntent_Run_PassthroughWhenDisabled(t *testing.T) { + t.Parallel() + ctx := context.Background() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + ts := newTestMCPServer(t, echoTool()) + + cfg := makeConfig("pass-srv", ts.URL) + cfg.ModelIntent = false + + tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil) + t.Cleanup(cleanup) + require.Len(t, tools, 1) + + // Without model_intent, input is passed through unchanged. + resp, err := tools[0].Run(ctx, fantasy.ToolCall{ + ID: "call-3", + Name: "pass-srv__echo", + Input: `{"input":"direct"}`, + }) + require.NoError(t, err) + assert.False(t, resp.IsError) + assert.Equal(t, "echo: direct", resp.Content) +} + +func TestModelIntent_Run_FallbackOnBadJSON(t *testing.T) { + t.Parallel() + ctx := context.Background() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + ts := newTestMCPServer(t, echoTool()) + + cfg := makeConfig("bad-srv", ts.URL) + cfg.ModelIntent = true + + tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil) + t.Cleanup(cleanup) + require.Len(t, tools, 1) + + // Malformed JSON should not panic — the error is returned + // from the JSON unmarshal in Run(), not from unwrap. + resp, err := tools[0].Run(ctx, fantasy.ToolCall{ + ID: "call-bad", + Name: "bad-srv__echo", + Input: `not-json`, + }) + require.NoError(t, err) + assert.True(t, resp.IsError, "malformed input should produce an error response") +} diff --git a/codersdk/mcp.go b/codersdk/mcp.go index 081eb19a77..3d7a535b7f 100644 --- a/codersdk/mcp.go +++ b/codersdk/mcp.go @@ -64,9 +64,10 @@ type MCPServerConfig struct { // Availability policy set by admin. Availability string `json:"availability"` // "force_on", "default_on", "default_off" - Enabled bool `json:"enabled"` - CreatedAt time.Time `json:"created_at" format:"date-time"` - UpdatedAt time.Time `json:"updated_at" format:"date-time"` + Enabled bool `json:"enabled"` + ModelIntent bool `json:"model_intent"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` // Per-user state (populated for non-admin requests). AuthConnected bool `json:"auth_connected"` @@ -97,6 +98,7 @@ type CreateMCPServerConfigRequest struct { Availability string `json:"availability" validate:"required,oneof=force_on default_on default_off"` Enabled bool `json:"enabled"` + ModelIntent bool `json:"model_intent"` } // UpdateMCPServerConfigRequest is the request to update an MCP server config. @@ -124,6 +126,7 @@ type UpdateMCPServerConfigRequest struct { Availability *string `json:"availability,omitempty" validate:"omitempty,oneof=force_on default_on default_off"` Enabled *bool `json:"enabled,omitempty"` + ModelIntent *bool `json:"model_intent,omitempty"` } func (c *Client) MCPServerConfigs(ctx context.Context) ([]MCPServerConfig, error) { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index f442aace81..e69b5fd48a 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2406,6 +2406,7 @@ export interface CreateMCPServerConfigRequest { readonly tool_deny_list?: readonly string[]; readonly availability: string; readonly enabled: boolean; + readonly model_intent: boolean; } // From codersdk/organizations.go @@ -4001,6 +4002,7 @@ export interface MCPServerConfig { */ readonly availability: string; // "force_on", "default_on", "default_off" readonly enabled: boolean; + readonly model_intent: boolean; readonly created_at: string; readonly updated_at: string; /** @@ -7232,6 +7234,7 @@ export interface UpdateMCPServerConfigRequest { readonly tool_deny_list?: string[]; readonly availability?: string; readonly enabled?: boolean; + readonly model_intent?: boolean; } // From codersdk/notifications.go diff --git a/site/src/components/ai-elements/tool.stories.tsx b/site/src/components/ai-elements/tool.stories.tsx index 28fd3ff130..f1f526c776 100644 --- a/site/src/components/ai-elements/tool.stories.tsx +++ b/site/src/components/ai-elements/tool.stories.tsx @@ -491,6 +491,7 @@ const sampleMCPServers = [ tool_deny_list: [], availability: "default_on", enabled: true, + model_intent: false, auth_connected: true, created_at: "2025-01-01T00:00:00Z", updated_at: "2025-01-01T00:00:00Z", @@ -650,6 +651,53 @@ export const MCPToolNoServer: Story = { }, }; +export const MCPToolModelIntentRunning: Story = { + args: { + name: "linear__list_issues", + status: "running", + args: { project: "backend" }, + modelIntent: "Fetching backend issues from Linear", + mcpServerConfigId: "mcp-server-1", + mcpServers: sampleMCPServers, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Should show the model intent label instead of the raw tool name. + expect( + canvas.getByText("Fetching backend issues from Linear"), + ).toBeInTheDocument(); + // Spinner should be visible while running. + expect(canvasElement.querySelector(".animate-spin")).not.toBeNull(); + }, +}; + +export const MCPToolModelIntentCompleted: Story = { + args: { + name: "github__create_pull_request", + status: "completed", + args: { title: "Fix auth flow", base: "main" }, + result: { url: "https://github.com/org/repo/pull/42" }, + modelIntent: "creating pull request for auth fix", + mcpServerConfigId: "mcp-server-1", + mcpServers: [ + { + ...sampleMCPServers[0], + slug: "github", + display_name: "GitHub", + icon_url: + "https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg", + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Intent should be capitalized. + expect( + canvas.getByText("Creating pull request for auth fix"), + ).toBeInTheDocument(); + }, +}; + // --------------------------------------------------------------------------- // WriteFile stories // --------------------------------------------------------------------------- diff --git a/site/src/components/ai-elements/tool/Tool.tsx b/site/src/components/ai-elements/tool/Tool.tsx index b9b1182dd1..d8a7f98fec 100644 --- a/site/src/components/ai-elements/tool/Tool.tsx +++ b/site/src/components/ai-elements/tool/Tool.tsx @@ -69,6 +69,8 @@ interface ToolProps extends Omit, "children"> { mcpServerConfigId?: string; /** Available MCP server configs for icon/name lookup. */ mcpServers?: readonly TypesGen.MCPServerConfig[]; + /** Human-readable intent extracted from the model's tool-call args. */ + modelIntent?: string; } // Props passed to each tool-specific renderer function. Each renderer @@ -85,6 +87,7 @@ type ToolRendererProps = { subagentStatusOverrides?: Map; mcpServerConfigId?: string; mcpServers?: readonly TypesGen.MCPServerConfig[]; + modelIntent?: string; }; // --------------------------------------------------------------------------- @@ -497,6 +500,7 @@ const GenericToolRenderer: FC = ({ isError, mcpServerConfigId, mcpServers, + modelIntent, }) => { const theme = useTheme(); const isDark = theme.palette.mode === "dark"; @@ -534,12 +538,18 @@ const GenericToolRenderer: FC = ({ isRunning={isRunning} serverName={mcpServer?.display_name} /> - + {modelIntent ? ( + + {modelIntent.charAt(0).toUpperCase() + modelIntent.slice(1)} + + ) : ( + + )} {isError && ( @@ -646,6 +656,7 @@ export const Tool = memo( subagentStatusOverrides, mcpServerConfigId, mcpServers, + modelIntent, ref, ...props }: ToolProps) => { @@ -676,6 +687,7 @@ export const Tool = memo( subagentStatusOverrides={subagentStatusOverrides} mcpServerConfigId={mcpServerConfigId} mcpServers={mcpServers} + modelIntent={modelIntent} /> ); diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx index 755fb7a870..e739c5ecfa 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx @@ -480,6 +480,7 @@ const makeMCPServer = ( tool_deny_list: overrides.tool_deny_list ?? [], availability: overrides.availability ?? "default_on", enabled: overrides.enabled ?? true, + model_intent: overrides.model_intent ?? false, created_at: overrides.created_at ?? now, updated_at: overrides.updated_at ?? now, auth_connected: overrides.auth_connected ?? false, diff --git a/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx index fd08fdec97..8900b939c6 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx @@ -367,6 +367,7 @@ const BlockList: FC<{ } mcpServerConfigId={tool.mcpServerConfigId} mcpServers={mcpServers} + modelIntent={tool.modelIntent} /> ); } @@ -407,6 +408,7 @@ const BlockList: FC<{ } mcpServerConfigId={tool.mcpServerConfigId} mcpServers={mcpServers} + modelIntent={tool.modelIntent} /> ))} diff --git a/site/src/pages/AgentsPage/components/AgentDetail/messageParsing.ts b/site/src/pages/AgentsPage/components/AgentDetail/messageParsing.ts index 8b3b43463a..881b6be1db 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/messageParsing.ts +++ b/site/src/pages/AgentsPage/components/AgentDetail/messageParsing.ts @@ -93,6 +93,12 @@ export const mergeTools = ( for (const call of calls) { seen.add(call.id); const result = resultById.get(call.id); + // Extract model_intent from the tool call args if present. + const callArgs = call.args as Record | undefined; + const modelIntent = + typeof callArgs?.model_intent === "string" + ? callArgs.model_intent + : undefined; merged.push({ id: call.id, name: call.name, @@ -101,6 +107,7 @@ export const mergeTools = ( isError: result?.isError ?? false, status: result ? (result.isError ? "error" : "completed") : "completed", mcpServerConfigId: call.mcpServerConfigId || result?.mcpServerConfigId, + modelIntent, }); } diff --git a/site/src/pages/AgentsPage/components/AgentDetail/streamState.ts b/site/src/pages/AgentsPage/components/AgentDetail/streamState.ts index 98d6b86fc1..9a0ae7873b 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/streamState.ts +++ b/site/src/pages/AgentsPage/components/AgentDetail/streamState.ts @@ -60,6 +60,13 @@ export const applyMessagePartToStreamState = ( part.args_delta, ); + // Extract model_intent from the incrementally parsed args. + const merged = nextArgs.value as Record | undefined; + const modelIntent = + typeof merged?.model_intent === "string" + ? merged.model_intent + : existing?.modelIntent; + return { ...nextState, blocks: ensureToolBlock(nextState.blocks, toolCallID), @@ -72,6 +79,7 @@ export const applyMessagePartToStreamState = ( argsRaw: nextArgs.rawText, mcpServerConfigId: part.mcp_server_config_id || existing?.mcpServerConfigId, + modelIntent, }, }, }; @@ -201,6 +209,7 @@ export const buildStreamTools = ( isError: result?.isError ?? false, status: result ? (result.isError ? "error" : "completed") : "running", mcpServerConfigId: call.mcpServerConfigId || result?.mcpServerConfigId, + modelIntent: call.modelIntent, }); } diff --git a/site/src/pages/AgentsPage/components/AgentDetail/types.ts b/site/src/pages/AgentsPage/components/AgentDetail/types.ts index 57a1510670..85e69e73bd 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/types.ts +++ b/site/src/pages/AgentsPage/components/AgentDetail/types.ts @@ -25,6 +25,7 @@ export type MergedTool = { isError: boolean; status: "completed" | "error" | "running"; mcpServerConfigId?: string; + modelIntent?: string; }; export type RenderBlock = @@ -79,6 +80,7 @@ type StreamToolCall = { args?: unknown; argsRaw?: string; mcpServerConfigId?: string; + modelIntent?: string; }; type StreamToolResult = { diff --git a/site/src/pages/AgentsPage/components/MCPServerAdminPanel.stories.tsx b/site/src/pages/AgentsPage/components/MCPServerAdminPanel.stories.tsx index 73bfb7ba5c..a22e9b9936 100644 --- a/site/src/pages/AgentsPage/components/MCPServerAdminPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/MCPServerAdminPanel.stories.tsx @@ -33,6 +33,7 @@ const createServerConfig = ( tool_deny_list: overrides.tool_deny_list ?? [], availability: overrides.availability ?? "default_on", enabled: overrides.enabled ?? true, + model_intent: overrides.model_intent ?? false, created_at: overrides.created_at ?? now, updated_at: overrides.updated_at ?? now, auth_connected: overrides.auth_connected ?? false, diff --git a/site/src/pages/AgentsPage/components/MCPServerAdminPanel.tsx b/site/src/pages/AgentsPage/components/MCPServerAdminPanel.tsx index e2ab9fb172..f7d9b75c77 100644 --- a/site/src/pages/AgentsPage/components/MCPServerAdminPanel.tsx +++ b/site/src/pages/AgentsPage/components/MCPServerAdminPanel.tsx @@ -23,6 +23,7 @@ import { Button } from "#/components/Button/Button"; import { ExternalImage } from "#/components/ExternalImage/ExternalImage"; import { IconField } from "#/components/IconField/IconField"; import { Input } from "#/components/Input/Input"; +import { Label } from "#/components/Label/Label"; import { Select, SelectContent, @@ -231,6 +232,7 @@ interface MCPServerFormValues { apiKeyTouched: boolean; availability: string; enabled: boolean; + modelIntent: boolean; toolAllowList: string; toolDenyList: string; customHeaders: Array<{ key: string; value: string }>; @@ -259,6 +261,7 @@ const buildInitialValues = ( apiKeyTouched: false, availability: server?.availability ?? "default_off", enabled: server?.enabled ?? true, + modelIntent: server?.model_intent ?? false, toolAllowList: joinList(server?.tool_allow_list), toolDenyList: joinList(server?.tool_deny_list), customHeaders: [], @@ -312,6 +315,7 @@ const ServerForm: FC = ({ auth_type: values.authType, availability: values.availability, enabled: values.enabled, + model_intent: values.modelIntent, ...(values.authType === "oauth2" && { oauth2_client_id: values.oauth2ClientID.trim(), oauth2_client_secret: effectiveOAuth2Secret, @@ -771,7 +775,25 @@ const ServerForm: FC = ({ {" "} {/* ── Tool governance row ── */}
+
+
+ +

+ Require the model to describe each tool call's purpose in + natural language, shown as a status label in the UI. +

+
+ { + form.setFieldValue("modelIntent", v); + }} + disabled={isDisabled} + /> +
+ {" "} = ({ disabled={isDisabled} /> -