Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

- Stop Slack channel, history, thread, and DM pagination with a clear error when a cursor repeats instead of looping indefinitely. Thanks @SebTardif! (#159)
- Cancel hung Slack Desktop Node decoders when stopping sync, watch, or doctor. Thanks @SebTardif! (#163)
- Stop MCP stdio servers and unblock full stdin pipes when sync is canceled. Thanks @SebTardif! (#164)

### Maintenance

Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,8 @@ The subprocess receives a minimal environment plus known Slack/Codex token varia

Tool discovery selects either the Codex Slack connector contract or the reference `slack_list_channels`, `slack_get_channel_history`, `slack_get_thread_replies`, and `slack_get_users` contract.

Ctrl-C cancels MCP sync and stops its stdio server, including when the server stops reading requests and fills the stdin pipe.

## External Archive Providers

Use `[[providers]]` to adapt another local archive to the canonical SQLite
Expand Down
54 changes: 54 additions & 0 deletions internal/mcpclient/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,60 @@ func TestStdioClientUsesMinimalEnvironment(t *testing.T) {
require.JSONEq(t, `{"allowed":"visible","secret":""}`, text)
}

func TestNewStdioRejectsCanceledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
client, err := NewStdio(ctx, StdioOptions{Command: "/bin/sleep", Args: []string{"30"}})
if client != nil {
_ = client.Close()
}
require.ErrorIs(t, err, context.Canceled)
require.Nil(t, client)
}

func TestNewStdioStopsChildWhenContextCanceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
client, err := NewStdio(ctx, StdioOptions{Command: "/bin/sleep", Args: []string{"30"}})
require.NoError(t, err)
cancel()
select {
case <-client.waitCh:
_ = client.stdin.Close()
case <-time.After(5 * time.Second):
_ = client.Close()
t.Fatal("MCP stdio child still running after spawn context cancel")
}
}

func TestStdioWriteUnblocksWhenContextCanceled(t *testing.T) {
client, err := NewStdio(context.Background(), StdioOptions{Command: "/bin/sleep", Args: []string{"30"}})
require.NoError(t, err)
defer func() { _ = client.Close() }()

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() {
done <- client.write(ctx, map[string]any{
"jsonrpc": "2.0",
"method": "initialize",
"params": map[string]any{"blob": strings.Repeat("x", 2<<20)},
})
}()
select {
case err := <-done:
t.Fatalf("write returned before cancel: %v", err)
case <-time.After(100 * time.Millisecond):
}
cancel()
select {
case err := <-done:
require.ErrorIs(t, err, context.Canceled)
case <-time.After(5 * time.Second):
t.Fatal("write stayed blocked after context cancel")
}
}

func TestMCPStdioHelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_MCP_STDIO_HELPER") != "1" {
return
Expand Down
34 changes: 29 additions & 5 deletions internal/mcpclient/stdio.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,13 @@ type StdioClient struct {
waitErr error
}

func NewStdio(_ context.Context, opts StdioOptions) (*StdioClient, error) {
func NewStdio(ctx context.Context, opts StdioOptions) (*StdioClient, error) {
if strings.TrimSpace(opts.Command) == "" {
return nil, errors.New("MCP stdio command is required")
}
if err := ctx.Err(); err != nil {
return nil, err
}
if opts.ProtocolVersion == "" {
opts.ProtocolVersion = DefaultProtocolVersion
}
Expand All @@ -64,7 +67,7 @@ func NewStdio(_ context.Context, opts StdioOptions) (*StdioClient, error) {
if opts.ClientVersion == "" {
opts.ClientVersion = "dev"
}
cmd := exec.Command(opts.Command, opts.Args...)
cmd := exec.CommandContext(ctx, opts.Command, opts.Args...)
cmd.Env = stdioEnvironment(opts)
stdin, err := cmd.StdinPipe()
if err != nil {
Expand Down Expand Up @@ -277,10 +280,31 @@ func (c *StdioClient) write(ctx context.Context, value any) error {
}
c.writeMu.Lock()
defer c.writeMu.Unlock()
if _, err := c.stdin.Write(append(raw, '\n')); err != nil {
return fmt.Errorf("write MCP stdio request: %w", err)
if err := ctx.Err(); err != nil {
return err
}
// Write from a goroutine so a child that stops reading stdin cannot
// park the caller after context cancel. Closing stdin and killing the
// process unblocks a full pipe, matching provider.Sync.
errCh := make(chan error, 1)
go func() {
_, writeErr := c.stdin.Write(append(raw, '\n'))
errCh <- writeErr
}()
select {
case <-ctx.Done():
_ = c.stdin.Close()
if c.cmd != nil && c.cmd.Process != nil {
_ = c.cmd.Process.Kill()
}
<-errCh
return ctx.Err()
case err := <-errCh:
if err != nil {
return fmt.Errorf("write MCP stdio request: %w", err)
}
return nil
}
return nil
}

func (c *StdioClient) readLoop(stdout io.Reader) {
Expand Down