feat: validate receive files list args

This commit is contained in:
zhaoyilun
2026-07-10 01:43:11 +08:00
parent b1891dd6cd
commit fce1232f21
7 changed files with 155 additions and 16 deletions

View File

@@ -14,12 +14,16 @@ import (
const ToolNameReceiveFiles = "isphere_receive_files"
type ReceiveFilesArgs struct {
ConversationID string `json:"conversation_id,omitempty" jsonschema:"conversation id filter for file listing"`
MessageID string `json:"message_id,omitempty" jsonschema:"message id filter for file listing"`
FileID string `json:"file_id,omitempty" jsonschema:"file id filter for file listing"`
NameContains string `json:"name_contains,omitempty" jsonschema:"case-insensitive filename substring filter"`
Mode string `json:"mode,omitempty" jsonschema:"file receive mode; C14 supports list only"`
Limit int `json:"limit,omitempty" jsonschema:"maximum number of files to return; zero or negative returns all matches"`
ConversationID string `json:"conversation_id,omitempty" jsonschema:"conversation id filter for file listing"`
MessageID string `json:"message_id,omitempty" jsonschema:"message id filter for file listing"`
FileID string `json:"file_id,omitempty" jsonschema:"file id filter for file listing"`
NameContains string `json:"name_contains,omitempty" jsonschema:"case-insensitive filename substring filter"`
Mode string `json:"mode,omitempty" jsonschema:"file receive mode; C14 supports list only"`
OutputDir string `json:"output_dir,omitempty" jsonschema:"download output directory; not accepted in current list-only mode"`
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"`
Preview bool `json:"preview,omitempty" jsonschema:"accepted for contract compatibility in list mode"`
Limit int `json:"limit,omitempty" jsonschema:"maximum number of files to return; zero or negative returns all matches"`
}
func RegisterISphereFileTools(server *mcp.Server, source ReceiveMessagesSource) {
@@ -39,6 +43,12 @@ func RegisterISphereFileTools(server *mcp.Server, source ReceiveMessagesSource)
if mode != "list" {
return nil, nil, fmt.Errorf("isphere_receive_files mode %q is not available; only list is supported", input.Mode)
}
if err := validateLocalReadonlySourceAndCursor(ToolNameReceiveFiles, input.SourcePreference, input.Cursor); err != nil {
return nil, nil, err
}
if strings.TrimSpace(input.OutputDir) != "" {
return nil, nil, fmt.Errorf("isphere_receive_files output_dir is only available for download mode, which is not implemented yet")
}
messages, err := source.ReceiveMessages(ctx, isphere.ReceiveMessagesQuery{Limit: 0})
if err != nil {
return nil, nil, err

View File

@@ -88,3 +88,70 @@ func TestISphereReceiveFilesToolReturnsLogBackedFiles(t *testing.T) {
t.Fatalf("source queries = %+v, want one unbounded receive query", fake.queries)
}
}
func TestISphereReceiveFilesToolValidatesContractArgs(t *testing.T) {
fake := &fakeReceiveMessagesSource{
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{
ID: "msg-file-1",
ConversationID: "alice@imopenfire1-lanzhou|bob@imopenfire1-lanzhou",
Text: "redacted-report.docx",
Timestamp: "1783423807000",
Subject: "FILE_TRANSFER_CANCEL",
}}},
}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereFileTools(server, fake)
})
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameReceiveFiles,
Arguments: map[string]any{
"mode": "list",
"name_contains": "report",
"source_preference": "local_readonly",
"preview": true,
"cursor": "",
"output_dir": "",
"limit": 5,
},
})
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: ToolNameReceiveFiles,
Arguments: map[string]any{"mode": "list", "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: ToolNameReceiveFiles,
Arguments: map[string]any{"mode": "list", "cursor": "next-page"},
})
if err == nil && !cursorResult.IsError {
t.Fatalf("non-empty cursor was accepted before pagination exists")
}
outputResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameReceiveFiles,
Arguments: map[string]any{"mode": "list", "output_dir": "C:\\tmp\\isphere"},
})
if err == nil && !outputResult.IsError {
t.Fatalf("output_dir was accepted in list mode before download exists")
}
downloadResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameReceiveFiles,
Arguments: map[string]any{"mode": "download", "file_id": "msg-file-1:redacted-report.docx"},
})
if err == nil && !downloadResult.IsError {
t.Fatalf("download mode was accepted before cache mapping exists")
}
}