feat: validate contact and group search args
This commit is contained in:
@@ -12,8 +12,11 @@ import (
|
||||
const ToolNameSearchContacts = "isphere_search_contacts"
|
||||
|
||||
type SearchContactsArgs struct {
|
||||
Query string `json:"query" jsonschema:"contact search query; currently matches bare JIDs from local readonly message logs"`
|
||||
Limit int `json:"limit,omitempty" jsonschema:"maximum number of contacts to return; zero or negative returns all matches"`
|
||||
Query string `json:"query" jsonschema:"contact search query; currently matches bare JIDs from local readonly message logs"`
|
||||
Limit int `json:"limit,omitempty" jsonschema:"maximum number of contacts to return; zero or negative returns all matches"`
|
||||
Cursor string `json:"cursor,omitempty" jsonschema:"pagination cursor; currently only empty cursor is supported"`
|
||||
SourcePreference string `json:"source_preference,omitempty" jsonschema:"source selector; currently supports auto or local_readonly only"`
|
||||
IncludeInactive bool `json:"include_inactive,omitempty" jsonschema:"accepted for contract compatibility; local readonly message logs do not expose inactive state"`
|
||||
}
|
||||
|
||||
func RegisterISphereContactTools(server *mcp.Server, source ReceiveMessagesSource) {
|
||||
@@ -25,6 +28,9 @@ func RegisterISphereContactTools(server *mcp.Server, source ReceiveMessagesSourc
|
||||
Name: ToolNameSearchContacts,
|
||||
Description: "Search iSphere contact candidates from the configured read-only message source.",
|
||||
}, func(ctx context.Context, _ *mcp.CallToolRequest, input SearchContactsArgs) (*mcp.CallToolResult, map[string]any, error) {
|
||||
if err := validateLocalReadonlySourceAndCursor(ToolNameSearchContacts, input.SourcePreference, input.Cursor); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
started := time.Now().UTC()
|
||||
messages, err := source.ReceiveMessages(ctx, isphere.ReceiveMessagesQuery{Limit: 0})
|
||||
if err != nil {
|
||||
|
||||
@@ -74,3 +74,50 @@ func TestISphereSearchContactsToolReturnsJIDContacts(t *testing.T) {
|
||||
t.Fatalf("source queries = %+v, want one unbounded receive query", fake.queries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestISphereSearchContactsToolValidatesContractArgs(t *testing.T) {
|
||||
fake := &fakeReceiveMessagesSource{
|
||||
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{
|
||||
ID: "msg-1",
|
||||
SenderID: "alice@imopenfire1-lanzhou",
|
||||
ReceiverID: "bob@imopenfire1-lanzhou",
|
||||
}}},
|
||||
}
|
||||
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
||||
RegisterISphereContactTools(server, fake)
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: ToolNameSearchContacts,
|
||||
Arguments: map[string]any{
|
||||
"query": "alice",
|
||||
"source_preference": "local_readonly",
|
||||
"include_inactive": true,
|
||||
"cursor": "",
|
||||
"limit": 10,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call with safe contract args: %v", err)
|
||||
}
|
||||
if callResult.IsError {
|
||||
t.Fatalf("call result is error: %+v", callResult)
|
||||
}
|
||||
|
||||
bridgeResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: ToolNameSearchContacts,
|
||||
Arguments: map[string]any{"query": "alice", "source_preference": "bridge"},
|
||||
})
|
||||
if err == nil && !bridgeResult.IsError {
|
||||
t.Fatalf("bridge source_preference was accepted before connector exists")
|
||||
}
|
||||
|
||||
cursorResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: ToolNameSearchContacts,
|
||||
Arguments: map[string]any{"query": "alice", "cursor": "next-page"},
|
||||
})
|
||||
if err == nil && !cursorResult.IsError {
|
||||
t.Fatalf("non-empty cursor was accepted before pagination exists")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,11 @@ import (
|
||||
const ToolNameSearchGroups = "isphere_search_groups"
|
||||
|
||||
type SearchGroupsArgs struct {
|
||||
Query string `json:"query" jsonschema:"group search query; currently matches bare group JIDs from local readonly message logs"`
|
||||
Limit int `json:"limit,omitempty" jsonschema:"maximum number of groups to return; zero or negative returns all matches"`
|
||||
Query string `json:"query" jsonschema:"group search query; currently matches bare group JIDs from local readonly message logs"`
|
||||
Limit int `json:"limit,omitempty" jsonschema:"maximum number of groups to return; zero or negative returns all matches"`
|
||||
Cursor string `json:"cursor,omitempty" jsonschema:"pagination cursor; currently only empty cursor is supported"`
|
||||
SourcePreference string `json:"source_preference,omitempty" jsonschema:"source selector; currently supports auto or local_readonly only"`
|
||||
IncludeArchived bool `json:"include_archived,omitempty" jsonschema:"accepted for contract compatibility; local readonly message logs do not expose archived state"`
|
||||
}
|
||||
|
||||
func RegisterISphereGroupTools(server *mcp.Server, source ReceiveMessagesSource) {
|
||||
@@ -25,6 +28,9 @@ func RegisterISphereGroupTools(server *mcp.Server, source ReceiveMessagesSource)
|
||||
Name: ToolNameSearchGroups,
|
||||
Description: "Search iSphere group candidates from the configured read-only message source.",
|
||||
}, func(ctx context.Context, _ *mcp.CallToolRequest, input SearchGroupsArgs) (*mcp.CallToolResult, map[string]any, error) {
|
||||
if err := validateLocalReadonlySourceAndCursor(ToolNameSearchGroups, input.SourcePreference, input.Cursor); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
started := time.Now().UTC()
|
||||
messages, err := source.ReceiveMessages(ctx, isphere.ReceiveMessagesQuery{Limit: 0})
|
||||
if err != nil {
|
||||
|
||||
@@ -79,3 +79,51 @@ func TestISphereSearchGroupsToolReturnsJIDGroups(t *testing.T) {
|
||||
t.Fatalf("source queries = %+v, want one unbounded receive query", fake.queries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestISphereSearchGroupsToolValidatesContractArgs(t *testing.T) {
|
||||
fake := &fakeReceiveMessagesSource{
|
||||
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{
|
||||
ID: "msg-group-1",
|
||||
ConversationType: "groupchat",
|
||||
SenderID: "project-room@conference.imopenfire1-lanzhou",
|
||||
ReceiverID: "user@imopenfire1-lanzhou",
|
||||
}}},
|
||||
}
|
||||
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
||||
RegisterISphereGroupTools(server, fake)
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: ToolNameSearchGroups,
|
||||
Arguments: map[string]any{
|
||||
"query": "project",
|
||||
"source_preference": "local_readonly",
|
||||
"include_archived": true,
|
||||
"cursor": "",
|
||||
"limit": 10,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call with safe contract args: %v", err)
|
||||
}
|
||||
if callResult.IsError {
|
||||
t.Fatalf("call result is error: %+v", callResult)
|
||||
}
|
||||
|
||||
bridgeResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: ToolNameSearchGroups,
|
||||
Arguments: map[string]any{"query": "project", "source_preference": "bridge"},
|
||||
})
|
||||
if err == nil && !bridgeResult.IsError {
|
||||
t.Fatalf("bridge source_preference was accepted before connector exists")
|
||||
}
|
||||
|
||||
cursorResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: ToolNameSearchGroups,
|
||||
Arguments: map[string]any{"query": "project", "cursor": "next-page"},
|
||||
})
|
||||
if err == nil && !cursorResult.IsError {
|
||||
t.Fatalf("non-empty cursor was accepted before pagination exists")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,14 +58,18 @@ func RegisterISphereReadTools(server *mcp.Server, source ReceiveMessagesSource)
|
||||
}
|
||||
|
||||
func validateReceiveMessagesArgs(input ReceiveMessagesArgs) error {
|
||||
sourcePreference := strings.ToLower(strings.TrimSpace(input.SourcePreference))
|
||||
return validateLocalReadonlySourceAndCursor(ToolNameReceiveMessages, input.SourcePreference, input.Cursor)
|
||||
}
|
||||
|
||||
func validateLocalReadonlySourceAndCursor(toolName string, sourcePreferenceValue string, cursorValue string) error {
|
||||
sourcePreference := strings.ToLower(strings.TrimSpace(sourcePreferenceValue))
|
||||
switch sourcePreference {
|
||||
case "", "auto", "local_readonly":
|
||||
default:
|
||||
return fmt.Errorf("isphere_receive_messages source_preference %q is not available; only auto and local_readonly are supported", input.SourcePreference)
|
||||
return fmt.Errorf("%s source_preference %q is not available; only auto and local_readonly are supported", toolName, sourcePreferenceValue)
|
||||
}
|
||||
if strings.TrimSpace(input.Cursor) != "" {
|
||||
return fmt.Errorf("isphere_receive_messages cursor pagination is not available yet")
|
||||
if strings.TrimSpace(cursorValue) != "" {
|
||||
return fmt.Errorf("%s cursor pagination is not available yet", toolName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user