Skip to content

feat(mcp): scope a session to one corpus, and make usage readable - #175

Open
fmasi wants to merge 1 commit into
fix/attachment-store-isolationfrom
feat/collection-scoping
Open

feat(mcp): scope a session to one corpus, and make usage readable#175
fmasi wants to merge 1 commit into
fix/attachment-store-isolationfrom
feat/collection-scoping

Conversation

@fmasi

@fmasi fmasi commented Aug 20, 2026

Copy link
Copy Markdown
Owner

2/3 of a stack. Base: the store-isolation PR.

Isolating the attachment stores left the biggest hole open. grep_email walks files rather than an index, and every corpus here shares a root — the profiles resolve to the same directory and differ only by selection rules. So an unscoped grep read every corpus at once, however well the vector collections and stores were separated. It is also the tool most likely to be reached for when someone wants "find this anywhere".

Scoping. grep now walks exactly the files its collection's profile selects — the same set indexing used, resolved in ~0.2s, and the two profiles select disjoint sets. Verified: the same term returns one corpus's folders under one collection and the other's under the other. A named collection with no profile behind it raises rather than falling back to the whole root, since that fallback would quietly do the thing the caller asked to avoid.

Ambiguity refuses. resolve_collection used to fall back to whichever onboarding manifest was written last — fine with one corpus, dangerous with nine. With more than one it now raises and names them; with exactly one it still resolves silently.

Every response echoes its collection, so cross-corpus bleed is visible in the output rather than inferred from an assumed default.

Filename search restored deliberately. It worked only as a side effect of the mbox envelope bug leaving raw MIME headers in body text (fixed in #168), and the usage log shows callers relying on it. grep now matches decoded attachment names.

mailrag usage reports tool shares, which arguments callers actually pass, slow calls, and scans that ran out of budget. The log is only worth writing if it gets read, and it only gets read if reading is one command. Its first week already corrected an impression: grep felt heavily used at 9% of calls against search's 53% — what made it memorable was being decisive, not frequent.

Also records the collection→profile mapping in the manifest instead of inferring it by scanning a directory for *.profile.json, now that scoping permanently depends on it.

Four changes so an agent researching one corpus is not shown another by
accident. Not access control — asking for two corpora deliberately still works;
being handed the wrong one silently must not.

grep was the live exposure and nothing had closed it. It walks files, not an
index, and every corpus here shares a root: the two profiles both resolve to the
same directory and differ only by selection rules. So an unscoped grep read
every corpus at once, however well the vector collections and attachment stores
were separated. It now walks exactly the files its collection's profile selects
— the same set indexing used, resolved in ~0.2s — and the two profiles select
disjoint sets. Verified: the same term returns work folders under the work
collection and personal ones under personal.

Refusing matters as much as scoping. A named collection with no profile behind
it raises rather than falling back to the whole root, because that fallback
would quietly do the thing the caller asked to avoid.

Ambiguity now refuses too. resolve_collection used to fall back to whichever
onboarding manifest was written last, which is fine with one corpus and
dangerous with several. With more than one it raises and names them; with
exactly one it still resolves silently.

Every response echoes the collection that answered it, so cross-corpus bleed is
visible in the output rather than inferred from an assumed default. Same
principle as `complete` on a scan and an absent-not-empty attachment list.

Filename search is restored deliberately. It worked only as a side effect of the
mbox envelope bug leaving raw MIME headers in body text; fixing the parse
removed it, and the usage log shows callers relying on it. grep now matches
decoded attachment names, without the base64 noise.

Also adds `mailrag usage`: tool shares, which arguments callers actually pass,
slow calls, and scans that ran out of budget. The log was only worth writing if
it gets read, and it only gets read if reading is one command rather than a
fresh script. Its first week already corrected an impression — grep felt heavily
used at 9% of calls against search's 53%; what made it memorable was being
decisive, not frequent.

tests/conftest.py now isolates MAILRAG_PROFILE_DIR as well as the attachment
store. A fallback test was passing only because two real profiles exist on this
machine.
Comment thread src/mcp_server/scoping.py
path = collection_profiles().get(collection)
if path is None:
return None
cached = _FILES_CACHE.get(path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_FILES_CACHE doesn't invalidate when a profile's content changes.

_PROFILE_CACHE uses an mtime-signature cache key, so a profile edit flushes the profile→path mapping. But _FILES_CACHE is keyed by profile path only — not mtime. So if a user edits a profile's selection_rules between calls, collection_profiles() correctly reloads (mtime changed), returns the same {collection → path}, but then _FILES_CACHE.get(path) returns the old file list.

This means a grep right after a profile edit (without clear_cache() or a process restart) would scope against the stale rule set. The docstring on clear_cache() mentions re-onboarding but not profile edits.

Simplest fix: include the profile mtime in _FILES_CACHE's key, or share the signature key from _PROFILE_CACHE:

Suggested change
cached = _FILES_CACHE.get(path)
path = collection_profiles().get(collection)
if path is None:
return None
try:
mtime = os.path.getmtime(path)
except OSError:
mtime = 0.0
cached = _FILES_CACHE.get((path, mtime))
if cached is not None:
return cached

(And update the store line to _FILES_CACHE[(path, mtime)] = files.)

Or, if the use case of editing profiles mid-session is considered out of scope, at least document that caveat on clear_cache.

Comment thread src/mcp_server/grep.py
# filenames gives it back deliberately, without the base64 noise.
named = [n for n in meta["attachment_names"] if rx.search(n)]
if named:
matches = [f"[attachment] {n}" for n in named]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No test covers this new behavior. The PR description explicitly calls out "Filename search restored deliberately" as a conscious design decision, but there's no test asserting that grep_email matches and returns [attachment] <name> entries when the regex hits an attachment filename.

The existing test_attachment_names_surfaced_but_bytes_not_searched in test_grep_email.py tests that attachment bytes aren't searched — the opposite direction. A test like this is missing:

def test_attachment_filename_is_searched(self):
    with _Corpus() as root:
        rows = _matches("targets.xlsx", root=root)  # or whatever filename exists in _Corpus
        hit = next(r for r in rows if "[attachment]" in str(r.get("matches", [])))
        self.assertIn("[attachment] targets.xlsx", hit["matches"])

Per the repo's TDD rule, deliberately-restored behaviour needs coverage.

Comment thread src/mcp_server/grep.py
"root": corpus,
# Which corpus answered, echoed back so a caller can see the scope it
# actually got rather than the one it assumed.
"collection": collection,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistency with search results: _thread_to_dict / _thread_to_full_dict only add collection when it is truthy (if collection: row["collection"] = collection), so callers can test presence-of-key. Here "collection": collection is always included even when collection=None, producing {"collection": null, "scoped": false, ...}.

A caller that does if "collection" in result to detect scoped responses would get false positives from unscoped grep calls. Suggest matching the search convention:

Suggested change
"collection": collection,
"stop_reason": stop_reason,
"elapsed_s": round(time.monotonic() - started, 3),
"root": corpus,
"scoped": scoped_files is not None,

and emitting "collection" only when non-None (alongside the existing comment), or document the difference explicitly.

@claude

claude Bot commented Aug 20, 2026

Copy link
Copy Markdown

Review summary

The scoping logic is well-reasoned and the refusal-over-silent-fallback design is the right call. Three findings below, two inline; one here.


Stale module docstring (src/mcp_server/server.py lines 25–27)

The module-level docstring still describes the old resolution order:

Collection — collection arg > MAILRAG_COLLECTION env > the most recent onboarding manifest

The actual order after this PR is: explicit arg → env → single profile (auto-resolved) or raise on ambiguity → manifest as last resort. The docstring should be updated to match, since it's what a caller reads before touching any of the code.


Inline findings

File Line Finding
src/mcp_server/scoping.py 80 _FILES_CACHE doesn't invalidate when a profile's selection_rules change mid-session — mtime gates _PROFILE_CACHE but not the file list cache
src/mcp_server/grep.py 410 No test for the deliberately-restored attachment filename matching
src/mcp_server/grep.py 429 "collection": null always present in grep output; search omits the key when unscoped — inconsistent for callers checking key presence

What's not flagged

  • usage_report.py: clean, tests are well-targeted and cover the aggregations that actually drove decisions.
  • resolve_collection ambiguity-refuses logic: correct. The test coverage (explicit arg, env, single-profile, multi-profile) is thorough.
  • conftest.py isolation fix: the right fix; the comment explaining why clear=True was wrong is useful.
  • Double os.path.expanduser in _cmd_usage (cli.py:347) — resolve_usage_log() already expands, so the second call is redundant on the resolved path, though harmless. Not worth a blocking note.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant