package tools import ( "context" "time" "github.com/modelcontextprotocol/go-sdk/mcp" "isphere-ai-bridge/internal/isphere" "isphere-ai-bridge/internal/msglib" ) 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"` 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) { RegisterISphereContactToolsWithDisplayEntities(server, source, nil) } func RegisterISphereContactToolsWithDisplayEntities(server *mcp.Server, source ReceiveMessagesSource, displaySource DisplayEntitySource) { if source == nil { source = isphere.EncryptedPacketLogSource{} } mcp.AddTool[SearchContactsArgs, map[string]any](server, &mcp.Tool{ 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 { return nil, nil, err } logContacts := isphere.SearchContactsFromMessages(messages.Messages, isphere.SearchContactsQuery{Query: input.Query, Limit: input.Limit}) contacts := logContacts if displaySource != nil { entities, err := displaySource.DisplayEntities(ctx, msglib.DisplayEntitiesOptions{ EntityType: msglib.EntityTypeContacts, Query: input.Query, Limit: displayEntityQueryLimit(input.Limit), }) if err != nil { return nil, nil, err } contacts = mergeContacts(contactsFromDisplayEntities(entities), logContacts.Contacts, input.Limit) } return nil, searchContactsResultToMap(contacts, started, time.Now().UTC()), nil }) } func searchContactsResultToMap(result isphere.SearchContactsResult, started time.Time, finished time.Time) map[string]any { contacts := make([]map[string]any, 0, len(result.Contacts)) for _, contact := range result.Contacts { contacts = append(contacts, map[string]any{ "contact_id": contact.ContactID, "display_name": contact.DisplayName, "account": contact.Account, "department": nil, "title": nil, "source": contact.Source, "confidence": contact.Confidence, "raw_ref": contact.RawRef, }) } return map[string]any{ "ok": true, "contacts": contacts, "next_cursor": nil, "audit": map[string]any{ "tool": ToolNameSearchContacts, "source": "local_readonly", "execution_mode": "read", "started_at": started.Format(time.RFC3339Nano), "finished_at": finished.Format(time.RFC3339Nano), "result": "ok", }, } }