Skip to content

Commit 6aa4ffd

Browse files
authored
Merge pull request #17 from ethpandaops/fix/stdio-mcp-args-toml-array
fix: serialize stdio MCP Args as TOML inline array
2 parents abd787a + a12c992 commit 6aa4ffd

4 files changed

Lines changed: 189 additions & 4 deletions

File tree

examples/mcp_stdio_args/main.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Package main demonstrates configuring a stdio MCP server with command-line
2+
// arguments (Args).
3+
//
4+
// The placeholder command `/bin/sh -c "cat"` is used so the example can run
5+
// without depending on a real MCP binary being installed — it is enough for
6+
// codex to load the config and complete the app-server handshake. Replace the
7+
// Command and Args with a real stdio MCP launcher (for example
8+
// `npx -y @playwright/mcp@latest`) in practice.
9+
//
10+
// Historical context: before Args were serialized as a TOML inline array,
11+
// running this example failed with "transport closed while waiting for
12+
// response" because codex rejected the config with
13+
// `invalid type: string ..., expected a sequence`.
14+
package main
15+
16+
import (
17+
"context"
18+
"fmt"
19+
"log/slog"
20+
"os"
21+
"time"
22+
23+
codexsdk "github.com/ethpandaops/codex-agent-sdk-go"
24+
)
25+
26+
func main() {
27+
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
28+
29+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
30+
defer cancel()
31+
32+
client := codexsdk.NewClient()
33+
34+
defer func() {
35+
if err := client.Close(); err != nil {
36+
fmt.Fprintf(os.Stderr, "failed to close client: %v\n", err)
37+
}
38+
}()
39+
40+
err := client.Start(ctx,
41+
codexsdk.WithLogger(logger),
42+
codexsdk.WithMCPServers(map[string]codexsdk.MCPServerConfig{
43+
"stdio-args": &codexsdk.MCPStdioServerConfig{
44+
Command: "/bin/sh",
45+
Args: []string{"-c", "cat"},
46+
},
47+
}),
48+
)
49+
if err != nil {
50+
fmt.Fprintf(os.Stderr, "client.Start failed: %v\n", err)
51+
os.Exit(1)
52+
}
53+
54+
fmt.Println("client started: stdio MCP server with Args configured successfully")
55+
}

integration/mcp_tools_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,40 @@ func TestSDKTools_ReturnValue(t *testing.T) {
361361
require.True(t, mentionedNumber, "Agent should mention the returned number (42)")
362362
}
363363

364+
// TestMCPStdio_ConfigLoadsWithArgs is a regression test for the bug where
365+
// MCPStdioServerConfig.Args was serialized as repeated scalar -c overrides,
366+
// each replacing the prior value at the same dotted path. That produced a
367+
// single string for `args` instead of a TOML array, and codex exited during
368+
// config validation before the app-server handshake could complete.
369+
//
370+
// The surfaced error was "transport closed while waiting for response" —
371+
// which pointed at the transport layer, not at config serialization.
372+
//
373+
// This test uses `/bin/sh -c 'cat'` as a placeholder stdio "server": it never
374+
// speaks MCP, but codex only needs the config to load cleanly for client.Start
375+
// to succeed. Inner MCP handshake failures surface via GetMCPStatus, not Start.
376+
func TestMCPStdio_ConfigLoadsWithArgs(t *testing.T) {
377+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
378+
defer cancel()
379+
380+
client := codexsdk.NewClient()
381+
defer client.Close()
382+
383+
err := client.Start(ctx,
384+
codexsdk.WithPermissionMode("bypassPermissions"),
385+
codexsdk.WithMCPServers(map[string]codexsdk.MCPServerConfig{
386+
"stdio-args": &codexsdk.MCPStdioServerConfig{
387+
Command: "/bin/sh",
388+
Args: []string{"-c", "cat"},
389+
},
390+
}),
391+
)
392+
if err != nil {
393+
skipIfCLINotInstalled(t, err)
394+
t.Fatalf("client.Start failed with stdio MCP args (likely Args serialization regression): %v", err)
395+
}
396+
}
397+
364398
func TestMCPStatus_IncludesSDKServerMetadata(t *testing.T) {
365399
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
366400
defer cancel()

internal/cli/cli_test.go

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,13 @@ func TestBuildExecArgs_WithMCPServers_SSE(t *testing.T) {
878878
}
879879

880880
// TestBuildExecArgs_WithMCPServers_Stdio tests stdio MCP server serialization.
881+
//
882+
// Args must be emitted as a single TOML inline array under one -c override.
883+
// Emitting one -c mcp_servers.NAME.args=VAL per element does NOT accumulate
884+
// into an array — each -c override replaces the previous value at the same
885+
// dotted path. Codex's config schema requires Vec<String>, so repeated scalar
886+
// overrides cause the subprocess to exit before handshake with
887+
// `invalid type: string ..., expected a sequence`.
881888
func TestBuildExecArgs_WithMCPServers_Stdio(t *testing.T) {
882889
options := &config.Options{
883890
MCPServers: map[string]mcp.ServerConfig{
@@ -893,12 +900,90 @@ func TestBuildExecArgs_WithMCPServers_Stdio(t *testing.T) {
893900

894901
require.Contains(t, args, "mcp_servers.local-tool.type=stdio")
895902
require.Contains(t, args, "mcp_servers.local-tool.command=/usr/bin/my-tool")
896-
require.Contains(t, args, "mcp_servers.local-tool.args=--port")
897-
require.Contains(t, args, "mcp_servers.local-tool.args=8080")
903+
require.Contains(t, args, `mcp_servers.local-tool.args=["--port","8080"]`,
904+
"Args must be serialized as a single TOML inline array override")
905+
require.NotContains(t, args, "mcp_servers.local-tool.args=--port",
906+
"Args must NOT be emitted as repeated scalar -c overrides")
907+
require.NotContains(t, args, "mcp_servers.local-tool.args=8080",
908+
"Args must NOT be emitted as repeated scalar -c overrides")
898909
require.Contains(t, args, "mcp_servers.local-tool.env.DEBUG=true")
899910
require.Contains(t, args, "mcp_servers.local-tool.env.HOME=/tmp")
900911
}
901912

913+
// TestBuildExecArgs_WithMCPServers_StdioArgsNpx covers the real-world npx case
914+
// from the issue report: `npx -y @playwright/mcp@latest`.
915+
func TestBuildExecArgs_WithMCPServers_StdioArgsNpx(t *testing.T) {
916+
options := &config.Options{
917+
MCPServers: map[string]mcp.ServerConfig{
918+
"playwright": &mcp.StdioServerConfig{
919+
Command: "npx",
920+
Args: []string{"-y", "@playwright/mcp@latest"},
921+
},
922+
},
923+
}
924+
925+
args := BuildExecArgs("test", options)
926+
927+
require.Contains(t, args, `mcp_servers.playwright.args=["-y","@playwright/mcp@latest"]`)
928+
}
929+
930+
// TestBuildExecArgs_WithMCPServers_StdioArgsSingleton ensures even a single arg
931+
// is wrapped in array form — codex config declares args as Vec<String>, so a
932+
// single scalar value fails type-checking even with one element.
933+
func TestBuildExecArgs_WithMCPServers_StdioArgsSingleton(t *testing.T) {
934+
options := &config.Options{
935+
MCPServers: map[string]mcp.ServerConfig{
936+
"solo": &mcp.StdioServerConfig{
937+
Command: "tool",
938+
Args: []string{"only-arg"},
939+
},
940+
},
941+
}
942+
943+
args := BuildExecArgs("test", options)
944+
945+
require.Contains(t, args, `mcp_servers.solo.args=["only-arg"]`)
946+
require.NotContains(t, args, "mcp_servers.solo.args=only-arg")
947+
}
948+
949+
// TestBuildExecArgs_WithMCPServers_StdioArgsEscaping verifies that args with
950+
// quotes, backslashes, or other special characters are safely escaped so the
951+
// resulting TOML is valid.
952+
func TestBuildExecArgs_WithMCPServers_StdioArgsEscaping(t *testing.T) {
953+
options := &config.Options{
954+
MCPServers: map[string]mcp.ServerConfig{
955+
"edge": &mcp.StdioServerConfig{
956+
Command: "echo",
957+
Args: []string{`has "quote"`, `c:\path\to`},
958+
},
959+
},
960+
}
961+
962+
args := BuildExecArgs("test", options)
963+
964+
require.Contains(t, args, `mcp_servers.edge.args=["has \"quote\"","c:\\path\\to"]`)
965+
}
966+
967+
// TestBuildExecArgs_WithMCPServers_StdioArgsEmpty ensures no args= override is
968+
// emitted at all when Args is empty — emitting args="" would make codex see a
969+
// scalar empty string instead of an empty Vec<String>.
970+
func TestBuildExecArgs_WithMCPServers_StdioArgsEmpty(t *testing.T) {
971+
options := &config.Options{
972+
MCPServers: map[string]mcp.ServerConfig{
973+
"no-args": &mcp.StdioServerConfig{
974+
Command: "my-cmd",
975+
},
976+
},
977+
}
978+
979+
args := BuildExecArgs("test", options)
980+
981+
for _, a := range args {
982+
require.NotContains(t, a, "mcp_servers.no-args.args",
983+
"Empty Args must not produce any args= override")
984+
}
985+
}
986+
902987
// TestBuildExecArgs_WithMCPServers_SDKSkipped tests that SDK servers are skipped.
903988
func TestBuildExecArgs_WithMCPServers_SDKSkipped(t *testing.T) {
904989
options := &config.Options{

internal/cli/command.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os"
66
"sort"
77
"strconv"
8+
"strings"
89

910
"github.com/ethpandaops/codex-agent-sdk-go/internal/config"
1011
"github.com/ethpandaops/codex-agent-sdk-go/internal/mcp"
@@ -65,8 +66,18 @@ func serializeMCPServerConfigArgs(servers map[string]mcp.ServerConfig) []string
6566
args = append(args, "-c", fmt.Sprintf("%s.type=stdio", prefix))
6667
args = append(args, "-c", fmt.Sprintf("%s.command=%s", prefix, c.Command))
6768

68-
for _, arg := range c.Args {
69-
args = append(args, "-c", fmt.Sprintf("%s.args=%s", prefix, arg))
69+
// Args must be emitted as a single TOML inline array override.
70+
// Repeated scalar `-c key=val` overrides replace (not append) at
71+
// the same dotted path, which would leave `args` as a string and
72+
// fail codex config validation (`expected a sequence`).
73+
if len(c.Args) > 0 {
74+
quoted := make([]string, len(c.Args))
75+
for i, a := range c.Args {
76+
quoted[i] = strconv.Quote(a)
77+
}
78+
79+
args = append(args, "-c",
80+
fmt.Sprintf("%s.args=[%s]", prefix, strings.Join(quoted, ",")))
7081
}
7182

7283
envKeys := make([]string, 0, len(c.Env))

0 commit comments

Comments
 (0)