feat: add httptest Robinhood MCP mock
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
package rhntest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Server is an httptest Robinhood MCP mock that speaks JSON-RPC tools/call.
|
||||
type Server struct {
|
||||
URL string
|
||||
Token string // if non-empty, require Authorization: Bearer <Token>
|
||||
|
||||
mu sync.Mutex
|
||||
srv *httptest.Server
|
||||
results map[string]json.RawMessage
|
||||
rpcErrs map[string]string
|
||||
httpStatus int
|
||||
httpBody string
|
||||
lastName string
|
||||
lastArgs map[string]any
|
||||
}
|
||||
|
||||
// New starts an httptest server and registers t.Cleanup(Close).
|
||||
func New(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
s := &Server{
|
||||
results: make(map[string]json.RawMessage),
|
||||
rpcErrs: make(map[string]string),
|
||||
}
|
||||
s.srv = httptest.NewServer(http.HandlerFunc(s.serve))
|
||||
s.URL = s.srv.URL
|
||||
t.Cleanup(s.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
// Set registers a JSON-RPC result for a tool name.
|
||||
func (s *Server) Set(name string, result json.RawMessage) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.results[name] = result
|
||||
}
|
||||
|
||||
// SetHTTPError makes every request return this HTTP status and body.
|
||||
func (s *Server) SetHTTPError(status int, body string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.httpStatus = status
|
||||
s.httpBody = body
|
||||
}
|
||||
|
||||
// SetRPCError makes tools/call for name return a JSON-RPC error.
|
||||
func (s *Server) SetRPCError(name, message string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.rpcErrs[name] = message
|
||||
}
|
||||
|
||||
// LastName is the most recent tools/call params.name.
|
||||
func (s *Server) LastName() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.lastName
|
||||
}
|
||||
|
||||
// LastArgs is the most recent tools/call params.arguments.
|
||||
func (s *Server) LastArgs() map[string]any {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.lastArgs
|
||||
}
|
||||
|
||||
// Close shuts down the httptest server.
|
||||
func (s *Server) Close() {
|
||||
s.srv.Close()
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type rpcReply struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Token != "" && r.Header.Get("Authorization") != "Bearer "+s.Token {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
httpStatus := s.httpStatus
|
||||
httpBody := s.httpBody
|
||||
s.mu.Unlock()
|
||||
if httpStatus != 0 {
|
||||
w.WriteHeader(httpStatus)
|
||||
_, _ = w.Write([]byte(httpBody))
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ID json.RawMessage `json:"id"`
|
||||
Params struct {
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
} `json:"params"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.lastName = req.Params.Name
|
||||
s.lastArgs = req.Params.Arguments
|
||||
rpcMsg, hasRPC := s.rpcErrs[req.Params.Name]
|
||||
result, hasResult := s.results[req.Params.Name]
|
||||
s.mu.Unlock()
|
||||
|
||||
reply := rpcReply{JSONRPC: "2.0", ID: req.ID}
|
||||
if hasRPC {
|
||||
reply.Error = &rpcError{Message: rpcMsg}
|
||||
} else if !hasResult {
|
||||
reply.Error = &rpcError{Message: "unknown tool"}
|
||||
} else {
|
||||
reply.Result = result
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(reply)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package rhntest_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"s1d3sw1ped/robinhood-agentic-mcp/internal/rhntest"
|
||||
)
|
||||
|
||||
func TestServer_toolsCall(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := rhntest.New(t)
|
||||
s.Token = "tok"
|
||||
s.Set("get_accounts", json.RawMessage(`{"accounts":[]}`))
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||
"params": map[string]any{"name": "get_accounts", "arguments": map[string]any{}},
|
||||
})
|
||||
req, _ := http.NewRequest(http.MethodPost, s.URL, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer tok")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("%d %s", resp.StatusCode, raw)
|
||||
}
|
||||
var out struct {
|
||||
Result json.RawMessage `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(out.Result) != `{"accounts":[]}` {
|
||||
t.Fatalf("%s", out.Result)
|
||||
}
|
||||
if s.LastName() != "get_accounts" {
|
||||
t.Fatalf("%q", s.LastName())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user