diff --git a/client/client.go b/client/client.go new file mode 100644 index 0000000..51feb03 --- /dev/null +++ b/client/client.go @@ -0,0 +1,23 @@ +package client + +import ( + "context" + "encoding/json" + "net/http" +) + +// Client is a Robinhood Agentic MCP client. +type Client struct { + URL, Token, Name, Version string + HTTP *http.Client + Hook Caller + session any +} + +// Call invokes an MCP tool by name and returns its JSON result. +func (c *Client) Call(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) { + if c.Hook != nil { + return c.Hook.Call(ctx, name, args) + } + return c.rpcCall(ctx, name, args) +} diff --git a/client/rpc.go b/client/rpc.go new file mode 100644 index 0000000..2813880 --- /dev/null +++ b/client/rpc.go @@ -0,0 +1,68 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" +) + +type rpcReq struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params any `json:"params"` +} + +type rpcResp struct { + Result json.RawMessage `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +func (c *Client) rpcCall(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) { + body, err := json.Marshal(rpcReq{ + JSONRPC: "2.0", + ID: 1, + Method: "tools/call", + Params: map[string]any{"name": name, "arguments": args}, + }) + if err != nil { + return nil, ToolErrorf(name, "%w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, bytes.NewReader(body)) + if err != nil { + return nil, ToolErrorf(name, "%w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + httpClient := c.HTTP + if httpClient == nil { + httpClient = http.DefaultClient + } + resp, err := httpClient.Do(req) + if err != nil { + return nil, ToolErrorf(name, "%w", err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, ToolErrorf(name, "%w", err) + } + if resp.StatusCode >= 300 { + return nil, ToolErrorf(name, "http %d: %s", resp.StatusCode, raw) + } + var out rpcResp + if err := json.Unmarshal(raw, &out); err != nil { + return nil, ToolErrorf(name, "%w", err) + } + if out.Error != nil { + return nil, ToolErrorf(name, "%s", out.Error.Message) + } + return out.Result, nil +} diff --git a/client/rpc_test.go b/client/rpc_test.go new file mode 100644 index 0000000..4b94797 --- /dev/null +++ b/client/rpc_test.go @@ -0,0 +1,52 @@ +package client_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "s1d3sw1ped/robinhood-agentic-mcp/client" + "s1d3sw1ped/robinhood-agentic-mcp/internal/rhntest" +) + +func TestClientCall_rpcRoundTrip(t *testing.T) { + t.Parallel() + s := rhntest.New(t) + s.Token = "tok" + s.Set("get_equity_quotes", json.RawMessage(`{"quotes":[{"symbol":"MU"}]}`)) + c := &client.Client{URL: s.URL, Token: "tok"} + raw, err := c.Call(context.Background(), "get_equity_quotes", map[string]any{"symbols": []string{"MU"}}) + if err != nil { + t.Fatal(err) + } + if string(raw) != `{"quotes":[{"symbol":"MU"}]}` { + t.Fatalf("%s", raw) + } + if s.LastName() != "get_equity_quotes" { + t.Fatal(s.LastName()) + } +} + +func TestClientCall_httpErrorIsToolError(t *testing.T) { + t.Parallel() + s := rhntest.New(t) + s.SetHTTPError(500, "nope") + c := &client.Client{URL: s.URL} + _, err := c.Call(context.Background(), "get_accounts", map[string]any{}) + var te *client.ToolError + if !errors.As(err, &te) || te.Name != "get_accounts" { + t.Fatalf("%v", err) + } +} + +func TestClientCall_hook(t *testing.T) { + t.Parallel() + c := &client.Client{Hook: client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) { + return json.RawMessage(`{"ok":true}`), nil + })} + raw, err := c.Call(context.Background(), "x", nil) + if err != nil || string(raw) != `{"ok":true}` { + t.Fatalf("%s %v", raw, err) + } +}