Files
larksuite__cli/shortcuts/common/mcp_client_test.go

439 lines
15 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestDoMCPCallUnauthorizedHTTPError(t *testing.T) {
for _, tt := range []struct {
name string
isBot bool
wantHint string
forbidden string
}{
{name: "user", wantHint: "auth login --recommend --no-wait --json", forbidden: "bot identity"},
{name: "bot", isBot: true, wantHint: "valid app credentials for the bot identity", forbidden: "auth login"},
} {
t.Run(tt.name, func(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusUnauthorized,
Status: "401 Unauthorized",
Body: io.NopCloser(strings.NewReader("unauthorized")),
}, nil
})}
_, err := DoMCPCall(context.Background(), client, "fetch-doc", map[string]interface{}{"doc_id": "doc_1"}, "token", "https://example.com/mcp", tt.isBot)
if got := output.ExitCodeOf(err); got != output.ExitAuth {
t.Fatalf("expected auth exit code (%d), got %d", output.ExitAuth, got)
}
var authErr *errs.AuthenticationError
if !errors.As(err, &authErr) {
t.Fatalf("error = %T %v, want *errs.AuthenticationError", err, err)
}
if authErr.Subtype != errs.SubtypeTokenInvalid || authErr.Code != http.StatusUnauthorized {
t.Errorf("authentication error = %+v, want token_invalid code 401", authErr)
}
if !strings.Contains(authErr.Hint, tt.wantHint) || strings.Contains(authErr.Hint, tt.forbidden) {
t.Errorf("hint = %q, want %q and no %q", authErr.Hint, tt.wantHint, tt.forbidden)
}
})
}
}
func TestDoMCPCallUnauthorizedHTTPUnknownStructuredErrorUsesAuthRecovery(t *testing.T) {
payloads := []struct {
name string
body string
wantCode string
}{
{name: "top-level", body: `{"code":987654321,"msg":"unknown MCP auth failure"}`, wantCode: "987654321"},
{name: "JSON-RPC", body: `{"error":{"code":-32001,"message":"unknown MCP auth failure"}}`, wantCode: "-32001"},
}
identities := []struct {
name string
isBot bool
wantHint string
forbidden string
}{
{name: "user", wantHint: "auth login --recommend --no-wait --json", forbidden: "bot identity"},
{name: "bot", isBot: true, wantHint: "valid app credentials for the bot identity", forbidden: "auth login"},
}
for _, payload := range payloads {
for _, identity := range identities {
t.Run(payload.name+"/"+identity.name, func(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusUnauthorized,
Status: "401 Unauthorized",
Body: io.NopCloser(strings.NewReader(payload.body)),
}, nil
})}
_, err := DoMCPCall(context.Background(), client, "fetch-doc", nil, "token", "https://example.com/mcp", identity.isBot)
if got := output.ExitCodeOf(err); got != output.ExitAuth {
t.Fatalf("exit code = %d, want auth exit code %d", got, output.ExitAuth)
}
var authErr *errs.AuthenticationError
if !errors.As(err, &authErr) {
t.Fatalf("error = %T %v, want *errs.AuthenticationError", err, err)
}
if authErr.Subtype != errs.SubtypeTokenInvalid || authErr.Code != http.StatusUnauthorized {
t.Errorf("authentication error = %+v, want token_invalid code 401", authErr)
}
if !strings.Contains(authErr.Message, payload.wantCode) {
t.Errorf("message = %q, want upstream code %s preserved as diagnostic context", authErr.Message, payload.wantCode)
}
if !strings.Contains(authErr.Hint, identity.wantHint) || strings.Contains(authErr.Hint, identity.forbidden) {
t.Errorf("hint = %q, want %q and no %q", authErr.Hint, identity.wantHint, identity.forbidden)
}
})
}
}
}
func TestDoMCPCallUnauthorizedHTTPKnownNestedCodeKeepsBusinessClassification(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusUnauthorized,
Status: "401 Unauthorized",
Body: io.NopCloser(strings.NewReader(
`{"error":{"code":99991679,"message":"missing scope","permission_violations":[{"subject":"drive:file:read"}]}}`,
)),
}, nil
})}
_, err := DoMCPCall(context.Background(), client, "fetch-doc", nil, "token", "https://example.com/mcp", true)
var permission *errs.PermissionError
if !errors.As(err, &permission) {
t.Fatalf("error = %T %v, want *errs.PermissionError", err, err)
}
if permission.Subtype != errs.SubtypeMissingScope || permission.Code != 99991679 || permission.Identity != "bot" {
t.Fatalf("permission error = %+v, want bot missing_scope code 99991679", permission)
}
if len(permission.MissingScopes) != 1 || permission.MissingScopes[0] != "drive:file:read" {
t.Errorf("missing_scopes = %v, want [drive:file:read]", permission.MissingScopes)
}
if strings.Contains(permission.Hint, "auth login") || !strings.Contains(permission.Hint, "app developer") {
t.Errorf("bot hint = %q, want developer recovery without user OAuth", permission.Hint)
}
}
func TestDoMCPCallJSONRPCErrorUsesLarkClassification(t *testing.T) {
t.Parallel()
client := &http.Client{
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Body: io.NopCloser(strings.NewReader(`{"error":{"code":99991668,"message":"user_access_token invalid"}}`)),
}, nil
}),
}
_, err := DoMCPCall(context.Background(), client, "fetch-doc", map[string]interface{}{"doc_id": "doc_1"}, "uat-token", "https://example.com/mcp", false)
if err == nil {
t.Fatal("expected error, got nil")
}
if got := output.ExitCodeOf(err); got != output.ExitAuth {
t.Fatalf("expected auth exit code (%d), got %d", output.ExitAuth, got)
}
var authErr *errs.AuthenticationError
if !errors.As(err, &authErr) {
t.Fatalf("expected *errs.AuthenticationError, got %T: %v", err, err)
}
if !strings.Contains(authErr.Hint, "auth login") {
t.Fatalf("user MCP authentication recovery = %q, want user login", authErr.Hint)
}
}
func TestDoMCPCallPermissionErrorKeepsCallingIdentity(t *testing.T) {
tests := []struct {
name string
body string
subtype errs.Subtype
wantScope string
wantBotHint string
}{
{
name: "missing scope",
body: `{"error":{"code":99991679,"message":"missing scope","permission_violations":[{"subject":"drive:file:read"}]}}`,
subtype: errs.SubtypeMissingScope,
wantScope: "drive:file:read",
wantBotHint: "app developer",
},
{
name: "token scope insufficient",
body: `{"error":{"code":99991676,"message":"token scope insufficient","permission_violations":[{"subject":"drive:file:read"}]}}`,
subtype: errs.SubtypeTokenScopeInsufficient,
wantScope: "drive:file:read",
wantBotHint: "token's granted scopes",
},
{
name: "user unauthorized code",
body: `{"error":{"code":230027,"message":"operation unauthorized"}}`,
subtype: errs.SubtypeUserUnauthorized,
wantBotHint: "required bot permissions",
},
}
identities := []struct {
name string
isBot bool
}{
{name: "user"},
{name: "bot", isBot: true},
}
for _, tt := range tests {
for _, identity := range identities {
t.Run(tt.name+"/"+identity.name, func(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Body: io.NopCloser(strings.NewReader(tt.body)),
}, nil
})}
_, err := DoMCPCall(context.Background(), client, "fetch-doc", nil, "token", "https://example.com/mcp", identity.isBot)
var permission *errs.PermissionError
if !errors.As(err, &permission) {
t.Fatalf("error = %T %v, want *errs.PermissionError", err, err)
}
if permission.Subtype != tt.subtype || permission.Code == 0 {
t.Errorf("permission error = %+v, want subtype %q and upstream code", permission, tt.subtype)
}
if permission.Identity != identity.name {
t.Errorf("identity = %q, want %q", permission.Identity, identity.name)
}
if tt.wantScope != "" && (len(permission.MissingScopes) != 1 || permission.MissingScopes[0] != tt.wantScope) {
t.Errorf("missing_scopes = %v, want [%s]", permission.MissingScopes, tt.wantScope)
}
if identity.isBot {
for _, forbidden := range []string{"auth login", "verification_url", "device_code", "user authorization"} {
if strings.Contains(strings.ToLower(permission.Hint+"\n"+permission.Message), forbidden) {
t.Errorf("bot error contains user OAuth guidance %q: %+v", forbidden, permission)
}
}
if !strings.Contains(permission.Hint, tt.wantBotHint) {
t.Errorf("bot hint = %q, want %q", permission.Hint, tt.wantBotHint)
}
} else if !strings.Contains(permission.Hint, "auth login") {
t.Errorf("user hint = %q, want user OAuth recovery", permission.Hint)
}
})
}
}
}
func TestDoMCPCallMessageOnlyAuthorizationRecoveryUsesCallingIdentity(t *testing.T) {
for _, tt := range []struct {
name string
isBot bool
wantHint string
forbidden string
}{
{name: "user", wantHint: "auth login", forbidden: "bot identity"},
{name: "bot", isBot: true, wantHint: "valid app credentials for the bot identity", forbidden: "auth login"},
} {
t.Run(tt.name, func(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Body: io.NopCloser(strings.NewReader(`{"error":"unauthorized"}`)),
}, nil
})}
_, err := DoMCPCall(context.Background(), client, "fetch-doc", nil, "token", "https://example.com/mcp", tt.isBot)
var authErr *errs.AuthenticationError
if !errors.As(err, &authErr) {
t.Fatalf("error = %T %v, want *errs.AuthenticationError", err, err)
}
if !strings.Contains(authErr.Hint, tt.wantHint) || strings.Contains(authErr.Hint, tt.forbidden) {
t.Errorf("hint = %q, want %q and no %q", authErr.Hint, tt.wantHint, tt.forbidden)
}
})
}
}
func TestDoMCPCallHTTPBusinessErrorKeepsBotIdentity(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusForbidden,
Status: "403 Forbidden",
Body: io.NopCloser(strings.NewReader(
`{"code":99991679,"msg":"missing scope","error":{"permission_violations":[{"subject":"drive:file:read"}]}}`,
)),
}, nil
})}
_, err := DoMCPCall(context.Background(), client, "fetch-doc", nil, "token", "https://example.com/mcp", true)
var permission *errs.PermissionError
if !errors.As(err, &permission) {
t.Fatalf("error = %T %v, want *errs.PermissionError", err, err)
}
if permission.Identity != "bot" || permission.Subtype != errs.SubtypeMissingScope ||
len(permission.MissingScopes) != 1 || permission.MissingScopes[0] != "drive:file:read" {
t.Fatalf("permission error = %+v, want bot missing_scope with drive:file:read", permission)
}
if strings.Contains(permission.Hint, "auth login") || !strings.Contains(permission.Hint, "app developer") {
t.Errorf("bot hint = %q, want developer recovery without user OAuth", permission.Hint)
}
}
func TestDoMCPCallHTTPUnknownBusinessErrorPreservesFallback(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadRequest,
Status: "400 Bad Request",
Body: io.NopCloser(strings.NewReader(`{"code":987654321,"msg":"unknown MCP failure"}`)),
}, nil
})}
_, err := DoMCPCall(context.Background(), client, "fetch-doc", nil, "token", "https://example.com/mcp", true)
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error = %T %v, want *errs.APIError", err, err)
}
if apiErr.Subtype != errs.SubtypeUnknown || apiErr.Code != 987654321 ||
!strings.Contains(apiErr.Message, "MCP HTTP 400 400 Bad Request: [987654321] unknown MCP failure") {
t.Errorf("unknown MCP error fallback changed: %+v", apiErr)
}
}
func TestDoMCPCallSetsHeadersAndUnwrapsResult(t *testing.T) {
t.Parallel()
var seen *http.Request
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
seen = req
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Body: io.NopCloser(strings.NewReader(`{"result":{"jsonrpc":"2.0","result":{"ok":true}}}`)),
}, nil
}),
}
got, err := DoMCPCall(context.Background(), client, "fetch-doc", map[string]interface{}{"doc_id": "doc_1"}, "tat-token", "https://example.com/mcp", true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
result, ok := got.(map[string]interface{})
if !ok || result["ok"] != true {
t.Fatalf("unexpected result: %#v", got)
}
if seen == nil {
t.Fatalf("expected request to be captured")
}
if seen.Header.Get("X-Lark-MCP-TAT") != "tat-token" {
t.Fatalf("expected bot token header, got %q", seen.Header.Get("X-Lark-MCP-TAT"))
}
if seen.Header.Get("X-Lark-MCP-Allowed-Tools") != "fetch-doc" {
t.Fatalf("expected allowed tools header, got %q", seen.Header.Get("X-Lark-MCP-Allowed-Tools"))
}
}
func TestNormalizeMCPToolResult(t *testing.T) {
t.Parallel()
tests := []struct {
name string
raw interface{}
wantKey string
wantVal interface{}
wantErr string
}{
{
name: "map result",
raw: map[string]interface{}{"ok": true},
wantKey: "ok",
wantVal: true,
},
{
name: "text result",
raw: "plain text",
wantKey: "message",
wantVal: "plain text",
},
{
name: "scalar result",
raw: 42,
wantKey: "result",
wantVal: 42,
},
{
name: "map error field",
raw: map[string]interface{}{"error": "permission denied"},
wantErr: "MCP: permission denied",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := normalizeMCPToolResult(tt.raw)
if tt.wantErr != "" {
requireProblem(t, err, errs.CategoryAPI, errs.SubtypeUnknown, tt.wantErr)
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got[tt.wantKey] != tt.wantVal {
t.Fatalf("unexpected result: %#v", got)
}
})
}
}
func TestExtractMCPResult(t *testing.T) {
t.Parallel()
jsonResult := ExtractMCPResult(map[string]interface{}{
"content": []interface{}{
map[string]interface{}{
"type": "text",
"text": `{"doc_id":"doc_1"}`,
},
},
})
resultMap, ok := jsonResult.(map[string]interface{})
if !ok || resultMap["doc_id"] != "doc_1" {
t.Fatalf("unexpected parsed json result: %#v", jsonResult)
}
textResult := ExtractMCPResult(map[string]interface{}{
"content": []interface{}{
map[string]interface{}{"type": "text", "text": "line1"},
map[string]interface{}{"type": "text", "text": "line2"},
},
})
if textResult != "line1\nline2" {
t.Fatalf("unexpected text result: %#v", textResult)
}
}