Files
dingtalk-workspace-cli/internal/transport/stdio.go
2026-07-15 11:41:30 +08:00

290 lines
6.9 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package transport
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"strings"
"sync"
"sync/atomic"
)
// StdioClient manages a local MCP server subprocess, communicating via
// stdin/stdout using JSON-RPC 2.0 (newline-delimited).
type StdioClient struct {
command string
args []string
env map[string]string
cmd *exec.Cmd
stdin io.WriteCloser
stdout *bufio.Reader
stderr io.ReadCloser
mu sync.Mutex // serializes JSON-RPC requests
nextID int64
started bool
lifecycleMu sync.Mutex
initialized bool
initResult InitializeResult
}
var stdioCommandContext = exec.CommandContext
// NewStdioClient creates a StdioClient for the given command.
// The subprocess is not started until Start() is called.
func NewStdioClient(command string, args []string, env map[string]string) *StdioClient {
return &StdioClient{
command: command,
args: args,
env: env,
}
}
// Start launches the subprocess.
func (s *StdioClient) Start(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.started {
return nil
}
cmd := stdioCommandContext(ctx, s.command, s.args...)
// Build environment: inherit current env + merge plugin-specific vars.
cmd.Env = os.Environ()
for k, v := range s.env {
cmd.Env = append(cmd.Env, k+"="+v)
}
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdio: create stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
stdin.Close()
return fmt.Errorf("stdio: create stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
stdin.Close()
stdout.Close()
return fmt.Errorf("stdio: create stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("stdio: start process %q: %w", s.command, err)
}
s.cmd = cmd
s.stdin = stdin
s.stdout = bufio.NewReaderSize(stdout, 64*1024)
s.stderr = stderr
s.started = true
// Drain stderr in background for debug logging.
go s.drainStderr()
return nil
}
// Stop kills the subprocess and waits for it to exit.
// A non-zero exit code after Kill is expected and not treated as an error.
func (s *StdioClient) Stop() error {
s.lifecycleMu.Lock()
defer s.lifecycleMu.Unlock()
s.mu.Lock()
defer s.mu.Unlock()
if !s.started || s.cmd == nil {
s.initialized = false
s.initResult = InitializeResult{}
return nil
}
s.stdin.Close()
if s.cmd.Process != nil {
_ = s.cmd.Process.Kill()
_ = s.cmd.Wait()
s.started = false
s.initialized = false
s.initResult = InitializeResult{}
return nil
}
err := s.cmd.Wait()
s.started = false
s.initialized = false
s.initResult = InitializeResult{}
return err
}
// Initialize sends the JSON-RPC initialize request.
func (s *StdioClient) Initialize(ctx context.Context) (InitializeResult, error) {
s.lifecycleMu.Lock()
defer s.lifecycleMu.Unlock()
return s.initialize(ctx)
}
// EnsureInitialized lazily starts the subprocess and performs the MCP
// initialize handshake exactly once for the current process lifecycle.
func (s *StdioClient) EnsureInitialized(ctx context.Context) error {
s.lifecycleMu.Lock()
defer s.lifecycleMu.Unlock()
if s.initialized {
return nil
}
if err := s.Start(context.Background()); err != nil {
return err
}
_, err := s.initialize(ctx)
return err
}
func (s *StdioClient) initialize(ctx context.Context) (InitializeResult, error) {
if s.initialized {
return s.initResult, nil
}
params := map[string]any{
"protocolVersion": supportedProtocolVersions[0],
"capabilities": map[string]any{},
"clientInfo": map[string]any{
"name": "dws-cli",
"version": "1.0.0",
},
}
var result InitializeResult
if err := s.call(ctx, "initialize", params, &result); err != nil {
return InitializeResult{}, err
}
s.initialized = true
s.initResult = result
return result, nil
}
// ListTools sends the tools/list JSON-RPC request.
func (s *StdioClient) ListTools(ctx context.Context) (ToolsListResult, error) {
var result ToolsListResult
if err := s.call(ctx, "tools/list", nil, &result); err != nil {
return ToolsListResult{}, err
}
return result, nil
}
// CallTool sends the tools/call JSON-RPC request.
func (s *StdioClient) CallTool(ctx context.Context, tool string, arguments map[string]any) (ToolCallResult, error) {
params := map[string]any{
"name": tool,
"arguments": arguments,
}
var result ToolCallResult
if err := s.call(ctx, "tools/call", params, &result); err != nil {
return ToolCallResult{}, err
}
return result, nil
}
// call sends a JSON-RPC request and reads the response. It is serialized
// by the mutex to ensure one request at a time over the stdio pipe.
func (s *StdioClient) call(ctx context.Context, method string, params any, result any) error {
s.mu.Lock()
defer s.mu.Unlock()
if !s.started {
return fmt.Errorf("stdio: process not started")
}
id := atomic.AddInt64(&s.nextID, 1)
req := requestEnvelope{
JSONRPC: "2.0",
ID: int(id),
Method: method,
Params: params,
}
reqData, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("stdio: marshal request: %w", err)
}
// Write request line.
reqData = append(reqData, '\n')
if _, err := s.stdin.Write(reqData); err != nil {
return fmt.Errorf("stdio: write request: %w", err)
}
// Read response line (respects context cancellation).
type readResult struct {
line []byte
err error
}
ch := make(chan readResult, 1)
go func() {
line, err := s.stdout.ReadBytes('\n')
ch <- readResult{line, err}
}()
select {
case <-ctx.Done():
return fmt.Errorf("stdio: %w", ctx.Err())
case rr := <-ch:
if rr.err != nil {
return fmt.Errorf("stdio: read response: %w", rr.err)
}
var resp responseEnvelope
if err := json.Unmarshal(rr.line, &resp); err != nil {
return fmt.Errorf("stdio: unmarshal response: %w", err)
}
if resp.Error != nil {
return fmt.Errorf("stdio: RPC error %d: %s", resp.Error.Code, resp.Error.Message)
}
if result != nil {
if err := json.Unmarshal(resp.Result, result); err != nil {
return fmt.Errorf("stdio: unmarshal result: %w", err)
}
}
return nil
}
}
// drainStderr reads stderr in the background and logs lines at debug level.
func (s *StdioClient) drainStderr() {
scanner := bufio.NewScanner(s.stderr)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" {
slog.Debug("stdio: subprocess stderr", "command", s.command, "line", line)
}
}
}