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
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:
@@ -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
@@ -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,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
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user