342 lines
12 KiB
Go
342 lines
12 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/labstack/echo/v5"
|
|
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestNormalizeStructuredContentKeepsObjects(t *testing.T) {
|
|
result := normalizeStructuredContent(map[string]any{"memos": []any{map[string]any{"name": "memos/a"}}})
|
|
require.Equal(t, map[string]any{"memos": []any{map[string]any{"name": "memos/a"}}}, result)
|
|
}
|
|
|
|
func TestNormalizeStructuredContentWrapsArrays(t *testing.T) {
|
|
result := normalizeStructuredContent([]any{map[string]any{"tag": "work"}})
|
|
require.Equal(t, map[string]any{"result": []any{map[string]any{"tag": "work"}}}, result)
|
|
}
|
|
|
|
func TestNormalizeStructuredContentUsesOKForNil(t *testing.T) {
|
|
result := normalizeStructuredContent(nil)
|
|
require.Equal(t, map[string]any{"ok": true}, result)
|
|
}
|
|
|
|
func TestNormalizeStructuredContentWrapsScalars(t *testing.T) {
|
|
result := normalizeStructuredContent("created")
|
|
require.Equal(t, map[string]any{"result": "created"}, result)
|
|
}
|
|
|
|
func TestNewStructuredToolResultUsesObjectStructuredContent(t *testing.T) {
|
|
result, err := newStructuredToolResult([]any{"one"})
|
|
require.NoError(t, err)
|
|
require.IsType(t, map[string]any{}, result.StructuredContent)
|
|
require.Equal(t, map[string]any{"result": []any{"one"}}, result.StructuredContent)
|
|
require.NotEmpty(t, result.Content)
|
|
text, ok := result.Content[0].(*sdkmcp.TextContent)
|
|
require.True(t, ok)
|
|
require.JSONEq(t, `{"result":["one"]}`, text.Text)
|
|
}
|
|
|
|
func TestNewToolErrorResult(t *testing.T) {
|
|
result := newToolErrorResult("resource not found")
|
|
require.True(t, result.IsError)
|
|
// Error results carry no structuredContent: tools declare an outputSchema
|
|
// for their success payload, and strict clients validate structuredContent
|
|
// against it — an error object would fail validation and mask the message.
|
|
require.Nil(t, result.StructuredContent)
|
|
require.NotEmpty(t, result.Content)
|
|
text, ok := result.Content[0].(*sdkmcp.TextContent)
|
|
require.True(t, ok)
|
|
require.Equal(t, "resource not found", text.Text)
|
|
}
|
|
|
|
func TestDecodeJSONValue(t *testing.T) {
|
|
value, err := decodeJSONValue([]byte(`{"ok":true}`))
|
|
require.NoError(t, err)
|
|
require.Equal(t, map[string]any{"ok": true}, value)
|
|
|
|
value, err = decodeJSONValue([]byte{})
|
|
require.NoError(t, err)
|
|
require.Nil(t, value)
|
|
|
|
value, err = decodeJSONValue([]byte(" \n\t "))
|
|
require.NoError(t, err)
|
|
require.Nil(t, value)
|
|
|
|
value, err = decodeJSONValue([]byte(`[1,"two"]`))
|
|
require.NoError(t, err)
|
|
require.Equal(t, []any{float64(1), "two"}, value)
|
|
|
|
_, err = decodeJSONValue([]byte(`{`))
|
|
require.Error(t, err)
|
|
require.True(t, errorsIsJSONSyntax(err), "wrapped syntax errors should remain inspectable")
|
|
}
|
|
|
|
func errorsIsJSONSyntax(err error) bool {
|
|
var syntaxError *json.SyntaxError
|
|
return err != nil && errors.As(err, &syntaxError)
|
|
}
|
|
|
|
func TestBuildAPIRequestMapsPathQueryAndBody(t *testing.T) {
|
|
operation := &openAPIOperation{
|
|
Method: "PATCH",
|
|
Path: "/api/v1/memos/{memo}",
|
|
Parameters: []openAPIParameter{
|
|
{Name: "memo", In: "path", Required: true, Schema: jsonSchema{"type": "string"}},
|
|
{Name: "updateMask", In: "query", Schema: jsonSchema{"type": "string"}},
|
|
},
|
|
RequestBody: &openAPIRequestBody{Required: true},
|
|
}
|
|
arguments := map[string]any{
|
|
"memo": "abc123",
|
|
"updateMask": "content",
|
|
"body": map[string]any{
|
|
"memo": map[string]any{
|
|
"name": "memos/abc123",
|
|
"content": "updated",
|
|
},
|
|
},
|
|
}
|
|
|
|
req, err := buildAPIRequest(context.Background(), operation, arguments, "Bearer pat")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "PATCH", req.Method)
|
|
require.Equal(t, "/api/v1/memos/abc123", req.URL.Path)
|
|
require.Equal(t, "content", req.URL.Query().Get("updateMask"))
|
|
require.Equal(t, "Bearer pat", req.Header.Get("Authorization"))
|
|
|
|
body, err := io.ReadAll(req.Body)
|
|
require.NoError(t, err)
|
|
require.JSONEq(t, `{"memo":{"name":"memos/abc123","content":"updated"}}`, string(body))
|
|
}
|
|
|
|
func TestBuildAPIRequestAcceptsResourceNamesForPathParameters(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
value string
|
|
wantPath string
|
|
}{
|
|
{name: "canonical memo name", path: "/api/v1/memos/{memo}", value: "memos/abc123", wantPath: "/api/v1/memos/abc123"},
|
|
{name: "bare memo id", path: "/api/v1/memos/{memo}", value: "abc123", wantPath: "/api/v1/memos/abc123"},
|
|
{name: "canonical name on nested route", path: "/api/v1/memos/{memo}/comments", value: "memos/abc123", wantPath: "/api/v1/memos/abc123/comments"},
|
|
{name: "canonical attachment name", path: "/api/v1/attachments/{attachment}", value: "attachments/att42", wantPath: "/api/v1/attachments/att42"},
|
|
{name: "foreign prefix left untouched", path: "/api/v1/memos/{memo}", value: "attachments/att42", wantPath: "/api/v1/memos/attachments%2Fatt42"},
|
|
{name: "multi-segment value left untouched", path: "/api/v1/memos/{memo}", value: "memos/abc/extra", wantPath: "/api/v1/memos/memos%2Fabc%2Fextra"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
parameterName := test.path[strings.Index(test.path, "{")+1 : strings.Index(test.path, "}")]
|
|
operation := &openAPIOperation{
|
|
Method: "GET",
|
|
Path: test.path,
|
|
Parameters: []openAPIParameter{{Name: parameterName, In: "path", Required: true, Schema: jsonSchema{"type": "string"}}},
|
|
}
|
|
req, err := buildAPIRequest(context.Background(), operation, map[string]any{parameterName: test.value}, "")
|
|
require.NoError(t, err)
|
|
require.Equal(t, test.wantPath, req.URL.EscapedPath())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildAPIRequestAcceptsHierarchicalResourceNamesForPathParameters(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
parameters []string
|
|
arguments map[string]any
|
|
wantPath string
|
|
}{
|
|
{
|
|
name: "canonical reaction name",
|
|
path: "/api/v1/memos/{memo}/reactions/{reaction}",
|
|
parameters: []string{"memo", "reaction"},
|
|
arguments: map[string]any{
|
|
"memo": "memos/abc123",
|
|
"reaction": "memos/abc123/reactions/reaction456",
|
|
},
|
|
wantPath: "/api/v1/memos/abc123/reactions/reaction456",
|
|
},
|
|
{
|
|
name: "canonical nested name with bare parent id",
|
|
path: "/api/v1/memos/{memo}/reactions/{reaction}",
|
|
parameters: []string{"memo", "reaction"},
|
|
arguments: map[string]any{
|
|
"memo": "abc123",
|
|
"reaction": "memos/abc123/reactions/reaction456",
|
|
},
|
|
wantPath: "/api/v1/memos/abc123/reactions/reaction456",
|
|
},
|
|
{
|
|
name: "parameter declaration order does not matter",
|
|
path: "/api/v1/users/{user}/views/{view}",
|
|
parameters: []string{"view", "user"},
|
|
arguments: map[string]any{
|
|
"user": "users/user123",
|
|
"view": "users/user123/views/view456",
|
|
},
|
|
wantPath: "/api/v1/users/user123/views/view456",
|
|
},
|
|
{
|
|
name: "canonical nested name on action route",
|
|
path: "/api/v1/users/{user}/webhooks/{webhook}:getSigningSecret",
|
|
parameters: []string{"user", "webhook"},
|
|
arguments: map[string]any{
|
|
"user": "users/user123",
|
|
"webhook": "users/user123/webhooks/webhook456",
|
|
},
|
|
wantPath: "/api/v1/users/user123/webhooks/webhook456:getSigningSecret",
|
|
},
|
|
{
|
|
name: "mismatched parent is left untouched",
|
|
path: "/api/v1/memos/{memo}/reactions/{reaction}",
|
|
parameters: []string{"memo", "reaction"},
|
|
arguments: map[string]any{
|
|
"memo": "memos/abc123",
|
|
"reaction": "memos/other/reactions/reaction456",
|
|
},
|
|
wantPath: "/api/v1/memos/abc123/reactions/memos%2Fother%2Freactions%2Freaction456",
|
|
},
|
|
{
|
|
name: "extra nested segment is left untouched",
|
|
path: "/api/v1/memos/{memo}/reactions/{reaction}",
|
|
parameters: []string{"memo", "reaction"},
|
|
arguments: map[string]any{
|
|
"memo": "memos/abc123",
|
|
"reaction": "memos/abc123/reactions/reaction456/extra",
|
|
},
|
|
wantPath: "/api/v1/memos/abc123/reactions/memos%2Fabc123%2Freactions%2Freaction456%2Fextra",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
parameters := make([]openAPIParameter, 0, len(test.parameters))
|
|
for _, name := range test.parameters {
|
|
parameters = append(parameters, openAPIParameter{Name: name, In: "path", Required: true, Schema: jsonSchema{"type": "string"}})
|
|
}
|
|
operation := &openAPIOperation{
|
|
Method: "GET",
|
|
Path: test.path,
|
|
Parameters: parameters,
|
|
}
|
|
req, err := buildAPIRequest(context.Background(), operation, test.arguments, "")
|
|
require.NoError(t, err)
|
|
require.Equal(t, test.wantPath, req.URL.EscapedPath())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildAPIRequestAcceptsCanonicalReactionNameForCuratedOperation(t *testing.T) {
|
|
spec, err := loadMCPServiceOpenAPISpec()
|
|
require.NoError(t, err)
|
|
registry, err := buildOperationRegistry(spec)
|
|
require.NoError(t, err)
|
|
operation := registry["MemoService_DeleteMemoReaction"]
|
|
require.NotNil(t, operation)
|
|
|
|
req, err := buildAPIRequest(context.Background(), operation, map[string]any{
|
|
"memo": "memos/abc123",
|
|
"reaction": "memos/abc123/reactions/reaction456",
|
|
}, "")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "/api/v1/memos/abc123/reactions/reaction456", req.URL.EscapedPath())
|
|
}
|
|
|
|
func TestBuildAPIRequestRequiresPathParameters(t *testing.T) {
|
|
operation := &openAPIOperation{
|
|
Method: "GET",
|
|
Path: "/api/v1/memos/{memo}",
|
|
Parameters: []openAPIParameter{{Name: "memo", In: "path", Required: true}},
|
|
}
|
|
|
|
_, err := buildAPIRequest(context.Background(), operation, map[string]any{}, "")
|
|
require.ErrorContains(t, err, `missing required path parameter "memo"`)
|
|
}
|
|
|
|
func TestBuildAPIRequestRequiresRequestBody(t *testing.T) {
|
|
operation := &openAPIOperation{
|
|
Method: "POST",
|
|
Path: "/api/v1/memos",
|
|
RequestBody: &openAPIRequestBody{Required: true},
|
|
}
|
|
|
|
_, err := buildAPIRequest(context.Background(), operation, map[string]any{}, "")
|
|
require.ErrorContains(t, err, `missing required request body "body"`)
|
|
}
|
|
|
|
func TestBuildAPIRequestEscapesPathAndStringifiesPrimitiveQueryParameters(t *testing.T) {
|
|
operation := &openAPIOperation{
|
|
Method: "DELETE",
|
|
Path: "/api/v1/memos/{memo}",
|
|
Parameters: []openAPIParameter{
|
|
{Name: "memo", In: "path", Required: true, Schema: jsonSchema{"type": "string"}},
|
|
{Name: "force", In: "query", Schema: jsonSchema{"type": "boolean"}},
|
|
{Name: "limit", In: "query", Schema: jsonSchema{"type": "integer"}},
|
|
},
|
|
}
|
|
|
|
req, err := buildAPIRequest(context.Background(), operation, map[string]any{
|
|
"memo": "abc 123",
|
|
"force": true,
|
|
"limit": 10,
|
|
}, "")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "/api/v1/memos/abc%20123", req.URL.EscapedPath())
|
|
require.Equal(t, "true", req.URL.Query().Get("force"))
|
|
require.Equal(t, "10", req.URL.Query().Get("limit"))
|
|
}
|
|
|
|
func TestExecuteOperationReturnsObjectStructuredContent(t *testing.T) {
|
|
echoServer := echo.New()
|
|
echoServer.GET("/api/v1/memos", func(c *echo.Context) error {
|
|
require.Equal(t, "Bearer token", c.Request().Header.Get("Authorization"))
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"memos": []any{map[string]any{"name": "memos/abc123"}},
|
|
})
|
|
})
|
|
|
|
operation := &openAPIOperation{
|
|
Method: "GET",
|
|
Path: "/api/v1/memos",
|
|
}
|
|
adapter := newAPIAdapter(echoServer)
|
|
|
|
result, err := adapter.execute(context.Background(), operation, map[string]any{}, "Bearer token")
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
require.Equal(t, map[string]any{
|
|
"memos": []any{map[string]any{"name": "memos/abc123"}},
|
|
}, result.StructuredContent)
|
|
}
|
|
|
|
func TestExecuteOperationConvertsAPIErrorsToToolErrors(t *testing.T) {
|
|
echoServer := echo.New()
|
|
echoServer.GET("/api/v1/memos/:memo", func(c *echo.Context) error {
|
|
return c.JSON(http.StatusNotFound, map[string]any{"message": "missing memo"})
|
|
})
|
|
|
|
operation := &openAPIOperation{
|
|
Method: "GET",
|
|
Path: "/api/v1/memos/{memo}",
|
|
Parameters: []openAPIParameter{{Name: "memo", In: "path", Required: true}},
|
|
}
|
|
adapter := newAPIAdapter(echoServer)
|
|
|
|
result, err := adapter.execute(context.Background(), operation, map[string]any{"memo": "missing"}, "")
|
|
require.NoError(t, err)
|
|
require.True(t, result.IsError)
|
|
require.Nil(t, result.StructuredContent)
|
|
text, ok := result.Content[0].(*sdkmcp.TextContent)
|
|
require.True(t, ok)
|
|
require.Contains(t, text.Text, "404")
|
|
require.Contains(t, text.Text, "missing memo")
|
|
}
|