From 127731666398985d42905dbc78f8a927b44335ec Mon Sep 17 00:00:00 2001 From: zhaoyilun Date: Fri, 10 Jul 2026 03:09:37 +0800 Subject: [PATCH] feat: enrich search with msglib display entities --- docs/current-status-card.md | 9 +- .../capability-source-matrix.md | 8 +- ...tage-c-log-backed-receive-messages-loop.md | 63 ++++++++-- internal/mcpserver/server.go | 51 +++++++-- internal/mcpserver/server_test.go | 24 ++++ internal/tools/display_entities.go | 108 ++++++++++++++++++ internal/tools/isphere_contacts.go | 19 ++- internal/tools/isphere_contacts_test.go | 66 +++++++++++ internal/tools/isphere_groups.go | 19 ++- internal/tools/isphere_groups_test.go | 55 +++++++++ scripts/verify-go-mcp.ps1 | 8 +- 11 files changed, 400 insertions(+), 30 deletions(-) create mode 100644 internal/tools/display_entities.go diff --git a/docs/current-status-card.md b/docs/current-status-card.md index 9d1caf1..f887548 100644 --- a/docs/current-status-card.md +++ b/docs/current-status-card.md @@ -27,9 +27,9 @@ Remote base before local roadmap commits: `b2d839e Merge branch 'codex/n15-repor - `scripts/verify-win-helper.ps1` and `scripts/verify-go-mcp.ps1` provide repeatable local checks for the helper, eight-tool MCP surface, default empty-source receive/contact/group/file calls, synthetic configured-file receive/contact/group/file smoke, and synthetic configured-directory receive/contact/group/file smoke. - `isphere_receive_messages` now returns the contract-facing `ok`, `conversation`, `messages`, `next_cursor`, and `audit` envelope while retaining legacy parser-native message fields for compatibility. - `isphere_receive_messages` accepts the read-contract argument set for the current local-readonly path: `conversation_id`, `query`, `since`, `limit`, `include_attachment_metadata`, `source_preference`, `preview`, and empty `cursor`. -- `isphere_search_contacts` and `isphere_search_groups` accept and validate the safe search-contract args for the current local-readonly path, but display names/member data are not yet enriched from `MsgLib.db` until C30 wires the C29 bounded display-entity reader into the MCP search tools. +- `isphere_search_contacts` and `isphere_search_groups` accept and validate the safe search-contract args for the current local-readonly path and can optionally enrich results from the env-configured C29 `MsgLib.db` display-entity reader; default behavior remains log/JID-backed when MsgLib env vars are absent. - `isphere_receive_files` list mode accepts and validates the safe file-list contract args for the current local-readonly path while keeping download blocked. -- `native/MsgLibReadSidecar` provides a bounded x86 .NET read-only boundary for `MsgLib.db` schema/display-source checks and bounded contact/group display-entity reads; `internal/msglib` wraps it from Go, but MCP tools are not wired to it yet. +- `native/MsgLibReadSidecar` provides a bounded x86 .NET read-only boundary for `MsgLib.db` schema/display-source checks and bounded contact/group display-entity reads; `internal/msglib` wraps it from Go, and C30 wires it as an optional MCP contact/group enrichment source. - N12-pre and N12R documents define safe offline evidence intake and internal-sandbox live UIA capture procedures. - N13/N14/N15 produced selector catalog/report validation infrastructure, but those are supporting validation assets only. @@ -56,7 +56,7 @@ N13/N14/N15 are pre-business validation results. They can help identify UI eleme ## Current loop -Current loop: `Stage C / Loop C30 - integrate MsgLib display entities into contact/group search`. +Current loop: `Stage C / Loop C31 - optional real MsgLib MCP enrichment smoke`. Plan file: `docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md`. @@ -98,10 +98,11 @@ Current loop output so far: 27. C27: added `native/MsgLibReadSidecar` with protocol `isphere.msglib.v1`, x86 build/verify scripts, and safe `self_check`, `schema_summary`, and `display_sources` operations; evidence-backed smoke returned 8 display-source candidates without row/message values. 28. C28: added `internal/msglib` Go client wrapper for the sidecar protocol with env config, fake-sidecar unit tests, timeout handling, structured sidecar errors, `SelfCheck`, and `DisplaySources`; no MCP tool wiring or message/display row reads yet. 29. C29: added bounded `display_entities` support across the x86 sidecar, Go client, and verification script; evidence-backed verification returned sanitized contact/group counts and source-table names only, with no message bodies, file paths, raw rows, sends, writes, hooks, or injection. +30. C30: wired `DisplayEntities` into `isphere_search_contacts` and `isphere_search_groups` as an optional env-configured enrichment source with fake-source tests; default MCP verification now clears MsgLib env to keep the standard smoke deterministic. ## Next business mainline -1. Run Stage C Loop C30: wire C29 `DisplayEntities` into `isphere_search_contacts` and `isphere_search_groups` as an env-configured optional enrichment source, with fake-sidecar tests first; keep message bodies, file paths, downloads, sends, writes, hooks, injection, and raw row dumps out of scope. +1. Run Stage C Loop C31: add an optional copied-DB MCP enrichment smoke/hardening path that proves contact/group enrichment through the real sidecar while printing only sanitized counts/source metadata by default. 2. Leave `isphere_send_message` blocked until bridge/API, UIA action-chain, or protocol connector evidence is validated. 3. Leave actual file download/cache mapping as a later connector node until a message-to-cache hash or client download source is validated. 4. Keep UIA selector/report work as fallback support, not as the primary roadmap. diff --git a/docs/source-discovery/capability-source-matrix.md b/docs/source-discovery/capability-source-matrix.md index cd7ef28..ec7d3be 100644 --- a/docs/source-discovery/capability-source-matrix.md +++ b/docs/source-discovery/capability-source-matrix.md @@ -17,9 +17,9 @@ Schema notes: `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md` | MCP tool | Primary source | Fallback source | Evidence file | Decision status | Next implementation slice | | --- | --- | --- | --- | --- | --- | -| `isphere_receive_messages` | decrypted `PacketReader.ProcessPacket` XMPP `` stanzas via `internal/isphere.EncryptedPacketLogSource`; configured by `ISPHERE_PACKET_LOG_FILE` or `ISPHERE_PACKET_LOG_DIR` | decrypted `Smark.SendReceive` transport stanzas; decrypted `SaveToDB` `Chat_OnMessageArrived` traces; copied `MsgLib.db` through a future x86 read-only sidecar/helper | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#field-mapping-evidence`; `docs/source-discovery/2026-07-10-dotnet-wrapper-static-analysis.md` | C20 supports and validates the current safe receive-message contract args; C29 adds bounded contact/group display-entity extraction; DB-backed message listing is still not implemented | Defer DB-backed message listing; first run C30 contact/group search enrichment | -| `isphere_search_contacts` | bare sender/receiver JIDs extracted from normalized log-backed messages loaded from configured PacketReader file or directory source | validated copied `MsgLib.db` tables: `TD_Roster`, `TD_CustomEffigy`, `tblRecent`, `tblChatLevel`, `tblPersonMsg`; decrypted roster/contact stanzas from `Smark.SendReceive`; UIA helper source if display names are still missing | `docs/source-discovery/2026-07-10-msglib-readonly-schema-extraction.md` | C29 verifies bounded contact display-entity extraction through the x86 sidecar and Go client; MCP search is not enriched yet | C30 wire optional MsgLib enrichment into `isphere_search_contacts` | -| `isphere_search_groups` | decrypted `PacketReader.ProcessPacket` `type="groupchat"` stanzas and conference/MUC JIDs loaded from configured PacketReader file or directory source | validated copied `MsgLib.db` tables: `tblMsgGroupPersonMsg`, `TD_WorkGroupAuth`, `tblRecent`; UIA helper source if group display names are still missing | `docs/source-discovery/2026-07-10-msglib-readonly-schema-extraction.md`; C9 ignored-log scan: PacketReader 86 groupchat/86 conference hits; Smark 24 groupchat/658 conference/631 muc hits | C29 verifies bounded group display-entity extraction through the x86 sidecar and Go client; MCP search is not enriched yet | C30 wire optional MsgLib enrichment into `isphere_search_groups` | +| `isphere_receive_messages` | decrypted `PacketReader.ProcessPacket` XMPP `` stanzas via `internal/isphere.EncryptedPacketLogSource`; configured by `ISPHERE_PACKET_LOG_FILE` or `ISPHERE_PACKET_LOG_DIR` | decrypted `Smark.SendReceive` transport stanzas; decrypted `SaveToDB` `Chat_OnMessageArrived` traces; copied `MsgLib.db` through a future x86 read-only sidecar/helper | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#field-mapping-evidence`; `docs/source-discovery/2026-07-10-dotnet-wrapper-static-analysis.md` | C20 supports and validates the current safe receive-message contract args; C30 wires bounded contact/group display entities into search enrichment; DB-backed message listing is still not implemented | Defer DB-backed message listing; run optional real MsgLib MCP enrichment smoke | +| `isphere_search_contacts` | bare sender/receiver JIDs extracted from normalized log-backed messages loaded from configured PacketReader file or directory source | validated copied `MsgLib.db` tables: `TD_Roster`, `TD_CustomEffigy`, `tblRecent`, `tblChatLevel`, `tblPersonMsg`; decrypted roster/contact stanzas from `Smark.SendReceive`; UIA helper source if display names are still missing | `docs/source-discovery/2026-07-10-msglib-readonly-schema-extraction.md` | C30 wires optional MsgLib display-entity enrichment into `isphere_search_contacts`; standard verification keeps MsgLib env cleared by default | C31 optional copied-DB MCP enrichment smoke with sanitized output | +| `isphere_search_groups` | decrypted `PacketReader.ProcessPacket` `type="groupchat"` stanzas and conference/MUC JIDs loaded from configured PacketReader file or directory source | validated copied `MsgLib.db` tables: `tblMsgGroupPersonMsg`, `TD_WorkGroupAuth`, `tblRecent`; UIA helper source if group display names are still missing | `docs/source-discovery/2026-07-10-msglib-readonly-schema-extraction.md`; C9 ignored-log scan: PacketReader 86 groupchat/86 conference hits; Smark 24 groupchat/658 conference/631 muc hits | C30 wires optional MsgLib display-entity enrichment into `isphere_search_groups`; standard verification keeps MsgLib env cleared by default | C31 optional copied-DB MCP enrichment smoke with sanitized output | | `isphere_receive_files` | decrypted `PacketReader.ProcessPacket` file-transfer message stanzas via `internal/isphere.ListFilesFromMessages` and `isphere_receive_files` list mode; configured PacketReader file or directory source; `zyl\importal\` cache entries remain unlinked | decrypted `Smark.SendReceive` and `SaveToDB` traces for file-reference reconciliation; future UIA/client connector for download | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#c12-file-transfer-evidence-precheck` | C22 verifies safe file-list contract args; no log-to-cache/download mapping yet | defer download mapping; precheck contact/group display-name evidence | | `isphere_send_message` | none validated yet | existing bridge/API/local service preferred; UIA write connector only after internal-sandbox action evidence; network/protocol connector only after protocol discovery | `docs/source-discovery/2026-07-10-send-message-source-precheck.md` | C15 blocked: contract exists, but no committed bridge/API/local service, helper write op, UIA action chain, or protocol connector is validated | do not implement write behavior; harden existing read source configuration | | `isphere_send_file` | none validated yet | depends on send-message connector plus outbound file/upload evidence | `docs/source-discovery/2026-07-10-send-message-source-precheck.md` | blocked behind `isphere_send_message` connector selection and file upload/source policy | defer until send-message connector is validated | @@ -39,4 +39,4 @@ Start Stage C with a narrow log-backed `isphere_receive_messages` source abstrac ## Deferred source work -`MsgLib.db` has a validated copied read-only open path through the bundled 32-bit `System.Data.SQLite.dll` and password `123`. C27 adds `MsgLibReadSidecar` as the bounded x86 .NET reader boundary, C28 adds the Go `internal/msglib` process client, and C29 adds bounded `display_entities` extraction. The next step is optional contact/group MCP enrichment from that source. +`MsgLib.db` has a validated copied read-only open path through the bundled 32-bit `System.Data.SQLite.dll` and password `123`. C27 adds `MsgLibReadSidecar` as the bounded x86 .NET reader boundary, C28 adds the Go `internal/msglib` process client, C29 adds bounded `display_entities` extraction, and C30 wires it as optional contact/group MCP enrichment. The next step is an optional copied-DB MCP smoke with sanitized output. diff --git a/docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md b/docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md index 3286c1a..ffe3b0b 100644 --- a/docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md +++ b/docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md @@ -2302,7 +2302,8 @@ Bounded display entity extraction result: **Goal:** Wire the C29 bounded `DisplayEntities` source into `isphere_search_contacts` and `isphere_search_groups` as an optional env-configured enrichment path, while preserving the current log-backed fallback and keeping message bodies, file paths, downloads, sends, writes, hooks, injection, DB mutation, and raw row dumps out of scope. -**Planned files:** +**Files:** +- Create: `internal/tools/display_entities.go` - Modify: `internal/tools/isphere_contacts.go` - Modify: `internal/tools/isphere_contacts_test.go` - Modify: `internal/tools/isphere_groups.go` @@ -2318,24 +2319,24 @@ Bounded display entity extraction result: - Add an injectable display-entity source interface around `internal/msglib.Client`. - Keep default MCP behavior unchanged when `ISPHERE_MSGLIB_SIDECAR_EXE`, `ISPHERE_MSGLIB_SQLITE_DLL`, or `ISPHERE_MSGLIB_DB` are absent. - When all env vars are present, enrich contact/group search results from `DisplayEntities` with bounded `query` and `limit`. -- Normal tests use fake display-entity source data; verification script should include a synthetic/fake enrichment smoke before any real copied DB smoke. +- Normal tests use fake display-entity source data; `scripts/verify-go-mcp.ps1` clears MsgLib env by default to keep standard smoke deterministic. - Do not expose a new MCP tool in this loop. -- [ ] **Step 1: Write failing contact/group enrichment tests** +- [x] **Step 1: Write failing contact/group enrichment tests** -Add tests proving contacts/groups can consume injected display entities and still pass existing log-backed/default behavior. +Added fake-source tests proving `isphere_search_contacts` and `isphere_search_groups` can consume injected MsgLib display entities, pass query/limit/entity type, and return `msglib_readonly` metadata. Verified RED with missing `RegisterISphereContactToolsWithDisplayEntities` and `RegisterISphereGroupToolsWithDisplayEntities`. -- [ ] **Step 2: Implement optional display-entity source wiring** +- [x] **Step 2: Implement optional display-entity source wiring** -Implement source interface and env-configured `internal/msglib` client construction without changing default empty-source behavior. +Added `DisplayEntitySource`, mapping/merge helpers, contact/group registration functions with optional display entity source, and `NewServerFromEnv` MsgLib env wiring. Partial MsgLib env config now fails fast; default nil source remains unchanged. -- [ ] **Step 3: Update verification/docs** +- [x] **Step 3: Update verification/docs** -Extend `scripts/verify-go-mcp.ps1` with synthetic/fake enrichment smoke and update status/matrix/plan. +Updated `scripts/verify-go-mcp.ps1` to clear MsgLib env for deterministic default verification and added `msglib_configured=false` to the verification result. Updated status/matrix/plan. -- [ ] **Step 4: Full verification and commit** +- [x] **Step 4: Full verification and commit** -Run: +Run before commit: ```powershell git diff --check @@ -2346,6 +2347,48 @@ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-msglib-sideca powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-go-mcp.ps1 ``` +Final verification passed: `git diff --check`, `go test ./...`, explicit `go build ./cmd/isphere-mcp`, default sidecar smoke, evidence-backed sidecar smoke with sanitized `display_entity_summaries`, and `scripts/verify-go-mcp.ps1` with `msglib_configured=false`. + +--- + +## Loop C30 Result + +Optional MsgLib MCP enrichment result: + +- `isphere_search_contacts` can now use injected/Env MsgLib display entities and return `msglib_readonly` contact metadata. +- `isphere_search_groups` can now use injected/Env MsgLib display entities and return `msglib_readonly` group metadata. +- Default behavior stays log/JID-backed when MsgLib env is absent. +- Standard `verify-go-mcp.ps1` stays deterministic by clearing MsgLib env; real copied-DB MCP smoke is deferred to C31 so it can be sanitized separately. + +--- + +## Loop C31: Optional real MsgLib MCP enrichment smoke + +**Goal:** Add an optional verification path that proves `isphere_search_contacts` and `isphere_search_groups` can call the real copied-DB MsgLib sidecar through MCP, while printing only sanitized counts/source metadata by default. + +**Planned files:** +- Modify: `scripts/verify-go-mcp.ps1` or add a focused optional verification script. +- Modify: `docs/current-status-card.md` +- Modify: `docs/source-discovery/capability-source-matrix.md` +- Modify: `docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md` + +**Planned behavior:** +- Default verification remains deterministic and does not use real MsgLib env. +- Optional verification is gated by explicit env variables and copied DB paths. +- Output only counts, source names, and booleans; do not print display names, JIDs, message bodies, file paths, or raw rows. + +- [ ] **Step 1: Add optional real-MsgLib MCP smoke** + +Implement opt-in smoke with sanitized summary only. + +- [ ] **Step 2: Run default and optional verification** + +Run default full verification and optional copied-DB smoke if paths are available. + +- [ ] **Step 3: Update docs and commit** + +Record sanitized evidence and next loop. + --- ## Self-Review diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 8f6c443..4e5ffa0 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -9,15 +9,19 @@ import ( "isphere-ai-bridge/internal/helperclient" "isphere-ai-bridge/internal/isphere" + "isphere-ai-bridge/internal/msglib" "isphere-ai-bridge/internal/tools" ) const ( - ServerName = "isphere-ai-bridge" - ServerTitle = "iSphere AI Bridge" - ServerVersion = "0.1.0" - EnvPacketLogFileKey = "ISPHERE_PACKET_LOG_FILE" - EnvPacketLogDirKey = "ISPHERE_PACKET_LOG_DIR" + ServerName = "isphere-ai-bridge" + ServerTitle = "iSphere AI Bridge" + ServerVersion = "0.1.0" + EnvPacketLogFileKey = "ISPHERE_PACKET_LOG_FILE" + EnvPacketLogDirKey = "ISPHERE_PACKET_LOG_DIR" + EnvMsgLibSidecarExeKey = "ISPHERE_MSGLIB_SIDECAR_EXE" + EnvMsgLibSQLiteDLLKey = "ISPHERE_MSGLIB_SQLITE_DLL" + EnvMsgLibDBKey = "ISPHERE_MSGLIB_DB" ) func NewServer() *mcp.Server { @@ -44,10 +48,41 @@ func NewServerFromEnv() (*mcp.Server, error) { } source = loaded } - return NewServerWithReceiveSource(source), nil + displaySource, err := msgLibDisplayEntitySourceFromEnv() + if err != nil { + return nil, err + } + return NewServerWithSources(source, displaySource), nil +} + +func msgLibDisplayEntitySourceFromEnv() (tools.DisplayEntitySource, error) { + sidecarExe := strings.TrimSpace(os.Getenv(EnvMsgLibSidecarExeKey)) + sqliteDLL := strings.TrimSpace(os.Getenv(EnvMsgLibSQLiteDLLKey)) + dbPath := strings.TrimSpace(os.Getenv(EnvMsgLibDBKey)) + configured := 0 + for _, value := range []string{sidecarExe, sqliteDLL, dbPath} { + if value != "" { + configured++ + } + } + if configured == 0 { + return nil, nil + } + if configured != 3 { + return nil, fmt.Errorf("configure MsgLib source: set all of %s, %s, and %s, or set none", EnvMsgLibSidecarExeKey, EnvMsgLibSQLiteDLLKey, EnvMsgLibDBKey) + } + cfg, err := msglib.ConfigFromEnv() + if err != nil { + return nil, fmt.Errorf("configure MsgLib source: %w", err) + } + return msglib.NewClient(cfg), nil } func NewServerWithReceiveSource(source tools.ReceiveMessagesSource) *mcp.Server { + return NewServerWithSources(source, nil) +} + +func NewServerWithSources(source tools.ReceiveMessagesSource, displaySource tools.DisplayEntitySource) *mcp.Server { server := mcp.NewServer(&mcp.Implementation{ Name: ServerName, Title: ServerTitle, @@ -55,8 +90,8 @@ func NewServerWithReceiveSource(source tools.ReceiveMessagesSource) *mcp.Server }, nil) tools.RegisterWinHelperTools(server, helperclient.Client{}) tools.RegisterISphereReadTools(server, source) - tools.RegisterISphereContactTools(server, source) - tools.RegisterISphereGroupTools(server, source) + tools.RegisterISphereContactToolsWithDisplayEntities(server, source, displaySource) + tools.RegisterISphereGroupToolsWithDisplayEntities(server, source, displaySource) tools.RegisterISphereFileTools(server, source) return server } diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index ca1a984..00bf951 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -9,6 +9,7 @@ import ( "os" "reflect" "sort" + "strings" "testing" "time" @@ -63,7 +64,21 @@ func TestNewServerRegistersN8WinHelperToolsWithoutCallingHelper(t *testing.T) { } } +func TestNewServerFromEnvRejectsPartialMsgLibConfig(t *testing.T) { + clearMsgLibEnvForServerTest(t) + t.Setenv(EnvMsgLibSidecarExeKey, `C:\tools\MsgLibReadSidecar.exe`) + + _, err := NewServerFromEnv() + if err == nil { + t.Fatal("NewServerFromEnv returned nil error for partial MsgLib config") + } + if !strings.Contains(err.Error(), EnvMsgLibSQLiteDLLKey) || !strings.Contains(err.Error(), EnvMsgLibDBKey) { + t.Fatalf("error = %v, want missing MsgLib env context", err) + } +} + func TestNewServerFromEnvUsesPacketLogFile(t *testing.T) { + clearMsgLibEnvForServerTest(t) plaintext := `-------------------------------------------------------------------------------------------------------------------------------------------- 2026/7/7 15:30:07 @@ -128,6 +143,7 @@ func TestNewServerFromEnvUsesPacketLogFile(t *testing.T) { } func TestNewServerFromEnvUsesPacketLogDirectory(t *testing.T) { + clearMsgLibEnvForServerTest(t) plaintext := `-------------------------------------------------------------------------------------------------------------------------------------------- 2026/7/7 15:30:07 @@ -181,6 +197,14 @@ func TestNewServerFromEnvUsesPacketLogDirectory(t *testing.T) { } } +func clearMsgLibEnvForServerTest(t *testing.T) { + t.Helper() + t.Setenv(EnvMsgLibSidecarExeKey, "") + t.Setenv(EnvMsgLibSQLiteDLLKey, "") + t.Setenv(EnvMsgLibDBKey, "") + t.Setenv("ISPHERE_MSGLIB_PASSWORD", "") +} + func writeEncryptedPacketLogDirectoryForServerTest(t *testing.T, plaintext string) string { t.Helper() dir := t.TempDir() diff --git a/internal/tools/display_entities.go b/internal/tools/display_entities.go new file mode 100644 index 0000000..60c0968 --- /dev/null +++ b/internal/tools/display_entities.go @@ -0,0 +1,108 @@ +package tools + +import ( + "context" + "strings" + + "isphere-ai-bridge/internal/isphere" + "isphere-ai-bridge/internal/msglib" +) + +type DisplayEntitySource interface { + DisplayEntities(ctx context.Context, opts msglib.DisplayEntitiesOptions) ([]msglib.DisplayEntity, error) +} + +func displayEntityQueryLimit(limit int) int { + if limit <= 0 { + return msglib.DefaultDisplayEntityLimit + } + if limit > msglib.MaxDisplayEntityLimit { + return msglib.MaxDisplayEntityLimit + } + return limit +} + +func contactsFromDisplayEntities(entities []msglib.DisplayEntity) []isphere.Contact { + contacts := make([]isphere.Contact, 0, len(entities)) + for _, entity := range entities { + id := strings.TrimSpace(entity.JID) + name := strings.TrimSpace(entity.DisplayName) + if id == "" { + id = name + } + if name == "" { + name = id + } + if id == "" { + continue + } + contacts = append(contacts, isphere.Contact{ + ContactID: id, + DisplayName: name, + Account: strings.TrimSpace(entity.JID), + Source: "msglib_readonly", + Confidence: entity.Confidence, + RawRef: "msglib:" + entity.SourceTable, + }) + } + return contacts +} + +func groupsFromDisplayEntities(entities []msglib.DisplayEntity) []isphere.Group { + groups := make([]isphere.Group, 0, len(entities)) + for _, entity := range entities { + id := strings.TrimSpace(entity.JID) + name := strings.TrimSpace(entity.DisplayName) + if id == "" { + id = name + } + if name == "" { + name = id + } + if id == "" { + continue + } + groups = append(groups, isphere.Group{ + GroupID: id, + DisplayName: name, + Source: "msglib_readonly", + Confidence: entity.Confidence, + RawRef: "msglib:" + entity.SourceTable, + }) + } + return groups +} + +func mergeContacts(primary []isphere.Contact, fallback []isphere.Contact, limit int) isphere.SearchContactsResult { + seen := map[string]bool{} + contacts := make([]isphere.Contact, 0, len(primary)+len(fallback)) + for _, contact := range append(primary, fallback...) { + id := strings.TrimSpace(contact.ContactID) + if id == "" || seen[strings.ToLower(id)] { + continue + } + seen[strings.ToLower(id)] = true + contacts = append(contacts, contact) + if limit > 0 && len(contacts) >= limit { + break + } + } + return isphere.SearchContactsResult{Contacts: contacts} +} + +func mergeGroups(primary []isphere.Group, fallback []isphere.Group, limit int) isphere.SearchGroupsResult { + seen := map[string]bool{} + groups := make([]isphere.Group, 0, len(primary)+len(fallback)) + for _, group := range append(primary, fallback...) { + id := strings.TrimSpace(group.GroupID) + if id == "" || seen[strings.ToLower(id)] { + continue + } + seen[strings.ToLower(id)] = true + groups = append(groups, group) + if limit > 0 && len(groups) >= limit { + break + } + } + return isphere.SearchGroupsResult{Groups: groups} +} diff --git a/internal/tools/isphere_contacts.go b/internal/tools/isphere_contacts.go index 515dfe6..06327fb 100644 --- a/internal/tools/isphere_contacts.go +++ b/internal/tools/isphere_contacts.go @@ -7,6 +7,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "isphere-ai-bridge/internal/isphere" + "isphere-ai-bridge/internal/msglib" ) const ToolNameSearchContacts = "isphere_search_contacts" @@ -20,6 +21,10 @@ type SearchContactsArgs struct { } 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{} } @@ -36,7 +41,19 @@ func RegisterISphereContactTools(server *mcp.Server, source ReceiveMessagesSourc if err != nil { return nil, nil, err } - contacts := isphere.SearchContactsFromMessages(messages.Messages, isphere.SearchContactsQuery{Query: input.Query, Limit: input.Limit}) + 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 }) } diff --git a/internal/tools/isphere_contacts_test.go b/internal/tools/isphere_contacts_test.go index f61607a..e6ab2ba 100644 --- a/internal/tools/isphere_contacts_test.go +++ b/internal/tools/isphere_contacts_test.go @@ -8,6 +8,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "isphere-ai-bridge/internal/isphere" + "isphere-ai-bridge/internal/msglib" ) func TestISphereSearchContactsToolReturnsJIDContacts(t *testing.T) { @@ -75,6 +76,61 @@ func TestISphereSearchContactsToolReturnsJIDContacts(t *testing.T) { } } +func TestISphereSearchContactsToolUsesInjectedDisplayEntities(t *testing.T) { + fakeMessages := &fakeReceiveMessagesSource{} + fakeDisplay := &fakeDisplayEntitySource{entities: []msglib.DisplayEntity{{ + EntityType: msglib.EntityTypeContacts, + SourceTable: "TD_CustomEffigy", + JID: "alice@example", + DisplayName: "Alice Zhang", + Confidence: 0.9, + MatchedColumns: []string{"PersonJid", "PersonName"}, + }}} + session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { + RegisterISphereContactToolsWithDisplayEntities(server, fakeMessages, fakeDisplay) + }) + defer cleanup() + + callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: ToolNameSearchContacts, + Arguments: map[string]any{"query": "alice", "limit": 5}, + }) + if err != nil { + t.Fatalf("call %s: %v", ToolNameSearchContacts, err) + } + if callResult.IsError { + t.Fatalf("call result is error: %+v", callResult) + } + + var decoded struct { + Contacts []struct { + ContactID string `json:"contact_id"` + DisplayName string `json:"display_name"` + Account string `json:"account"` + Source string `json:"source"` + Confidence float64 `json:"confidence"` + RawRef string `json:"raw_ref"` + } `json:"contacts"` + } + payload, _ := json.Marshal(callResult.StructuredContent) + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("decode structured content %s: %v", payload, err) + } + if len(decoded.Contacts) != 1 { + t.Fatalf("contacts = %+v, want one", decoded.Contacts) + } + contact := decoded.Contacts[0] + if contact.ContactID != "alice@example" || contact.DisplayName != "Alice Zhang" || contact.Account != "alice@example" { + t.Fatalf("unexpected MsgLib contact identity: %+v", contact) + } + if contact.Source != "msglib_readonly" || contact.Confidence != 0.9 || contact.RawRef != "msglib:TD_CustomEffigy" { + t.Fatalf("unexpected MsgLib contact metadata: %+v", contact) + } + if len(fakeDisplay.queries) != 1 || fakeDisplay.queries[0].EntityType != msglib.EntityTypeContacts || fakeDisplay.queries[0].Query != "alice" || fakeDisplay.queries[0].Limit != 5 { + t.Fatalf("display queries = %+v", fakeDisplay.queries) + } +} + func TestISphereSearchContactsToolValidatesContractArgs(t *testing.T) { fake := &fakeReceiveMessagesSource{ result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{ @@ -121,3 +177,13 @@ func TestISphereSearchContactsToolValidatesContractArgs(t *testing.T) { t.Fatalf("non-empty cursor was accepted before pagination exists") } } + +type fakeDisplayEntitySource struct { + queries []msglib.DisplayEntitiesOptions + entities []msglib.DisplayEntity +} + +func (f *fakeDisplayEntitySource) DisplayEntities(_ context.Context, opts msglib.DisplayEntitiesOptions) ([]msglib.DisplayEntity, error) { + f.queries = append(f.queries, opts) + return f.entities, nil +} diff --git a/internal/tools/isphere_groups.go b/internal/tools/isphere_groups.go index 0558da3..c86ac65 100644 --- a/internal/tools/isphere_groups.go +++ b/internal/tools/isphere_groups.go @@ -7,6 +7,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "isphere-ai-bridge/internal/isphere" + "isphere-ai-bridge/internal/msglib" ) const ToolNameSearchGroups = "isphere_search_groups" @@ -20,6 +21,10 @@ type SearchGroupsArgs struct { } func RegisterISphereGroupTools(server *mcp.Server, source ReceiveMessagesSource) { + RegisterISphereGroupToolsWithDisplayEntities(server, source, nil) +} + +func RegisterISphereGroupToolsWithDisplayEntities(server *mcp.Server, source ReceiveMessagesSource, displaySource DisplayEntitySource) { if source == nil { source = isphere.EncryptedPacketLogSource{} } @@ -36,7 +41,19 @@ func RegisterISphereGroupTools(server *mcp.Server, source ReceiveMessagesSource) if err != nil { return nil, nil, err } - groups := isphere.SearchGroupsFromMessages(messages.Messages, isphere.SearchGroupsQuery{Query: input.Query, Limit: input.Limit}) + logGroups := isphere.SearchGroupsFromMessages(messages.Messages, isphere.SearchGroupsQuery{Query: input.Query, Limit: input.Limit}) + groups := logGroups + if displaySource != nil { + entities, err := displaySource.DisplayEntities(ctx, msglib.DisplayEntitiesOptions{ + EntityType: msglib.EntityTypeGroups, + Query: input.Query, + Limit: displayEntityQueryLimit(input.Limit), + }) + if err != nil { + return nil, nil, err + } + groups = mergeGroups(groupsFromDisplayEntities(entities), logGroups.Groups, input.Limit) + } return nil, searchGroupsResultToMap(groups, started, time.Now().UTC()), nil }) } diff --git a/internal/tools/isphere_groups_test.go b/internal/tools/isphere_groups_test.go index 413a0e8..2f99175 100644 --- a/internal/tools/isphere_groups_test.go +++ b/internal/tools/isphere_groups_test.go @@ -8,6 +8,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "isphere-ai-bridge/internal/isphere" + "isphere-ai-bridge/internal/msglib" ) func TestISphereSearchGroupsToolReturnsJIDGroups(t *testing.T) { @@ -80,6 +81,60 @@ func TestISphereSearchGroupsToolReturnsJIDGroups(t *testing.T) { } } +func TestISphereSearchGroupsToolUsesInjectedDisplayEntities(t *testing.T) { + fakeMessages := &fakeReceiveMessagesSource{} + fakeDisplay := &fakeDisplayEntitySource{entities: []msglib.DisplayEntity{{ + EntityType: msglib.EntityTypeGroups, + SourceTable: "TD_WorkGroupAuth", + JID: "project-room@conference", + DisplayName: "Project Room", + Confidence: 0.9, + MatchedColumns: []string{"GroupJID", "GroupName"}, + }}} + session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { + RegisterISphereGroupToolsWithDisplayEntities(server, fakeMessages, fakeDisplay) + }) + defer cleanup() + + callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: ToolNameSearchGroups, + Arguments: map[string]any{"query": "project", "limit": 5}, + }) + if err != nil { + t.Fatalf("call %s: %v", ToolNameSearchGroups, err) + } + if callResult.IsError { + t.Fatalf("call result is error: %+v", callResult) + } + + var decoded struct { + Groups []struct { + GroupID string `json:"group_id"` + DisplayName string `json:"display_name"` + Source string `json:"source"` + Confidence float64 `json:"confidence"` + RawRef string `json:"raw_ref"` + } `json:"groups"` + } + payload, _ := json.Marshal(callResult.StructuredContent) + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("decode structured content %s: %v", payload, err) + } + if len(decoded.Groups) != 1 { + t.Fatalf("groups = %+v, want one", decoded.Groups) + } + group := decoded.Groups[0] + if group.GroupID != "project-room@conference" || group.DisplayName != "Project Room" { + t.Fatalf("unexpected MsgLib group identity: %+v", group) + } + if group.Source != "msglib_readonly" || group.Confidence != 0.9 || group.RawRef != "msglib:TD_WorkGroupAuth" { + t.Fatalf("unexpected MsgLib group metadata: %+v", group) + } + if len(fakeDisplay.queries) != 1 || fakeDisplay.queries[0].EntityType != msglib.EntityTypeGroups || fakeDisplay.queries[0].Query != "project" || fakeDisplay.queries[0].Limit != 5 { + t.Fatalf("display queries = %+v", fakeDisplay.queries) + } +} + func TestISphereSearchGroupsToolValidatesContractArgs(t *testing.T) { fake := &fakeReceiveMessagesSource{ result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{ diff --git a/scripts/verify-go-mcp.ps1 b/scripts/verify-go-mcp.ps1 index 919b90c..341f189 100644 --- a/scripts/verify-go-mcp.ps1 +++ b/scripts/verify-go-mcp.ps1 @@ -94,8 +94,10 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() - if err := os.Unsetenv("ISPHERE_PACKET_LOG_FILE"); err != nil { - fail("server/env", err) + for _, key := range []string{"ISPHERE_PACKET_LOG_FILE", "ISPHERE_PACKET_LOG_DIR", "ISPHERE_MSGLIB_SIDECAR_EXE", "ISPHERE_MSGLIB_SQLITE_DLL", "ISPHERE_MSGLIB_DB", "ISPHERE_MSGLIB_PASSWORD"} { + if err := os.Unsetenv(key); err != nil { + fail("server/env", err) + } } serverTransport, clientTransport := mcp.NewInMemoryTransports() @@ -230,6 +232,7 @@ func main() { "real_isphere_login_required": false, "python_runtime_required": false, "action_tools_present": false, + "msglib_configured": false, } encoded, _ := json.Marshal(result) fmt.Println(string(encoded)) @@ -698,6 +701,7 @@ func fail(step string, err error) { python_runtime_required = $false real_isphere_login_required = $false action_tools_present = $false + msglib_configured = $script:harnessJson.msglib_configured } | ConvertTo-Json -Depth 8 -Compress } finally {