internal/mcpclient speaks streamable HTTP: initializes once and keeps the session the server gives, beginning it again once if the server forgot it; lists tools page by page; calls one with headers of its own, taking the answer as JSON or as events with progress before it; and bounds what it reads. mcptest is a server for tests. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
88 lines
2.7 KiB
Go
88 lines
2.7 KiB
Go
package mcpclient
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/mcpclient/mcptest"
|
|
)
|
|
|
|
func TestToolsAreListedPageByPageAndCalledInOneSession(t *testing.T) {
|
|
srv := mcptest.New()
|
|
defer srv.Close()
|
|
srv.PageSize = 2
|
|
c := &Client{URL: srv.URL, Header: func(context.Context) (http.Header, error) {
|
|
return http.Header{"Authorization": {"Bearer service"}}, nil
|
|
}}
|
|
tools, err := c.Tools(t.Context())
|
|
if err != nil || len(tools) != 3 || tools[2].Name != "big" {
|
|
t.Fatalf("tools %+v %v", tools, err)
|
|
}
|
|
raw, err := c.Call(t.Context(), "echo", json.RawMessage(`{"text":"hello"}`), http.Header{"X-Llmgw-User": {"ada"}}, nil)
|
|
if err != nil || !strings.Contains(string(raw), "hello") {
|
|
t.Fatalf("call %s %v", raw, err)
|
|
}
|
|
if srv.Sessions() != 1 {
|
|
t.Errorf("initialized %d times", srv.Sessions())
|
|
}
|
|
h := srv.Headers[0]
|
|
if h.Get("Authorization") != "Bearer service" || h.Get("X-Llmgw-User") != "ada" || h.Get("MCP-Protocol-Version") != "2025-06-18" {
|
|
t.Errorf("headers %v", h)
|
|
}
|
|
}
|
|
|
|
func TestAStreamedAnswerIsReadAndItsProgressHeard(t *testing.T) {
|
|
srv := mcptest.New()
|
|
defer srv.Close()
|
|
srv.Stream = true
|
|
c := &Client{URL: srv.URL}
|
|
var heard []string
|
|
raw, err := c.Call(t.Context(), "echo", json.RawMessage(`{"text":"streamed"}`), nil, func(p json.RawMessage) {
|
|
heard = append(heard, string(p))
|
|
})
|
|
if err != nil || !strings.Contains(string(raw), "streamed") {
|
|
t.Fatalf("call %s %v", raw, err)
|
|
}
|
|
if len(heard) != 1 || !strings.Contains(heard[0], "halfway") {
|
|
t.Errorf("progress %v", heard)
|
|
}
|
|
}
|
|
|
|
func TestASessionTheServerForgotIsBegunAgainOnce(t *testing.T) {
|
|
srv := mcptest.New()
|
|
defer srv.Close()
|
|
c := &Client{URL: srv.URL}
|
|
if _, err := c.Tools(t.Context()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv.Forget()
|
|
if _, err := c.Call(t.Context(), "echo", json.RawMessage(`{"text":"again"}`), nil, nil); err != nil {
|
|
t.Fatalf("after the server restarted: %v", err)
|
|
}
|
|
if srv.Sessions() != 2 {
|
|
t.Errorf("sessions %d", srv.Sessions())
|
|
}
|
|
}
|
|
|
|
func TestTheServersErrorsAndAnAnswerTooLargeAreSaid(t *testing.T) {
|
|
srv := mcptest.New()
|
|
defer srv.Close()
|
|
c := &Client{URL: srv.URL, MaxBytes: 64 << 10}
|
|
var rpcErr *Error
|
|
if _, err := c.Call(t.Context(), "nosuch", nil, nil, nil); !errors.As(err, &rpcErr) || rpcErr.Code != -32602 {
|
|
t.Errorf("unknown tool: %v", err)
|
|
}
|
|
if _, err := c.Call(t.Context(), "big", nil, nil, nil); err == nil || !strings.Contains(err.Error(), "larger than") {
|
|
t.Errorf("a megabyte through a 64 KiB limit: %v", err)
|
|
}
|
|
// A tool's own failure is its result, not an error of the protocol.
|
|
raw, err := c.Call(t.Context(), "fail", nil, nil, nil)
|
|
if err != nil || !strings.Contains(string(raw), `"isError":true`) {
|
|
t.Errorf("fail: %s %v", raw, err)
|
|
}
|
|
}
|