mcp接口增加分页

This commit is contained in:
andy committed 2026-09-06 17:57:58 +08:00
1 parent 7243319bbb
commit 7f4adad7b1
16 files changed
+1157 -281

No files matched your search

+178 -19
View File
@@ -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
}