feat: add model_intent option to MCP server configs (#23717)

Add a per-MCP-server `model_intent` toggle that wraps tool schemas with
a
`model_intent` field, requiring the LLM to provide a human-readable
description of each tool call's purpose. The intent string is shown as a
status label in the UI instead of opaque tool names, and is
transparently
stripped before the call reaches the remote MCP server.

Built-in tools have rich specialized renderers (terminal blocks, file
diffs,
etc.) and don't need this. MCP tools hit `GenericToolRenderer` which
only
shows raw tool names and JSON — that's where model_intent adds value.

The model learns what to provide via the JSON Schema `description` on
the
`model_intent` property itself — no system prompt changes needed.

<details>
<summary>Implementation details</summary>

### Architecture

Inspired by the `withModelIntent()` pattern from `coder/blink`, adapted
for
Go + React. The wrapping is entirely in the `mcpclient` layer — tool
implementations never see `model_intent`.

**Schema wrapping** (`mcpToolWrapper.Info()`): When enabled, wraps the
original tool parameters under a `properties` key and adds a
`model_intent`
string field with a rich description that teaches the model inline.

**Input unwrapping** (`mcpToolWrapper.Run()`): Strips `model_intent` and
unwraps `properties` before forwarding to the remote MCP server. Handles
three input shapes models may produce:
1. `{ model_intent, properties: {...} }` — correct format
2. `{ model_intent, key: val, ... }` — flat, no wrapper
3. Malformed — falls through gracefully

**Frontend extraction**: `streamState.ts` extracts `model_intent` from
incrementally parsed streaming JSON. `messageParsing.ts` extracts it
from
persisted tool call args.

**UI rendering**: `GenericToolRenderer` shows the capitalized intent
string
as the primary label when available, falling back to the raw tool name.

### Changes
- Database: `model_intent` boolean column on `mcp_server_configs`
- SDK: `ModelIntent` field on config/create/update types
- API: pass-through in create/update handlers + converter
- mcpclient: schema wrapping in `Info()`, input unwrapping in `Run()`
- Frontend: extraction from streaming + persisted args
- UI: intent label in `GenericToolRenderer`, toggle in admin panel
- Tests: 6 new tests (schema wrapping, unwrapping, passthrough,
fallback)

### Decision log
- **Option lives on MCPServerConfig, not model config**: Built-in tools
  already have rich renderers; only MCP tools benefit from model_intent.
- **No system prompt changes**: The JSON Schema `description` on the
  `model_intent` property teaches the model inline.
- **Pointer bool on update request**: Follows existing pattern (`*bool`)
  so PATCH requests don't reset the value when omitted.

</details>
This commit is contained in:
Kyle Carberry
2026-03-27 14:23:25 +00:00
committed by GitHub
parent 50c0c89503
commit d973a709df
21 changed files with 402 additions and 32 deletions
+1
View File
@@ -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])))
@@ -0,0 +1 @@
ALTER TABLE mcp_server_configs DROP COLUMN model_intent;
@@ -0,0 +1,2 @@
ALTER TABLE mcp_server_configs
ADD COLUMN model_intent BOOLEAN NOT NULL DEFAULT false;
+1
View File
@@ -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 {
+27 -12
View File
@@ -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
}
@@ -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
+13 -3
View File
@@ -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,
}
}
+75 -7
View File
@@ -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
+159
View File
@@ -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")
}