mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
Closes CODAGT-1131 Fifth PR in the inline MCP servers stack, on top of #29520. A chat owner can now declare MCP servers by URL and headers on their own chat, and chatd connects to them every turn alongside the org-configured ones. They go in a new `inline_mcp_servers` field on `POST /api/v2/chats` and `POST /api/v2/chats/{chat}/messages`, which replaces the whole set (omit to keep, `[]` to remove). Root chats only. `GET /api/v2/chats/{chat}` returns them, with the URL blanked for non-owners and header values never returned. Subagents only get servers marked `allow_in_subagents`, a root chat in explore mode gets none, and a root chat in plan mode gets all of them. URLs must be `https`, or `http` to an IP literal in `CODER_MCP_ALLOWED_PRIVATE_CIDRS`. Header values have no minimum length, so anything under the redactor's 8 byte floor from #29518 is accepted but not redacted, which the docs call out. Every cap is exported from `codersdk`. It's behind the `chat-inline-mcp-servers` experiment, and the `--disable-chat-caller-supplied-tools` kill switch from #29466 covers it too. With either off, declaring servers gets a 403 and existing chats stop connecting to theirs, but `[]` still works so a client can detach. Load failures show up in the chat debug panel as failed connections, as do org servers with an invalid config. Docs are hand-written in `mcp-servers.md`.
320 lines
12 KiB
Go
320 lines
12 KiB
Go
package coderd_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/database/dbgen"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
"github.com/coder/serpent"
|
|
)
|
|
|
|
func chatTextContent(text string) []codersdk.ChatInputPart {
|
|
return []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: text}}
|
|
}
|
|
|
|
func TestInlineMCPServers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("CreateAndGet", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, db := newChatClientWithDatabase(t, withChatWorkerDisabled)
|
|
user := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModel(t, client)
|
|
|
|
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: user.OrganizationID,
|
|
Content: chatTextContent("hello"),
|
|
InlineMCPServers: []codersdk.InlineMCPServerRequest{{
|
|
Slug: "orders",
|
|
URL: "https://mcp.example.com/v1",
|
|
Headers: map[string]string{"Authorization": "Bearer secret-value-123"},
|
|
ToolAllowList: []string{"lookup"},
|
|
AllowInSubagents: true,
|
|
ForwardCoderHeaders: true,
|
|
}},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
rows, err := db.GetChatMCPServersByChatID(dbauthz.AsSystemRestricted(ctx), chat.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 1)
|
|
require.Equal(t, "orders", rows[0].Slug)
|
|
require.JSONEq(t, `{"Authorization":"Bearer secret-value-123"}`, rows[0].Headers)
|
|
|
|
got, err := client.GetChat(ctx, chat.ID)
|
|
require.NoError(t, err)
|
|
servers := got.InlineMCPServers
|
|
require.Len(t, servers, 1)
|
|
require.Equal(t, rows[0].ID, servers[0].ID)
|
|
require.Equal(t, "orders", servers[0].Slug)
|
|
require.Equal(t, "https://mcp.example.com/v1", servers[0].URL)
|
|
require.True(t, servers[0].HasCustomHeaders)
|
|
require.Equal(t, []string{"lookup"}, servers[0].ToolAllowList)
|
|
require.Equal(t, []string{}, servers[0].ToolDenyList)
|
|
require.True(t, servers[0].AllowInSubagents)
|
|
require.True(t, servers[0].ForwardCoderHeaders)
|
|
|
|
raw, err := json.Marshal(got)
|
|
require.NoError(t, err)
|
|
require.NotContains(t, string(raw), "secret-value-123")
|
|
})
|
|
|
|
t.Run("ExperimentDisabled", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, db := newChatClientWithDatabase(t, func(o *coderdtest.Options) {
|
|
o.DeploymentValues.Experiments = serpent.StringArray{string(codersdk.ExperimentAgentLifecycleHooks)}
|
|
})
|
|
user := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModel(t, client)
|
|
|
|
_, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: user.OrganizationID,
|
|
Content: chatTextContent("hello"),
|
|
InlineMCPServers: []codersdk.InlineMCPServerRequest{{Slug: "orders", URL: "https://mcp.example.com/v1"}},
|
|
})
|
|
sdkErr := requireSDKError(t, err, http.StatusForbidden)
|
|
require.Equal(t, "Inline MCP servers are not enabled on this deployment.", sdkErr.Message)
|
|
|
|
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: user.OrganizationID,
|
|
Content: chatTextContent("hello"),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
|
|
Content: chatTextContent("again"),
|
|
InlineMCPServers: &[]codersdk.InlineMCPServerRequest{{Slug: "orders", URL: "https://mcp.example.com/v1"}},
|
|
})
|
|
sdkErr = requireSDKError(t, err, http.StatusForbidden)
|
|
require.Equal(t, "Inline MCP servers are not enabled on this deployment.", sdkErr.Message)
|
|
|
|
dbgen.ChatMCPServer(t, db, database.ChatMCPServer{ChatID: chat.ID, Slug: "stored"})
|
|
got, err := client.GetChat(ctx, chat.ID)
|
|
require.NoError(t, err)
|
|
require.Empty(t, got.InlineMCPServers)
|
|
})
|
|
|
|
t.Run("CallerSuppliedToolsDisabled", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, db := newChatClientWithDatabase(t, withChatWorkerDisabled, func(o *coderdtest.Options) {
|
|
o.DeploymentValues.DisableChatCallerSuppliedTools = serpent.Bool(true)
|
|
})
|
|
user := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModel(t, client)
|
|
|
|
_, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: user.OrganizationID,
|
|
Content: chatTextContent("hello"),
|
|
InlineMCPServers: []codersdk.InlineMCPServerRequest{{Slug: "orders", URL: "https://mcp.example.com/v1"}},
|
|
})
|
|
sdkErr := requireSDKError(t, err, http.StatusForbidden)
|
|
require.Equal(t, "Caller-supplied tools are disabled on this deployment.", sdkErr.Message)
|
|
|
|
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: user.OrganizationID,
|
|
Content: chatTextContent("hello"),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
|
|
Content: chatTextContent("again"),
|
|
InlineMCPServers: &[]codersdk.InlineMCPServerRequest{{Slug: "orders", URL: "https://mcp.example.com/v1"}},
|
|
})
|
|
sdkErr = requireSDKError(t, err, http.StatusForbidden)
|
|
require.Equal(t, "Caller-supplied tools are disabled on this deployment.", sdkErr.Message)
|
|
|
|
dbgen.ChatMCPServer(t, db, database.ChatMCPServer{ChatID: chat.ID, Slug: "stale"})
|
|
_, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
|
|
Content: chatTextContent("detach"),
|
|
InlineMCPServers: &[]codersdk.InlineMCPServerRequest{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
rows, err := db.GetChatMCPServersByChatID(dbauthz.AsSystemRestricted(ctx), chat.ID)
|
|
require.NoError(t, err)
|
|
require.Empty(t, rows)
|
|
})
|
|
|
|
t.Run("Validation", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := newChatClient(t, withChatWorkerDisabled)
|
|
user := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModel(t, client)
|
|
|
|
_, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: user.OrganizationID,
|
|
Content: chatTextContent("hello"),
|
|
InlineMCPServers: []codersdk.InlineMCPServerRequest{{Slug: "bad slug", URL: "https://mcp.example.com/v1"}},
|
|
})
|
|
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
|
|
require.Equal(t, "Invalid inline_mcp_servers.", sdkErr.Message)
|
|
require.NotEmpty(t, sdkErr.Validations)
|
|
require.Equal(t, "inline_mcp_servers[0].slug", sdkErr.Validations[0].Field)
|
|
|
|
_, err = client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: user.OrganizationID,
|
|
Content: chatTextContent("hello"),
|
|
InlineMCPServers: []codersdk.InlineMCPServerRequest{{
|
|
Slug: "local",
|
|
URL: "http://127.0.0.1:1/mcp",
|
|
Headers: map[string]string{"Authorization": "Bearer x"},
|
|
}},
|
|
})
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("ReplaceKeepClear", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, db := newChatClientWithDatabase(t, withChatWorkerDisabled)
|
|
user := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModel(t, client)
|
|
dbCtx := dbauthz.AsSystemRestricted(ctx)
|
|
|
|
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: user.OrganizationID,
|
|
Content: chatTextContent("hello"),
|
|
InlineMCPServers: []codersdk.InlineMCPServerRequest{
|
|
{Slug: "a", URL: "https://a.example.com/mcp"},
|
|
{Slug: "b", URL: "https://b.example.com/mcp"},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
|
|
Content: chatTextContent("keep"),
|
|
})
|
|
require.NoError(t, err)
|
|
rows, err := db.GetChatMCPServersByChatID(dbCtx, chat.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 2)
|
|
|
|
_, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
|
|
Content: chatTextContent("replace"),
|
|
InlineMCPServers: &[]codersdk.InlineMCPServerRequest{{Slug: "c", URL: "https://c.example.com/mcp"}},
|
|
})
|
|
require.NoError(t, err)
|
|
rows, err = db.GetChatMCPServersByChatID(dbCtx, chat.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 1)
|
|
require.Equal(t, "c", rows[0].Slug)
|
|
|
|
_, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
|
|
Content: chatTextContent("clear"),
|
|
InlineMCPServers: &[]codersdk.InlineMCPServerRequest{},
|
|
})
|
|
require.NoError(t, err)
|
|
rows, err = db.GetChatMCPServersByChatID(dbCtx, chat.ID)
|
|
require.NoError(t, err)
|
|
require.Empty(t, rows)
|
|
})
|
|
|
|
t.Run("ChildChatRejected", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, db := newChatClientWithDatabase(t, withChatWorkerDisabled)
|
|
user := coderdtest.CreateFirstUser(t, client.Client)
|
|
model := createChatModel(t, client)
|
|
|
|
parent, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: user.OrganizationID,
|
|
Content: chatTextContent("hello"),
|
|
})
|
|
require.NoError(t, err)
|
|
child := dbgen.Chat(t, db, database.Chat{
|
|
OrganizationID: user.OrganizationID,
|
|
OwnerID: user.UserID,
|
|
LastModelConfigID: model.ID,
|
|
ParentChatID: uuid.NullUUID{UUID: parent.ID, Valid: true},
|
|
RootChatID: uuid.NullUUID{UUID: parent.ID, Valid: true},
|
|
})
|
|
|
|
_, err = client.CreateChatMessage(ctx, child.ID, codersdk.CreateChatMessageRequest{
|
|
Content: chatTextContent("child"),
|
|
InlineMCPServers: &[]codersdk.InlineMCPServerRequest{{Slug: "orders", URL: "https://mcp.example.com/v1"}},
|
|
})
|
|
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
|
|
require.Equal(t, "inline_mcp_servers can only be declared on a root chat.", sdkErr.Message)
|
|
|
|
// An empty declaration is still a declaration on a child chat.
|
|
_, err = client.CreateChatMessage(ctx, child.ID, codersdk.CreateChatMessageRequest{
|
|
Content: chatTextContent("child again"),
|
|
InlineMCPServers: &[]codersdk.InlineMCPServerRequest{},
|
|
})
|
|
sdkErr = requireSDKError(t, err, http.StatusBadRequest)
|
|
require.Equal(t, "inline_mcp_servers can only be declared on a root chat.", sdkErr.Message)
|
|
})
|
|
|
|
t.Run("NonOwnerURLHidden", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := newChatClient(t, withChatWorkerDisabled)
|
|
user := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModel(t, client)
|
|
|
|
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: user.OrganizationID,
|
|
Content: chatTextContent("hello"),
|
|
InlineMCPServers: []codersdk.InlineMCPServerRequest{{Slug: "orders", URL: "https://mcp.example.com/v1"}},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
got, err := client.GetChat(ctx, chat.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, got.InlineMCPServers, 1)
|
|
require.Equal(t, "https://mcp.example.com/v1", got.InlineMCPServers[0].URL)
|
|
|
|
reader, readerUser := coderdtest.CreateAnotherUser(t, client.Client, user.OrganizationID)
|
|
err = client.UpdateChatACL(ctx, chat.ID, codersdk.UpdateChatACL{
|
|
UserRoles: map[string]codersdk.ChatRole{readerUser.ID.String(): codersdk.ChatRoleRead},
|
|
})
|
|
require.NoError(t, err)
|
|
got, err = reader.GetChat(ctx, chat.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, got.InlineMCPServers, 1)
|
|
require.Equal(t, "orders", got.InlineMCPServers[0].Slug)
|
|
require.Empty(t, got.InlineMCPServers[0].URL)
|
|
})
|
|
|
|
t.Run("LoadFailureNonFatal", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, db := newChatClientWithDatabase(t, withChatWorkerDisabled)
|
|
user := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModel(t, client)
|
|
|
|
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: user.OrganizationID,
|
|
Content: chatTextContent("hello"),
|
|
})
|
|
require.NoError(t, err)
|
|
dbgen.ChatMCPServer(t, db, database.ChatMCPServer{ChatID: chat.ID, Slug: "broken", Headers: "not json"})
|
|
|
|
got, err := client.GetChat(ctx, chat.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, chat.ID, got.ID)
|
|
require.Empty(t, got.InlineMCPServers)
|
|
})
|
|
}
|