feat: register isphere receive messages tool

This commit is contained in:
zhaoyilun
2026-07-09 23:10:07 +08:00
parent fe2a1947f3
commit 57eb7a5fc7
7 changed files with 227 additions and 13 deletions

View File

@@ -1,7 +1,7 @@
# Capability Source Matrix # Capability Source Matrix
Date: 2026-07-09 Date: 2026-07-09
Stage: B source discovery Stage: C log-backed receive implementation
Evidence index: `docs/source-discovery/2026-07-09-n12-pre-zyl-index.md` Evidence index: `docs/source-discovery/2026-07-09-n12-pre-zyl-index.md`
Schema notes: `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md` Schema notes: `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md`
@@ -17,7 +17,7 @@ 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 | | MCP tool | Primary source | Fallback source | Evidence file | Decision status | Next implementation slice |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| `isphere_receive_messages` | decrypted `PacketReader.ProcessPacket` XMPP `<message>` stanzas | decrypted `Smark.SendReceive` transport stanzas; decrypted `SaveToDB` `Chat_OnMessageArrived` traces; wrapped `MsgLib.db` after DB wrapper is solved | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#field-mapping-evidence` | selected for Stage C log-backed parser | first | | `isphere_receive_messages` | decrypted `PacketReader.ProcessPacket` XMPP `<message>` stanzas via `internal/isphere.EncryptedPacketLogSource` | decrypted `Smark.SendReceive` transport stanzas; decrypted `SaveToDB` `Chat_OnMessageArrived` traces; wrapped `MsgLib.db` after DB wrapper is solved | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#field-mapping-evidence` | C3 registered and unit-verified; default server source is empty until operator verification/loader wiring | fixture-backed operator verification and docs |
| `isphere_search_contacts` | decrypted packet/send-receive JIDs and later roster stanzas from `Smark.SendReceive` | `MsgLib.db` contact/conversation tables after DB wrapper is solved; UIA helper source if display names are missing | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#log-decryption-result` | candidate pending parser coverage | second | | `isphere_search_contacts` | decrypted packet/send-receive JIDs and later roster stanzas from `Smark.SendReceive` | `MsgLib.db` contact/conversation tables after DB wrapper is solved; UIA helper source if display names are missing | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#log-decryption-result` | candidate pending parser coverage | second |
| `isphere_search_groups` | decrypted XMPP group/message stanzas if groupchat/group JIDs appear | `MsgLib.db` group/conversation tables after DB wrapper is solved; UIA helper source if group display names are missing | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#field-mapping-evidence` | candidate pending group stanza evidence | third | | `isphere_search_groups` | decrypted XMPP group/message stanzas if groupchat/group JIDs appear | `MsgLib.db` group/conversation tables after DB wrapper is solved; UIA helper source if group display names are missing | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#field-mapping-evidence` | candidate pending group stanza evidence | third |
| `isphere_receive_files` | decrypted `PacketReader.ProcessPacket` file-transfer message stanzas plus `zyl\importal\<hash>` cache mapping | decrypted `Smark.SendReceive` and `SaveToDB` traces for file-reference reconciliation | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#field-mapping-evidence` | candidate pending cache-reference mapping | fourth | | `isphere_receive_files` | decrypted `PacketReader.ProcessPacket` file-transfer message stanzas plus `zyl\importal\<hash>` cache mapping | decrypted `Smark.SendReceive` and `SaveToDB` traces for file-reference reconciliation | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#field-mapping-evidence` | candidate pending cache-reference mapping | fourth |

View File

@@ -655,7 +655,7 @@ Implemented and verified in this loop:
- Tool source: `isphere.EncryptedPacketLogSource.ReceiveMessages(ctx, isphere.ReceiveMessagesQuery{Limit: input.Limit})`. - Tool source: `isphere.EncryptedPacketLogSource.ReceiveMessages(ctx, isphere.ReceiveMessagesQuery{Limit: input.Limit})`.
- Tool output shape: a map/object with `messages`, where each message carries `id`, `conversation_id`, `conversation_type`, `sender_id`, `receiver_id`, `text`, `timestamp`, `subject`, `receipt_id`, and `read`. - Tool output shape: a map/object with `messages`, where each message carries `id`, `conversation_id`, `conversation_type`, `sender_id`, `receiver_id`, `text`, `timestamp`, `subject`, `receipt_id`, and `read`.
- [ ] **Step 1: Write failing tool registration tests** - [x] **Step 1: Write failing tool registration tests**
Add or update tests so they fail before implementation: Add or update tests so they fail before implementation:
@@ -666,19 +666,19 @@ go test ./internal/mcpserver -run TestNewServerRegistersN8WinHelperToolsWithoutC
Expected initial failures: `isphere_receive_messages` is not registered and the server/tool lists still expose only the four helper tools. Expected initial failures: `isphere_receive_messages` is not registered and the server/tool lists still expose only the four helper tools.
- [ ] **Step 2: Implement `internal/tools/isphere_read.go`** - [x] **Step 2: Implement `internal/tools/isphere_read.go`**
Register `isphere_receive_messages` with `mcp.AddTool`, convert the C2 `isphere.Message` slice into the explicit JSON/map output shape, and keep the only public input parameter as `limit`. Use a source interface so tests can inject a synthetic C2 source without raw evidence files. Register `isphere_receive_messages` with `mcp.AddTool`, convert the C2 `isphere.Message` slice into the explicit JSON/map output shape, and keep the only public input parameter as `limit`. Use a source interface so tests can inject a synthetic C2 source without raw evidence files.
- [ ] **Step 3: Register business read tool in `mcpserver.NewServer`** - [x] **Step 3: Register business read tool in `mcpserver.NewServer`**
Register the helper tools plus `isphere_receive_messages`. If no live log-line loader exists yet, wire an empty `isphere.EncryptedPacketLogSource{}` as the default so registration is testable without committing raw logs. Register the helper tools plus `isphere_receive_messages`. If no live log-line loader exists yet, wire an empty `isphere.EncryptedPacketLogSource{}` as the default so registration is testable without committing raw logs.
- [ ] **Step 4: Update existing helper-tool guard tests** - [x] **Step 4: Update existing helper-tool guard tests**
Rename or adjust the old “exactly four” helper test so it still blocks forbidden write/sending tool names but explicitly allows `isphere_receive_messages`. Update `internal/mcpserver/server_test.go` to expect five tools: four helper tools plus `isphere_receive_messages`. Rename or adjust the old “exactly four” helper test so it still blocks forbidden write/sending tool names but explicitly allows `isphere_receive_messages`. Update `internal/mcpserver/server_test.go` to expect five tools: four helper tools plus `isphere_receive_messages`.
- [ ] **Step 5: Run full verification** - [x] **Step 5: Run full verification**
Run: Run:
@@ -690,22 +690,75 @@ go build ./cmd/isphere-mcp
Expected: all exit 0. Expected: all exit 0.
- [ ] **Step 6: Commit Loop C3 and rewrite Loop C4** - [x] **Step 6: Commit Loop C3 and rewrite Loop C4**
Commit exact C3 paths only. Rewrite Loop C4 around the actual MCP tool output shape and the fact that default server wiring has no raw evidence loader yet. Commit exact C3 paths only. Rewrite Loop C4 around the actual MCP tool output shape and the fact that default server wiring has no raw evidence loader yet.
--- ---
## Loop C3 Result
Implemented and verified in this loop:
- `internal/tools.RegisterISphereReadTools(server, source)` registers `isphere_receive_messages` with a single public input parameter: `limit`.
- `internal/tools.ToolNameReceiveMessages` is `isphere_receive_messages`.
- The tool calls the C2 source interface with `isphere.ReceiveMessagesQuery{Limit: input.Limit}` and returns structured content shaped as `messages: [{ id, conversation_id, conversation_type, sender_id, receiver_id, text, timestamp, subject, receipt_id, read }]`.
- `internal/mcpserver.NewServer()` now registers four WinHelper observation tools plus `isphere_receive_messages`; default source wiring uses an empty `isphere.EncryptedPacketLogSource{}` so no raw evidence is committed or loaded by default.
- Guard tests still keep helper tools scoped to helper observation operations while allowing `receive` as a legitimate read capability name for the new business tool.
- Verification completed for C3 with `git diff --check`, `go test ./...`, and `go build ./cmd/isphere-mcp`; the generated root `isphere-mcp.exe` build artifact was removed.
- Loop C4 was rewritten around the actual five-tool surface and empty default source behavior.
---
## Loop C4: Operator verification and docs ## Loop C4: Operator verification and docs
**Current draft goal:** Add a verification command or fixture-backed smoke test for `isphere_receive_messages` and update user-facing docs. **Goal:** Update the repeatable operator verification and user-facing docs for the five-tool surface after C3 registers `isphere_receive_messages`.
**Planned files:** **Planned files:**
- Modify: `scripts/verify-go-mcp.ps1` - Modify: `scripts/verify-go-mcp.ps1`
- Modify: `docs/go-mcp-runbook.md` - Modify: `docs/go-mcp-runbook.md`
- Modify: `docs/current-status-card.md` - Modify: `docs/current-status-card.md`
- Modify: `docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md`
**Rewrite rule before implementation:** Only write this loop after C3 confirms the actual MCP response shape. **Interfaces from Loop C3:**
- Server tool list: four WinHelper observation tools plus `isphere_receive_messages`.
- Default `isphere_receive_messages` source: `isphere.EncryptedPacketLogSource{}` with no configured lines, so the operator smoke call should return `messages: []` until a real evidence loader is wired.
- Verification script should still call `win_helper_version`, and should additionally call `isphere_receive_messages` with a small `limit` and assert the returned `messages` field is an array.
- [ ] **Step 1: Capture current verification red state**
Run before editing the script:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify-go-mcp.ps1
```
Expected: FAIL because the existing harness still expects exactly four tools and treats `receive` as a forbidden term.
- [ ] **Step 2: Update `scripts/verify-go-mcp.ps1`**
Change expected tools to five, remove `receive` from the forbidden write/scope terms, and add an SDK call to `isphere_receive_messages` with `{ "limit": 5 }`. Assert `messages` exists and is an array; for the default empty source, assert the smoke result has zero messages.
- [ ] **Step 3: Update operator docs**
Update `docs/go-mcp-runbook.md` and `docs/current-status-card.md` so they describe the current five-tool surface, the empty default read source, and the fact that raw N12-pre evidence still stays under ignored `runs/` paths.
- [ ] **Step 4: Run full verification**
Run:
```powershell
git diff --check
go test ./...
go build ./cmd/isphere-mcp
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify-go-mcp.ps1
```
Expected: all exit 0. Remove any generated root `isphere-mcp.exe` after build.
- [ ] **Step 5: Commit Loop C4 and rewrite next loop**
Commit exact C4 paths only. Rewrite the next loop around the remaining gap: a safe configurable source loader or fixture path for `isphere_receive_messages`, without committing raw evidence.
--- ---

View File

@@ -4,6 +4,7 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"isphere-ai-bridge/internal/helperclient" "isphere-ai-bridge/internal/helperclient"
"isphere-ai-bridge/internal/isphere"
"isphere-ai-bridge/internal/tools" "isphere-ai-bridge/internal/tools"
) )
@@ -20,5 +21,6 @@ func NewServer() *mcp.Server {
Version: ServerVersion, Version: ServerVersion,
}, nil) }, nil)
tools.RegisterWinHelperTools(server, helperclient.Client{}) tools.RegisterWinHelperTools(server, helperclient.Client{})
tools.RegisterISphereReadTools(server, isphere.EncryptedPacketLogSource{})
return server return server
} }

View File

@@ -52,7 +52,7 @@ func TestNewServerRegistersN8WinHelperToolsWithoutCallingHelper(t *testing.T) {
gotNames = append(gotNames, tool.Name) gotNames = append(gotNames, tool.Name)
} }
sort.Strings(gotNames) sort.Strings(gotNames)
wantNames := []string{"win_helper_dump_uia", "win_helper_scan_windows", "win_helper_self_check", "win_helper_version"} wantNames := []string{"isphere_receive_messages", "win_helper_dump_uia", "win_helper_scan_windows", "win_helper_self_check", "win_helper_version"}
if !reflect.DeepEqual(gotNames, wantNames) { if !reflect.DeepEqual(gotNames, wantNames) {
t.Fatalf("server tool names = %#v, want %#v", gotNames, wantNames) t.Fatalf("server tool names = %#v, want %#v", gotNames, wantNames)
} }

View File

@@ -0,0 +1,55 @@
package tools
import (
"context"
"github.com/modelcontextprotocol/go-sdk/mcp"
"isphere-ai-bridge/internal/isphere"
)
const ToolNameReceiveMessages = "isphere_receive_messages"
type ReceiveMessagesSource interface {
ReceiveMessages(ctx context.Context, query isphere.ReceiveMessagesQuery) (isphere.ReceiveMessagesResult, error)
}
type ReceiveMessagesArgs struct {
Limit int `json:"limit,omitempty" jsonschema:"maximum number of messages to return; zero or negative returns all available messages"`
}
func RegisterISphereReadTools(server *mcp.Server, source ReceiveMessagesSource) {
if source == nil {
source = isphere.EncryptedPacketLogSource{}
}
mcp.AddTool[ReceiveMessagesArgs, map[string]any](server, &mcp.Tool{
Name: ToolNameReceiveMessages,
Description: "Return iSphere messages from the configured read-only log-backed source.",
}, func(ctx context.Context, _ *mcp.CallToolRequest, input ReceiveMessagesArgs) (*mcp.CallToolResult, map[string]any, error) {
result, err := source.ReceiveMessages(ctx, isphere.ReceiveMessagesQuery{Limit: input.Limit})
if err != nil {
return nil, nil, err
}
return nil, receiveMessagesResultToMap(result), nil
})
}
func receiveMessagesResultToMap(result isphere.ReceiveMessagesResult) map[string]any {
messages := make([]map[string]any, 0, len(result.Messages))
for _, message := range result.Messages {
messages = append(messages, map[string]any{
"id": message.ID,
"conversation_id": message.ConversationID,
"conversation_type": message.ConversationType,
"sender_id": message.SenderID,
"receiver_id": message.ReceiverID,
"text": message.Text,
"timestamp": message.Timestamp,
"subject": message.Subject,
"receipt_id": message.ReceiptID,
"read": message.Read,
})
}
return map[string]any{"messages": messages}
}

View File

@@ -0,0 +1,104 @@
package tools
import (
"context"
"encoding/json"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
"isphere-ai-bridge/internal/isphere"
)
func TestISphereReceiveMessagesToolReturnsSourceMessages(t *testing.T) {
fake := &fakeReceiveMessagesSource{
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{
ID: "msg-1",
ConversationID: "sender@imopenfire1-lanzhou|receiver@imopenfire1-lanzhou",
ConversationType: "chat",
SenderID: "sender@imopenfire1-lanzhou",
ReceiverID: "receiver@imopenfire1-lanzhou",
Text: "redacted",
Timestamp: "1783423807000",
Subject: "FILE_TRANSFER_CANCEL",
ReceiptID: "receipt-1",
Read: true,
}}},
}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereReadTools(server, fake)
})
defer cleanup()
listResult, err := session.ListTools(context.Background(), &mcp.ListToolsParams{})
if err != nil {
t.Fatalf("list tools: %v", err)
}
gotNames := make([]string, 0, len(listResult.Tools))
for _, tool := range listResult.Tools {
gotNames = append(gotNames, tool.Name)
}
assertSameStrings(t, gotNames, []string{ToolNameReceiveMessages})
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameReceiveMessages,
Arguments: map[string]any{"limit": 5},
})
if err != nil {
t.Fatalf("call %s: %v", ToolNameReceiveMessages, err)
}
if callResult.IsError {
t.Fatalf("call result is error: %+v", callResult)
}
if len(fake.queries) != 1 || fake.queries[0].Limit != 5 {
t.Fatalf("queries = %+v, want one limit=5", fake.queries)
}
var decoded struct {
Messages []struct {
ID string `json:"id"`
ConversationID string `json:"conversation_id"`
ConversationType string `json:"conversation_type"`
SenderID string `json:"sender_id"`
ReceiverID string `json:"receiver_id"`
Text string `json:"text"`
Timestamp string `json:"timestamp"`
Subject string `json:"subject"`
ReceiptID string `json:"receipt_id"`
Read bool `json:"read"`
} `json:"messages"`
}
payload, err := json.Marshal(callResult.StructuredContent)
if err != nil {
t.Fatalf("marshal structured content: %v", err)
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if len(decoded.Messages) != 1 {
t.Fatalf("messages = %+v, want one", decoded.Messages)
}
msg := decoded.Messages[0]
if msg.ID != "msg-1" || msg.Text != "redacted" || msg.Subject != "FILE_TRANSFER_CANCEL" {
t.Fatalf("unexpected message content: %+v", msg)
}
if msg.ConversationID != "sender@imopenfire1-lanzhou|receiver@imopenfire1-lanzhou" {
t.Fatalf("conversation_id = %q", msg.ConversationID)
}
if msg.SenderID != "sender@imopenfire1-lanzhou" || msg.ReceiverID != "receiver@imopenfire1-lanzhou" {
t.Fatalf("sender/receiver = %q/%q", msg.SenderID, msg.ReceiverID)
}
if msg.Timestamp != "1783423807000" || msg.ReceiptID != "receipt-1" || !msg.Read {
t.Fatalf("metadata = %+v", msg)
}
}
type fakeReceiveMessagesSource struct {
queries []isphere.ReceiveMessagesQuery
result isphere.ReceiveMessagesResult
}
func (f *fakeReceiveMessagesSource) ReceiveMessages(_ context.Context, query isphere.ReceiveMessagesQuery) (isphere.ReceiveMessagesResult, error) {
f.queries = append(f.queries, query)
return f.result, nil
}

View File

@@ -21,7 +21,7 @@ var expectedWinHelperToolNames = []string{
"win_helper_dump_uia", "win_helper_dump_uia",
} }
func TestWinHelperToolsRegisterExactlyFourReadOnlyTools(t *testing.T) { func TestWinHelperToolsRegisterOnlyHelperTools(t *testing.T) {
fake := &fakeHelperCaller{} fake := &fakeHelperCaller{}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterWinHelperTools(server, fake) RegisterWinHelperTools(server, fake)
@@ -37,7 +37,7 @@ func TestWinHelperToolsRegisterExactlyFourReadOnlyTools(t *testing.T) {
for _, tool := range result.Tools { for _, tool := range result.Tools {
gotNames = append(gotNames, tool.Name) gotNames = append(gotNames, tool.Name)
lower := strings.ToLower(tool.Name) lower := strings.ToLower(tool.Name)
forbidden := []string{"send", "search", "file", "login", "conversation", "upload", "download", "receive"} forbidden := []string{"send", "search", "file", "login", "conversation", "upload", "download"}
for _, word := range forbidden { for _, word := range forbidden {
if strings.Contains(lower, word) { if strings.Contains(lower, word) {
t.Fatalf("tool %q contains forbidden action word %q", tool.Name, word) t.Fatalf("tool %q contains forbidden action word %q", tool.Name, word)