Skip to content

Commit 38ca096

Browse files
stephentoubCopilot
andauthored
Make MCPStdioServerConfig.args optional across all SDKs (#1347)
The runtime schema now allows args to be omitted from stdio MCP server configs. Update user-facing types to match: - Node.js: args: string[] -> args?: string[] - Go: add omitempty to Args json tag (nil/empty not serialized) - Python: mark args as NotRequired[list[str]] - Rust: add skip_serializing_if = Vec::is_empty - .NET: make Args nullable (IList<string>? with no lazy init) Add an E2E test for each SDK that creates a session with an MCPStdioServerConfig that omits args entirely, verifying the optional field works end-to-end. Fixes #1231 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0f4d7ce commit 38ca096

12 files changed

Lines changed: 181 additions & 5 deletions

File tree

dotnet/src/Types.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1842,7 +1842,7 @@ public sealed class McpStdioServerConfig : McpServerConfig
18421842
/// Arguments to pass to the command.
18431843
/// </summary>
18441844
[JsonPropertyName("args")]
1845-
public IList<string> Args { get => field ??= []; set; }
1845+
public IList<string>? Args { get; set; }
18461846

18471847
/// <summary>
18481848
/// Environment variables to pass to the server.

dotnet/test/E2E/SessionMcpAndAgentConfigE2ETests.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,34 @@ public async Task Should_Accept_MCP_Server_Configuration_On_Session_Create()
4141
await session.DisposeAsync();
4242
}
4343

44+
[Fact]
45+
public async Task Should_Accept_MCP_Server_Configuration_Without_Args()
46+
{
47+
var mcpServers = new Dictionary<string, McpServerConfig>
48+
{
49+
["test-server"] = new McpStdioServerConfig
50+
{
51+
Command = "echo",
52+
Tools = ["*"]
53+
}
54+
};
55+
56+
var session = await CreateSessionAsync(new SessionConfig
57+
{
58+
McpServers = mcpServers
59+
});
60+
61+
Assert.Matches(@"^[a-f0-9-]+$", session.SessionId);
62+
63+
await session.SendAsync(new MessageOptions { Prompt = "What is 2+2?" });
64+
65+
var message = await TestHelper.GetFinalAssistantMessageAsync(session);
66+
Assert.NotNull(message);
67+
Assert.Contains("4", message!.Data.Content);
68+
69+
await session.DisposeAsync();
70+
}
71+
4472
[Fact]
4573
public async Task Should_Accept_MCP_Server_Configuration_On_Session_Resume()
4674
{

go/internal/e2e/mcp_and_agents_e2e_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,47 @@ func TestMCPServersE2E(t *testing.T) {
5757
session.Disconnect()
5858
})
5959

60+
t.Run("accept MCP server config without args", func(t *testing.T) {
61+
ctx.ConfigureForTest(t)
62+
63+
mcpServers := map[string]copilot.MCPServerConfig{
64+
"test-server": copilot.MCPStdioServerConfig{
65+
Command: "echo",
66+
Tools: []string{"*"},
67+
},
68+
}
69+
70+
session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{
71+
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
72+
MCPServers: mcpServers,
73+
})
74+
if err != nil {
75+
t.Fatalf("Failed to create session: %v", err)
76+
}
77+
78+
if session.SessionID == "" {
79+
t.Error("Expected non-empty session ID")
80+
}
81+
82+
_, err = session.Send(t.Context(), copilot.MessageOptions{
83+
Prompt: "What is 2+2?",
84+
})
85+
if err != nil {
86+
t.Fatalf("Failed to send message: %v", err)
87+
}
88+
89+
message, err := testharness.GetFinalAssistantMessage(t.Context(), session)
90+
if err != nil {
91+
t.Fatalf("Failed to get final message: %v", err)
92+
}
93+
94+
if md, ok := message.Data.(*copilot.AssistantMessageData); !ok || !strings.Contains(md.Content, "4") {
95+
t.Errorf("Expected message to contain '4', got: %v", message.Data)
96+
}
97+
98+
session.Disconnect()
99+
})
100+
60101
t.Run("accept MCP server config on resume", func(t *testing.T) {
61102
ctx.ConfigureForTest(t)
62103

go/types.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -480,7 +480,7 @@ type MCPStdioServerConfig struct {
480480
Tools []string `json:"tools"`
481481
Timeout int `json:"timeout,omitempty"`
482482
Command string `json:"command"`
483-
Args []string `json:"args"`
483+
Args []string `json:"args,omitempty"`
484484
Env map[string]string `json:"env,omitempty"`
485485
Cwd string `json:"cwd,omitempty"`
486486
}

nodejs/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1165,7 +1165,7 @@ interface MCPServerConfigBase {
11651165
export interface MCPStdioServerConfig extends MCPServerConfigBase {
11661166
type?: "local" | "stdio";
11671167
command: string;
1168-
args: string[];
1168+
args?: string[];
11691169
/**
11701170
* Environment variables to pass to the server.
11711171
*/

nodejs/test/e2e/mcp_and_agents.e2e.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,30 @@ describe("MCP Servers and Custom Agents", async () => {
4444
await session.disconnect();
4545
});
4646

47+
it("should accept MCP server configuration without args", async () => {
48+
const mcpServers: Record<string, MCPServerConfig> = {
49+
"test-server": {
50+
type: "local",
51+
command: "echo",
52+
tools: ["*"],
53+
} as MCPStdioServerConfig,
54+
};
55+
56+
const session = await client.createSession({
57+
onPermissionRequest: approveAll,
58+
mcpServers,
59+
});
60+
61+
expect(session.sessionId).toBeDefined();
62+
63+
const message = await session.sendAndWait({
64+
prompt: "What is 2+2?",
65+
});
66+
expect(message?.data.content).toContain("4");
67+
68+
await session.disconnect();
69+
});
70+
4771
it("should accept MCP server configuration on session resume", async () => {
4872
// Create a session first
4973
const session1 = await client.createSession({ onPermissionRequest: approveAll });

python/copilot/session.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -786,7 +786,7 @@ class MCPStdioServerConfig(TypedDict, total=False):
786786
type: NotRequired[Literal["local", "stdio"]] # Server type
787787
timeout: NotRequired[int] # Timeout in milliseconds
788788
command: str # Command to run
789-
args: list[str] # Command arguments
789+
args: NotRequired[list[str]] # Command arguments
790790
env: NotRequired[dict[str, str]] # Environment variables
791791
cwd: NotRequired[str] # Working directory
792792

python/e2e/test_mcp_and_agents_e2e.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,27 @@ async def test_should_accept_mcp_server_configuration_on_session_create(
4444

4545
await session.disconnect()
4646

47+
async def test_should_accept_mcp_server_configuration_without_args(self, ctx: E2ETestContext):
48+
"""Test that MCP server configuration works without args field"""
49+
mcp_servers: dict[str, MCPServerConfig] = {
50+
"test-server": {
51+
"command": "echo",
52+
"tools": ["*"],
53+
}
54+
}
55+
56+
session = await ctx.client.create_session(
57+
on_permission_request=PermissionHandler.approve_all, mcp_servers=mcp_servers
58+
)
59+
60+
assert session.session_id is not None
61+
62+
message = await session.send_and_wait("What is 2+2?")
63+
assert message is not None
64+
assert "4" in message.data.content
65+
66+
await session.disconnect()
67+
4768
async def test_should_accept_mcp_server_configuration_on_session_resume(
4869
self, ctx: E2ETestContext
4970
):

rust/src/types.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -781,7 +781,7 @@ pub struct McpStdioServerConfig {
781781
/// Subprocess executable.
782782
pub command: String,
783783
/// Arguments to pass to the subprocess.
784-
#[serde(default)]
784+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
785785
pub args: Vec<String>,
786786
/// Environment variables to set on the subprocess. Values are passed
787787
/// through literally to the child process.

rust/tests/e2e/mcp_and_agents.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,48 @@ async fn accept_mcp_server_config_on_create() {
3838
.await;
3939
}
4040

41+
#[tokio::test]
42+
async fn accept_mcp_server_config_without_args() {
43+
with_e2e_context(
44+
"mcp_and_agents",
45+
"accept_mcp_server_config_without_args",
46+
|ctx| {
47+
Box::pin(async move {
48+
ctx.set_default_copilot_user();
49+
let client = ctx.start_client().await;
50+
51+
let mcp_servers = HashMap::from([(
52+
"test-server".to_string(),
53+
McpServerConfig::Stdio(McpStdioServerConfig {
54+
tools: vec!["*".to_string()],
55+
command: "echo".to_string(),
56+
..McpStdioServerConfig::default()
57+
}),
58+
)]);
59+
60+
let session = client
61+
.create_session(
62+
ctx.approve_all_session_config()
63+
.with_mcp_servers(mcp_servers),
64+
)
65+
.await
66+
.expect("create session");
67+
68+
let answer = session
69+
.send_and_wait("What is 2+2?")
70+
.await
71+
.expect("send")
72+
.expect("assistant message");
73+
assert!(assistant_message_content(&answer).contains('4'));
74+
75+
session.disconnect().await.expect("disconnect session");
76+
client.stop().await.expect("stop client");
77+
})
78+
},
79+
)
80+
.await;
81+
}
82+
4183
#[tokio::test]
4284
async fn accept_mcp_server_config_on_resume() {
4385
with_e2e_context(

0 commit comments

Comments
 (0)