263 lines
6.4 KiB
Go
263 lines
6.4 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/auth"
|
|
mcp "github.com/modelcontextprotocol/go-sdk/mcp"
|
|
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// Token holds the OAuth/bearer fields ConnectSession needs.
|
|
type Token struct {
|
|
AccessToken, RefreshToken, TokenType string
|
|
Expiry time.Time
|
|
ClientID, ClientSecret string
|
|
AuthURL, TokenURL, RedirectURL string
|
|
}
|
|
|
|
// ConnectSession opens a streamable MCP session using tok.
|
|
func ConnectSession(ctx context.Context, url string, tok Token, name, version string) (*Client, error) {
|
|
sess, err := connectSession(ctx, url, tok, name, version)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Client{URL: url, Token: tok.AccessToken, Name: name, Version: version, session: sess}, nil
|
|
}
|
|
|
|
// AttachSession sets the SDK session used by Call when Hook is nil.
|
|
func (c *Client) AttachSession(sess *mcp.ClientSession) {
|
|
c.session = sess
|
|
}
|
|
|
|
func connectSession(ctx context.Context, mcpURL string, tok Token, name, version string) (*mcp.ClientSession, error) {
|
|
var handler auth.OAuthHandler
|
|
if tok.ClientID != "" && tok.TokenURL != "" {
|
|
oc := &oauth2.Config{
|
|
ClientID: tok.ClientID,
|
|
ClientSecret: tok.ClientSecret,
|
|
RedirectURL: tok.RedirectURL,
|
|
Endpoint: oauth2.Endpoint{AuthURL: tok.AuthURL, TokenURL: tok.TokenURL},
|
|
}
|
|
ot := &oauth2.Token{
|
|
AccessToken: tok.AccessToken,
|
|
RefreshToken: tok.RefreshToken,
|
|
TokenType: tok.TokenType,
|
|
Expiry: tok.Expiry,
|
|
}
|
|
h, err := auth.NewAuthorizationCodeHandler(&auth.AuthorizationCodeHandlerConfig{
|
|
RedirectURL: tok.RedirectURL,
|
|
PreregisteredClient: &oauthex.ClientCredentials{
|
|
ClientID: tok.ClientID,
|
|
},
|
|
InitialTokenSource: oc.TokenSource(ctx, ot),
|
|
AuthorizationCodeFetcher: func(context.Context, *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
|
|
return nil, fmt.Errorf("robinhood session expired; run Login again")
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
handler = h
|
|
}
|
|
httpClient := &http.Client{Timeout: 60 * time.Second}
|
|
if handler == nil && tok.AccessToken != "" {
|
|
httpClient.Transport = bearerRT{token: tok.AccessToken, base: http.DefaultTransport}
|
|
}
|
|
t := &mcp.StreamableClientTransport{
|
|
Endpoint: mcpURL,
|
|
HTTPClient: httpClient,
|
|
OAuthHandler: handler,
|
|
DisableStandaloneSSE: true,
|
|
}
|
|
cli := mcp.NewClient(&mcp.Implementation{Name: name, Version: version}, nil)
|
|
return cli.Connect(ctx, t, nil)
|
|
}
|
|
|
|
func (c *Client) sessionCall(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) {
|
|
if c.session == nil {
|
|
return nil, ToolErrorf(name, "session is nil")
|
|
}
|
|
res, err := c.session.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args})
|
|
if err != nil {
|
|
return nil, ToolErrorf(name, "%w", err)
|
|
}
|
|
raw, err := toolJSON(res)
|
|
if err != nil {
|
|
return nil, ToolErrorf(name, "%w", err)
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
func toolJSON(res *mcp.CallToolResult) (json.RawMessage, error) {
|
|
if res == nil {
|
|
return nil, fmt.Errorf("empty tool result")
|
|
}
|
|
// GetError is only set by SetError on the server; the err field is not marshaled to clients.
|
|
if err := res.GetError(); err != nil {
|
|
return nil, err
|
|
}
|
|
texts, types := collectTextAndTypes(res)
|
|
concat := strings.Join(texts, "")
|
|
// IsError is the client-visible soft-fail flag; error text lives in Content.
|
|
if res.IsError {
|
|
if concat == "" {
|
|
return nil, fmt.Errorf("tool error")
|
|
}
|
|
return nil, fmt.Errorf("%s", concat)
|
|
}
|
|
if res.StructuredContent != nil {
|
|
b, err := json.Marshal(res.StructuredContent)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.RawMessage(b), nil
|
|
}
|
|
for _, t := range texts {
|
|
if json.Valid([]byte(t)) {
|
|
return json.RawMessage(t), nil
|
|
}
|
|
}
|
|
if concat == "" {
|
|
return json.RawMessage(`{}`), nil
|
|
}
|
|
if json.Valid([]byte(concat)) {
|
|
return json.RawMessage(concat), nil
|
|
}
|
|
if raw := extractFirstJSON(concat); raw != nil {
|
|
return raw, nil
|
|
}
|
|
structured := "nil"
|
|
if res.StructuredContent != nil {
|
|
structured = "present"
|
|
}
|
|
return nil, fmt.Errorf("non-json tool result (isError=%v types=[%s] structured=%s textLen=%d prefix=%q)",
|
|
res.IsError, strings.Join(types, " "), structured, utf8.RuneCountInString(concat), runePrefix(concat, 80))
|
|
}
|
|
|
|
func collectTextAndTypes(res *mcp.CallToolResult) (texts, types []string) {
|
|
for _, c := range res.Content {
|
|
types = append(types, contentTypeName(c))
|
|
t, ok := c.(*mcp.TextContent)
|
|
if !ok {
|
|
continue
|
|
}
|
|
texts = append(texts, t.Text)
|
|
}
|
|
return texts, types
|
|
}
|
|
|
|
func contentTypeName(c mcp.Content) string {
|
|
switch c.(type) {
|
|
case *mcp.TextContent:
|
|
return "text"
|
|
case *mcp.ImageContent:
|
|
return "image"
|
|
case *mcp.AudioContent:
|
|
return "audio"
|
|
case *mcp.ResourceLink:
|
|
return "resource_link"
|
|
case *mcp.EmbeddedResource:
|
|
return "resource"
|
|
case *mcp.ToolUseContent:
|
|
return "tool_use"
|
|
case *mcp.ToolResultContent:
|
|
return "tool_result"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
func runePrefix(s string, n int) string {
|
|
if n <= 0 || s == "" {
|
|
return ""
|
|
}
|
|
i := 0
|
|
for j := range s {
|
|
if i == n {
|
|
return s[:j]
|
|
}
|
|
i++
|
|
}
|
|
return s
|
|
}
|
|
|
|
// extractFirstJSON returns the first balanced {...} or [...] substring that is
|
|
// valid JSON. Brace matching skips quoted strings and respects escapes.
|
|
func extractFirstJSON(s string) json.RawMessage {
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] != '{' && s[i] != '[' {
|
|
continue
|
|
}
|
|
end := balancedJSONEnd(s, i)
|
|
if end <= i {
|
|
continue
|
|
}
|
|
cand := s[i:end]
|
|
if json.Valid([]byte(cand)) {
|
|
return json.RawMessage(cand)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func balancedJSONEnd(s string, start int) int {
|
|
depth := 0
|
|
inString := false
|
|
escape := false
|
|
for i := start; i < len(s); i++ {
|
|
c := s[i]
|
|
if inString {
|
|
if escape {
|
|
escape = false
|
|
continue
|
|
}
|
|
if c == '\\' {
|
|
escape = true
|
|
continue
|
|
}
|
|
if c == '"' {
|
|
inString = false
|
|
}
|
|
continue
|
|
}
|
|
switch c {
|
|
case '"':
|
|
inString = true
|
|
case '{', '[':
|
|
depth++
|
|
case '}', ']':
|
|
depth--
|
|
if depth == 0 {
|
|
return i + 1
|
|
}
|
|
if depth < 0 {
|
|
return -1
|
|
}
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
type bearerRT struct {
|
|
token string
|
|
base http.RoundTripper
|
|
}
|
|
|
|
func (b bearerRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
r := req.Clone(req.Context())
|
|
r.Header.Set("Authorization", "Bearer "+b.token)
|
|
base := b.base
|
|
if base == nil {
|
|
base = http.DefaultTransport
|
|
}
|
|
return base.RoundTrip(r)
|
|
}
|