Merge pull request 'dev → main: MCP SDK v2 port, create-gate thresholds in Settings' (#186) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 52s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / Build & push image (push) Successful in 16s

This commit was merged in pull request #186.
This commit is contained in:
2026-09-24 07:31:51 -04:00
14 changed files with 439 additions and 106 deletions
+118
View File
@@ -120,6 +120,12 @@ const loadingTuning = ref(false);
const kbDupThresholdSnippet = ref("0.82");
const kbDupThresholdNote = ref("0.93");
const kbDupThresholdTask = ref("0.93");
// The create gate's bars (#4385). Defaults mirror services/dedup.py.
const kbGateThreshold = ref("0.9");
const kbGateThresholdSnippet = ref("0.96");
const kbGateThresholdLesson = ref("0.96");
const kbGateThresholdNoteCopy = ref("0.98");
const kbGateNoteOverlapFloor = ref("0.87");
// PLAN_MATCH_DEFAULT_THRESHOLD in services/dedup.py.
const kbPlanMatchThreshold = ref("0.80");
const savingKbInject = ref(false);
@@ -277,6 +283,14 @@ async function saveKbInject() {
const dupSnip = Math.min(1, Math.max(0, Number(kbDupThresholdSnippet.value) || 0.82));
const dupNote = Math.min(1, Math.max(0, Number(kbDupThresholdNote.value) || 0.93));
const dupTask = Math.min(1, Math.max(0, Number(kbDupThresholdTask.value) || 0.93));
// The gate BLOCKS a write, so its bars have a floor the server enforces too:
// 0.80 for a block, 0.70 for the overlap list.
const gateAt = (v: string, d: number, lo: number) => Math.min(1, Math.max(lo, Number(v) || d));
const gate = gateAt(kbGateThreshold.value, 0.9, 0.8);
const gateSnip = gateAt(kbGateThresholdSnippet.value, 0.96, 0.8);
const gateLesson = gateAt(kbGateThresholdLesson.value, 0.96, 0.8);
const gateCopy = gateAt(kbGateThresholdNoteCopy.value, 0.98, 0.8);
const gateOverlap = gateAt(kbGateNoteOverlapFloor.value, 0.87, 0.7);
// Same `|| default` guard: a floor of 0 would hand back an existing plan
// for every new one, and no plan could be started without force.
const planT = Math.min(1, Math.max(0, Number(kbPlanMatchThreshold.value) || 0.8));
@@ -317,6 +331,11 @@ async function saveKbInject() {
kbDupThresholdSnippet.value = String(dupSnip);
kbDupThresholdNote.value = String(dupNote);
kbDupThresholdTask.value = String(dupTask);
kbGateThreshold.value = String(gate);
kbGateThresholdSnippet.value = String(gateSnip);
kbGateThresholdLesson.value = String(gateLesson);
kbGateThresholdNoteCopy.value = String(gateCopy);
kbGateNoteOverlapFloor.value = String(gateOverlap);
kbPlanMatchThreshold.value = String(planT);
kbWritePathThreshold.value = String(wpT);
kbRuleHintThreshold.value = String(rhT);
@@ -369,6 +388,11 @@ async function saveKbInject() {
kb_duplicate_threshold_snippet: String(dupSnip),
kb_duplicate_threshold_note: String(dupNote),
kb_duplicate_threshold_task: String(dupTask),
kb_gate_threshold: String(gate),
kb_gate_threshold_snippet: String(gateSnip),
kb_gate_threshold_lesson: String(gateLesson),
kb_gate_threshold_note_copy: String(gateCopy),
kb_gate_note_overlap_floor: String(gateOverlap),
kb_plan_match_threshold: String(planT),
});
kbInjectSaved.value = true;
@@ -866,6 +890,15 @@ onMounted(async () => {
if (allSettings.kb_duplicate_threshold_task !== undefined) {
kbDupThresholdTask.value = allSettings.kb_duplicate_threshold_task;
}
for (const [key, target] of [
["kb_gate_threshold", kbGateThreshold],
["kb_gate_threshold_snippet", kbGateThresholdSnippet],
["kb_gate_threshold_lesson", kbGateThresholdLesson],
["kb_gate_threshold_note_copy", kbGateThresholdNoteCopy],
["kb_gate_note_overlap_floor", kbGateNoteOverlapFloor],
] as const) {
if (allSettings[key] !== undefined) target.value = allSettings[key];
}
if (allSettings.kb_plan_match_threshold !== undefined) {
kbPlanMatchThreshold.value = allSettings.kb_plan_match_threshold;
}
@@ -2019,6 +2052,91 @@ async function deleteUser(userId: number) {
</p>
</div>
<div class="field">
<label for="kb-gate-threshold">Create gate — general block threshold</label>
<input
id="kb-gate-threshold"
v-model="kbGateThreshold"
type="number"
min="0.8"
max="1"
step="0.01"
class="fs-input input"
style="max-width: 8rem"
/>
<p class="field-hint">
When a new record is at least this alike to an existing one of the same kind, the create is refused and pointed at the existing record to update. Applies to kinds without their own bar below (processes, and anything new). Higher = fewer refusals, more duplicates let through. Cannot go below 0.80: the gate blocks a write, and a low bar refuses everything on a shared topic.
</p>
</div>
<div class="field">
<label for="kb-gate-threshold-snippet">Create gate — snippets</label>
<input
id="kb-gate-threshold-snippet"
v-model="kbGateThresholdSnippet"
type="number"
min="0.8"
max="1"
step="0.01"
class="fs-input input"
style="max-width: 8rem"
/>
<p class="field-hint">
The semantic backstop for snippets. Code and location are checked first and exactly, so this only catches a reworded copy; it sits above the band where deliberate siblings (a button and its outline variant) land.
</p>
</div>
<div class="field">
<label for="kb-gate-threshold-lesson">Create gate — lessons</label>
<input
id="kb-gate-threshold-lesson"
v-model="kbGateThresholdLesson"
type="number"
min="0.8"
max="1"
step="0.01"
class="fs-input input"
style="max-width: 8rem"
/>
<p class="field-hint">
Lessons about one area read alike without being the same lesson, so this is high. A refusal loses a real lesson outright; a miss leaves two you can merge.
</p>
</div>
<div class="field">
<label for="kb-gate-threshold-note-copy">Create gate — notes and tasks (copy)</label>
<input
id="kb-gate-threshold-note-copy"
v-model="kbGateThresholdNoteCopy"
type="number"
min="0.8"
max="1"
step="0.01"
class="fs-input input"
style="max-width: 8rem"
/>
<p class="field-hint">
Notes and tasks are refused only as a near-exact copy. Consecutive dev-logs and parts of one design score 0.90–0.98 without being duplicates, so a lower bar refuses the next one.
</p>
</div>
<div class="field">
<label for="kb-gate-note-overlap-floor">Create gate — notes and tasks (listed overlaps)</label>
<input
id="kb-gate-note-overlap-floor"
v-model="kbGateNoteOverlapFloor"
type="number"
min="0.7"
max="1"
step="0.01"
class="fs-input input"
style="max-width: 8rem"
/>
<p class="field-hint">
Below the copy bar, matches above this are listed on the create reply (up to three) for the session to judge, and the record is still created. Lower = more listed, more of them noise. Never above the copy bar.
</p>
</div>
<div class="field">
<label for="kb-plan-match-threshold">Existing-plan match threshold</label>
<input
+6 -5
View File
@@ -19,11 +19,12 @@ dependencies = [
"caldav>=1.3",
"icalendar>=5.0",
"APScheduler>=3.10,<4.0",
# Capped below 2.0: that release removed `mcp.server.fastmcp`, which
# src/scribe/mcp/server.py imports to build the whole tool surface. The
# ceiling is a real incompatibility, not caution — lift it in the same
# change that ports server.py to the 2.x API.
"mcp[cli]>=1.0,<2",
# Floor 2.2: server.py uses the 2.x `mcp.server.mcpserver.MCPServer` API
# (transport settings on `streamable_http_app`, `call_tool(..., context)`,
# `ToolError`), ported and read against 2.2.0 (#2196). No ceiling: CI
# installs with `uv sync --locked`, so a new major arrives only through a
# deliberate `uv lock`, which is where its breakage should be diagnosed.
"mcp[cli]>=2.2",
"fastembed>=0.4",
"pgvector>=0.3",
]
+1 -1
View File
@@ -1,7 +1,7 @@
"""Per-request MCP context.
The ASGI middleware (mcp/server.py) populates `_user_id_ctx` from
scope['scribe_user_id'] before dispatching to FastMCP tool handlers.
scope['scribe_user_id'] before dispatching to MCPServer tool handlers.
Tool functions then call `current_user_id()` to retrieve it.
Tools must never fall back to a default user — `current_user_id()` raises
+49 -42
View File
@@ -1,9 +1,10 @@
"""FastMCP instance + Quart mount-point. Tools are registered in mcp/tools/."""
"""MCPServer instance + Quart mount-point. Tools are registered in mcp/tools/."""
from __future__ import annotations
import difflib
from mcp.server.fastmcp import FastMCP
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.server.transport_security import TransportSecuritySettings
from quart import Quart
@@ -256,10 +257,10 @@ def _body_calls_write_tool(body: bytes) -> bool:
return False
class StrictArgsFastMCP(FastMCP):
"""A FastMCP that REJECTS tool calls carrying undeclared arguments.
class StrictArgsMCPServer(MCPServer):
"""An MCPServer that REJECTS tool calls carrying undeclared arguments.
FastMCP validates arguments with a pydantic model built from the tool
The SDK validates arguments with a pydantic model built from the tool
signature, and pydantic's default extra-field policy is "ignore" — so a
misnamed argument simply vanishes and the tool runs with that field's
default. On the create/update tools the default is "", which turns a
@@ -274,7 +275,10 @@ class StrictArgsFastMCP(FastMCP):
declared cannot have been meant to be dropped.
"""
async def call_tool(self, name, arguments):
# ToolError, not ValueError: the SDK returns either one's text to the
# caller as an error result, but logs anything that is not a ToolError as
# an unexpected crash. A misnamed argument is the caller's mistake.
async def call_tool(self, name, arguments, context=None):
try:
tool = self._tool_manager.get_tool(name)
except Exception:
@@ -288,56 +292,35 @@ class StrictArgsFastMCP(FastMCP):
close = difflib.get_close_matches(arg, sorted(declared), n=1)
suggestion = f" (did you mean '{close[0]}'?)" if close else ""
hints.append(f"'{arg}'{suggestion}")
raise ValueError(
raise ToolError(
f"{name} does not accept argument(s) {', '.join(hints)}. "
f"It accepts: {', '.join(sorted(declared))}. Nothing was "
"created or changed — retry with the declared names."
)
return await super().call_tool(name, arguments)
return await super().call_tool(name, arguments, context)
def build_mcp_server() -> FastMCP:
"""Build the FastMCP instance with all tools registered.
def build_mcp_server() -> MCPServer:
"""Build the MCPServer instance with all tools registered.
DNS-rebinding protection is disabled: FastMCP's default allow-list is
just localhost variants, which means any deployment behind a reverse
proxy (Traefik with a hostname like devassistant.traefik.internal,
Cloudflare, nginx, etc.) gets 421 Misdirected Request. The threat
model that protection addresses — a malicious browser page rebinding
DNS to hit a localhost MCP — doesn't apply here: this is HTTP transport
behind a reverse proxy with bearer-token auth as the real security
boundary.
Transport settings (statelessness, DNS-rebinding protection) are not
here: since SDK v2 they belong to the ASGI app, built in `mount_mcp`.
"""
# stateless_http=True: don't hand the client a persistent Mcp-Session-Id.
# The stateful default strands Claude Code after a container redeploy —
# it reconnects with the now-unknown session id, the server returns 404,
# and the client won't re-initialize on a 404 (Claude Code issue #60949),
# so the connection stays dead until a manual /mcp retry. Stateless makes
# every request self-contained (bearer-auth only), so a post-deploy
# reconnect just works. Trade-off: no server-pushed list_changed stream,
# which we don't use — tools are re-fetched on reconnect anyway.
mcp = StrictArgsFastMCP(
"scribe",
instructions=_INSTRUCTIONS.strip(),
stateless_http=True,
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False,
),
)
mcp = StrictArgsMCPServer("scribe", instructions=_INSTRUCTIONS.strip())
from scribe.mcp.tools import register_all
register_all(mcp)
return mcp
def mount_mcp(app: Quart) -> None:
"""Mount the FastMCP streamable-HTTP ASGI sub-app at /mcp on the Quart app.
"""Mount the MCPServer streamable-HTTP ASGI sub-app at /mcp on the Quart app.
A small ASGI middleware between Quart and the FastMCP sub-app validates the
A small ASGI middleware between Quart and the MCPServer sub-app validates the
Bearer token against the api_keys table. Authenticated requests have their
user_id attached to the ASGI scope under "scribe_user_id" for tool handlers
to read.
FastMCP's streamable_http session manager owns a task group that must be
The SDK's streamable_http session manager owns a task group that must be
running before it can serve requests. In a stand-alone Starlette deployment
that would happen via the Starlette `lifespan` parameter. Since we're hosted
inside Quart, we hook the session manager's `run()` async context manager
@@ -346,7 +329,31 @@ def mount_mcp(app: Quart) -> None:
from scribe.mcp.auth import resolve_bearer
mcp = build_mcp_server()
mcp_asgi = mcp.streamable_http_app()
# stateless_http=True: don't hand the client a persistent Mcp-Session-Id.
# The stateful default strands Claude Code after a container redeploy —
# it reconnects with the now-unknown session id, the server returns 404,
# and the client won't re-initialize on a 404 (Claude Code issue #60949),
# so the connection stays dead until a manual /mcp retry. Stateless makes
# every request self-contained (bearer-auth only), so a post-deploy
# reconnect just works. Trade-off: no server-pushed list_changed stream,
# which we don't use — tools are re-fetched on reconnect anyway. (Clients
# on the 2026-07-28 revision are stateless by protocol and take the SDK's
# modern path regardless; this governs the 2025-era handshake path.)
#
# DNS-rebinding protection is disabled: the SDK's default allow-list is
# just localhost variants, which means any deployment behind a reverse
# proxy (Traefik with a hostname like devassistant.traefik.internal,
# Cloudflare, nginx, etc.) gets 421 Misdirected Request. The threat
# model that protection addresses — a malicious browser page rebinding
# DNS to hit a localhost MCP — doesn't apply here: this is HTTP transport
# behind a reverse proxy with bearer-token auth as the real security
# boundary.
mcp_asgi = mcp.streamable_http_app(
stateless_http=True,
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False,
),
)
app.mcp_instance = mcp
@app.before_serving
@@ -416,11 +423,11 @@ def mount_mcp(app: Quart) -> None:
if scope["type"] == "http":
path = scope.get("path", "")
if path == "/mcp" or path.startswith("/mcp/"):
# Don't rewrite the path: FastMCP's streamable_http_app mounts
# Don't rewrite the path: the SDK's streamable_http_app mounts
# its handler at /mcp by default. If we strip the prefix to "/",
# FastMCP's internal routing returns 404 because there's no
# handler at "/" — only at "/mcp". Pass the scope through
# untouched and let FastMCP's own routing match.
# its internal routing returns 404 because there's no handler
# at "/" — only at "/mcp". Pass the scope through untouched and
# let the SDK's own routing match.
return await auth_wrapped(scope, receive, send)
return await original_asgi(scope, receive, send)
+2 -2
View File
@@ -1,7 +1,7 @@
"""MCP tool implementations.
Each tool module exposes a `register(mcp)` function that attaches its tools
to a FastMCP instance. `register_all(mcp)` is the single entry point called
to an MCPServer instance. `register_all(mcp)` is the single entry point called
from `mcp.server.build_mcp_server`.
"""
from scribe.mcp.tools import (
@@ -13,7 +13,7 @@ from scribe.mcp.tools import (
def register_all(mcp) -> None:
"""Register every tool module's tools on the given FastMCP instance."""
"""Register every tool module's tools on the given MCPServer instance."""
search.register(mcp)
retrieval_tuning.register(mcp)
wide_net.register(mcp)
+1 -1
View File
@@ -537,7 +537,7 @@ _ITEM_KEYS = {"title", "body", "type", "status", "priority", "kind", "tags", "sy
def _batch_items(records: list[dict], *, what: str = "record") -> list[batch_svc.BatchItem]:
"""Parse the door's plain dicts into BatchItems, refusing unknown keys.
Strict for the same reason StrictArgsFastMCP is (#2709): a misspelt key
Strict for the same reason StrictArgsMCPServer is (#2709): a misspelt key
silently dropped — `milestone` for a per-record milestone, `desc` for body —
creates a record that looks right and is missing what the caller sent.
"""
+63 -11
View File
@@ -113,6 +113,31 @@ _NOTE_OVERLAP_LIMIT = 3
# gate was not part of the measurement, so it keeps the general bar.
_COPY_BAND_TYPES = {"note"}
# The constants above are the DEFAULTS; each is a setting (rule 25, #4385),
# because how alike two distinct records get depends on how uniform a corpus
# is, which nobody can know from here.
GATE_THRESHOLD_KEYS = {
"general": "kb_gate_threshold",
SNIPPET_NOTE_TYPE: "kb_gate_threshold_snippet",
LESSON_NOTE_TYPE: "kb_gate_threshold_lesson",
"note_copy": "kb_gate_threshold_note_copy",
"note_overlap": "kb_gate_note_overlap_floor",
}
GATE_DEFAULT_THRESHOLDS = {
"general": _SEMANTIC_THRESHOLD,
SNIPPET_NOTE_TYPE: _SNIPPET_SEMANTIC_THRESHOLD,
LESSON_NOTE_TYPE: _LESSON_SEMANTIC_THRESHOLD,
"note_copy": _NOTE_COPY_THRESHOLD,
"note_overlap": _NOTE_OVERLAP_FLOOR,
}
# A BLOCK bar may not be set below this. The gate refuses the write, so a
# mistyped 0.1 would refuse every create that shared a topic with anything —
# the report's floors can go low because a report only proposes.
_GATE_MIN_BLOCK = 0.80
# The overlap floor only decides what is LISTED for the session to judge, so it
# may go lower — but not so low that the three slots fill with noise.
_GATE_MIN_OVERLAP = 0.70
# The gate queries per CHUNK of the candidate (#280) — this caps how many
# searches one save may cost. Eight chunks ≈ five thousand words of candidate;
# a duplicate hiding past that is the duplicate report's job to find, not a
@@ -248,16 +273,43 @@ async def _find_snippet_by_structure(
def _semantic_threshold(note_type: str) -> float:
"""The semantic bar for this kind — a lookup, so the kinds that need a
different one are named in a single place rather than in a conditional
that grows a branch per kind."""
if note_type == SNIPPET_NOTE_TYPE:
return _SNIPPET_SEMANTIC_THRESHOLD
if note_type == LESSON_NOTE_TYPE:
return _LESSON_SEMANTIC_THRESHOLD
"""The DEFAULT semantic bar for this kind — what `gate_bars` falls back on
when the user has not set one."""
return GATE_DEFAULT_THRESHOLDS[_gate_key(note_type)]
def _gate_key(note_type: str) -> str:
if note_type in (SNIPPET_NOTE_TYPE, LESSON_NOTE_TYPE):
return note_type
if note_type in _COPY_BAND_TYPES:
return _NOTE_COPY_THRESHOLD
return _SEMANTIC_THRESHOLD
return "note_copy"
return "general"
async def _gate_setting(user_id: int, key: str, lo: float) -> float:
from scribe.services.settings import get_setting
default = GATE_DEFAULT_THRESHOLDS[key]
try:
value = float(await get_setting(user_id, GATE_THRESHOLD_KEYS[key], str(default)))
except Exception:
# Fail-open like the rest of the gate: an unreadable setting falls back
# to the measured default rather than blocking or waving through.
value = default
return min(1.0, max(lo, value))
async def gate_bars(user_id: int, note_type: str) -> tuple[float, float]:
"""(block_at, overlap_floor) for `note_type` on this user's install.
The overlap floor is only read for the copy-band kinds, and never sits
above the block bar — a floor over the bar would list nothing.
"""
block_at = await _gate_setting(user_id, _gate_key(note_type), _GATE_MIN_BLOCK)
if note_type not in _COPY_BAND_TYPES:
return block_at, block_at
floor = await _gate_setting(user_id, "note_overlap", _GATE_MIN_OVERLAP)
return block_at, min(floor, block_at)
@dataclass
@@ -355,7 +407,7 @@ async def find_duplicate_note(
# under its name and embedded under `name — trigger`, so the query
# document is built the way the corpus was, from `data`.
doc_title = embeddings_svc.document_title(title, note_type, data, body)
block_at = _semantic_threshold(note_type)
block_at, overlap_floor = await gate_bars(user_id, note_type)
collect = overlaps is not None and note_type in _COPY_BAND_TYPES
near: dict[int, NoteOverlap] = {}
for query in embeddings_svc.chunk_document(doc_title, body)[:_GATE_MAX_CHUNKS]:
@@ -369,7 +421,7 @@ async def find_duplicate_note(
user_id, query, project_id=project_id, is_task=is_task,
orphan_only=(project_id is None),
limit=3,
threshold=_NOTE_OVERLAP_FLOOR if collect else block_at,
threshold=overlap_floor if collect else block_at,
# Owner-only, deliberately: this gate BLOCKS a create and tells
# the caller to update the match instead. Matching someone
# else's record would refuse their write and point them at
+6
View File
@@ -37,6 +37,12 @@ if TYPE_CHECKING:
# compaction, a resume or a long read does not kill it; short enough that a
# session gone overnight reads as gone. The cost either way is stated rather
# than hidden: readers show the age beside `live`, never the boolean alone.
#
# NOT A SETTING, deliberately (#4385 weighed it). Settings are per user, and a
# claim is read by everyone who can see the task: a per-user lease would make
# one shared task read as live to one collaborator and dead to another, and
# "is anyone on this?" only means something if every reader gets the same
# answer. It is also read synchronously in `to_dict`, which has no user to ask.
CLAIM_LEASE = timedelta(hours=2)
+21
View File
@@ -178,3 +178,24 @@ def _no_rule_overlap():
with patch("scribe.services.dedup.find_overlapping_rules",
AsyncMock(return_value=[])):
yield
@pytest.fixture(autouse=True)
def _default_gate_bars():
"""Read the create gate's bars as their defaults, not from settings (#4385).
Every create through the note gate now asks the user's settings for its
similarity bars, which is a database read on a path the gate's unit tests
run without one. Stubbed one level down — `_gate_setting`, not `gate_bars`
— so the copy-band logic above it (which kinds read an overlap floor, the
floor never sitting above the bar) still runs in every test. The setting
read itself is tested in tests/test_gate_settings.py, which binds the real
function at import time, before this patch runs.
"""
from scribe.services.dedup import GATE_DEFAULT_THRESHOLDS
async def _default(user_id, key, lo):
return GATE_DEFAULT_THRESHOLDS[key]
with patch("scribe.services.dedup._gate_setting", AsyncMock(side_effect=_default)):
yield
+1 -1
View File
@@ -321,7 +321,7 @@ def plain_rule_detail():
class FakeMCP:
"""Stand-in for the FastMCP server a tool module's ``register(mcp)`` is
"""Stand-in for the MCPServer a tool module's ``register(mcp)`` is
handed: records the ``name=`` of every ``@mcp.tool(...)`` registration in
``names`` and leaves the function untouched, so a test can assert which
tools a module exposes."""
+78
View File
@@ -0,0 +1,78 @@
"""The create gate's similarity bars are settings, clamped and fail-open (#4385)."""
from unittest.mock import AsyncMock, patch
import pytest
from scribe.services.dedup import (
_GATE_MIN_BLOCK,
_GATE_MIN_OVERLAP,
GATE_DEFAULT_THRESHOLDS,
_gate_setting,
)
# Bound at import, before the autouse stub in conftest replaces the module
# attribute — so these tests exercise the real read.
_real_gate_setting = _gate_setting
def _setting(value):
return patch("scribe.services.settings.get_setting", AsyncMock(return_value=value))
@pytest.mark.asyncio
async def test_a_set_value_is_used():
with _setting("0.95"):
assert await _real_gate_setting(7, "note_copy", _GATE_MIN_BLOCK) == 0.95
@pytest.mark.asyncio
async def test_a_block_bar_cannot_be_set_low_enough_to_refuse_everything():
with _setting("0.1"):
assert await _real_gate_setting(7, "general", _GATE_MIN_BLOCK) == _GATE_MIN_BLOCK
@pytest.mark.asyncio
async def test_the_overlap_floor_has_its_own_lower_clamp():
with _setting("0.1"):
assert await _real_gate_setting(7, "note_overlap", _GATE_MIN_OVERLAP) == _GATE_MIN_OVERLAP
@pytest.mark.asyncio
async def test_an_unparseable_value_falls_back_to_the_default():
with _setting("lots"):
got = await _real_gate_setting(7, "snippet", _GATE_MIN_BLOCK)
assert got == GATE_DEFAULT_THRESHOLDS["snippet"]
@pytest.mark.asyncio
async def test_an_unreadable_setting_falls_back_to_the_default():
with patch("scribe.services.settings.get_setting",
AsyncMock(side_effect=RuntimeError("db down"))):
got = await _real_gate_setting(7, "lesson", _GATE_MIN_BLOCK)
assert got == GATE_DEFAULT_THRESHOLDS["lesson"]
@pytest.mark.asyncio
async def test_the_overlap_floor_never_sits_above_the_block_bar():
from scribe.services.dedup import gate_bars
async def _set(user_id, key, lo):
return {"note_copy": 0.85, "note_overlap": 0.90}[key]
with patch("scribe.services.dedup._gate_setting", AsyncMock(side_effect=_set)):
assert await gate_bars(7, "note") == (0.85, 0.85)
@pytest.mark.asyncio
async def test_a_kind_outside_the_copy_band_reads_no_overlap_floor():
from scribe.services.dedup import gate_bars
seen = []
async def _set(user_id, key, lo):
seen.append(key)
return GATE_DEFAULT_THRESHOLDS[key]
with patch("scribe.services.dedup._gate_setting", AsyncMock(side_effect=_set)):
assert await gate_bars(7, "process") == (0.90, 0.90)
assert seen == ["general"]
+6 -6
View File
@@ -3,7 +3,7 @@
These exercise the ASGI dispatch + auth middleware by driving the app's ASGI
callable directly. We avoid Quart's test_client() because it expects Quart's
request pipeline to set `app._preserved_context` as a side effect, but our
ASGI middleware forwards /mcp requests to FastMCP without touching the Quart
ASGI middleware forwards /mcp requests to MCPServer without touching the Quart
pipeline (correct production behavior), which causes test_client to fail on
teardown.
@@ -85,12 +85,12 @@ async def test_mcp_endpoint_invalid_token_returns_401():
@pytest.mark.asyncio
async def test_mcp_endpoint_valid_token_passes_auth():
"""With a valid Bearer, the request must successfully reach FastMCP's
"""With a valid Bearer, the request must successfully reach MCPServer's
initialize handler. Asserting `!= 401` is too weak: it lets a 404 from
a path-mismatch (the original bug) through. FastMCP responds 200 to a
a path-mismatch (the original bug) through. MCPServer responds 200 to a
well-formed initialize handshake.
FastMCP's session manager normally starts via Quart's @before_serving
MCPServer's session manager normally starts via Quart's @before_serving
hook in production. This raw-ASGI test doesn't go through Quart's
serving lifecycle, so we manually enter the session manager."""
fake_key = MagicMock()
@@ -113,7 +113,7 @@ async def test_mcp_endpoint_valid_token_passes_auth():
status, _ = await _send_request(
app, "POST", "/mcp",
headers={
# FastMCP's transport_security module enforces a Host
# MCPServer's transport_security module enforces a Host
# header (DNS-rebinding protection); without it the
# request gets a 421 Misdirected Request.
"Host": "testserver",
@@ -123,7 +123,7 @@ async def test_mcp_endpoint_valid_token_passes_auth():
},
body=initialize_body,
)
assert status == 200, f"expected 200 from FastMCP initialize, got {status}"
assert status == 200, f"expected 200 from MCPServer initialize, got {status}"
# Note: there's no explicit "non-/mcp paths bypass the middleware" test here
+10 -9
View File
@@ -1,23 +1,24 @@
"""Unknown tool arguments are rejected, never silently dropped (#2709).
The failure this pins: FastMCP validates tool arguments with a pydantic model
The failure this pins: MCPServer validates tool arguments with a pydantic model
whose extra-field policy is "ignore", so `create_note(content=...)` — a
plausible near-miss for `body=`, primed by add_task_log's `content` — ran
successfully, stored `body: ""`, and left a record embedding/search cannot
see. The call REPORTED SUCCESS. Two real notes were persisted body-less
before the pattern was noticed.
The fix is at the dispatch seam, not per-tool: StrictArgsFastMCP rejects any
The fix is at the dispatch seam, not per-tool: StrictArgsMCPServer rejects any
call carrying arguments the tool does not declare, with a did-you-mean when
one is close. An error the caller sees once beats data half-written forever.
"""
import pytest
from mcp.server.mcpserver.exceptions import ToolError
from scribe.mcp.server import StrictArgsFastMCP
from scribe.mcp.server import StrictArgsMCPServer
def _echo_server() -> StrictArgsFastMCP:
mcp = StrictArgsFastMCP("strict-test")
def _echo_server() -> StrictArgsMCPServer:
mcp = StrictArgsMCPServer("strict-test")
@mcp.tool()
def echo(text: str = "") -> str:
@@ -36,7 +37,7 @@ async def test_unknown_argument_is_an_error_not_a_silent_drop():
"""The load-bearing property: the call must FAIL, because succeeding is
what turned a typo into data loss."""
mcp = _echo_server()
with pytest.raises(ValueError) as exc:
with pytest.raises(ToolError) as exc:
await mcp.call_tool("echo", {"txt": "hi"})
msg = str(exc.value)
assert "'txt'" in msg
@@ -51,7 +52,7 @@ async def test_empty_arguments_pass():
async def test_unknown_tool_keeps_the_upstream_error():
"""The gate must not swallow or reshape 'no such tool' — that error path
belongs to FastMCP and clients already understand it."""
belongs to MCPServer and clients already understand it."""
mcp = _echo_server()
with pytest.raises(Exception) as exc:
await mcp.call_tool("no_such_tool", {"text": "hi"})
@@ -65,8 +66,8 @@ async def test_the_original_regression_create_note_with_content():
from scribe.mcp.server import build_mcp_server
mcp = build_mcp_server()
assert isinstance(mcp, StrictArgsFastMCP) # the guard is actually mounted
with pytest.raises(ValueError) as exc:
assert isinstance(mcp, StrictArgsMCPServer) # the guard is actually mounted
with pytest.raises(ToolError) as exc:
await mcp.call_tool(
"create_note", {"title": "t", "content": "the body text"}
)
Generated
+77 -28
View File
@@ -513,6 +513,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpcore2"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11" },
{ name = "truststore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
@@ -529,12 +542,28 @@ wheels = [
]
[[package]]
name = "httpx-sse"
version = "0.4.3"
name = "httpx2"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
dependencies = [
{ name = "anyio", marker = "sys_platform != 'emscripten'" },
{ name = "httpcore2", marker = "sys_platform != 'emscripten'" },
{ name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" },
{ name = "idna" },
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
{ url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" },
]
[[package]]
name = "httpx2-jsfetch"
version = "1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
]
[[package]]
@@ -609,11 +638,11 @@ wheels = [
[[package]]
name = "idna"
version = "3.11"
version = "3.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
@@ -825,15 +854,15 @@ wheels = [
[[package]]
name = "mcp"
version = "1.27.2"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "httpx2" },
{ name = "jsonschema" },
{ name = "mcp-types" },
{ name = "opentelemetry-api" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
@@ -843,9 +872,9 @@ dependencies = [
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/27/3c/347cf965d313f5d41764e7d46bea6ffe7d9ef13b983cc429b0340962a082/mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef", size = 621116, upload-time = "2026-05-29T17:16:04.039Z" }
sdist = { url = "https://files.pythonhosted.org/packages/76/31/ac54fb0fdd5b37de704486e288bba4fbbb463f24cfcfedbede407b854513/mcp-2.2.0.tar.gz", hash = "sha256:2dc37ecb1974becdcebdbf7561e7c15a07dbbf20ba21ba16c3593b3038b3afbd", size = 4084129, upload-time = "2026-09-07T16:06:23.439Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/11/252c6f971dc4f16af1d98a1c469d8ba523aab00d1bb76b4d3bc1ff32eacc/mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5", size = 220498, upload-time = "2026-05-29T17:16:02.442Z" },
{ url = "https://files.pythonhosted.org/packages/1b/ff/8e7eade68b8a28f7da0ed1085544341b51f9c935dbf6b95c76b7edfea6a0/mcp-2.2.0-py3-none-any.whl", hash = "sha256:bde982589473a060ae145e3406e9a5333fe538c97229ba841f5a7f92be004f81", size = 365656, upload-time = "2026-09-07T16:06:19.711Z" },
]
[package.optional-dependencies]
@@ -854,6 +883,19 @@ cli = [
{ name = "typer" },
]
[[package]]
name = "mcp-types"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/91/762d7755d971aff8a28d75f7961656148edf27875c8026e6385aaab08ae7/mcp_types-2.2.0.tar.gz", hash = "sha256:d3ed53703ddd10d9c6399f29d322bb66f3f67ab41348ac8556ba23e07fedefad", size = 65892, upload-time = "2026-09-07T16:06:25.187Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/d7/6ffba5d8cd5dd9b8a19478875c50e04945314ba5074e84d749283f27f62d/mcp_types-2.2.0-py3-none-any.whl", hash = "sha256:ea476b73ee86709ab5abc9452385ed36cc05907e582355622e294595c9a04f13", size = 69106, upload-time = "2026-09-07T16:06:21.461Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
@@ -981,6 +1023,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/1d/1666dc64e78d8587d168fec4e3b7922b92eb286a2ddeebcf6acb55c7dc82/onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1cc6a518255f012134bc791975a6294806be9a3b20c4a54cca25194c90cf731", size = 17247021, upload-time = "2026-03-17T22:04:52.377Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.44.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" },
]
[[package]]
name = "packaging"
version = "26.0"
@@ -1146,20 +1200,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
@@ -1497,7 +1537,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.27" },
{ name = "hypercorn", specifier = ">=0.17" },
{ name = "icalendar", specifier = ">=5.0" },
{ name = "mcp", extras = ["cli"], specifier = ">=1.0,<2" },
{ name = "mcp", extras = ["cli"], specifier = ">=2.2" },
{ name = "pgvector", specifier = ">=0.3" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
@@ -1631,6 +1671,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
]
[[package]]
name = "truststore"
version = "0.10.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
]
[[package]]
name = "typer"
version = "0.24.1"