diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 8a0970306..99e2f142d 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1842,7 +1842,7 @@ public sealed class McpStdioServerConfig : McpServerConfig /// Arguments to pass to the command. /// [JsonPropertyName("args")] - public IList Args { get => field ??= []; set; } + public IList? Args { get; set; } /// /// Environment variables to pass to the server. diff --git a/dotnet/test/E2E/SessionMcpAndAgentConfigE2ETests.cs b/dotnet/test/E2E/SessionMcpAndAgentConfigE2ETests.cs index f736c6576..1fd7feedd 100644 --- a/dotnet/test/E2E/SessionMcpAndAgentConfigE2ETests.cs +++ b/dotnet/test/E2E/SessionMcpAndAgentConfigE2ETests.cs @@ -41,6 +41,34 @@ public async Task Should_Accept_MCP_Server_Configuration_On_Session_Create() await session.DisposeAsync(); } + [Fact] + public async Task Should_Accept_MCP_Server_Configuration_Without_Args() + { + var mcpServers = new Dictionary + { + ["test-server"] = new McpStdioServerConfig + { + Command = "echo", + Tools = ["*"] + } + }; + + var session = await CreateSessionAsync(new SessionConfig + { + McpServers = mcpServers + }); + + Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); + + await session.SendAsync(new MessageOptions { Prompt = "What is 2+2?" }); + + var message = await TestHelper.GetFinalAssistantMessageAsync(session); + Assert.NotNull(message); + Assert.Contains("4", message!.Data.Content); + + await session.DisposeAsync(); + } + [Fact] public async Task Should_Accept_MCP_Server_Configuration_On_Session_Resume() { diff --git a/go/internal/e2e/mcp_and_agents_e2e_test.go b/go/internal/e2e/mcp_and_agents_e2e_test.go index 8777eec88..e7273edf2 100644 --- a/go/internal/e2e/mcp_and_agents_e2e_test.go +++ b/go/internal/e2e/mcp_and_agents_e2e_test.go @@ -57,6 +57,47 @@ func TestMCPServersE2E(t *testing.T) { session.Disconnect() }) + t.Run("accept MCP server config without args", func(t *testing.T) { + ctx.ConfigureForTest(t) + + mcpServers := map[string]copilot.MCPServerConfig{ + "test-server": copilot.MCPStdioServerConfig{ + Command: "echo", + Tools: []string{"*"}, + }, + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + MCPServers: mcpServers, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + if session.SessionID == "" { + t.Error("Expected non-empty session ID") + } + + _, err = session.Send(t.Context(), copilot.MessageOptions{ + Prompt: "What is 2+2?", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + message, err := testharness.GetFinalAssistantMessage(t.Context(), session) + if err != nil { + t.Fatalf("Failed to get final message: %v", err) + } + + if md, ok := message.Data.(*copilot.AssistantMessageData); !ok || !strings.Contains(md.Content, "4") { + t.Errorf("Expected message to contain '4', got: %v", message.Data) + } + + session.Disconnect() + }) + t.Run("accept MCP server config on resume", func(t *testing.T) { ctx.ConfigureForTest(t) diff --git a/go/types.go b/go/types.go index 19bc892cc..be3496c89 100644 --- a/go/types.go +++ b/go/types.go @@ -480,7 +480,7 @@ type MCPStdioServerConfig struct { Tools []string `json:"tools"` Timeout int `json:"timeout,omitempty"` Command string `json:"command"` - Args []string `json:"args"` + Args []string `json:"args,omitempty"` Env map[string]string `json:"env,omitempty"` Cwd string `json:"cwd,omitempty"` } diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index a8e3bdfe5..00cb177a6 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1165,7 +1165,7 @@ interface MCPServerConfigBase { export interface MCPStdioServerConfig extends MCPServerConfigBase { type?: "local" | "stdio"; command: string; - args: string[]; + args?: string[]; /** * Environment variables to pass to the server. */ diff --git a/nodejs/test/e2e/mcp_and_agents.e2e.test.ts b/nodejs/test/e2e/mcp_and_agents.e2e.test.ts index 2bd9ac6d8..93a8df7a4 100644 --- a/nodejs/test/e2e/mcp_and_agents.e2e.test.ts +++ b/nodejs/test/e2e/mcp_and_agents.e2e.test.ts @@ -44,6 +44,30 @@ describe("MCP Servers and Custom Agents", async () => { await session.disconnect(); }); + it("should accept MCP server configuration without args", async () => { + const mcpServers: Record = { + "test-server": { + type: "local", + command: "echo", + tools: ["*"], + } as MCPStdioServerConfig, + }; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + mcpServers, + }); + + expect(session.sessionId).toBeDefined(); + + const message = await session.sendAndWait({ + prompt: "What is 2+2?", + }); + expect(message?.data.content).toContain("4"); + + await session.disconnect(); + }); + it("should accept MCP server configuration on session resume", async () => { // Create a session first const session1 = await client.createSession({ onPermissionRequest: approveAll }); diff --git a/python/copilot/session.py b/python/copilot/session.py index 4789724fb..82736cddd 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -786,7 +786,7 @@ class MCPStdioServerConfig(TypedDict, total=False): type: NotRequired[Literal["local", "stdio"]] # Server type timeout: NotRequired[int] # Timeout in milliseconds command: str # Command to run - args: list[str] # Command arguments + args: NotRequired[list[str]] # Command arguments env: NotRequired[dict[str, str]] # Environment variables cwd: NotRequired[str] # Working directory diff --git a/python/e2e/test_mcp_and_agents_e2e.py b/python/e2e/test_mcp_and_agents_e2e.py index b61e5b2eb..1119a71f9 100644 --- a/python/e2e/test_mcp_and_agents_e2e.py +++ b/python/e2e/test_mcp_and_agents_e2e.py @@ -44,6 +44,27 @@ async def test_should_accept_mcp_server_configuration_on_session_create( await session.disconnect() + async def test_should_accept_mcp_server_configuration_without_args(self, ctx: E2ETestContext): + """Test that MCP server configuration works without args field""" + mcp_servers: dict[str, MCPServerConfig] = { + "test-server": { + "command": "echo", + "tools": ["*"], + } + } + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, mcp_servers=mcp_servers + ) + + assert session.session_id is not None + + message = await session.send_and_wait("What is 2+2?") + assert message is not None + assert "4" in message.data.content + + await session.disconnect() + async def test_should_accept_mcp_server_configuration_on_session_resume( self, ctx: E2ETestContext ): diff --git a/rust/src/types.rs b/rust/src/types.rs index bd1fb6928..70f0c16b7 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -781,7 +781,7 @@ pub struct McpStdioServerConfig { /// Subprocess executable. pub command: String, /// Arguments to pass to the subprocess. - #[serde(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub args: Vec, /// Environment variables to set on the subprocess. Values are passed /// through literally to the child process. diff --git a/rust/tests/e2e/mcp_and_agents.rs b/rust/tests/e2e/mcp_and_agents.rs index ab74d73bb..a08275cde 100644 --- a/rust/tests/e2e/mcp_and_agents.rs +++ b/rust/tests/e2e/mcp_and_agents.rs @@ -38,6 +38,48 @@ async fn accept_mcp_server_config_on_create() { .await; } +#[tokio::test] +async fn accept_mcp_server_config_without_args() { + with_e2e_context( + "mcp_and_agents", + "accept_mcp_server_config_without_args", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + + let mcp_servers = HashMap::from([( + "test-server".to_string(), + McpServerConfig::Stdio(McpStdioServerConfig { + tools: vec!["*".to_string()], + command: "echo".to_string(), + ..McpStdioServerConfig::default() + }), + )]); + + let session = client + .create_session( + ctx.approve_all_session_config() + .with_mcp_servers(mcp_servers), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait("What is 2+2?") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains('4')); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + #[tokio::test] async fn accept_mcp_server_config_on_resume() { with_e2e_context( diff --git a/test/snapshots/mcp_and_agents/accept_mcp_server_config_without_args.yaml b/test/snapshots/mcp_and_agents/accept_mcp_server_config_without_args.yaml new file mode 100644 index 000000000..9fe2fcd07 --- /dev/null +++ b/test/snapshots/mcp_and_agents/accept_mcp_server_config_without_args.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 2+2? + - role: assistant + content: 2 + 2 = 4 diff --git a/test/snapshots/mcp_and_agents/should_accept_mcp_server_configuration_without_args.yaml b/test/snapshots/mcp_and_agents/should_accept_mcp_server_configuration_without_args.yaml new file mode 100644 index 000000000..9fe2fcd07 --- /dev/null +++ b/test/snapshots/mcp_and_agents/should_accept_mcp_server_configuration_without_args.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 2+2? + - role: assistant + content: 2 + 2 = 4