@@ -5,6 +5,102 @@ All notable changes to this project will be documented in this file.
55The format is based on [ Keep a Changelog] ( https://keepachangelog.com/en/1.0.0/ ) ,
66and this project adheres to [ Semantic Versioning] ( https://semver.org/spec/v2.0.0.html ) .
77
8+ ## [ 2.5.0] - 2026-04-25
9+
10+ A focused MCP-server hardening pass. Closed three exploitable security
11+ holes in the agent-facing tools, added the missing ` ToolAnnotations `
12+ that gate Anthropic Directory submission, exposed structured output
13+ alongside the rendered text on every tool that carries data, and
14+ added the three tools an agent obviously wants — ` read_doc ` to follow
15+ up a ` grep_docs ` hit, plus ` add_source ` / ` remove_source ` to manage
16+ the user registry programmatically.
17+
18+ ### Added
19+ - ** ` read_doc(library, path, line_start?, line_end?) ` ** — read a
20+ Markdown file from a fetched library, optionally line-sliced. The
21+ natural follow-up after ` grep_docs ` returns a hit; agents no longer
22+ need filesystem access for surrounding context. Path is resolved
23+ and confirmed to stay under the library root.
24+ - ** ` add_source(name, url, ...) ` ** — add or update a user source
25+ alias in the writable ` sources.yaml ` . Refuses to shadow a builtin
26+ alias unless ` force=true ` ; URL is HTTPS-only and validated against
27+ the same SSRF rules as ` fetch_url ` . Atomic write (tmp + rename).
28+ - ** ` remove_source(name, delete_cache?) ` ** — remove a user source
29+ alias and optionally its cached docs directory. Cannot remove
30+ builtins (suggest ` add_source(force=true) ` to shadow instead).
31+ Cache deletion does a defense-in-depth resolved-path check.
32+ - ** ` ToolAnnotations ` on every tool** — ` readOnlyHint ` /
33+ ` destructiveHint ` / ` idempotentHint ` / ` openWorldHint ` / ` title ` .
34+ Required for Anthropic Directory submission and unlocks host
35+ auto-approve for the four read-only tools.
36+ - ** Server ` instructions ` ** — system-prompt hint telling agents the
37+ call ordering (list_sources → ensure_docs → grep_docs → read_doc).
38+ - ** Progress notifications** — ` ensure_docs ` forwards
39+ ` FETCH_COMPLETED ` events as MCP progress to clients that supplied
40+ a ` progressToken ` on the call.
41+ - ** Structured output** (` outputSchema ` + ` structuredContent ` ) on
42+ ` list_sources ` , ` list_indexed ` , ` grep_docs ` , ` read_doc ` ,
43+ ` ensure_docs ` , ` add_source ` , ` remove_source ` . Clients that consume
44+ ` structuredContent ` get parseable JSON; clients that don't still
45+ see the rendered Markdown text.
46+
47+ ### Fixed
48+ - ** SSRF in ` fetch_url ` ** — schema previously accepted any string
49+ with no scheme/host enforcement. An agent could request
50+ ` http://169.254.169.254/ ` , ` http://localhost ` , ` file:///etc/passwd ` ,
51+ etc. Now validated upfront with the same ` UrlValidator `
52+ (HTTPS-only, no localhost / private / link-local IPs) the crawler
53+ uses, instead of relying on the slow pipeline error path.
54+ - ** Path traversal in ` grep_docs ` / ` read_doc ` via ` library ` ** —
55+ ` docs_dir / library ` did not validate ` library ` , so
56+ ` library="../../etc" ` walked anywhere the process could read.
57+ ` read_doc ` 's ` path ` arg was similarly unchecked. Both now reject
58+ unsafe names (` is_safe_library_name ` ) and ` read_doc ` additionally
59+ resolves the joined path and confirms it stays under the library
60+ root.
61+ - ** ReDoS in ` grep_docs ` ** — pattern was compiled with no length
62+ cap and run line-by-line over every cached ` .md ` ; Python ` re `
63+ has no timeout knob. Now cap pattern length at 1000 chars and
64+ apply a 10s wall-clock budget across files.
65+ - ** ` isError ` flag was being silently dropped** — the previous
66+ ` _call_tool ` returned a bare ` list[TextContent] ` , which the SDK's
67+ legacy path hardcodes as ` isError=False ` regardless of what the
68+ handler intended. Every error your tools raised was being reported
69+ to clients as success. Now ` _call_tool ` returns ` CallToolResult `
70+ directly so ` isError ` propagates correctly.
71+ - ** ` ensure_docs ` partial-fetch detection** — a crash mid-crawl
72+ used to leave files on disk with no meta, and the next call
73+ would re-fetch (correct, but wasteful) or — if a stale meta from
74+ a prior run was present — trust the half-fetched cache. Meta
75+ writes are now atomic (tmp + rename) and a ` partial=true ` flag
76+ marks half-fetches so ` _cache_fresh ` treats them as stale.
77+ - ** ` grep_docs ` honors ` context > 1 ` ** — the schema advertised
78+ ` maximum: 3 ` but the implementation only ever rendered one line
79+ either side. Now renders up to ` context ` lines on each side.
80+ - ** ` load_user_sources ` silently swallowed YAML errors** — a typo
81+ in the user's ` sources.yaml ` produced "Unknown source" instead of
82+ surfacing the parse failure. Now logs a warning at WARNING level.
83+
84+ ### Changed
85+ - ** Tighter input validation** in the MCP ` _call_tool ` dispatcher:
86+ required strings checked with ` _require_str ` , ints coerced with
87+ ` _coerce_int ` . Errors that used to surface as ugly
88+ "invalid literal for int(...)" now return clear messages naming
89+ the bad argument.
90+ - ** Tighter input schemas** : ` https:// ` pattern on ` fetch_url.url ` ,
91+ ` enum ` on ` category ` , regex + ` maxLength ` on ` library ` everywhere
92+ it appears, ` maxLength: 1000 ` on ` grep_docs.pattern ` , integer
93+ bounds on ` max_tokens ` / ` max_pages ` .
94+ - ` _cache_fresh ` now also requires the source directory to contain
95+ at least one ` .md ` file — a manually-` rm -rf ` 'd cache no longer
96+ reports as fresh.
97+ - ` _PROFILE_ALIASES ` mapping deleted; ` _resolve_profile ` now goes
98+ through ` ProfileName ` directly, eliminating drift.
99+ - 39 new MCP tests (61 total in ` test_mcp_tools.py ` , 316 in the
100+ full suite). Coverage includes SSRF rejection, path traversal,
101+ oversized regex, partial-meta freshness, structured payloads,
102+ and the new write tools.
103+
8104## [ 2.4.0] - 2026-04-26
9105
10106A two-pass cleanup. The first pass closed every claim the code didn't back
0 commit comments