Spaces:
Runtime error
Runtime error
| package mcp | |
| import ( | |
| "bufio" | |
| "context" | |
| "encoding/json" | |
| "fmt" | |
| "io" | |
| "os/exec" | |
| "sync" | |
| ) | |
| // Client is a JSON-RPC client speaking to an MCP server over the child | |
| // process's stdin/stdout. A background reader dispatches responses to pending | |
| // callers keyed by request id; server-initiated notifications are ignored. | |
| type Client struct { | |
| cmd *exec.Cmd | |
| stdin io.WriteCloser | |
| stdout *bufio.Reader | |
| mu sync.Mutex | |
| nextID int | |
| pending map[int]chan response | |
| encMu sync.Mutex // serialises writes to stdin | |
| closeOnce sync.Once | |
| readErr error | |
| } | |
| // newClient wires a client to an already-started command's pipes and starts | |
| // the reader loop. | |
| func newClient(cmd *exec.Cmd, stdin io.WriteCloser, stdout io.Reader) *Client { | |
| c := &Client{ | |
| cmd: cmd, | |
| stdin: stdin, | |
| stdout: bufio.NewReaderSize(stdout, 1<<20), | |
| pending: make(map[int]chan response), | |
| } | |
| go c.readLoop() | |
| return c | |
| } | |
| func (c *Client) readLoop() { | |
| for { | |
| line, err := c.stdout.ReadBytes('\n') | |
| if len(line) > 0 { | |
| var msg response | |
| if jErr := json.Unmarshal(line, &msg); jErr == nil { | |
| c.dispatch(msg) | |
| } | |
| } | |
| if err != nil { | |
| c.mu.Lock() | |
| c.readErr = err | |
| for id, ch := range c.pending { | |
| close(ch) | |
| delete(c.pending, id) | |
| } | |
| c.mu.Unlock() | |
| return | |
| } | |
| } | |
| } | |
| func (c *Client) dispatch(msg response) { | |
| if msg.ID == nil { | |
| // Server-initiated notification or request: ignored for the MVP. | |
| return | |
| } | |
| c.mu.Lock() | |
| ch, ok := c.pending[*msg.ID] | |
| if ok { | |
| delete(c.pending, *msg.ID) | |
| } | |
| c.mu.Unlock() | |
| if ok { | |
| ch <- msg | |
| close(ch) | |
| } | |
| } | |
| // call sends a request and waits for the matching response or ctx expiry. | |
| func (c *Client) call(ctx context.Context, method string, params any) (json.RawMessage, error) { | |
| c.mu.Lock() | |
| if c.readErr != nil { | |
| c.mu.Unlock() | |
| return nil, fmt.Errorf("mcp server closed: %w", c.readErr) | |
| } | |
| c.nextID++ | |
| id := c.nextID | |
| ch := make(chan response, 1) | |
| c.pending[id] = ch | |
| c.mu.Unlock() | |
| if err := c.write(request{JSONRPC: "2.0", ID: &id, Method: method, Params: params}); err != nil { | |
| c.mu.Lock() | |
| delete(c.pending, id) | |
| c.mu.Unlock() | |
| return nil, err | |
| } | |
| select { | |
| case <-ctx.Done(): | |
| c.mu.Lock() | |
| delete(c.pending, id) | |
| c.mu.Unlock() | |
| return nil, fmt.Errorf("mcp %s timed out: %w", method, ctx.Err()) | |
| case msg, ok := <-ch: | |
| if !ok { | |
| return nil, fmt.Errorf("mcp %s: server closed before responding", method) | |
| } | |
| if msg.Error != nil { | |
| return nil, fmt.Errorf("mcp %s error %d: %s", method, msg.Error.Code, msg.Error.Message) | |
| } | |
| return msg.Result, nil | |
| } | |
| } | |
| // notify sends a notification (no id, no response expected). | |
| func (c *Client) notify(method string, params any) error { | |
| return c.write(request{JSONRPC: "2.0", Method: method, Params: params}) | |
| } | |
| func (c *Client) write(r request) error { | |
| b, err := json.Marshal(r) | |
| if err != nil { | |
| return err | |
| } | |
| b = append(b, '\n') | |
| c.encMu.Lock() | |
| defer c.encMu.Unlock() | |
| _, err = c.stdin.Write(b) | |
| return err | |
| } | |
| // Close terminates the underlying process and releases pipes. | |
| func (c *Client) Close() { | |
| c.closeOnce.Do(func() { | |
| _ = c.stdin.Close() | |
| if c.cmd != nil && c.cmd.Process != nil { | |
| _ = c.cmd.Process.Kill() | |
| _ = c.cmd.Wait() | |
| } | |
| }) | |
| } | |