mcp接口增加分页
This commit is contained in:
1 parent
7243319bbb
commit
7f4adad7b1
16 files changed
+1157
-281
No files matched your search
+178
-19
@@ -427,9 +427,10 @@ func TestMCPListsSevenBoundedReadOnlyTools(t *testing.T) {
|
||||
var decoded struct {
|
||||
Result struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
InputSchema map[string]any `json:"inputSchema"`
|
||||
Annotations map[string]any `json:"annotations"`
|
||||
Name string `json:"name"`
|
||||
InputSchema map[string]any `json:"inputSchema"`
|
||||
OutputSchema map[string]any `json:"outputSchema"`
|
||||
Annotations map[string]any `json:"annotations"`
|
||||
} `json:"tools"`
|
||||
} `json:"result"`
|
||||
}
|
||||
@@ -447,6 +448,9 @@ func TestMCPListsSevenBoundedReadOnlyTools(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("tool %s lacks properties schema: %#v", tool.Name, tool.InputSchema)
|
||||
}
|
||||
if tool.InputSchema["additionalProperties"] != false {
|
||||
t.Fatalf("tool %s must reject unknown arguments: %#v", tool.Name, tool.InputSchema)
|
||||
}
|
||||
if tool.Name == toolSearchPlaceCandidates {
|
||||
if properties["place_name"] == nil || properties["longitude"] != nil || properties["latitude"] != nil {
|
||||
t.Fatalf("place search schema = %#v", tool.InputSchema)
|
||||
@@ -454,6 +458,27 @@ func TestMCPListsSevenBoundedReadOnlyTools(t *testing.T) {
|
||||
} else if properties["longitude"] == nil || properties["latitude"] == nil {
|
||||
t.Fatalf("tool %s lacks bounded coordinate schema: %#v", tool.Name, tool.InputSchema)
|
||||
}
|
||||
limitSchema, limitOK := properties["limit"].(map[string]any)
|
||||
offsetSchema, offsetOK := properties["offset"].(map[string]any)
|
||||
if !limitOK || !offsetOK || limitSchema["minimum"] != float64(1) || limitSchema["maximum"] != float64(20) ||
|
||||
offsetSchema["minimum"] != float64(0) || offsetSchema["maximum"] != float64(service.MaxPageOffset) {
|
||||
t.Fatalf("tool %s pagination schema is not bounded: limit=%#v offset=%#v", tool.Name, limitSchema, offsetSchema)
|
||||
}
|
||||
wantDefaultLimit := float64(10)
|
||||
if tool.Name == toolResolveIncidentContext || tool.Name == toolGetResponsibleUnits {
|
||||
wantDefaultLimit = 20
|
||||
}
|
||||
if limitSchema["default"] != wantDefaultLimit || offsetSchema["default"] != float64(0) {
|
||||
t.Fatalf("tool %s pagination defaults: limit=%#v offset=%#v", tool.Name, limitSchema["default"], offsetSchema["default"])
|
||||
}
|
||||
outputProperties := tool.OutputSchema["properties"].(map[string]any)
|
||||
metadata := outputProperties["metadata"].(map[string]any)
|
||||
metadataProperties := metadata["properties"].(map[string]any)
|
||||
for _, field := range []string{"total_count", "limit", "offset", "has_more", "next_offset"} {
|
||||
if metadataProperties[field] == nil || !stringSliceContains(metadata["required"], field) {
|
||||
t.Fatalf("tool %s output metadata lacks required %s: %#v", tool.Name, field, metadata)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"user_id", "tenant_id", "role", "scope_mode", "allowed_towns", "sql"} {
|
||||
if _, exists := properties[forbidden]; exists {
|
||||
t.Fatalf("tool %s exposes forbidden authorization/query field %s", tool.Name, forbidden)
|
||||
@@ -469,16 +494,18 @@ func TestMCPListsSevenBoundedReadOnlyTools(t *testing.T) {
|
||||
|
||||
func TestMCPDispatchesEverySpatialTool(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
arguments string
|
||||
name string
|
||||
arguments string
|
||||
wantLimit int
|
||||
wantOffset int
|
||||
}{
|
||||
{name: toolSearchPlaceCandidates, arguments: `{"place_name":"观水镇","limit":2}`},
|
||||
{name: toolResolveIncidentContext, arguments: `{"longitude":121.7,"latitude":37.2}`},
|
||||
{name: toolFindNearbyWaterSources, arguments: `{"longitude":121.7,"latitude":37.2,"radius_meters":1000,"limit":2}`},
|
||||
{name: toolFindCommandPostCandidates, arguments: `{"longitude":121.7,"latitude":37.2}`},
|
||||
{name: toolListNearbyAccessLines, arguments: `{"longitude":121.7,"latitude":37.2}`},
|
||||
{name: toolGetResponsibleUnits, arguments: `{"longitude":121.7,"latitude":37.2}`},
|
||||
{name: toolFindNearbyRiskAreas, arguments: `{"longitude":121.7,"latitude":37.2}`},
|
||||
{name: toolSearchPlaceCandidates, arguments: `{"place_name":"观水镇","limit":2,"offset":7}`, wantLimit: 2, wantOffset: 7},
|
||||
{name: toolResolveIncidentContext, arguments: `{"longitude":121.7,"latitude":37.2,"limit":19,"offset":8}`, wantLimit: 19, wantOffset: 8},
|
||||
{name: toolFindNearbyWaterSources, arguments: `{"longitude":121.7,"latitude":37.2,"radius_meters":1000,"limit":2,"offset":9}`, wantLimit: 2, wantOffset: 9},
|
||||
{name: toolFindCommandPostCandidates, arguments: `{"longitude":121.7,"latitude":37.2,"limit":3,"offset":10}`, wantLimit: 3, wantOffset: 10},
|
||||
{name: toolListNearbyAccessLines, arguments: `{"longitude":121.7,"latitude":37.2,"limit":4,"offset":11}`, wantLimit: 4, wantOffset: 11},
|
||||
{name: toolGetResponsibleUnits, arguments: `{"longitude":121.7,"latitude":37.2,"limit":18,"offset":12}`, wantLimit: 18, wantOffset: 12},
|
||||
{name: toolFindNearbyRiskAreas, arguments: `{"longitude":121.7,"latitude":37.2,"limit":5,"offset":13}`, wantLimit: 5, wantOffset: 13},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -493,6 +520,9 @@ func TestMCPDispatchesEverySpatialTool(t *testing.T) {
|
||||
if response.Code != http.StatusOK || spatial.called != tt.name {
|
||||
t.Fatalf("status=%d called=%q body=%s", response.Code, spatial.called, response.Body.String())
|
||||
}
|
||||
if spatial.limit != tt.wantLimit || spatial.offset != tt.wantOffset {
|
||||
t.Fatalf("pagination=(%d,%d), want (%d,%d)", spatial.limit, spatial.offset, tt.wantLimit, tt.wantOffset)
|
||||
}
|
||||
var decoded struct {
|
||||
Result struct {
|
||||
Content []struct {
|
||||
@@ -590,6 +620,113 @@ func TestMCPToolErrorsAreStableAndSanitized(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPPaginationDefaultsBoundariesAndInvalidValues(t *testing.T) {
|
||||
t.Run("omitted values are delegated to service defaults", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
tool string
|
||||
arguments string
|
||||
}{
|
||||
{tool: toolSearchPlaceCandidates, arguments: `{"place_name":"观水镇"}`},
|
||||
{tool: toolResolveIncidentContext, arguments: `{"longitude":121.7,"latitude":37.2}`},
|
||||
{tool: toolFindNearbyWaterSources, arguments: `{"longitude":121.7,"latitude":37.2}`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.tool, func(t *testing.T) {
|
||||
spatial := &fakeMCPSpatialService{}
|
||||
handler := newTestMCPHandler(t, spatial, 4096, time.Second)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, authenticatedMCPRequest(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"`+tt.tool+`","arguments":`+tt.arguments+`}}`))
|
||||
|
||||
if response.Code != http.StatusOK || spatial.called != tt.tool || spatial.limit != 0 || spatial.offset != 0 {
|
||||
t.Fatalf("status=%d called=%q pagination=(%d,%d) body=%s", response.Code, spatial.called, spatial.limit, spatial.offset, response.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
boundaries := []struct {
|
||||
name string
|
||||
tool string
|
||||
arguments string
|
||||
wantLimit int
|
||||
wantOffset int
|
||||
}{
|
||||
{name: "inclusive minimum", tool: toolSearchPlaceCandidates, arguments: `{"place_name":"观水镇","limit":1,"offset":0}`, wantLimit: 1, wantOffset: 0},
|
||||
{name: "inclusive maximum", tool: toolGetResponsibleUnits, arguments: `{"longitude":121.7,"latitude":37.2,"limit":20,"offset":10000}`, wantLimit: 20, wantOffset: service.MaxPageOffset},
|
||||
}
|
||||
for _, tt := range boundaries {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
spatial := &fakeMCPSpatialService{}
|
||||
handler := newTestMCPHandler(t, spatial, 4096, time.Second)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, authenticatedMCPRequest(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"`+tt.tool+`","arguments":`+tt.arguments+`}}`))
|
||||
|
||||
if response.Code != http.StatusOK || spatial.called != tt.tool || spatial.limit != tt.wantLimit || spatial.offset != tt.wantOffset {
|
||||
t.Fatalf("status=%d called=%q pagination=(%d,%d) body=%s", response.Code, spatial.called, spatial.limit, spatial.offset, response.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
invalid := []struct {
|
||||
name string
|
||||
tool string
|
||||
arguments string
|
||||
}{
|
||||
{name: "explicit zero limit", tool: toolSearchPlaceCandidates, arguments: `{"place_name":"观水镇","limit":0}`},
|
||||
{name: "limit above maximum", tool: toolFindNearbyWaterSources, arguments: `{"longitude":121.7,"latitude":37.2,"limit":21}`},
|
||||
{name: "negative offset", tool: toolResolveIncidentContext, arguments: `{"longitude":121.7,"latitude":37.2,"offset":-1}`},
|
||||
{name: "offset above maximum", tool: toolGetResponsibleUnits, arguments: `{"longitude":121.7,"latitude":37.2,"offset":10001}`},
|
||||
}
|
||||
for _, tt := range invalid {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
spatial := &fakeMCPSpatialService{}
|
||||
handler := newTestMCPHandler(t, spatial, 4096, time.Second)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, authenticatedMCPRequest(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"`+tt.tool+`","arguments":`+tt.arguments+`}}`))
|
||||
|
||||
if response.Code != http.StatusOK || spatial.called != "" || !strings.Contains(response.Body.String(), `"code":"INVALID_ARGUMENT"`) {
|
||||
t.Fatalf("status=%d called=%q body=%s", response.Code, spatial.called, response.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPToolErrorIncludesPaginationMetadataAndMirroredContent(t *testing.T) {
|
||||
handler := newTestMCPHandler(t, &fakeMCPSpatialService{}, 4096, time.Second)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, authenticatedMCPRequest(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"fire_safety_search_place_candidates","arguments":{"place_name":"观水镇","limit":0}}}`))
|
||||
|
||||
var decoded struct {
|
||||
Result struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
StructuredContent map[string]any `json:"structuredContent"`
|
||||
IsError bool `json:"isError"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if !decoded.Result.IsError || len(decoded.Result.Content) != 1 {
|
||||
t.Fatalf("unexpected error result: %s", response.Body.String())
|
||||
}
|
||||
var textContent map[string]any
|
||||
if err := json.Unmarshal([]byte(decoded.Result.Content[0].Text), &textContent); err != nil {
|
||||
t.Fatalf("decode mirrored text: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(textContent, decoded.Result.StructuredContent) {
|
||||
t.Fatalf("error text and structuredContent differ: text=%#v structured=%#v", textContent, decoded.Result.StructuredContent)
|
||||
}
|
||||
metadata := decoded.Result.StructuredContent["metadata"].(map[string]any)
|
||||
if metadata["total_count"] != float64(0) || metadata["limit"] != float64(0) || metadata["offset"] != float64(0) || metadata["has_more"] != false {
|
||||
t.Fatalf("unexpected error pagination metadata: %#v", metadata)
|
||||
}
|
||||
if value, exists := metadata["next_offset"]; !exists || value != nil {
|
||||
t.Fatalf("next_offset must be present and null: %#v", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPDoesNotExecuteIDLessToolNotification(t *testing.T) {
|
||||
spatial := &fakeMCPSpatialService{}
|
||||
handler := newTestMCPHandler(t, spatial, 4096, time.Second)
|
||||
@@ -625,10 +762,25 @@ func authenticatedMCPRequest(body string) *http.Request {
|
||||
return request
|
||||
}
|
||||
|
||||
func stringSliceContains(value any, candidate string) bool {
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, item := range items {
|
||||
if item == candidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type fakeMCPSpatialService struct {
|
||||
called string
|
||||
failure error
|
||||
waitForCancellation bool
|
||||
limit int
|
||||
offset int
|
||||
}
|
||||
|
||||
func (f *fakeMCPSpatialService) prepare(ctx context.Context, tool string) error {
|
||||
@@ -643,49 +795,56 @@ func (f *fakeMCPSpatialService) prepare(ctx context.Context, tool string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeMCPSpatialService) SearchPlaceCandidates(ctx context.Context, _ string, _ int) (service.QueryResult[[]domain.PlaceCandidate], error) {
|
||||
func (f *fakeMCPSpatialService) SearchPlaceCandidates(ctx context.Context, _ string, limit, offset int) (service.QueryResult[[]domain.PlaceCandidate], error) {
|
||||
f.limit, f.offset = limit, offset
|
||||
if err := f.prepare(ctx, toolSearchPlaceCandidates); err != nil {
|
||||
return service.QueryResult[[]domain.PlaceCandidate]{}, err
|
||||
}
|
||||
return emptyMCPResult([]domain.PlaceCandidate{}), nil
|
||||
}
|
||||
|
||||
func (f *fakeMCPSpatialService) ResolveIncidentContext(ctx context.Context, _ domain.Coordinate) (service.QueryResult[[]domain.IncidentContext], error) {
|
||||
func (f *fakeMCPSpatialService) ResolveIncidentContext(ctx context.Context, _ domain.Coordinate, limit, offset int) (service.QueryResult[[]domain.IncidentContext], error) {
|
||||
f.limit, f.offset = limit, offset
|
||||
if err := f.prepare(ctx, toolResolveIncidentContext); err != nil {
|
||||
return service.QueryResult[[]domain.IncidentContext]{}, err
|
||||
}
|
||||
return emptyMCPResult([]domain.IncidentContext{}), nil
|
||||
}
|
||||
|
||||
func (f *fakeMCPSpatialService) FindNearbyWaterSources(ctx context.Context, _ domain.Coordinate, _ float64, _ int) (service.QueryResult[[]domain.WaterSource], error) {
|
||||
func (f *fakeMCPSpatialService) FindNearbyWaterSources(ctx context.Context, _ domain.Coordinate, _ float64, limit, offset int) (service.QueryResult[[]domain.WaterSource], error) {
|
||||
f.limit, f.offset = limit, offset
|
||||
if err := f.prepare(ctx, toolFindNearbyWaterSources); err != nil {
|
||||
return service.QueryResult[[]domain.WaterSource]{}, err
|
||||
}
|
||||
return emptyMCPResult([]domain.WaterSource{}), nil
|
||||
}
|
||||
|
||||
func (f *fakeMCPSpatialService) FindCommandPostCandidates(ctx context.Context, _ domain.Coordinate, _ float64, _ int) (service.QueryResult[[]domain.CommandPostCandidate], error) {
|
||||
func (f *fakeMCPSpatialService) FindCommandPostCandidates(ctx context.Context, _ domain.Coordinate, _ float64, limit, offset int) (service.QueryResult[[]domain.CommandPostCandidate], error) {
|
||||
f.limit, f.offset = limit, offset
|
||||
if err := f.prepare(ctx, toolFindCommandPostCandidates); err != nil {
|
||||
return service.QueryResult[[]domain.CommandPostCandidate]{}, err
|
||||
}
|
||||
return emptyMCPResult([]domain.CommandPostCandidate{}), nil
|
||||
}
|
||||
|
||||
func (f *fakeMCPSpatialService) ListNearbyAccessLines(ctx context.Context, _ domain.Coordinate, _ float64, _ int) (service.QueryResult[[]domain.AccessLine], error) {
|
||||
func (f *fakeMCPSpatialService) ListNearbyAccessLines(ctx context.Context, _ domain.Coordinate, _ float64, limit, offset int) (service.QueryResult[[]domain.AccessLine], error) {
|
||||
f.limit, f.offset = limit, offset
|
||||
if err := f.prepare(ctx, toolListNearbyAccessLines); err != nil {
|
||||
return service.QueryResult[[]domain.AccessLine]{}, err
|
||||
}
|
||||
return emptyMCPResult([]domain.AccessLine{}), nil
|
||||
}
|
||||
|
||||
func (f *fakeMCPSpatialService) GetResponsibleUnits(ctx context.Context, _ domain.Coordinate) (service.QueryResult[[]domain.ResponsibleUnit], error) {
|
||||
func (f *fakeMCPSpatialService) GetResponsibleUnits(ctx context.Context, _ domain.Coordinate, limit, offset int) (service.QueryResult[[]domain.ResponsibleUnit], error) {
|
||||
f.limit, f.offset = limit, offset
|
||||
if err := f.prepare(ctx, toolGetResponsibleUnits); err != nil {
|
||||
return service.QueryResult[[]domain.ResponsibleUnit]{}, err
|
||||
}
|
||||
return emptyMCPResult([]domain.ResponsibleUnit{}), nil
|
||||
}
|
||||
|
||||
func (f *fakeMCPSpatialService) FindNearbyRiskAreas(ctx context.Context, _ domain.Coordinate, _ float64, _ int) (service.QueryResult[[]domain.RiskArea], error) {
|
||||
func (f *fakeMCPSpatialService) FindNearbyRiskAreas(ctx context.Context, _ domain.Coordinate, _ float64, limit, offset int) (service.QueryResult[[]domain.RiskArea], error) {
|
||||
f.limit, f.offset = limit, offset
|
||||
if err := f.prepare(ctx, toolFindNearbyRiskAreas); err != nil {
|
||||
return service.QueryResult[[]domain.RiskArea]{}, err
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user