Skip to content

Commit 1918da5

Browse files
cursoragentendolith
andcommitted
docs(mcp): clarify tool schemas for search, body search, and aggregate
Document supported search_messages query operators (subset of Gmail syntax), metadata-only default search vs search_message_bodies for body FTS, body query syntax (ANDed terms, quoted phrases, filter vs free-text), and matches_truncated on capped excerpt lists. Clarify aggregate group_by=time buckets by calendar year. Add schema contract tests and toolPropertyDescription helper for mcp-go map schemas. Update docs/usage/chat.md. Co-authored-by: endolith <endolith@gmail.com>
1 parent 12b162f commit 1918da5

4 files changed

Lines changed: 193 additions & 20 deletions

File tree

‎docs/usage/chat.md‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,15 @@ The MCP server exposes the following tools to connected AI clients:
4747

4848
| Tool | Description | Parameters |
4949
|---|---|---|
50-
| `search_messages` | Search with Gmail-like query syntax. When [vector search](/usage/vector-search/) is configured, supports semantic and hybrid modes. | `query` (string, required), `mode` (string: `fts`/`vector`/`hybrid`, default `fts`), `explain` (bool), `limit` (int), `offset` (int), `account` (string) |
50+
| `search_messages` | Search with a subset of Gmail query syntax (not full Gmail compatibility). When [vector search](/usage/vector-search/) is configured, supports semantic and hybrid modes. | `query` (string, required), `mode` (string: `fts`/`vector`/`hybrid`, default `fts`), `explain` (bool), `limit` (int), `offset` (int), `account` (string) |
5151
| `find_similar_messages` | Nearest-neighbor search from a seed message's embedding. Requires vector search to be configured and an active index generation. | `message_id` (int, required), `limit` (int), `account` (string), `after` (string), `before` (string), `has_attachment` (bool) |
5252
| `search_by_domains` | Find messages where any participant (`from`, `to`, or `cc`) belongs to one of several domains, regardless of direction. | `domains` (comma-separated string, required), `limit` (int), `offset` (int), `after` (string), `before` (string) |
5353
| `get_message` | Get full message details by ID | `id` (int) |
5454
| `list_messages` | List messages with filters | `from` (string), `to` (string), `label` (string), `after` (string), `before` (string), `has_attachment` (bool), `limit` (int), `offset` (int), `account` (string) |
5555
| `get_attachment` | Get attachment content by ID | `attachment_id` (int) |
5656
| `export_attachment` | Save attachment to filesystem | `attachment_id` (int), `destination` (string) |
5757
| `get_stats` | Archive overview statistics. Includes vector index state when configured. | — |
58-
| `aggregate` | Grouped statistics (top senders, domains, labels, time series) | `group_by` (string: sender/recipient/domain/label/time), `limit` (int), `after` (string), `before` (string), `account` (string) |
58+
| `aggregate` | Grouped statistics (top senders, domains, labels, or message volume by calendar year) | `group_by` (string: sender/recipient/domain/label/time), `limit` (int), `after` (string), `before` (string), `account` (string) |
5959
| `stage_deletion` | Stage messages for deletion (creates manifest only) | `query` (string) OR structured filters: `from` (string), `domain` (string), `label` (string), `after` (string), `before` (string), `has_attachment` (bool); optional: `account` (string) |
6060

6161
`search_messages` and `list_messages` return paginated JSON:
@@ -76,6 +76,29 @@ backend cannot report a full result count, `total` is `-1`; use
7676
`has_more` as the pagination signal. `list_messages` uses this
7777
`total = -1` shape because it does not run a separate count query.
7878

79+
### `search_messages` query syntax
80+
81+
Supported operators: `from:`, `to:`, `cc:`, `bcc:`, `subject:`, `label:` (or `l:`), `has:attachment`, `before:`/`after:` (YYYY-MM-DD), `older_than:`/`newer_than:` (e.g. `7d`, `2w`, `1m`, `1y`), `larger:`/`smaller:` (e.g. `5M`). Bare domains on `from:`/`to:` match any address at that domain. Multiple terms are ANDed.
82+
83+
Not supported: negation (`-has:attachment`), `OR`, or parentheses grouping.
84+
85+
Free text matches subject, snippet, and sender fields first. If that returns no results, the server falls back to full-text search including message bodies (when the FTS index is available).
86+
87+
### `aggregate` response
88+
89+
`group_by=time` buckets messages by **calendar year** only. Each row's `Key` is a year string (e.g. `"2024"`). Month or day granularity is not available via MCP.
90+
91+
All `group_by` values return a JSON array of objects with these fields:
92+
93+
| Field | Description |
94+
|---|---|
95+
| `Key` | Grouping value (email, domain, label name, or year) |
96+
| `Count` | Number of messages in the group |
97+
| `TotalSize` | Sum of `size_estimate` in bytes |
98+
| `AttachmentSize` | Sum of attachment sizes in bytes |
99+
| `AttachmentCount` | Number of attachments |
100+
| `TotalUnique` | Total number of distinct groups (same on every row) |
101+
79102
`find_similar_messages` is only registered when the server starts with vector search configured. `search_messages` is always available, but `mode=vector` and `mode=hybrid` return `vector_not_enabled` when the server is not configured for vector search. Vector and hybrid queries require at least one free-text term (operator-only queries return `missing_free_text`). They support `offset`/`limit` pagination inside the configured hybrid ranking window; when `[vector.search].max_page_size_hybrid` is positive, an `offset` at or beyond that cap returns `pagination_limit`. Use `mode=fts` for deeper pagination or adjust that config cap.
80103

81104
In `mode=vector` and `mode=hybrid`, the paginated response also includes

‎internal/mcp/handlers.go‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ const (
3030
maxSearchMessagesLimit = 50
3131
defaultSearchLimit = 20
3232
maxContextSnippets = 5
33-
// searchContextChars is the max byte length of each context_snippets entry in search_message_bodies.
33+
// searchContextChars is the max byte length of each matches[] snippet in
34+
// search_message_bodies and search_in_message.
3435
searchContextChars = 300
3536
defaultBodyChars = 2000
3637
// maxBodyChars caps the body slice returned by get_message regardless of what
@@ -223,6 +224,8 @@ func (h *handlers) readAttachmentFile(contentHash string) ([]byte, error) {
223224
type searchMessageItem struct {
224225
query.MessageSummary
225226

227+
// MatchesTruncated is true when more than maxContextSnippets (5) match
228+
// excerpts were found; only the first 5 are returned.
226229
Matches []messageMatch `json:"matches,omitempty"`
227230
MatchesTruncated bool `json:"matches_truncated,omitempty"`
228231
}
@@ -281,7 +284,7 @@ func (h *handlers) searchMessages(ctx context.Context, req mcp.CallToolRequest)
281284
}
282285

283286
// searchMessageBodies performs full-text search over message bodies and returns
284-
// context_snippets — short excerpts centered on each matched term. Requires at
287+
// matches — short excerpts centered on each matched term. Requires at
285288
// least one free-text term; use search_messages for filter-only queries.
286289
func (h *handlers) searchMessageBodies(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
287290
args := req.GetArguments()
@@ -306,7 +309,11 @@ func (h *handlers) searchMessageBodies(ctx context.Context, req mcp.CallToolRequ
306309
}
307310

308311
if len(q.TextTerms) == 0 {
309-
return mcp.NewToolResultError("search_message_bodies requires at least one free-text term; use search_messages for filter-only queries"), nil
312+
return mcp.NewToolResultError(
313+
"search_message_bodies requires at least one free-text term (bare word or quoted phrase); " +
314+
"Gmail operators such as from: or subject: are metadata filters and do not count — " +
315+
"use search_messages for filter-only queries",
316+
), nil
310317
}
311318

312319
results, err := h.engine.Search(ctx, q, limit+1, offset)

‎internal/mcp/server.go‎

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -194,28 +194,43 @@ func ServeHTTPWithOptions(ctx context.Context, opts ServeOptions, addr string) e
194194
}
195195
}
196196

197+
// Shared search_messages schema text. The parser implements a subset of Gmail
198+
// syntax — not full Gmail compatibility. Keep this in sync with
199+
// internal/search/parser.go and the SearchFast → Search fallback in handlers.go.
200+
const (
201+
searchMessagesOperatorDoc = "Supported operators: from:, to:, cc:, bcc:, subject:, label: (or l:), has:attachment, " +
202+
"before:/after: (YYYY-MM-DD), older_than:/newer_than: (e.g. 7d, 2w, 1m, 1y), larger:/smaller: (e.g. 5M). " +
203+
"Bare domains on from:/to: match any address at that domain. Multiple terms are ANDed. " +
204+
"Not supported: negation (-), OR, or parentheses grouping."
205+
searchMessagesFreeTextDoc = "Without mode, free text matches subject, snippet, and sender/recipient metadata only (not bodies). " +
206+
"Use search_message_bodies for full-body keyword search."
207+
searchMessagesPaginationDoc = "Paginate with offset/limit (default limit 20, max 50). " +
208+
"Response: data, total, returned, offset, has_more."
209+
)
210+
197211
func searchMessagesTool(vectorAvailable bool) mcp.Tool {
212+
searchIntro := "Search emails using a subset of Gmail query syntax (not full Gmail compatibility). " +
213+
searchMessagesOperatorDoc + " " + searchMessagesFreeTextDoc + " "
214+
queryDesc := "Search query (e.g. 'from:alice subject:meeting after:2024-01-01'). " +
215+
"See tool description for supported operators and limitations."
216+
198217
if !vectorAvailable {
199218
return mcp.NewTool(ToolSearchMessages,
200-
mcp.WithDescription("Search email metadata (subject, sender, recipients, labels, dates) using Gmail-like query syntax. "+
201-
"Supports from:, to:, subject:, label:, has:attachment, before:, after:, and free text (matched against subject/snippet only, not body). "+
202-
"For full message body keyword search, use search_message_bodies instead. "+
203-
"Paginate with offset/limit (default limit 20, max 50). Response: data, total, returned, offset, has_more."),
219+
mcp.WithDescription(searchIntro+searchMessagesPaginationDoc+
220+
"For full message body keyword search, use search_message_bodies."),
204221
mcp.WithReadOnlyHintAnnotation(true),
205222
mcp.WithString("query",
206223
mcp.Required(),
207-
mcp.Description("Gmail-style search query (e.g. 'from:alice subject:meeting after:2024-01-01')"),
224+
mcp.Description(queryDesc),
208225
),
209226
withAccount(),
210227
withLimit("20"),
211228
withOffset(),
212229
)
213230
}
214231
return mcp.NewTool(ToolSearchMessages,
215-
mcp.WithDescription("Search email metadata (subject, sender, recipients, labels, dates) using Gmail-like query syntax. "+
216-
"Supports from:, to:, subject:, label:, has:attachment, before:, after:, and free text (matched against subject/snippet only, not body). "+
217-
"For full message body keyword search, use search_message_bodies instead. "+
218-
"Paginate with offset/limit (default limit 20, max 50). Response: data, total, returned, offset, has_more. "+
232+
mcp.WithDescription(searchIntro+searchMessagesPaginationDoc+
233+
"For full message body keyword search, use search_message_bodies. "+
219234
"Vector search is configured: set mode=vector for pure semantic search or mode=hybrid to fuse BM25 and vector ranking via RRF. "+
220235
"Vector/hybrid hits include matches — embedded body chunks ranked by semantic similarity to the query (up to 5 per message). "+
221236
"Vector/hybrid require free-text terms; filter-only queries must omit mode. "+
@@ -224,7 +239,7 @@ func searchMessagesTool(vectorAvailable bool) mcp.Tool {
224239
mcp.WithReadOnlyHintAnnotation(true),
225240
mcp.WithString("query",
226241
mcp.Required(),
227-
mcp.Description("Gmail-style search query (e.g. 'from:alice subject:meeting after:2024-01-01'); mode=vector|hybrid require at least one free-text term"),
242+
mcp.Description(queryDesc+"; mode=vector|hybrid require at least one free-text term"),
228243
),
229244
withAccount(),
230245
withLimit("20"),
@@ -246,13 +261,23 @@ func searchMessageBodiesTool() mcp.Tool {
246261
return mcp.NewTool(ToolSearchMessageBodies,
247262
mcp.WithDescription("Search message bodies by keyword using full-text search (FTS). Returns messages whose body text contains the search terms, "+
248263
"plus matches — short excerpts (up to 5 per message, 300 bytes each) centered on each matched term, with char_offset and line. "+
249-
"Requires at least one free-text term; use search_messages for filter-only queries (from:, label:, etc.). "+
264+
"When matches_truncated is true on a hit, more than 5 excerpts matched — use search_in_message or get_message to read the full body. "+
265+
"Requires at least one free-text term (bare word or double-quoted phrase). "+
266+
"Known Gmail operators (from:, subject:, label:, etc.) apply as metadata filters only and do not satisfy the free-text requirement; "+
267+
"filter-only queries such as from:alice are rejected — use search_messages instead. "+
268+
"Unrecognized word:value tokens (e.g. RXD2:V2) are treated as literal body text, not filters. "+
269+
"Query syntax: space-separated words are ANDed (each must appear somewhere in the body); "+
270+
"a double-quoted phrase is one exact phrase (e.g. \"RXD2 V2\"); OR and NOT are not supported. "+
250271
"Paginate with offset/limit (default limit 20, max 50). Response: data, returned, offset, has_more. "+
251272
"(total is not available for body search; use has_more to detect more pages.)"),
252273
mcp.WithReadOnlyHintAnnotation(true),
253274
mcp.WithString("query",
254275
mcp.Required(),
255-
mcp.Description("Search query with at least one free-text term (e.g. 'quarterly report' or 'from:alice budget')"),
276+
mcp.Description("Body keyword query with at least one free-text term (bare word or quoted phrase). "+
277+
"Gmail operators (from:, subject:, etc.) are metadata filters, not body search — "+
278+
"subject:test alone is rejected; combine with body terms (from:alice budget) or use search_messages for filter-only queries. "+
279+
"Unrecognized word:value tokens (RXD2:V2) are literal text. "+
280+
"Space-separated words are ANDed; double quotes match an exact phrase; OR/NOT unsupported."),
256281
),
257282
withAccount(),
258283
withLimit("20"),
@@ -375,11 +400,12 @@ func getStatsTool() mcp.Tool {
375400

376401
func aggregateTool() mcp.Tool {
377402
return mcp.NewTool(ToolAggregate,
378-
mcp.WithDescription("Get grouped statistics (e.g. top senders, domains, labels, or message volume over time)."),
403+
mcp.WithDescription("Get grouped statistics (top senders, recipients, domains, labels, or message volume by calendar year). "+
404+
"Returns a JSON array of objects with fields Key, Count, TotalSize, AttachmentSize, AttachmentCount, and TotalUnique."),
379405
mcp.WithReadOnlyHintAnnotation(true),
380406
mcp.WithString("group_by",
381407
mcp.Required(),
382-
mcp.Description("Dimension to group by"),
408+
mcp.Description("Dimension to group by. When 'time', buckets are by calendar year only (Key is a year string like \"2024\")."),
383409
mcp.Enum("sender", "recipient", "domain", "label", "time"),
384410
),
385411
withAccount(),

0 commit comments

Comments
 (0)