diff --git a/internal/rhntest/server.go b/internal/rhntest/server.go new file mode 100644 index 0000000..c7c6754 --- /dev/null +++ b/internal/rhntest/server.go @@ -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 + + 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) +} diff --git a/internal/rhntest/server_test.go b/internal/rhntest/server_test.go new file mode 100644 index 0000000..019a51b --- /dev/null +++ b/internal/rhntest/server_test.go @@ -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()) + } +}