From b15461add9dc6eac0b7b1a4de9c708673e096038 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 22:29:54 +0000 Subject: [PATCH 1/9] =?UTF-8?q?fix(mcp):=20=D7=AA=D7=A7=D7=A8=D7=94=20?= =?UTF-8?q?=D7=9C=D7=A8=D7=A9=D7=99=D7=9E=D7=AA=20=D7=94=D7=9E=D7=95=D7=A2?= =?UTF-8?q?=D7=9E=D7=93=D7=99=D7=9D=20=D7=A9=D7=9C=20ambiguous=5Fsection,?= =?UTF-8?q?=20=D7=95=D7=98=D7=A1=D7=98=20=D7=A9=D7=9E=D7=A7=D7=91=D7=A2=20?= =?UTF-8?q?=D7=90=D7=AA=20=D7=97=D7=95=D7=96=D7=94=20Suggestions=20=D7=91?= =?UTF-8?q?=D7=9E=D7=A1=D7=9C=D7=95=D7=9C=20=D7=94-Markdown=20(#3426)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ‏`candidates` היה השדה היחיד בתשובת `codekeeper_docs_get_section` בלי תקרה: ‏`toc` חסום ב-400, ‏`suggestions` ב-50, ורק המועמדים חזרו כולם — נמדד בסקירת #3425: 13,030 מועמדים ו-1,393,787 בתים על שאילתה בת שלושה תווים בעמוד סינתטי של 512KB. הענף נדלק מכותרות שנושאות מזהה, ומאז #3428 הקורפוס שנושא אותם (amir-bug-patterns) מוגש. - התקרה היא 50 — אותו מספר כמו MAX_IDENTIFIER_SUGGESTIONS ומאותה סיבה (מלאי בסדר הופעה ולא דירוג, גבוה מספיק שכל עמוד אמיתי ייענה במלואו), והשוויון מקובע בטסט. ‏`candidates_truncated: true` מופיע רק כשנחתך, כמו `suggestions_truncated`: תצלום אפס-הדיף של הכלי על קורפוס ה-RST של main זהה בית-בית לפני ואחרי (5,928 רשומות, אותו sha256). - R6: החיתוך של `toc` ושל `candidates` עובר דרך עוזר אחד, ‏`_capped`, במקום עותק שני של "עד התקרה, ודגל רק כשנחתך". - הצרכן השני מ-#3426 הוא בפועל אותו אתר קריאה לשני הפורמטים (מאז #3428), והוא כותב `.titles`; נוסף טסט שמקבע את זה במסלול ה-Markdown, כי אזהרה ב-docstring אינה מנגנון. - תיאור הפרמטר `section` ו-docs/mcp-server.rst אומרים את התקרה והדגל; רשומה ב-whats-new. שורה ריקה אחת נוספה ב-server.py לפני `_build_docs_path_doc` (E302 שהגיע עם #3428; CI בוחר רק E9/F63/F7/F82 ולכן לא נפל שם). טסטים: 135 ב-test_mcp_docs_handlers + test_doc_sections ו-36 ב-test_mcp_server_build ירוקים; טסט התקרה וטסט השוויון נופלים על origin/main ב-worktree נפרד. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SfJTSpDAhDr2yhtmpFkwTx --- docs/mcp-server.rst | 8 +++++- docs/whats-new.rst | 1 + mcp_server/docs_handlers.py | 39 ++++++++++++++++++++++++---- mcp_server/server.py | 6 ++++- tests/test_mcp_docs_handlers.py | 46 +++++++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 7 deletions(-) diff --git a/docs/mcp-server.rst b/docs/mcp-server.rst index e8948f355..d61eb6a25 100644 --- a/docs/mcp-server.rst +++ b/docs/mcp-server.rst @@ -1213,7 +1213,13 @@ Claude Desktop בצורת המזהה שהחוזה מגדיר. **מזהה שחוזר פעמיים באותו עמוד הוא ``ambiguous_section``**, עם רשימת -המועמדים — בדיוק כמו כל כותרת כפולה. הכלי אינו מנחש. +המועמדים — בדיוק כמו כל כותרת כפולה. הכלי אינו מנחש. **והרשימה חסומה ב-50 +מועמדים**, אותו מספר ומאותה סיבה כמו ההצעות: מלאי בסדר הופעה ולא דירוג, +גבוה מספיק שכל עמוד אמיתי ייענה במלואו. אם היו יותר, התשובה נושאת +``candidates_truncated: true`` — ורק אז, כמו ``suggestions_truncated``. לפני +התקרה זה היה השדה היחיד בתשובה בלי גבול: עמוד סינתטי של 512KB עם 13,030 +כותרות שנפתחות באותו מזהה החזיר 13,030 מועמדים ו-1.4MB על שאילתה בת שלושה +תווים (#3426). **ותקרה על מספר הסקשנים בעמוד — אותה תקרה של האאוטליין, 50,000.** עמוד שחוצה אותה מוחזר כ-``{"ok": false, "error": "too_many_sections", "max": 50000}`` diff --git a/docs/whats-new.rst b/docs/whats-new.rst index ef65c8cd0..38d42b892 100644 --- a/docs/whats-new.rst +++ b/docs/whats-new.rst @@ -4,6 +4,7 @@ What's New 2026-09-20 ---------- +- **fix: ``codekeeper_docs_get_section`` חוסם את רשימת המועמדים של ``ambiguous_section`` ב-50 (#3426).** זה היה השדה היחיד בתשובה בלי תקרה: ``toc`` חסום ב-400 ו-``suggestions`` ב-50, ורק המועמדים חזרו כולם — נמדד 13,030 מועמדים ו-1.4MB על שאילתה בת שלושה תווים בעמוד סינתטי. הענף נדלק מכותרות שנושאות מזהה, ומאז #3428 הקורפוס שנושא אותם מוגש. אותו מספר כמו ההצעות ומאותה סיבה (מלאי בסדר הופעה, לא דירוג), והדגל ``candidates_truncated`` מופיע רק כשנחתך — כמו ``suggestions_truncated`` — ולכן תצלום אפס-הדיף של הכלי על 208 קובצי ה-RST זהה לפני ואחרי. ובאותו PR: טסט שמקבע שגם במסלול ה-Markdown ``suggestions`` הוא רשימת מחרוזות ולא ה-``NamedTuple`` (הצרכן השני מ-#3426 הוא אותו אתר קריאה, והוא כותב ``.titles``). - **feat: ``codekeeper_docs_get_section`` קורא גם קובצי Markdown, ולכל ריפו מדיניות נתיבים משלו.** עד היום הכלי ידע לקרוא ``.rst`` בלבד, ורק מתחת ל-``docs/``. הפארסר של Markdown נכתב ונמדד כבר קודם — ואף אחד לא קרא לו; זה השינוי שמחווט אותו. מעכשיו הסיומת בוחרת את הפארסר, ולכל ריפו מוצהר **איפה התיעוד שלו יושב ובאיזה פורמט**: ``CodeBot`` נשאר ``docs/`` עם ``.rst`` ללא שינוי, ו-``amir-bug-patterns`` מגיש ``.md`` משורש הריפו. **וזו הרחבה מכוונת של גבול הקריאה** — הכלי ציבורי, כלומר כל משתמש מאומת מפעיל אותו בלי אדמין, והריפו שנוסף ציבורי ב-GitHub. מה ש**לא** השתנה: מדיניות סינון הסודות חלה על המסלול הזה במלואה, ולכן ``path="secrets"`` מחזיר ``path_denied`` ולא ``not_found``, וגם ``secrets.md`` ו-``credentials.md`` חסומים; ותקרת 500KB של שירות המראה נשארת ההגנה על הפרסור, כי הכלי קורא את הקובץ כולו בלי ``lines``. **שני שערים ולא אחד:** ``MCP_DOCS_REPO`` מתיר בזמן ריצה, טבלת המדיניות בקוד יודעת לקרוא, וריפו שנמצא במשתנה הסביבה ואין לו מדיניות נדחה ב-``repo_not_configured`` במקום ליפול לברירת מחדל מתירנית. **ובקשה לפורמט שהריפו אינו מגיש נדחית בשמה** — ``suffix_not_allowed`` עם רשימת הסיומות שכן מוגשות, במקום להשלים סיומת בשקט מעל הקיימת ולחזור כ"לא נמצא" על קובץ שקיים. שני סירובים חדשים מגיעים מהפרסור עצמו: ``inconsistent_line_endings`` על ``\r`` בודד (CRLF עובד במלואו), ו-``too_many_sections`` על קובץ שחוצה את תקרת 50,000 הכותרות — Markdown דרך ברירת המחדל של הפרסר, ו-RST דרך התקרה שהכלי מעביר מאז #3429; סירוב שהפארסר מספק לו מספר שורה נושא גם ``line`` — תמיד ב-Markdown, ולא ב-``too_many_sections`` של RST, כי ``rst_parser`` מרים אותה בלי מספר. ``includes`` הוא שדה של RST ולכן בעמוד ``.md`` הוא תמיד ריק. **ומסלול ה-RST לא זז**: אפס דיף מדוד על 5,920 רשומות מ-208 קובצי RST, לפני ואחרי. ראו :ref:`mcp-docs-path-policy`. - **fix: מאגר הקריאות של ה-MCP נגזר ממכסת הזיכרון של הקונטיינר, ולא ממספר המעבדים של המארח (#3391).** כלי הקריאה רצים ב-``asyncio.to_thread``, כלומר ב-executor ברירת המחדל של הלולאה, שגודלו ``min(32, os.cpu_count() + 4)`` — ו-``os.cpu_count()`` בקונטיינר הוא מספר הליבות של **המארח**, כפי ש-CPython מתעד במפורש. שורת הקיבולת מהייצור אמרה את זה במספרים: ``read pool 12 threads (os.cpu_count=8, usable=8)`` על מכסה של ``0.50 cpu``. לקריאת מונגו זה לא מזיק; לפרסור בפייתון טהור אין מקביליות ב-CPU בגלל ה-GIL, ולכן 12 חוטים היו 12 עצי פרסור בזיכרון בו-זמנית — יותר ממה שנכנס ב-512MiB — ורגע לפני שהפרסור הופך לכלי ציבורי. מעכשיו ``attach_read_pool`` מתקין בעליית ה-lifespan של אפליקציית ה-ASGI מאגר משלו, בגודל ``(מגבלת הזיכרון − קו בסיס − שוליים) ÷ עלות פרסור אחד``: המגבלה נקראת מ-cgroup בזמן ריצה (``memory.max`` ב-v2, ‏``memory.limit_in_bytes`` ב-v1) ולא מקובעת בקוד, קו הבסיס והשוליים הם מדידות מהשירות בייצור עם תאריך, ועלות הפרסור נגזרת מ-``MAX_FILE_SIZE_FOR_DISPLAY`` כי הכלי דוחה ``too_large`` לפני שהוא מפרסר. על תוכנית הייצור זה **10** חוטים במקום 12 — עלות הפרסור נמדדה מחדש על הפרסר הנעוץ (``scripts/measure_md_parse_cost.py``; הקבוע נמדד על ``services.md_parser`` כהכנה לכלי ה-Markdown של PR 5, בעוד הכלי הציבורי היום מפרסר RST ב-``services.rst_parser``, שעולה כ-6 בתים לבית גם על העמוד הצפוף ביותר — והסקריפט מודד את שניהם), והיא תלויה בצפיפות הטוקנים של המסמך ולא בגודלו: המסמך הצפוף ביותר בקורפוס הוא הקבוע, וצורה עוינת של שורות-תבליט בודדות עולה פי ארבעה ממנו — מה ששום רוחב מאגר אינו סופג, ומחכה לתקרת טוקנים בפרסר עצמו. **ובעקבות הסקירה:** ``codekeeper_docs_get_section`` מעביר לפרסר את תקרת הסקשנים של האאוטליין (50,000) ומחזיר ``too_many_sections`` במקום לפרסר את הקובץ כולו — צורה עוינת של RST (כותרת בת תו אחד בכל שורה) עלתה 44.0MiB לפרסור, יותר מ-35.2MiB שהמאגר מקצה לחוט, ועכשיו נעצרת ב-20.1MiB; אף עמוד אמיתי אינו מתקרב לתקרה. רצפה של 2 (קריאה תקועה אחת לא משביתה את השאר), תקרה של 12, הרוחב שהייצור כבר הריץ (מעבר לתוכנית גדולה לא מרחיב את המאגר בלי החלטה; הגרסה הראשונה נקבה ב-32, והסקירה של #3429 הורידה אותה כי המחלק מתאר את הפרסור ולא את קריאות הטווח של האדמין; חסימת הקריאה במקור — #3433), וכשאין מגבלה קריאה — הרצפה, **לא** ``cpu_count + 4``, עם WARNING שאומר שזה קרה. **ושורת הקיבולת מפסיקה לחשב בעצמה:** היא נרשמת מתוך ה-lifespan, קוראת את ``_max_workers`` של המאגר שהותקן בפועל, ומדווחת גם את מגבלת הזיכרון לצד מכסת ה-CPU — כי רשומה שמתארת מצב ומתעדכנת בנפרד ממנו היא בדיוק מה שמשקר בשקט. ה-seam הוא עטיפת ``router.lifespan_context``, אותו seam שניקוז ה-PostHog כבר משתמש בו, כי ``FastMCP.streamable_http_app()`` בונה את Starlette עם ``lifespan=`` משלה ו-``on_startup`` אינו רץ. הטסט המרכזי נכנס ל-lifespan של האפליקציה האמיתית ומוכיח שקריאת ``to_thread`` נוחתת על חוט של המאגר החדש — לא שפונקציה נקראה; שורת הקיבולת נבדקת בתת-תהליך נקי דרך הפלט האמיתי ולא דרך ``caplog``. ראו "מודל הריצה של הכלים" ב-:doc:`mcp-server`. - **feat: ``codekeeper_docs_get_section`` מוצא סעיף גם לפי המזהה שלו, ולא רק לפי השם המלא.** הסוכנים מפנים זה לזה לפי מזהה — "קרא K11", "ראו U3" — ועד היום ``section="K11"`` החזיר ``section_not_found`` עם רשימת הצעות **ריקה**, כי ההתאמה הייתה שוויון מלא ו-``difflib`` עם ``cutoff=0.5`` אינו מוצא קרבה בין שלושה תווים לכותרת עברית ארוכה. כלומר הזרימה המרכזית של הכלי לא עבדה. מעכשיו, **ורק אחרי שהשוויון המלא לא מצא כלום**, שאילתה שבנויה כמזהה (עד שלוש אותיות ואחריהן עד שלוש ספרות, עם נקודה אופציונלית) מותאמת לכותרת שנפתחת באותו מזהה. **והמזהה נקרא כיחידה ולא כקידומת של מחרוזת**, ולכן ``K1`` מחזיר את ``K1`` בלבד ולעולם לא את ``K10`` עד ``K15`` — הצורה השבורה של הבדיקה הזאת היא אותה מחלקה בדיוק שבה גבול נתיב נבדק כרצף תווים. שאילתה שאינה בנויה כמזהה אינה עוברת במסלול הזה כלל, ולכן ``section="איך"`` ממשיך להחזיר אפס התאמות גם בעמוד שיש בו חמש-עשרה כותרות שמתחילות במילה הזאת. מזהה שחוזר פעמיים הוא ``ambiguous_section`` עם מועמדים, כמו כל כותרת כפולה, ושאילתת מזהה שלא נמצאה מקבלת ב-``suggestions`` קודם כול כותרת קרובה, אם יש כזו בעמוד, **ורק כשאין אף כותרת קרובה** את המזהים שכן קיימים — עד 50, ועם ``suggestions_truncated`` כשהיו עוד. **והתקרה גבוהה בכוונה**, כי רשימת מזהים אינה מדורגת: לשאלה "אלה שכן קיימים" אין תשובה "החמש הטובות", וחיתוך שלה מסתיר פריטים בלי קריטריון ובלי דרך לבקש את השאר. **והדגל הזה אומר את אותו דבר בשני המסלולים**: גם רשימת מזהים שנחתכה וגם רשימת כותרות קרובות שנחתכה מדליקות אותו. שדה בשם ``truncated`` שמשמעותו "נחתך" במסלול אחד ו"שקט" בשני הוא בדיוק ההפתעה שאנחנו מנסים למנוע. **ונקודה עוד יותר קלה לפספס: הנקודה שפותחת תת-מספור אינה גבול.** בכותרת ``K11. טקסט`` הנקודה **מסיימת** את המזהה, ובכותרת ``K11.1 טקסט`` היא **מפרידה** בתוך מזהה ארוך יותר — ולכן ``K11`` לעולם אינו מחזיר את ``K11.1``, וכותרת בתת-מספור נגישה בשמה המלא בלבד. זו אותה מחלקה בדיוק כמו ``K1`` שתופס את ``K10``: גבול שנבדק כתו בודד, בלי לשאול מה בא אחריו. **ההתנהגות על התיעוד כאן לא זזה, וזה נמדד ולא הונח:** הפלט של הכלי על כל קובצי ה-RST — כל כותרת, בכל צורותיה — זהה בית-בית לפני ואחרי. וההנחה שהכול נשען עליה, שאף כותרת בעמודים כאן אינה נפתחת במזהה, **אינה ספירה שנרשמה פעם אחת אלא טסט שרץ בכל CI**: ``tests/test_docs_headings_carry_no_identifier.py`` סורק את כל העמודים ונופל על כותרת שנפתחת במזהה, עם דוגמת autodoc אמיתית שמוכיחה שהוא מסוגל ליפול. **ומה שלא נבדק בקורפוס הזה נבדק בבקרה סינתטית**, כי סוללה שכל תשובותיה "לא נמצא" אינה מבדילה בין ענף כבוי לבין פרובים עיוורים. שני הדברים שמפתיעים קורא — קיצור המזהה, וכותרת שנושאת בקטיקים ולכן דורשת אותם בשאילתה — עברו לתיאור הפרמטר ``section``, שאינו נחתך אצל הלקוח כמו תיאור הכלי. ראו :ref:`mcp-docs-section`. diff --git a/mcp_server/docs_handlers.py b/mcp_server/docs_handlers.py index 8f13e5b8a..600cd7c27 100644 --- a/mcp_server/docs_handlers.py +++ b/mcp_server/docs_handlers.py @@ -56,6 +56,19 @@ DEFAULT_DOCS_REPO = "CodeBot" _TOC_MAX = 400 # תקרת פריטי TOC בתשובה (הגנת גודל) +#: תקרת המועמדים בתשובת ``ambiguous_section``. **אותו מספר כמו +#: ``doc_sections.MAX_IDENTIFIER_SUGGESTIONS``, ומאותה סיבה:** רשימת המועמדים +#: היא מלאי בסדר הופעה ולא דירוג, ולכן חיתוך שלה מסתיר פריטים בלי קריטריון — +#: והתקרה גבוהה מספיק כדי שכל עמוד אמיתי ייענה במלואו (כותרת כפולה בקורפוס +#: חוזרת פעמיים או שלוש). מה שהיא עוצרת נמדד בסקירת #3425: עמוד סינתטי של +#: 512KB עם 13,030 כותרות שנפתחות ב-``K11.`` החזיר 13,030 מועמדים — 1,393,787 +#: בתים ושיא הקצאה של 12.6MB — על שאילתה בת שלושה תווים, וזה היה השדה היחיד +#: בתשובה בלי גבול (``toc`` ב-``_TOC_MAX``, ‏``suggestions`` +#: ב-``MAX_IDENTIFIER_SUGGESTIONS``). הענף נדלק רק מכותרות שנושאות מזהה, ומאז +#: #3428 הקורפוס שנושא מזהים (``amir-bug-patterns``) מוגש. השוויון בין שני +#: המספרים מקובע בטסט, כמו שני גבולות האאוטליין והפארסר. אישו #3426. +_CANDIDATES_MAX = 50 + #: הסיומת ← **המודול** שמפרסר אותה. זהו המקום היחיד שאומר איזה פורמט הכלי #: יודע לקרוא בכלל, ו-``DOCS_PATH_POLICY`` למטה אומר מי מהם מוגש בכל ריפו. #: שתי שאלות שונות, ולכן שתי טבלאות — ו-``_validate_policy_tables`` קושר @@ -286,13 +299,23 @@ def _resolve_docs_path(path: str, policy: _DocsPathPolicy) -> _ResolvedPath: return _ResolvedPath(None, None, "missing_path") -def _toc(doc: doc_sections.Document) -> tuple[list, bool]: - items = doc_sections.build_toc(doc) - if len(items) > _TOC_MAX: - return items[:_TOC_MAX], True +def _capped(items: list, limit: int) -> tuple[list, bool]: + """‏``(items, truncated)`` — הרשימה עד ``limit``, והאם באמת נחתכה. + + **פונקציה אחת לשני הגבולות של התשובה** (``toc`` ו-``candidates``), כדי + שהשאלה "מתי הדגל נכון" תיענה במקום אחד: ``True`` רק כשהיו יותר מהתקרה, + ולעולם לא על רשימה שבדיוק בגודלה — אותו גבול כמו ``Capped.append`` + ב-``_ceiling`` וכמו ``max_sections`` בפארסר. + """ + if len(items) > limit: + return items[:limit], True return items, False +def _toc(doc: doc_sections.Document) -> tuple[list, bool]: + return _capped(doc_sections.build_toc(doc), _TOC_MAX) + + def _line_of(exc: BaseException) -> dict: """``{"line": N}`` כשהחריגה נושאת מספר שורה, ו-``{}`` כשלא. @@ -496,13 +519,19 @@ def docs_get_section( # כותרת כפולה → כל המועמדים עם breadcrumb (בלי לנחש) if len(matches) > 1: + candidates, candidates_truncated = _capped(matches, _CANDIDATES_MAX) base.update({ "ok": False, "error": "ambiguous_section", "requested": section, "candidates": [{ "title": s.title, "breadcrumb": list(s.breadcrumb), "level": s.level, "line_range": [s.heading_line, s.end_line], - } for s in matches], + } for s in candidates], }) + if candidates_truncated: + # רק כשנחתך — אותה מוסכמה של ``suggestions_truncated`` ו-``remaining_chars``: + # שדה שקיים תמיד היה משנה כל תשובת ``ambiguous_section`` בתצלום + # אפס-הדיף בלי ששום התנהגות השתנתה. + base["candidates_truncated"] = True return base # התאמה יחידה → הסקשן + ניווט (שכנים ותת-סקשנים) diff --git a/mcp_server/server.py b/mcp_server/server.py index ee9094438..d6f438b25 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -203,6 +203,8 @@ def _build_note_color_doc() -> str: # ייבוא היא ``import-time-side-effects``, ותיאור הכלי היה משתנה בין פריסות — # כלומר שני לקוחות היו קוראים שני חוזים שונים לאותו כלי. מה שה-ENV קובע # נאמר בתיאור במילים, בלי למנות ממנו. + + def _build_docs_path_doc() -> str: parts = [] for repo, policy in docs_handlers.DOCS_PATH_POLICY.items(): @@ -261,7 +263,9 @@ def _build_docs_path_doc() -> str: "sub-numbered heading is reachable by its full name only. " "An identifier that repeats in " "the file is ambiguous_section with candidates, like any duplicate " - "heading. A query that is not shaped like an identifier never takes " + "heading — at most 50 of them, in document order, with " + "candidates_truncated: true only when more were cut. A query that is " + "not shaped like an identifier never takes " "this path. (2) BACKTICKS: headings are returned as raw source, so a " "heading written with ``literal`` markup needs those backticks in the " "query too — section=\"MissingGreenlet\" finds nothing when the " diff --git a/tests/test_mcp_docs_handlers.py b/tests/test_mcp_docs_handlers.py index fbe329d81..b80f14f78 100644 --- a/tests/test_mcp_docs_handlers.py +++ b/tests/test_mcp_docs_handlers.py @@ -170,6 +170,52 @@ def test_ambiguous_section_returns_candidates_with_breadcrumb(): assert crumbs[0] != crumbs[1] # Alpha vs Beta +def test_candidates_are_capped_and_the_flag_appears_only_when_they_were_cut(monkeypatch): + """‏``candidates`` היה השדה היחיד בתשובה בלי תקרה (#3426). + + מזהה שחוזר בעמוד החזיר את **כל** המופעים — נמדד 13,030 מועמדים ו-1.39MB + על שאילתה בת שלושה תווים. עכשיו הוא חסום כמו ``toc`` ו-``suggestions``, + והדגל קיים רק כשהוא נכון, כדי שכל תשובת ``ambiguous_section`` על עמוד + רגיל תישאר זהה בית-בית (אפס-דיף). התקרה נקראת מהמודול בזמן הקריאה + ומוקטנת כאן ל-2, במקום לבנות חמישים ואחת כותרות — אותו נימוק כמו בטסט + תקרת הסקשנים. + """ + monkeypatch.setattr(docs_handlers, "_CANDIDATES_MAX", 2) + + at_the_cap = docs_handlers.docs_get_section( + _TextBackend(_identified_rst("P3 — ראשון", "P3 — שני")), path="x", section="P3") + assert at_the_cap["error"] == "ambiguous_section" + assert [c["title"] for c in at_the_cap["candidates"]] == ["P3 — ראשון", "P3 — שני"] + assert "candidates_truncated" not in at_the_cap, "עמוד שבדיוק בתקרה נענה במלואו, בלי דגל" + + over = docs_handlers.docs_get_section( + _TextBackend(_identified_rst("P3 — ראשון", "P3 — שני", "P3 — שלישי")), + path="x", section="P3") + assert over["error"] == "ambiguous_section" + assert [c["title"] for c in over["candidates"]] == ["P3 — ראשון", "P3 — שני"], "בסדר הופעה" + assert over["candidates_truncated"] is True + + +def test_the_candidates_cap_is_the_suggestions_inventory_number(): + """שני מלאים לא-מדורגים באותה תשובה — מספר אחד, כמו שני גבולות האאוטליין והפארסר.""" + assert docs_handlers._CANDIDATES_MAX == doc_sections.MAX_IDENTIFIER_SUGGESTIONS + + +def test_suggestions_on_a_markdown_page_are_a_list_of_strings_too(both_repos): + """הצרכן שנוסף ב-#3428 הוא אותו אתר קריאה לשני הפורמטים, והוא כותב ``.titles``. + + ‏#3426 ביקש שזה ייקבע בטסט ולא רק ב-docstring של ``Suggestions``: מי שישכח + את ``.titles`` ישלח ``[["K11"], false]`` — JSON תקין בלי שום שגיאה — וגם + במסלול ה-Markdown איש לא היה תופס את זה. + """ + md = "# K11. כשל\n\nטקסט\n\n# K12. דגל\n\nטקסט\n" + out = docs_handlers.docs_get_section(_TextBackend(md), path="x.md", + repo="amir-bug-patterns", section="K99") + assert out["error"] == "section_not_found" + assert out["suggestions"] == ["K11", "K12"] + assert all(isinstance(s, str) for s in out["suggestions"]) + + def test_include_subsections_false_smaller_than_true(): full = docs_handlers.docs_get_section(_FsBackend(), path="environment-variables", section="משתני סביבה - רפרנס", include_subsections=True) From 9795eb009ac13fa35a391bbb03bd9c88cabae4f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 23:01:04 +0000 Subject: [PATCH 2/9] =?UTF-8?q?fix(mcp):=20=D7=AA=D7=A7=D7=A8=D7=AA=20?= =?UTF-8?q?=D7=92=D7=95=D7=93=D7=9C=20=D7=9C=D7=92=D7=95=D7=A3=20=D7=91?= =?UTF-8?q?=D7=A7=D7=A9=D7=94=20=D7=95=D7=94=D7=92=D7=91=D7=9C=D7=AA=20?= =?UTF-8?q?=D7=A7=D7=A6=D7=91=20=D7=9C=D7=A4=D7=99=20=D7=96=D7=94=D7=95?= =?UTF-8?q?=D7=AA,=20=D7=A2=D7=9D=20=D7=A1=D7=99=D7=A8=D7=95=D7=91=20?= =?UTF-8?q?=D7=A9=D7=A0=D7=95=D7=A7=D7=91=20=D7=91=D7=A1=D7=99=D7=91=D7=AA?= =?UTF-8?q?=D7=95=20(#3431)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit עד היום המידלוור היחיד בשרת ה-MCP היה האימות: כל משתמש מאומת יכול היה לשלוח בקשות בכל גודל ובכל תדירות. שני גבולות חדשים, כל אחד בשכבה שלו, במודול חדש mcp_server/limits.py: - גודל הגוף: מידלוור ASGI טהור (BodySizeLimitMiddleware) שמסרב ב-413 {"error": "body_too_large", "max_bytes": ...} לפני שהטרנספורט של ה-SDK קורא ומפענח JSON. Content-Length שמצהיר על יותר מהתקרה נדחה לפני שנקרא בית אחד; כותרת חסרה או מכזבת נתפסת בספירה של מה שבאמת מגיע. חיץ ולא חריגה מתוך receive, כי _handle_post_request של ה-SDK עוטף את קריאת הגוף ב-try שתופס Exception ומחזיר שגיאת JSON-RPC בלי סיבה. ברירת המחדל 1MiB, נגזרת מ-MAX_CODE_SIZE (100,000 תווים, עד ~600KB כ-JSON עם ensure_ascii). - קצב לפי זהות: ב-AdminAwareFastMCP.call_tool, המתודה שה-SDK רושם כמטפל של tools/call, כלומר נקודה אחת שכל קריאת כלי עוברת בה בשני מצבי האימות (במצב OAuth PATAuthMiddleware אינו מותקן, ורק הקונטקסט של הקריאה רואה את הזהות). ההכרעה נופלת לפני שגוף סינכרוני נמסר לחוט ולפני שגוף אסינכרוני רץ; קריאה שנדחתה מחזירה תשובת כלי רגילה {"ok": false, "error": "rate_limited", "limit_per_minute": ..., "retry_after_seconds": ...}. מחוץ לבקשה (LookupError מ-request_context) אין את מי לחייב, ולכן הטסטים שקוראים לכלים ישירות ממשיכים כמו היום. 60 בדקה, מהמדידות בתגובה באישו: 0.24 שניות מעבד לעמוד RST עוין של 500KB, 0.47 למסמך Markdown הצפוף, 2.3 לצורה העוינת, מול מכסה של 0.5 מעבד (30 שניות-מעבד בדקה). - הפטור לנתיבי הדופק מבני ולא רשימה (blanket-policy-silent-block §7): /healthz אינו קריאת כלי ואין לו גוף, ומקובע בטסט על האפליקציה האמיתית. - rate_limiter.RateLimiter הקיים של הבוט משמש כמנוע (R6): ניקוי החלון אוחד ל-_live_entries, ונוספה seconds_until_allowed בשביל retry_after_seconds. - כיוון דרך MCP_MAX_REQUEST_BYTES (מינימום 65536) ו-MCP_RATE_LIMIT_PER_MINUTE (0 מכבה במפורש עם WARNING), נקראים ב-create_app ולא בזמן ייבוא; ערך פגום לעולם אינו מרחיב את הגבול (K12 §3). נרשמו ב-config_inspector_service ובתיעוד. אומת עם uvicorn אמיתי: 401 לפני 413 במצב PAT, 413 על Content-Length ועל גוף chunked בלי כותרת, 120 דגימות של /healthz תחת מגבלה של קריאה אחת בדקה כולן 200; ועם לקוח ה-MCP של ה-SDK על Streamable HTTP: הקריאה השנייה מחזירה rate_limited ו-tools/list אחריה עדיין עונה. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SfJTSpDAhDr2yhtmpFkwTx --- docs/environment-variables.rst | 12 + docs/mcp-server.rst | 72 ++++++ docs/whats-new.rst | 1 + mcp_server/app.py | 26 +- mcp_server/limits.py | 228 +++++++++++++++++ mcp_server/server.py | 89 ++++++- rate_limiter.py | 57 +++-- services/config_inspector_service.py | 20 ++ tests/test_mcp_limits.py | 365 +++++++++++++++++++++++++++ 9 files changed, 845 insertions(+), 25 deletions(-) create mode 100644 mcp_server/limits.py create mode 100644 tests/test_mcp_limits.py diff --git a/docs/environment-variables.rst b/docs/environment-variables.rst index a8bb3a994..644cae099 100644 --- a/docs/environment-variables.rst +++ b/docs/environment-variables.rst @@ -2576,6 +2576,18 @@ - ``300`` - ``120`` - MCP + * - ``MCP_MAX_REQUEST_BYTES`` + - תקרת גודל (בתים) לגוף בקשה לשרת ה-MCP, לכל הנתיבים ולכל הכלים. גוף גדול יותר — לפי ``Content-Length`` או לפי מה שבאמת מגיע — נדחה ב-``413`` עם ``{"error": "body_too_large", "max_bytes": ...}`` **לפני** שהטרנספורט מפענח JSON. מינימום ``65536``; ערך שאינו מספר — ברירת המחדל, עם WARNING. ראו "גבולות הבקשה" ב-:doc:`mcp-server`. + - לא + - ``1048576`` + - ``2097152`` + - MCP + * - ``MCP_RATE_LIMIT_PER_MINUTE`` + - כמה קריאות כלים מותרות לזהות אחת (משתמש מאומת) בחלון מתגלגל של דקה, בשרת ה-MCP. קריאה מעבר לזה מחזירה ``{"ok": false, "error": "rate_limited", "limit_per_minute": ..., "retry_after_seconds": ...}`` בלי לתפוס חוט. ‏``0`` מכבה במפורש (WARNING בעלייה); ערך שאינו מספר — ברירת המחדל. ‏``/healthz`` ושאר נתיבי ה-HTTP אינם נספרים. ראו "גבולות הבקשה" ב-:doc:`mcp-server`. + - לא + - ``60`` + - ``120`` + - MCP * - ``POSTHOG_PROJECT_TOKEN`` - טוקן הפרויקט ב-PostHog (``phc_...``) עבור מדידת השימוש בשרת ה-MCP. חסר (או חסר ``POSTHOG_HOST``) — בפרודקשן המדידה כבויה והשרת עולה רגיל; בסביבת פיתוח העלייה נכשלת ברעש. ראו :ref:`mcp-analytics`. - לא diff --git a/docs/mcp-server.rst b/docs/mcp-server.rst index e8948f355..532fdc107 100644 --- a/docs/mcp-server.rst +++ b/docs/mcp-server.rst @@ -406,6 +406,14 @@ Starlette עם ``lifespan=`` משלה, ולכן ``on_startup`` אינו רץ כ * - ``OUTPUT_BYTE_BUDGET`` - 256,000 - תקציב הבתים של תשובה אחת + * - ``DEFAULT_MAX_REQUEST_BYTES`` + - 1,048,576 + - גוף בקשה אחת, לכל הנתיבים, ב-``mcp_server/limits.py``; ‏``MCP_MAX_REQUEST_BYTES`` + מכוון אותו. ראו "גבולות הבקשה" + * - ``DEFAULT_RATE_LIMIT_PER_MINUTE`` + - 60 + - קריאות כלים לזהות אחת בדקה, ב-``mcp_server/limits.py``; + ‏``MCP_RATE_LIMIT_PER_MINUTE`` מכוון אותו. ראו "גבולות הבקשה" * - ``TREE_PER_PAGE_DEFAULT`` · ``TREE_PER_PAGE_MAX`` - 200 · 1000 - עמוד בעץ הקבצים @@ -539,6 +547,70 @@ Starlette עם ``lifespan=`` משלה, ולכן ``on_startup`` אינו רץ כ ריפו: הוא מסונן ל-``user_id`` של הקורא ולכן יכול להחזיר רק פתקים שלו, ומשתמש רגיל אינו יכול ליצור פתק ריפו מלכתחילה. +.. _mcp-request-limits: + +גבולות הבקשה — גודל הגוף והקצב +------------------------------- + +עד #3431 המידלוור היחיד בשרת היה האימות: כל משתמש מאומת יכול היה לשלוח בקשות +בכל גודל ובכל תדירות. תקרת האורך ל-``path`` של ``codekeeper_docs_get_section`` +(#3428) חסמה הגברה בבקשה בודדת, ולא נגעה לא במספר הבקשות ולא בשאר הכלים. +היום יש שני גבולות, כל אחד בשכבה שמתאימה לו, ושניהם חיים ב-``mcp_server/limits.py``. + +**גודל גוף הבקשה — במידלוור ASGI, לפני שהטרנספורט מפענח JSON.** הטרנספורט של +ה-SDK קורא את הגוף כולו ומפענח אותו לפני שאף כלי רואה ארגומנט, ולכן התקרה +הזאת היא הגבול היחיד על **כל** ארגומנט של **כל** כלי, קיים ועתידי. ברירת המחדל +היא 1MiB: הארגומנט הלגיטימי הגדול ביותר הוא ``content`` של +``codekeeper_save_file``, שחסום ב-``MAX_CODE_SIZE`` (100,000 תווים), ולקוח +שמקודד JSON עם ``ensure_ascii`` הופך כל תו עברי לשישה בתים — כלומר כ-600KB +במקרה הקיצוני, ו-1MiB משאיר לזה מרווח. ‏``Content-Length`` שמצהיר על יותר מזה +נדחה לפני שנקרא בית אחד; כותרת חסרה או מכזבת נתפסת בספירה של מה שבאמת מגיע. +הסירוב נוקב בסיבתו, באותה צורה של ה-401 של האימות: + +.. code-block:: json + + {"error": "body_too_large", "max_bytes": 1048576, "content_length": 20971520} + +במצב PAT האימות נשאר מחוץ לתקרה (בקשה בלי טוקן היא 401 גם כשהיא ענקית); במצב +OAuth התקרה היא השכבה החיצונית ברמת האפליקציה, ומסרבת לפני שנבדק אישור כלשהו. + +**קצב — לפי זהות, בנקודת השיגור של הכלים.** הזהות שאפשר לספור לפיה קיימת +במידלוור רק במצב PAT; במצב OAuth (הייצור) ה-SDK מאמת בתוך ``/mcp``, והמקום היחיד +שרואה את הזהות בשני המצבים הוא קריאת הכלי עצמה. לכן המגביל יושב ב-``call_tool`` +של ``AdminAwareFastMCP`` — המתודה שה-SDK רושם כמטפל של ``tools/call``, כלומר +נקודה אחת שכל קריאת כלי עוברת בה — ומכריע **לפני** שגוף סינכרוני נמסר לחוט +מהמאגר ולפני שגוף אסינכרוני רץ על הלולאה: קריאה שנדחתה אינה עולה עובד, ומה +שאינו קריאת כלי (``initialize``, ‏``tools/list``) אינו נספר. הוא בנוי מעל +``rate_limiter.RateLimiter`` — אותו חלון מתגלגל של דקה שהבוט משתמש בו, בזיכרון +ובלי Redis, וזה מספיק כי השירות רץ במופע אחד. ברירת המחדל היא 60 קריאות בדקה +לזהות, ומאיפה המספר: הבקשה הציבורית היקרה ביותר היום היא עמוד RST של 500KB — +‏0.24 שניות מעבד עם תקרת הסקשנים; במסלול ה-Markdown המסמך הצפוף האמיתי עולה +0.47 שניות והצורה העוינת 2.3; המכסה היא 0.5 מעבד, כלומר 30 שניות-מעבד בדקה. +שישים קריאות של המסמך הצפוף האמיתי הן 28 שניות — זהות אחת יכולה לכל היותר למלא +דקת מעבד אחת משלה, ולא לחנוק את השאר; קריאה רגילה (10–50ms) הופכת 60 בדקה +לאחוז עד שלושה מהמכסה. הסירוב הוא תשובת כלי רגילה, עם הזמן שנותר עד שהחלון +משתחרר: + +.. code-block:: json + + {"ok": false, "error": "rate_limited", "limit_per_minute": 60, "retry_after_seconds": 17} + +**הפטור לנתיבי הדופק הוא מבני, לא רשימה.** ‏``/healthz`` אינו קריאת כלי ולכן +לעולם אינו מגיע למגביל, וכ-``GET`` אין לו גוף לתקרה. זו המלכודת שמתוארת +ב-``blanket-policy-silent-block`` §7 — מגבלה גורפת שמחזירה ``429`` למוניטור +החיצוני ומכריזה על שירות בריא כמנותק — והיא נסגרת כאן על ידי המבנה, ומקובעת +בטסט על האפליקציה האמיתית: מאה דגימות של ``/healthz`` תחת מגבלה של קריאה אחת +בדקה מחזירות מאה ``200``. + +**מה נרשם בלוג.** בעלייה — שורה אחת עם שני הגבולות שנקבעו; סירוב על גודל — +WARNING עם הגודל והנתיב (לא הגוף ולא הטוקן); סירוב על קצב — WARNING אחד לזהות +לחלון, כדי שסוכן שממשיך לדפוק לא יהפוך את הלוג לתור. + +**כיוון.** ‏``MCP_MAX_REQUEST_BYTES`` (מינימום 65,536) ו-``MCP_RATE_LIMIT_PER_MINUTE`` +(‏``0`` מכבה במפורש, עם WARNING). שניהם נקראים ב-``create_app`` — בכניסה לשירות, +לא בזמן ייבוא — וערך שאינו מספר מחזיר את ברירת המחדל עם WARNING; בשום מסלול +טעות תצורה אינה הופכת לגבול רחב יותר. + .. _mcp-update-description: עדכון תיאור בלי גרסה חדשה diff --git a/docs/whats-new.rst b/docs/whats-new.rst index ef65c8cd0..56fac4196 100644 --- a/docs/whats-new.rst +++ b/docs/whats-new.rst @@ -4,6 +4,7 @@ What's New 2026-09-20 ---------- +- **fix (MCP): תקרת גודל לגוף בקשה והגבלת קצב לפי זהות, עם סירוב שנוקב בסיבתו (#3431).** עד עכשיו המידלוור היחיד היה האימות, וכל משתמש מאומת יכול היה לשלוח בקשות בכל גודל ובכל תדירות. שני גבולות חדשים ב-``mcp_server/limits.py``: גוף בקשה מעל 1MiB (``MCP_MAX_REQUEST_BYTES``) נדחה ב-``413 body_too_large`` במידלוור ASGI, לפני שהטרנספורט מפענח JSON — לפי ``Content-Length`` ואם היא חסרה או מכזבת, לפי מה שבאמת מגיע; וקריאות כלים מוגבלות ל-60 בדקה לזהות (``MCP_RATE_LIMIT_PER_MINUTE``) בנקודת השיגור של הכלים — המקום היחיד שרואה את הזהות גם במצב OAuth — ולפני שנתפס חוט, עם ``rate_limited`` ו-``retry_after_seconds`` בתשובה. המגביל הוא ``rate_limiter.RateLimiter`` הקיים של הבוט, לא עותק. ‏``/healthz`` פטור מבנית משניהם (``blanket-policy-silent-block`` §7), וזה מקובע בטסט. המספרים נגזרו מהמדידות ב-#3431: 0.24 שניות מעבד לעמוד RST עוין של 500KB, 0.47 למסמך ה-Markdown הצפוף, 2.3 לצורה העוינת, מול מכסה של 0.5 מעבד. ראו "גבולות הבקשה" ב-:doc:`mcp-server`. - **feat: ``codekeeper_docs_get_section`` קורא גם קובצי Markdown, ולכל ריפו מדיניות נתיבים משלו.** עד היום הכלי ידע לקרוא ``.rst`` בלבד, ורק מתחת ל-``docs/``. הפארסר של Markdown נכתב ונמדד כבר קודם — ואף אחד לא קרא לו; זה השינוי שמחווט אותו. מעכשיו הסיומת בוחרת את הפארסר, ולכל ריפו מוצהר **איפה התיעוד שלו יושב ובאיזה פורמט**: ``CodeBot`` נשאר ``docs/`` עם ``.rst`` ללא שינוי, ו-``amir-bug-patterns`` מגיש ``.md`` משורש הריפו. **וזו הרחבה מכוונת של גבול הקריאה** — הכלי ציבורי, כלומר כל משתמש מאומת מפעיל אותו בלי אדמין, והריפו שנוסף ציבורי ב-GitHub. מה ש**לא** השתנה: מדיניות סינון הסודות חלה על המסלול הזה במלואה, ולכן ``path="secrets"`` מחזיר ``path_denied`` ולא ``not_found``, וגם ``secrets.md`` ו-``credentials.md`` חסומים; ותקרת 500KB של שירות המראה נשארת ההגנה על הפרסור, כי הכלי קורא את הקובץ כולו בלי ``lines``. **שני שערים ולא אחד:** ``MCP_DOCS_REPO`` מתיר בזמן ריצה, טבלת המדיניות בקוד יודעת לקרוא, וריפו שנמצא במשתנה הסביבה ואין לו מדיניות נדחה ב-``repo_not_configured`` במקום ליפול לברירת מחדל מתירנית. **ובקשה לפורמט שהריפו אינו מגיש נדחית בשמה** — ``suffix_not_allowed`` עם רשימת הסיומות שכן מוגשות, במקום להשלים סיומת בשקט מעל הקיימת ולחזור כ"לא נמצא" על קובץ שקיים. שני סירובים חדשים מגיעים מהפרסור עצמו: ``inconsistent_line_endings`` על ``\r`` בודד (CRLF עובד במלואו), ו-``too_many_sections`` על קובץ שחוצה את תקרת 50,000 הכותרות — Markdown דרך ברירת המחדל של הפרסר, ו-RST דרך התקרה שהכלי מעביר מאז #3429; סירוב שהפארסר מספק לו מספר שורה נושא גם ``line`` — תמיד ב-Markdown, ולא ב-``too_many_sections`` של RST, כי ``rst_parser`` מרים אותה בלי מספר. ``includes`` הוא שדה של RST ולכן בעמוד ``.md`` הוא תמיד ריק. **ומסלול ה-RST לא זז**: אפס דיף מדוד על 5,920 רשומות מ-208 קובצי RST, לפני ואחרי. ראו :ref:`mcp-docs-path-policy`. - **fix: מאגר הקריאות של ה-MCP נגזר ממכסת הזיכרון של הקונטיינר, ולא ממספר המעבדים של המארח (#3391).** כלי הקריאה רצים ב-``asyncio.to_thread``, כלומר ב-executor ברירת המחדל של הלולאה, שגודלו ``min(32, os.cpu_count() + 4)`` — ו-``os.cpu_count()`` בקונטיינר הוא מספר הליבות של **המארח**, כפי ש-CPython מתעד במפורש. שורת הקיבולת מהייצור אמרה את זה במספרים: ``read pool 12 threads (os.cpu_count=8, usable=8)`` על מכסה של ``0.50 cpu``. לקריאת מונגו זה לא מזיק; לפרסור בפייתון טהור אין מקביליות ב-CPU בגלל ה-GIL, ולכן 12 חוטים היו 12 עצי פרסור בזיכרון בו-זמנית — יותר ממה שנכנס ב-512MiB — ורגע לפני שהפרסור הופך לכלי ציבורי. מעכשיו ``attach_read_pool`` מתקין בעליית ה-lifespan של אפליקציית ה-ASGI מאגר משלו, בגודל ``(מגבלת הזיכרון − קו בסיס − שוליים) ÷ עלות פרסור אחד``: המגבלה נקראת מ-cgroup בזמן ריצה (``memory.max`` ב-v2, ‏``memory.limit_in_bytes`` ב-v1) ולא מקובעת בקוד, קו הבסיס והשוליים הם מדידות מהשירות בייצור עם תאריך, ועלות הפרסור נגזרת מ-``MAX_FILE_SIZE_FOR_DISPLAY`` כי הכלי דוחה ``too_large`` לפני שהוא מפרסר. על תוכנית הייצור זה **10** חוטים במקום 12 — עלות הפרסור נמדדה מחדש על הפרסר הנעוץ (``scripts/measure_md_parse_cost.py``; הקבוע נמדד על ``services.md_parser`` כהכנה לכלי ה-Markdown של PR 5, בעוד הכלי הציבורי היום מפרסר RST ב-``services.rst_parser``, שעולה כ-6 בתים לבית גם על העמוד הצפוף ביותר — והסקריפט מודד את שניהם), והיא תלויה בצפיפות הטוקנים של המסמך ולא בגודלו: המסמך הצפוף ביותר בקורפוס הוא הקבוע, וצורה עוינת של שורות-תבליט בודדות עולה פי ארבעה ממנו — מה ששום רוחב מאגר אינו סופג, ומחכה לתקרת טוקנים בפרסר עצמו. **ובעקבות הסקירה:** ``codekeeper_docs_get_section`` מעביר לפרסר את תקרת הסקשנים של האאוטליין (50,000) ומחזיר ``too_many_sections`` במקום לפרסר את הקובץ כולו — צורה עוינת של RST (כותרת בת תו אחד בכל שורה) עלתה 44.0MiB לפרסור, יותר מ-35.2MiB שהמאגר מקצה לחוט, ועכשיו נעצרת ב-20.1MiB; אף עמוד אמיתי אינו מתקרב לתקרה. רצפה של 2 (קריאה תקועה אחת לא משביתה את השאר), תקרה של 12, הרוחב שהייצור כבר הריץ (מעבר לתוכנית גדולה לא מרחיב את המאגר בלי החלטה; הגרסה הראשונה נקבה ב-32, והסקירה של #3429 הורידה אותה כי המחלק מתאר את הפרסור ולא את קריאות הטווח של האדמין; חסימת הקריאה במקור — #3433), וכשאין מגבלה קריאה — הרצפה, **לא** ``cpu_count + 4``, עם WARNING שאומר שזה קרה. **ושורת הקיבולת מפסיקה לחשב בעצמה:** היא נרשמת מתוך ה-lifespan, קוראת את ``_max_workers`` של המאגר שהותקן בפועל, ומדווחת גם את מגבלת הזיכרון לצד מכסת ה-CPU — כי רשומה שמתארת מצב ומתעדכנת בנפרד ממנו היא בדיוק מה שמשקר בשקט. ה-seam הוא עטיפת ``router.lifespan_context``, אותו seam שניקוז ה-PostHog כבר משתמש בו, כי ``FastMCP.streamable_http_app()`` בונה את Starlette עם ``lifespan=`` משלה ו-``on_startup`` אינו רץ. הטסט המרכזי נכנס ל-lifespan של האפליקציה האמיתית ומוכיח שקריאת ``to_thread`` נוחתת על חוט של המאגר החדש — לא שפונקציה נקראה; שורת הקיבולת נבדקת בתת-תהליך נקי דרך הפלט האמיתי ולא דרך ``caplog``. ראו "מודל הריצה של הכלים" ב-:doc:`mcp-server`. - **feat: ``codekeeper_docs_get_section`` מוצא סעיף גם לפי המזהה שלו, ולא רק לפי השם המלא.** הסוכנים מפנים זה לזה לפי מזהה — "קרא K11", "ראו U3" — ועד היום ``section="K11"`` החזיר ``section_not_found`` עם רשימת הצעות **ריקה**, כי ההתאמה הייתה שוויון מלא ו-``difflib`` עם ``cutoff=0.5`` אינו מוצא קרבה בין שלושה תווים לכותרת עברית ארוכה. כלומר הזרימה המרכזית של הכלי לא עבדה. מעכשיו, **ורק אחרי שהשוויון המלא לא מצא כלום**, שאילתה שבנויה כמזהה (עד שלוש אותיות ואחריהן עד שלוש ספרות, עם נקודה אופציונלית) מותאמת לכותרת שנפתחת באותו מזהה. **והמזהה נקרא כיחידה ולא כקידומת של מחרוזת**, ולכן ``K1`` מחזיר את ``K1`` בלבד ולעולם לא את ``K10`` עד ``K15`` — הצורה השבורה של הבדיקה הזאת היא אותה מחלקה בדיוק שבה גבול נתיב נבדק כרצף תווים. שאילתה שאינה בנויה כמזהה אינה עוברת במסלול הזה כלל, ולכן ``section="איך"`` ממשיך להחזיר אפס התאמות גם בעמוד שיש בו חמש-עשרה כותרות שמתחילות במילה הזאת. מזהה שחוזר פעמיים הוא ``ambiguous_section`` עם מועמדים, כמו כל כותרת כפולה, ושאילתת מזהה שלא נמצאה מקבלת ב-``suggestions`` קודם כול כותרת קרובה, אם יש כזו בעמוד, **ורק כשאין אף כותרת קרובה** את המזהים שכן קיימים — עד 50, ועם ``suggestions_truncated`` כשהיו עוד. **והתקרה גבוהה בכוונה**, כי רשימת מזהים אינה מדורגת: לשאלה "אלה שכן קיימים" אין תשובה "החמש הטובות", וחיתוך שלה מסתיר פריטים בלי קריטריון ובלי דרך לבקש את השאר. **והדגל הזה אומר את אותו דבר בשני המסלולים**: גם רשימת מזהים שנחתכה וגם רשימת כותרות קרובות שנחתכה מדליקות אותו. שדה בשם ``truncated`` שמשמעותו "נחתך" במסלול אחד ו"שקט" בשני הוא בדיוק ההפתעה שאנחנו מנסים למנוע. **ונקודה עוד יותר קלה לפספס: הנקודה שפותחת תת-מספור אינה גבול.** בכותרת ``K11. טקסט`` הנקודה **מסיימת** את המזהה, ובכותרת ``K11.1 טקסט`` היא **מפרידה** בתוך מזהה ארוך יותר — ולכן ``K11`` לעולם אינו מחזיר את ``K11.1``, וכותרת בתת-מספור נגישה בשמה המלא בלבד. זו אותה מחלקה בדיוק כמו ``K1`` שתופס את ``K10``: גבול שנבדק כתו בודד, בלי לשאול מה בא אחריו. **ההתנהגות על התיעוד כאן לא זזה, וזה נמדד ולא הונח:** הפלט של הכלי על כל קובצי ה-RST — כל כותרת, בכל צורותיה — זהה בית-בית לפני ואחרי. וההנחה שהכול נשען עליה, שאף כותרת בעמודים כאן אינה נפתחת במזהה, **אינה ספירה שנרשמה פעם אחת אלא טסט שרץ בכל CI**: ``tests/test_docs_headings_carry_no_identifier.py`` סורק את כל העמודים ונופל על כותרת שנפתחת במזהה, עם דוגמת autodoc אמיתית שמוכיחה שהוא מסוגל ליפול. **ומה שלא נבדק בקורפוס הזה נבדק בבקרה סינתטית**, כי סוללה שכל תשובותיה "לא נמצא" אינה מבדילה בין ענף כבוי לבין פרובים עיוורים. שני הדברים שמפתיעים קורא — קיצור המזהה, וכותרת שנושאת בקטיקים ולכן דורשת אותם בשאילתה — עברו לתיאור הפרמטר ``section``, שאינו נחתך אצל הלקוח כמו תיאור הכלי. ראו :ref:`mcp-docs-section`. diff --git a/mcp_server/app.py b/mcp_server/app.py index eaeb72bd6..c3d507193 100644 --- a/mcp_server/app.py +++ b/mcp_server/app.py @@ -51,6 +51,14 @@ ) from .backend import ProductionBackend +from .limits import ( + DEFAULT_MAX_REQUEST_BYTES, + DEFAULT_RATE_LIMIT_PER_MINUTE, + MAX_REQUEST_BYTES_ENV, + MIN_MAX_REQUEST_BYTES, + RATE_LIMIT_ENV, + limit_from_env, +) from .server import build_app from .token_store import MCPTokenStore from .wiring import resolve_mongo @@ -116,6 +124,13 @@ def create_app(): backend = ProductionBackend(db_manager=db_manager, mongo_db=mongo) name = os.getenv("MCP_SERVER_NAME", "CodeKeeper") + # Request limits (#3431) — read here, at the service entry, not at import. + max_request_bytes = limit_from_env( + MAX_REQUEST_BYTES_ENV, DEFAULT_MAX_REQUEST_BYTES, minimum=MIN_MAX_REQUEST_BYTES + ) + rate_limit_per_minute = limit_from_env( + RATE_LIMIT_ENV, DEFAULT_RATE_LIMIT_PER_MINUTE, minimum=1, zero_disables=True + ) # Phase D: admin-only repo-browser tools (hidden + gated for non-admins). from .repo_backend import RepoBackend @@ -148,10 +163,19 @@ def create_app(): consent_routes=consent, repo_backend=repo_backend, name=name, + max_request_bytes=max_request_bytes, + rate_limit_per_minute=rate_limit_per_minute, ) # Fallback: PAT-only (Claude Code/Desktop) — runs without OAuth config. - return build_app(backend, MCPTokenStore(mongo), repo_backend=repo_backend, name=name) + return build_app( + backend, + MCPTokenStore(mongo), + repo_backend=repo_backend, + name=name, + max_request_bytes=max_request_bytes, + rate_limit_per_minute=rate_limit_per_minute, + ) app = create_app() diff --git a/mcp_server/limits.py b/mcp_server/limits.py new file mode 100644 index 000000000..ac7e0b1d3 --- /dev/null +++ b/mcp_server/limits.py @@ -0,0 +1,228 @@ +"""גבולות הבקשה של שרת ה-MCP — תקרת גודל לגוף הבקשה, והגבלת קצב לפי זהות (#3431). + +**מה היה חסר.** המידלוור היחיד שרץ היה ``PATAuthMiddleware`` — אימות ותו לא: כל +משתמש מאומת יכול היה לשלוח בקשות בכל גודל ובכל תדירות. ‏#3428 הוסיף תקרת אורך +ל-``path`` של כלי אחד, אחרי שנמדד שנתיב של 400KB עלה 608ms של מעבד — וזו +הקטנת משטח לבקשה בודדת, לא תחליף לשני הגבולות שכאן. + +**שני גבולות, בשתי שכבות שונות, ולא במקרה.** + +1. **גודל הגוף — במידלוור ASGI, לפני שהטרנספורט מפענח JSON.** הטרנספורט של + ה-SDK (``mcp/server/streamable_http.py``, ‏``_handle_post_request``, ‏mcp 1.28.1) + קורא את הגוף כולו ב-``await request.body()`` ואז ``json.loads`` — כלומר גוף + של 20MB נטען ומפוענח במלואו לפני שאף כלי רואה ארגומנט. התקרה כאן היא לכן + הגבול היחיד על **כל** ארגומנט של **כל** כלי, קיים ועתידי. + + **ולמה חיץ עד התקרה, ולא חריגה מתוך ``receive``.** אותו ``_handle_post_request`` + עוטף את קריאת הגוף ב-``try`` שתופס ``Exception`` ומחזיר שגיאת JSON-RPC משלו — + חריגה שהיינו מרימים מתוך ``receive`` הייתה נבלעת שם והופכת ל"שגיאת שרת" בלי + סיבה. לכן המידלוור קורא בעצמו את הודעות ``http.request`` עד התקרה, מסרב + ב-413 **בעצמו** כשהיא נחצית, ואחרת משדר את מה שכבר נקרא הלאה כמות שהוא. + גוף שמתחת לתקרה ממילא מוחזק בשלמותו על ידי ``request.body()``, ולכן החיץ אינו + עולה זיכרון נוסף. ‏``Content-Length`` שמצהיר על יותר מהתקרה נדחה עוד לפני + שנקרא בית אחד; כותרת חסרה או מכזבת נתפסת בספירה של מה שבאמת מגיע. + הפרוטוקול: הודעות ``http.request`` עם ``body`` ו-``more_body``, ו-``http.disconnect`` + — כפי ש-``starlette/requests.py::Request.stream`` (Starlette 1.6.0) קורא אותן. + +2. **קצב — בנקודת הכניסה של קריאות הכלים, לא במידלוור.** הזהות שהאישו מציע + לספור לפיה ("הטוקן כבר מזוהה במידלוור") קיימת ב-``request.state`` רק במצב PAT; + במצב OAuth — הייצור — ``PATAuthMiddleware`` אינו מותקן כלל, וה-SDK מאמת בתוך + ה-mount של ``/mcp`` (ראו את ה-docstring של ``mcp_server/auth.py``). המקום היחיד + שרואה את הזהות **בשני המצבים** הוא ``current_user_id`` על ה-``Context`` של + הקריאה, ולכן ההכרעה יושבת ב-``AdminAwareFastMCP.call_tool`` — המתודה שה-SDK + רושם כמטפל של ``tools/call`` (``FastMCP._setup_handlers``, ‏mcp 1.28.1), כלומר + נקודה אחת שכל קריאת כלי עוברת בה: גוף סינכרוני לפני שהוא נמסר לחוט, גוף + אסינכרוני לפני שהוא רץ על הלולאה. קריאה שנדחתה אינה עולה עובד. מה שאינו + קריאת כלי — ``initialize``, ‏``tools/list``, זרם ה-SSE — זול ואינו נספר. + + **הפטור לנתיבי הדופק הוא מבני, לא רשימה.** ‏``/healthz`` (וכל נתיב HTTP אחר) + לעולם אינו מגיע לנקודת השיגור של הכלים, ולכן המוניטור החיצוני שדוגם אותו + בקצב קבוע אינו יכול לקבל 429 — המלכודת של ``blanket-policy-silent-block`` §7. + וגם תקרת הגוף אינה נוגעת בו: ל-``GET`` אין גוף. שניהם מקובעים בטסט על + האפליקציה האמיתית, לא על ההיגיון. + +**הסירוב נוקב בסיבתו** — כמו ``path_too_long`` ו-``too_many_sections``: ‏413 עם +``{"error": "body_too_large", "max_bytes": ...}`` באותה צורה של ה-401 ב-``auth.py``, +וקריאת כלי שנחסמה מחזירה ``{"ok": false, "error": "rate_limited", +"limit_per_minute": ..., "retry_after_seconds": ...}`` — תשובת כלי רגילה, כי זה +מה שהסוכן קורא, ועם הזמן שנותר עד שהחלון משתחרר במקום "נסה שוב". + +**המספרים, ומאיפה.** ראו ליד הקבועים. שניהם ניתנים לכיוון במשתני סביבה שנקראים +ב-``create_app`` (בכניסה לשירות, לא בזמן ייבוא), דרך :func:`limit_from_env`, +שלעולם אינו הופך ערך פגום לגבול רחב יותר מברירת המחדל. +""" + +from __future__ import annotations + +import logging +import math +import os +import time +from typing import Any, Awaitable, Callable + +from starlette.datastructures import Headers +from starlette.responses import JSONResponse + +from rate_limiter import RateLimiter + +logger = logging.getLogger(__name__) + +#: תקרת גוף הבקשה בבתים. הארגומנט הלגיטימי הגדול ביותר הוא ``content`` של +#: ``codekeeper_save_file``, שחסום ב-``config.MAX_CODE_SIZE`` — 100,000 תווים +#: (``mcp_server/handlers.py::_max_code_size``). לקוח שמקודד JSON עם +#: ``ensure_ascii`` הופך כל תו עברי ל-``\\uXXXX``, שישה בתים, ולכן הגוף הגדול +#: ביותר שיכול להיות נכון הוא כ-600KB ועוד המעטפת. 1MiB משאיר לזה מרווח, +#: ועדיין נמוך בסדר גודל ומעלה מגוף שהטרנספורט היה מפענח בלי התקרה. +DEFAULT_MAX_REQUEST_BYTES = 1_048_576 + +#: הרצפה לערך ממשתנה הסביבה: תקרה של עשרה בתים היא הפסקת שירות, לא הקשחה — +#: הודעת ``initialize`` לבדה היא כמה מאות בתים. +MIN_MAX_REQUEST_BYTES = 65_536 + +#: קריאות כלים לזהות אחת בדקה. נגזר מהמדידות בתגובה ב-#3431 (2026-09-20): +#: הבקשה הציבורית היקרה ביותר היום היא עמוד RST של 500KB — ‏0.24 שניות מעבד +#: עם תקרת הסקשנים (0.035 לעמוד הצפוף האמיתי); במסלול ה-Markdown (#3428) +#: המסמך הצפוף האמיתי עולה 0.47 שניות והצורה העוינת 2.3; המכסה היא 0.5 מעבד, +#: כלומר 30 שניות-מעבד בדקה. שישים קריאות של המסמך הצפוף האמיתי הן 28 שניות — +#: זהות אחת יכולה לכל היותר למלא דקת מעבד אחת משלה, ולא יותר; קריאה רגילה +#: (10–50ms) הופכת 60 בדקה לאחוז עד שלושה מהמכסה. ומול מאגר של 10 חוטים, +#: 60 בדקה הן קריאה אחת בשנייה לזהות — הוגנות, לא חנק. +DEFAULT_RATE_LIMIT_PER_MINUTE = 60 + +MAX_REQUEST_BYTES_ENV = "MCP_MAX_REQUEST_BYTES" +RATE_LIMIT_ENV = "MCP_RATE_LIMIT_PER_MINUTE" + +BODY_TOO_LARGE = "body_too_large" +RATE_LIMITED = "rate_limited" + + +def limit_from_env(name: str, default: int, *, minimum: int, zero_disables: bool = False) -> int: + """ערך מספרי ממשתנה סביבה, בלי שטעות תצורה תפתח את הגבול. + + ריק או חסר — ברירת המחדל. ערך שאינו מספר שלם — ברירת המחדל, עם WARNING + שאומר מה נקרא (U3: משתנה סביבה הוא קלט חיצוני). ‏``0`` כשמותר לכבות — + כיבוי מפורש, עם WARNING, כי זה מצב שצריך להיראות בלוג העלייה ולא להתגלות + במקרה (K12 §3: בלי ברירת מחדל שקטה שמרחיבה). מתחת למינימום — המינימום, + עם WARNING. בשום מסלול ערך פגום אינו הופך לגבול רחב יותר מברירת המחדל. + """ + raw = (os.getenv(name) or "").strip() + if not raw: + return default + try: + value = int(raw) + except ValueError: + logger.warning("%s=%r is not an integer; using the default %d", name, raw, default) + return default + if value == 0 and zero_disables: + logger.warning("%s=0: this limit is switched off by configuration", name) + return 0 + if value < minimum: + logger.warning("%s=%d is below the minimum %d; using the minimum", name, value, minimum) + return minimum + return value + + +class BodySizeLimitMiddleware: + """מידלוור ASGI טהור: גוף בקשה גדול מ-``max_bytes`` נדחה ב-413 לפני שמישהו מפענח אותו. + + ASGI טהור ולא ``BaseHTTPMiddleware``, כי הוא צריך את ``receive`` עצמו: ההודעות + נקראות כאן, נספרות, ומשודרות הלאה — ו-``BaseHTTPMiddleware`` מסתיר את זה + מאחורי ``Request``. ראו את ה-docstring של המודול על למה חיץ ולא חריגה. + """ + + def __init__(self, app: Any, *, max_bytes: int = DEFAULT_MAX_REQUEST_BYTES) -> None: + self.app = app + self.max_bytes = int(max_bytes) + + async def __call__(self, scope: dict, receive: Callable[[], Awaitable[dict]], send: Any) -> None: + if scope.get("type") != "http": + await self.app(scope, receive, send) + return + + # U3: הכותרת היא קלט חיצוני. ספרות בלבד נחשבות מספר; כל צורה אחרת + # נשארת לשרת ולטרנספורט לדחות, והספירה למטה עדיין תופסת מה שבאמת מגיע. + declared = Headers(scope=scope).get("content-length") + if declared is not None and declared.isdigit() and int(declared) > self.max_bytes: + await self._refuse(scope, receive, send, content_length=int(declared)) + return + + buffered: list[dict] = [] + received = 0 + while True: + message = await receive() + buffered.append(message) + if message.get("type") != "http.request": + break # ``http.disconnect`` — משודר הלאה כמות שהוא + received += len(message.get("body", b"")) + if received > self.max_bytes: + await self._refuse(scope, receive, send, received_bytes=received) + return + if not message.get("more_body", False): + break + + async def replay() -> dict: + if buffered: + return buffered.pop(0) + return await receive() + + await self.app(scope, replay, send) + + async def _refuse(self, scope: dict, receive: Any, send: Any, **detail: int) -> None: + # גודל ונתיב בלבד — לא הגוף ולא הטוקן (K13). + logger.warning( + "mcp request refused: body over %d bytes (%s) on %s", + self.max_bytes, + ", ".join(f"{k}={v}" for k, v in detail.items()), + scope.get("path"), + ) + response = JSONResponse( + {"error": BODY_TOO_LARGE, "max_bytes": self.max_bytes, **detail}, status_code=413 + ) + await response(scope, receive, send) + + +class ToolRateLimiter: + """הגבלת קצב לפי זהות על קריאות כלים, מעל ``rate_limiter.RateLimiter`` הקיים. + + אותו מגביל שהבוט משתמש בו (R6: לא עותק שני של חלון מתגלגל) — בזיכרון, + ללא Redis, וזה מספיק כי שירות ה-MCP רץ במופע אחד (``numInstances: 1``, + ‏``docs/mcp-server.rst``). ‏``per_minute == 0`` פירושו כבוי במפורש. + """ + + def __init__(self, per_minute: int = DEFAULT_RATE_LIMIT_PER_MINUTE) -> None: + self.per_minute = int(per_minute) + self._limiter = RateLimiter(max_per_minute=self.per_minute) if self.per_minute > 0 else None + self._warned_at: dict[int, float] = {} + + @property + def enabled(self) -> bool: + return self._limiter is not None + + async def admit(self, user_id: int) -> dict | None: + """‏``None`` כשהקריאה מותרת; אחרת תשובת הסירוב שהכלי מחזיר במקום לרוץ.""" + if self._limiter is None: + return None + if await self._limiter.check_rate_limit(user_id): + return None + retry_after = await self._limiter.seconds_until_allowed(user_id) + self._warn_once_per_window(user_id, retry_after) + return { + "ok": False, + "error": RATE_LIMITED, + "limit_per_minute": self.per_minute, + "retry_after_seconds": math.ceil(retry_after), + } + + def _warn_once_per_window(self, user_id: int, retry_after: float) -> None: + # סירוב אחד נרשם לכל זהות לכל חלון — סוכן שממשיך לדפוק לא הופך את הלוג לתור. + now = time.monotonic() + last = self._warned_at.get(user_id) + if last is not None and now - last < 60.0: + return + self._warned_at[user_id] = now + logger.warning( + "mcp tool rate limit: identity %s exceeded %d calls/min; refused for the next %.0fs", + user_id, + self.per_minute, + retry_after, + ) diff --git a/mcp_server/server.py b/mcp_server/server.py index ee9094438..730c3d757 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -50,6 +50,14 @@ from . import docs_handlers, handlers, repo_handlers from .handlers import StrictInt, StrictLines +from .limits import ( + BODY_TOO_LARGE, + DEFAULT_MAX_REQUEST_BYTES, + DEFAULT_RATE_LIMIT_PER_MINUTE, + RATE_LIMITED, + BodySizeLimitMiddleware, + ToolRateLimiter, +) from .analytics import attach_shutdown_drain, instrument_mcp_server from .auth import ( PATAuthMiddleware, @@ -203,6 +211,8 @@ def _build_note_color_doc() -> str: # ייבוא היא ``import-time-side-effects``, ותיאור הכלי היה משתנה בין פריסות — # כלומר שני לקוחות היו קוראים שני חוזים שונים לאותו כלי. מה שה-ENV קובע # נאמר בתיאור במילים, בלי למנות ממנו. + + def _build_docs_path_doc() -> str: parts = [] for repo, policy in docs_handlers.DOCS_PATH_POLICY.items(): @@ -1207,6 +1217,14 @@ class AdminAwareFastMCP(FastMCP): doubt (no request context, unauthenticated, lookup error) ⇒ non-admin view. """ + def __init__( + self, *args: Any, tool_rate_limiter: ToolRateLimiter | None = None, **kwargs: Any + ) -> None: + # Set before ``super().__init__`` so the attribute exists whatever the + # SDK's constructor does; ``call_tool`` below reads it on every call. + self._tool_rate_limiter = tool_rate_limiter or ToolRateLimiter(DEFAULT_RATE_LIMIT_PER_MINUTE) + super().__init__(*args, **kwargs) + def add_tool(self, fn: Any, *args: Any, **kwargs: Any) -> Any: # type: ignore[override] """Register a tool, moving a sync body onto a worker thread first. @@ -1226,6 +1244,52 @@ def add_tool(self, fn: Any, *args: Any, **kwargs: Any) -> Any: # type: ignore[o serialize = _declares_write(_annotations_of(args, kwargs)) return super().add_tool(_offload_to_thread(fn, serialize=serialize), *args, **kwargs) + async def call_tool(self, name: str, arguments: dict[str, Any]) -> Any: # type: ignore[override] + """Decide the per-identity rate limit here, and nowhere else (#3431). + + The SDK registers this method as the low-level ``tools/call`` handler + (``FastMCP._setup_handlers`` — ``self._mcp_server.call_tool(...)(self.call_tool)``, + mcp 1.28.1), so every tool call passes through it: sync bodies before + they are handed to a worker, async bodies before they run on the loop. + A refused call therefore costs no thread. And the registered functions + stay exactly what ``add_tool`` built — the routing tests read + ``fn.__code__.co_name`` off them, which an outer wrapper would hide. + + The refusal goes through the tool's own ``convert_result``, so the + client sees the same shape a body's own refusal has + (``{"ok": false, "error": ...}`` as text content), not a transport error. + """ + user_id = self._caller_identity() + if user_id is not None: + refusal = await self._tool_rate_limiter.admit(user_id) + if refusal is not None: + tool = self._tool_manager.get_tool(name) + if tool is not None: + return tool.fn_metadata.convert_result(refusal) + # An unknown name is the SDK's own ToolError, unchanged. + return await super().call_tool(name, arguments) + + def _caller_identity(self) -> int | None: + """Whose budget a call is charged to — or ``None`` when there is nobody to charge. + + Outside a request ``Server.request_context`` raises ``LookupError`` + ("If called outside of a request context, this will raise a + LookupError" — ``mcp/server/lowlevel/server.py``): no client, nothing + to count, and the tool bodies keep answering as they do in the tests + that call them directly. Inside a request the identity is + ``current_user_id`` on the SDK's context — the same function every gate + uses, in both auth modes — and its documented failure, + ``PermissionError``, means the body itself is about to refuse. + """ + try: + self._mcp_server.request_context + except LookupError: + return None + try: + return current_user_id(self.get_context()) + except PermissionError: + return None + async def list_tools(self): # type: ignore[override] tools = await super().list_tools() if self._request_is_admin(): @@ -1270,6 +1334,7 @@ def build_mcp( auth_provider: Any = None, auth_settings: Any = None, repo_backend: Any = None, + rate_limit_per_minute: int = DEFAULT_RATE_LIMIT_PER_MINUTE, ) -> FastMCP: kwargs: dict[str, Any] = { "instructions": _INSTRUCTIONS, @@ -1281,7 +1346,9 @@ def build_mcp( # register) plus the auth layer that calls provider.load_access_token. kwargs["auth_server_provider"] = auth_provider kwargs["auth"] = auth_settings - mcp: FastMCP = AdminAwareFastMCP(name, **kwargs) + mcp: FastMCP = AdminAwareFastMCP( + name, tool_rate_limiter=ToolRateLimiter(rate_limit_per_minute), **kwargs + ) # PostHog MCP analytics. Additive: no tool is changed and no tool schema is # touched. Must run before ``streamable_http_app()`` below, which the same # call also wraps. See ``mcp_server/analytics.py`` for the privacy gate. @@ -2129,6 +2196,8 @@ def build_app( consent_routes: Any = None, repo_backend: Any = None, name: str = "CodeKeeper", + max_request_bytes: int = DEFAULT_MAX_REQUEST_BYTES, + rate_limit_per_minute: int = DEFAULT_RATE_LIMIT_PER_MINUTE, ): """Build the authenticated Streamable-HTTP ASGI app. @@ -2145,6 +2214,7 @@ def build_app( auth_provider=auth_provider if oauth else None, auth_settings=auth_settings if oauth else None, repo_backend=repo_backend, + rate_limit_per_minute=rate_limit_per_minute, ) app = mcp.streamable_http_app() # Starlette app exposing POST/GET /mcp # Drain analytics on ASGI shutdown, before uvicorn's event loop closes. @@ -2174,9 +2244,26 @@ def build_app( auth_provider=auth_provider if oauth else None, ) ) + # Request-body cap for every route, in both auth modes (#3431). Added + # before ``PATAuthMiddleware`` on purpose: ``add_middleware`` inserts at the + # front of the stack (``starlette/applications.py``, Starlette 1.6.0), so the + # middleware added last is the outermost — the PAT check stays outside, and an + # unauthenticated oversized POST is a 401 before it is a 413. In OAuth mode + # the SDK's auth wraps only the ``/mcp`` mount, so here the cap is the + # outermost app-level layer and refuses a 20MB body before any credential + # is looked at. ``/healthz`` is a GET: no body, nothing to cap. + app.add_middleware(BodySizeLimitMiddleware, max_bytes=max_request_bytes) if oauth: for route in consent_routes or []: app.router.routes.append(route) else: app.add_middleware(PATAuthMiddleware, token_store=token_store) + logger.info( + "mcp request limits: body <= %d bytes (413 %s), tool calls <= %s per identity " + "per minute (%s); /healthz sits outside both", + max_request_bytes, + BODY_TOO_LARGE, + rate_limit_per_minute if rate_limit_per_minute > 0 else "unlimited", + RATE_LIMITED, + ) return app diff --git a/rate_limiter.py b/rate_limiter.py index d8abe5271..c8d30c116 100644 --- a/rate_limiter.py +++ b/rate_limiter.py @@ -16,24 +16,32 @@ def __init__(self, max_per_minute: int = 30) -> None: self._requests: Dict[int, List[datetime]] = defaultdict(list) self._lock = asyncio.Lock() + def _live_entries(self, user_id: int, now: datetime) -> List[datetime]: + """הרשומות של המשתמש שעדיין בחלון, אחרי ניקוי הישנות — נקרא תחת המנעול. + + ניקוי אחד לשלוש השאלות (מותר? כמה בשימוש? כמה לחכות?), במקום שלושה + עותקים של אותה לולאה שצריכים להסכים זה עם זה לנצח. הרשומות נשמרות + בסדר הזמן, ולכן הראשונה שעדיין בחלון היא הגבול, ומה שלפניה נמחק + (תיקון off-by-one כאשר כל הערכים פגי-תוקף: אז נמחק הכול). + """ + entries = self._requests[user_id] + one_min_ago = now - timedelta(seconds=60) + delete_upto = len(entries) + for idx, ts in enumerate(entries): + if ts > one_min_ago: + delete_upto = idx + break + if delete_upto > 0: + del entries[:delete_upto] + return entries + async def check_rate_limit(self, user_id: int) -> bool: """מחזיר True אם מותר להמשיך, אחרת False.""" now = datetime.now(timezone.utc) - one_min_ago = now - timedelta(seconds=60) async with self._lock: - entries = self._requests[user_id] - # נקה בקשות ישנות מהחלון (תיקון off-by-one כאשר כל הערכים פגי-תוקף) - delete_upto = len(entries) - for idx, ts in enumerate(entries): - if ts > one_min_ago: - delete_upto = idx - break - if delete_upto > 0: - del entries[:delete_upto] - + entries = self._live_entries(user_id, now) if len(entries) >= self.max_per_minute: return False - entries.append(now) return True @@ -43,18 +51,21 @@ async def get_current_usage_ratio(self, user_id: int) -> float: מנקה ערכים ישנים לפני החישוב כדי לשקף את החלון המתגלגל של 60 שניות. """ now = datetime.now(timezone.utc) - one_min_ago = now - timedelta(seconds=60) async with self._lock: - entries = self._requests.get(user_id, []) - if entries: - delete_upto = len(entries) - for idx, ts in enumerate(entries): - if ts > one_min_ago: - delete_upto = idx - break - if delete_upto > 0: - del entries[:delete_upto] - used = len(self._requests.get(user_id, [])) + used = len(self._live_entries(user_id, now)) limit = max(1, int(self.max_per_minute)) return min(1.0, float(used) / float(limit)) + async def seconds_until_allowed(self, user_id: int) -> float: + """כמה שניות עד שהחלון משחרר מקום — ‏0.0 כשיש מקום כבר עכשיו. + + הרשומה הישנה ביותר בחלון היא שתפוג ראשונה, ואז ``check_rate_limit`` + יאשר שוב; לכן התשובה היא הזמן שנותר לה. זו התשובה שסירוב יכול לשאת + החוצה כ-``retry_after`` במקום "נסה שוב" סתמי. + """ + now = datetime.now(timezone.utc) + async with self._lock: + entries = self._live_entries(user_id, now) + if len(entries) < self.max_per_minute: + return 0.0 + return max(0.0, (entries[0] + timedelta(seconds=60) - now).total_seconds()) diff --git a/services/config_inspector_service.py b/services/config_inspector_service.py index c0507cb90..c8b0cf516 100644 --- a/services/config_inspector_service.py +++ b/services/config_inspector_service.py @@ -1913,6 +1913,26 @@ class ConfigService: description="מרווח בשניות בין מעברי ה-autosync של דפדפן הריפו ב-MCP (מינימום 30).", category="mcp", ), + "MCP_MAX_REQUEST_BYTES": ConfigDefinition( + key="MCP_MAX_REQUEST_BYTES", + services=("mcp",), + default="1048576", + description=( + "תקרת גודל (בתים) לגוף בקשה לשרת ה-MCP, לכל הכלים; גוף גדול יותר נדחה ב-413 " + "body_too_large לפני שמפוענח. מינימום 65536; ערך פגום — ברירת המחדל." + ), + category="mcp", + ), + "MCP_RATE_LIMIT_PER_MINUTE": ConfigDefinition( + key="MCP_RATE_LIMIT_PER_MINUTE", + services=("mcp",), + default="60", + description=( + "קריאות כלים לזהות אחת בדקה בשרת ה-MCP; קריאה מעבר לזה מחזירה rate_limited עם " + "retry_after_seconds. 0 מכבה במפורש (נרשם WARNING בעלייה); /healthz אינו נספר." + ), + category="mcp", + ), "POSTHOG_PROJECT_TOKEN": ConfigDefinition( key="POSTHOG_PROJECT_TOKEN", services=("mcp",), diff --git a/tests/test_mcp_limits.py b/tests/test_mcp_limits.py new file mode 100644 index 000000000..abbcff1ec --- /dev/null +++ b/tests/test_mcp_limits.py @@ -0,0 +1,365 @@ +"""גבולות הבקשה של שרת ה-MCP (#3431): תקרת גוף במידלוור, והגבלת קצב לפי זהות בנקודת השיגור. + +שני הגבולות נבדקים בשכבה שבה הם חיים — המידלוור מול הודעות ASGI ממש, והמגביל +דרך ``call_tool`` של ה-``FastMCP`` האמיתי — ואז על האפליקציה המלאה דרך +``TestClient``, כי הפטור לנתיבי הדופק והסדר מול האימות הם תכונות של ההרכבה +ולא של אף רכיב לבדו. בלי ``sleep``: התקרה והחלון מוקטנים במקום להמתין להם. +""" + +from __future__ import annotations + +import contextlib +import json +import logging + +import pytest + +pytest.importorskip("mcp") + +from mcp.server.fastmcp import Context # noqa: E402 +from mcp.server.lowlevel.server import request_ctx # noqa: E402 +from mcp.shared.context import RequestContext # noqa: E402 +from starlette.requests import ClientDisconnect, Request # noqa: E402 +from starlette.responses import PlainTextResponse # noqa: E402 +from starlette.testclient import TestClient # noqa: E402 + +import mcp_server.server as srv # noqa: E402 +from mcp_server import limits # noqa: E402 +from mcp_server.limits import BodySizeLimitMiddleware, ToolRateLimiter, limit_from_env # noqa: E402 +from mcp_server.server import _READ_ONLY_TOOL, build_app, build_mcp # noqa: E402 +from rate_limiter import RateLimiter # noqa: E402 + + +class _Backend: + """המשטח הקטן ביותר שהכלי הנבדק נוגע בו: ``get_file`` שמחזיר "לא נמצא".""" + + def get_file(self, *args, **kwargs): + return None + + +class _Store: + def verify(self, token): + return {"user_id": 7, "scopes": ["read"]} if token == "good" else None + + +# --------------------------------------------------------------------------- +# המידלוור, מול הודעות ASGI ממש +# --------------------------------------------------------------------------- + + +def _scope(path="/mcp", method="POST", headers=()): + return { + "type": "http", + "method": method, + "path": path, + "headers": [(k.encode(), v.encode()) for k, v in headers], + "query_string": b"", + "server": ("test", 80), + "scheme": "http", + } + + +class _Echo: + """אפליקציה שקוראת את הגוף כמו הטרנספורט — דרך ``Request.body()`` — ורושמת מה ראתה.""" + + def __init__(self): + self.calls = 0 + self.seen = None + + async def __call__(self, scope, receive, send): + self.calls += 1 + self.seen = await Request(scope, receive).body() + await PlainTextResponse("ok")(scope, receive, send) + + +async def _drive(app, scope, messages): + """מריץ את האפליקציה על רצף הודעות נתון; ``receive`` שנקרא מעבר לרצף מפיל את הטסט.""" + queue = list(messages) + sent = [] + + async def receive(): + assert queue, "receive() was called after the last message" + return queue.pop(0) + + async def send(message): + sent.append(message) + + await app(scope, receive, send) + return sent + + +def _status(sent): + return next(m["status"] for m in sent if m["type"] == "http.response.start") + + +def _json_body(sent): + return json.loads(b"".join(m.get("body", b"") for m in sent if m["type"] == "http.response.body")) + + +async def test_a_declared_length_over_the_cap_is_refused_before_a_byte_is_read(): + echo = _Echo() + mw = BodySizeLimitMiddleware(echo, max_bytes=100) + + sent = await _drive(mw, _scope(headers=[("content-length", "101")]), messages=[]) + + assert _status(sent) == 413 + assert _json_body(sent) == {"error": "body_too_large", "max_bytes": 100, "content_length": 101} + assert echo.calls == 0 + + +async def test_a_streamed_body_over_the_cap_is_refused_and_the_app_never_runs(): + """בלי ``Content-Length`` (או עם אחת שמכזבת) — הספירה של מה שבאמת מגיע היא שעוצרת.""" + echo = _Echo() + mw = BodySizeLimitMiddleware(echo, max_bytes=100) + chunks = [ + {"type": "http.request", "body": b"x" * 60, "more_body": True}, + {"type": "http.request", "body": b"y" * 60, "more_body": False}, + ] + + sent = await _drive(mw, _scope(headers=[("content-length", "90")]), chunks) + + assert _status(sent) == 413 + assert _json_body(sent) == {"error": "body_too_large", "max_bytes": 100, "received_bytes": 120} + assert echo.calls == 0 + + +async def test_a_body_under_the_cap_reaches_the_app_intact(): + echo = _Echo() + mw = BodySizeLimitMiddleware(echo, max_bytes=100) + chunks = [ + {"type": "http.request", "body": b"a" * 40, "more_body": True}, + {"type": "http.request", "body": b"b" * 50, "more_body": False}, + ] + + sent = await _drive(mw, _scope(), chunks) + + assert _status(sent) == 200 + assert echo.calls == 1 and echo.seen == b"a" * 40 + b"b" * 50 + + +async def test_exactly_the_cap_passes_and_one_more_byte_does_not(): + at_cap = _Echo() + await _drive(BodySizeLimitMiddleware(at_cap, max_bytes=5), _scope(), + [{"type": "http.request", "body": b"12345", "more_body": False}]) + assert at_cap.seen == b"12345" + + over = _Echo() + sent = await _drive(BodySizeLimitMiddleware(over, max_bytes=5), _scope(), + [{"type": "http.request", "body": b"123456", "more_body": False}]) + assert _status(sent) == 413 and over.calls == 0 + + +async def test_a_disconnect_is_handed_on_to_the_app_unchanged(): + seen = {} + + async def app(scope, receive, send): + with pytest.raises(ClientDisconnect): + await Request(scope, receive).body() + seen["disconnected"] = True + + await _drive(BodySizeLimitMiddleware(app, max_bytes=100), _scope(), [{"type": "http.disconnect"}]) + assert seen == {"disconnected": True} + + +async def test_a_non_http_scope_passes_straight_through(): + seen = {} + + async def app(scope, receive, send): + seen["type"] = scope["type"] + + await BodySizeLimitMiddleware(app, max_bytes=1)({"type": "lifespan"}, None, None) + assert seen == {"type": "lifespan"} + + +# --------------------------------------------------------------------------- +# האפליקציה המלאה: הסדר מול האימות, והפטור המבני לנתיב הדופק +# --------------------------------------------------------------------------- + + +def test_on_the_real_app_the_401_comes_before_the_413_and_the_413_names_its_reason(): + app = build_app(_Backend(), _Store(), max_request_bytes=1000, rate_limit_per_minute=1) + big = b"{" + b" " * 2000 + b"}" + + with TestClient(app) as client: + anonymous = client.post("/mcp", content=big, headers={"content-type": "application/json"}) + assert anonymous.status_code == 401 + + refused = client.post( + "/mcp", content=big, + headers={"content-type": "application/json", "authorization": "Bearer good"}, + ) + assert refused.status_code == 413 + assert refused.json() == {"error": "body_too_large", "max_bytes": 1000, "content_length": len(big)} + + small = client.post( + "/mcp", content=b"{}", + headers={"content-type": "application/json", "authorization": "Bearer good"}, + ) + assert small.status_code not in (413, 500), "a body under the cap must reach the transport" + + +def test_health_is_never_counted_or_capped(): + """הפטור של ``blanket-policy-silent-block`` §7 — מבני: ``/healthz`` אינו קריאת כלי ואין לו גוף.""" + app = build_app(_Backend(), _Store(), max_request_bytes=limits.MIN_MAX_REQUEST_BYTES, rate_limit_per_minute=1) + with TestClient(app) as client: + statuses = {client.get("/healthz").status_code for _ in range(100)} + assert statuses == {200} + + +# --------------------------------------------------------------------------- +# המגביל, דרך call_tool של ה-FastMCP האמיתי +# --------------------------------------------------------------------------- + + +@contextlib.contextmanager +def _inside_a_request(): + """מה שה-SDK עושה לכל ``tools/call``: ``request_ctx`` נקבע לפני שהמטפל נקרא. + + ``Server.request_context`` הוא ``request_ctx.get()`` (``mcp/server/lowlevel/server.py``, + ‏mcp 1.28.1), ומחוץ לבקשה הוא מרים ``LookupError`` — וזה בדיוק הסימן שהמגביל + קורא כדי לדעת שיש מישהו לחייב. ``RequestContext`` נבנה עם ארבעת שדות החובה + שלו (``mcp/shared/context.py``); סשן אינו נדרש, כי איש כאן אינו שולח הודעות. + """ + token = request_ctx.set(RequestContext(request_id=1, meta=None, session=None, lifespan_context=None)) + try: + yield + finally: + request_ctx.reset(token) + + +async def _call(mcp, name="codekeeper_get_file", arguments=None, *, inside_request=True): + with _inside_a_request() if inside_request else contextlib.nullcontext(): + blocks = await mcp.call_tool(name, arguments if arguments is not None else {"file_name": "x"}) + (block,) = blocks + return json.loads(block.text) + + +async def test_a_call_over_the_per_minute_budget_is_refused_with_the_reason_and_the_wait(monkeypatch): + monkeypatch.setattr(srv, "current_user_id", lambda ctx=None: 4242) + mcp = build_mcp(_Backend(), rate_limit_per_minute=2) + + first, second, third = await _call(mcp), await _call(mcp), await _call(mcp) + + assert first == {"found": False} and second == {"found": False} + assert third["ok"] is False and third["error"] == "rate_limited" + assert third["limit_per_minute"] == 2 + assert 0 < third["retry_after_seconds"] <= 60 + + +async def test_each_identity_has_its_own_budget(monkeypatch): + identity = {"id": 1} + monkeypatch.setattr(srv, "current_user_id", lambda ctx=None: identity["id"]) + mcp = build_mcp(_Backend(), rate_limit_per_minute=1) + + assert await _call(mcp) == {"found": False} + assert (await _call(mcp))["error"] == "rate_limited" + identity["id"] = 2 + assert await _call(mcp) == {"found": False}, "a second identity is not charged for the first" + + +async def test_outside_a_request_nothing_is_charged(monkeypatch): + """בלי בקשה אין לקוח לחייב: הטסטים שקוראים לכלים ישירות ממשיכים כמו היום.""" + monkeypatch.setattr(srv, "current_user_id", lambda ctx=None: 4242) + mcp = build_mcp(_Backend(), rate_limit_per_minute=1) + for _ in range(3): + assert await _call(mcp, inside_request=False) == {"found": False} + + +async def test_a_refused_call_does_not_run_the_body(monkeypatch): + monkeypatch.setattr(srv, "current_user_id", lambda ctx=None: 9) + calls = [] + + class _Counting(_Backend): + def get_file(self, *args, **kwargs): + calls.append(1) + return None + + mcp = build_mcp(_Counting(), rate_limit_per_minute=1) + await _call(mcp) + await _call(mcp) + assert len(calls) == 1, "the second call was refused before the body ran" + + +async def test_an_async_tool_is_limited_too(monkeypatch): + """הגוף האסינכרוני אינו עובר ב-``_offload_to_thread``, והמגביל חייב לעטוף גם אותו.""" + monkeypatch.setattr(srv, "current_user_id", lambda ctx=None: 3) + mcp = build_mcp(_Backend(), rate_limit_per_minute=1) + + @mcp.tool(name="probe_async", annotations=_READ_ONLY_TOOL) + async def probe_async(ctx: Context) -> dict: + return {"ok": True} + + assert await _call(mcp, "probe_async", {}) == {"ok": True} + assert (await _call(mcp, "probe_async", {}))["error"] == "rate_limited" + + +async def test_zero_switches_the_limiter_off_explicitly(monkeypatch): + monkeypatch.setattr(srv, "current_user_id", lambda ctx=None: 5) + mcp = build_mcp(_Backend(), rate_limit_per_minute=0) + for _ in range(80): + assert await _call(mcp) == {"found": False} + + +async def test_without_an_identity_nothing_is_charged_and_the_body_answers(monkeypatch): + """בלי זהות (``PermissionError``) המגביל אינו מכריע — הגוף הוא שמסרב, כמו היום.""" + + def _no_identity(ctx=None): + raise PermissionError("unauthenticated") + + monkeypatch.setattr(srv, "current_user_id", _no_identity) + mcp = build_mcp(_Backend(), rate_limit_per_minute=1) + with pytest.raises(Exception, match="unauthenticated"): + await _call(mcp) + with pytest.raises(Exception, match="unauthenticated"): + await _call(mcp) + + +async def test_a_refusal_is_logged_once_per_identity_per_window(caplog): + limiter = ToolRateLimiter(per_minute=1) + with caplog.at_level(logging.WARNING, logger="mcp_server.limits"): + assert await limiter.admit(11) is None + assert (await limiter.admit(11))["error"] == "rate_limited" + assert (await limiter.admit(11))["error"] == "rate_limited" + warnings = [r for r in caplog.records if "rate limit" in r.getMessage()] + assert len(warnings) == 1 + assert "11" in warnings[0].getMessage() + + +# --------------------------------------------------------------------------- +# הגדרה ממשתני סביבה: טעות תצורה לעולם אינה מרחיבה +# --------------------------------------------------------------------------- + + +def test_limit_from_env_never_widens_on_a_bad_value(monkeypatch, caplog): + name = "MCP_LIMIT_UNDER_TEST" + monkeypatch.delenv(name, raising=False) + assert limit_from_env(name, 60, minimum=1) == 60 + + with caplog.at_level(logging.WARNING, logger="mcp_server.limits"): + monkeypatch.setenv(name, "sixty") + assert limit_from_env(name, 60, minimum=1) == 60 + monkeypatch.setenv(name, "10") + assert limit_from_env(name, 1_048_576, minimum=65_536) == 65_536 + monkeypatch.setenv(name, "0") + assert limit_from_env(name, 60, minimum=1, zero_disables=True) == 0 + assert limit_from_env(name, 60, minimum=1) == 1, "0 without zero_disables is below the minimum" + assert sum("not an integer" in r.getMessage() for r in caplog.records) == 1 + assert sum("below the minimum" in r.getMessage() for r in caplog.records) == 2 + assert sum("switched off" in r.getMessage() for r in caplog.records) == 1 + + monkeypatch.setenv(name, "120") + assert limit_from_env(name, 60, minimum=1) == 120 + + +# --------------------------------------------------------------------------- +# ההרחבה של המגביל המשותף +# --------------------------------------------------------------------------- + + +async def test_seconds_until_allowed_is_zero_with_room_and_the_rest_of_the_window_without(): + limiter = RateLimiter(max_per_minute=1) + assert await limiter.seconds_until_allowed(1) == 0.0 + assert await limiter.check_rate_limit(1) is True + wait = await limiter.seconds_until_allowed(1) + assert 0 < wait <= 60 + assert await limiter.check_rate_limit(1) is False From 30f42c6904e9e49b718ecbdb5d64c28348cdf717 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 23:25:17 +0000 Subject: [PATCH 3/9] =?UTF-8?q?fix(parsers):=20rst=5Fparser=20=D7=91=D7=95?= =?UTF-8?q?=D7=93=D7=A7=20=D7=90=D7=AA=20=D7=94=D7=A7=D7=9C=D7=98=20=D7=91?= =?UTF-8?q?=D7=9B=D7=A0=D7=99=D7=A1=D7=94,=20=D7=95=D7=91=D7=A8=D7=99?= =?UTF-8?q?=D7=A8=D7=AA=20=D7=94=D7=9E=D7=97=D7=93=D7=9C=20=D7=A9=D7=9C=20?= =?UTF-8?q?max=5Fsections=20=D7=9E=D7=99=D7=95=D7=A9=D7=A8=D7=AA=20=D7=9C-?= =?UTF-8?q?MAX=5FSECTIONS=20(#3421,=20#3420)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit שני הפארסרים מתועדים כבני-החלפה, ועל קלט שאינו מחרוזת הם התנהגו אחרת: rst_parser.parse_document(None) החזיר מסמך ריק בשקט (מפה ריקה שמתחזה למפה של קובץ בלי כותרות), ו-17 נפל ב-AttributeError גולמי מתוך .replace, בעוד md_parser זרק TypeError שאומר מה התקבל. וברירת המחדל של max_sections הייתה None ב-rst_parser ו-MAX_SECTIONS ב-md_parser, כלומר קורא ששכח להעביר תקרה קיבל הגנה מהפארסר האחד ולא מהשני. - #3421: בדיקת כניסה אחת לשניהם, doc_sections.require_str, שמרימה TypeError עם אותן מילים ("parse_document expects str, got NoneType"). אף קורא לא נשען על הצורה הישנה: המטפל, הסורק והסקריפטים מעבירים תמיד מחרוזת. docs_get_section אינו תופס את החריגה, כמו שלא תפס אותה מ-md_parser: "חוזה נשבר" ולא "הקלט נדחה", ו-content שם תמיד מחרוזת. - #3420, ההכרעה: יישור. MAX_SECTIONS עובר ל-services.doc_sections (המודול המשותף), מיוצא מחדש משני הפארסרים, והוא ברירת המחדל בשניהם. נמדד לפני השינוי על כל 208 קובצי ה-RST ב-docs/: 1,384 סקשנים בסך הכול, הקובץ העשיר ביותר (docs/mcp-server.rst) נושא 50 מול תקרה של 50,000, אחד לאלף, ואף קובץ אינו נחסם. אפס-דיף על docs_get_section: 5,931 רשומות זהות בית-בית לפני ואחרי על אותו קורפוס. - בעקבות היישור docs_get_section אינו מעביר תקרה לאף פארסר (עד עכשיו העביר ל-rst_parser את _ceiling.MAX_SYMBOLS במפורש, כי ברירת המחדל שם הייתה None), ו-"max" בסירוב הוא doc_sections.MAX_SECTIONS, המספר שהפרסר באמת השתמש בו. הייבוא של _ceiling מהמטפל הוסר. - outline_scanners/rst.py ממשיך להעביר את _ceiling.MAX_SYMBOLS במפורש (המספר של המפה, כותרות ותוויות יחד), וטסט חדש מקבע זאת: מוטציה שמוחקת את הארגומנט מפילה אותו. - scripts/measure_md_parse_cost.py: שלוש הצורות רצות עכשיו על ברירת המחדל בשני הפרסרים, כמו הכלי; מתועד בדוקסטרינג, והסקריפט רץ מקצה לקצה. טסטים: 12 טסטים חדשים או שנערכו נופלים על origin/main; הפין של הסורק עובר שם בכוונה. שלוש מוטציות ב-worktree (הסורק בלי הארגומנט, המטפל שמעביר תקרה שוב, require_str שממיר None ל-"") מפילות כל אחת את הטסט שלה. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SfJTSpDAhDr2yhtmpFkwTx --- docs/mcp-server.rst | 14 +-- docs/whats-new.rst | 1 + mcp_server/docs_handlers.py | 37 ++++---- mcp_server/outline_scanners/_ceiling.py | 6 +- mcp_server/outline_scanners/rst.py | 7 ++ scripts/measure_md_parse_cost.py | 5 +- services/doc_sections.py | 104 +++++++++++++++++----- services/md_parser.py | 68 +++++---------- services/rst_parser.py | 46 ++++++---- tests/test_mcp_docs_handlers.py | 109 +++++++++++++----------- tests/test_mcp_outline.py | 27 ++++++ tests/test_md_parser.py | 11 ++- tests/test_rst_parser.py | 62 +++++++++++++- 13 files changed, 328 insertions(+), 169 deletions(-) diff --git a/docs/mcp-server.rst b/docs/mcp-server.rst index e8948f355..f4d744b19 100644 --- a/docs/mcp-server.rst +++ b/docs/mcp-server.rst @@ -1147,11 +1147,15 @@ Claude Desktop לשורה שאי אפשר לבקש גרועה ממפה שאין. **CRLF עובד במלואו.** ``too_many_sections`` - הקובץ חוצה את תקרת הכותרות — 50,000 בשני המסלולים, אבל הדרך שונה: - Markdown דרך ברירת המחדל של ``md_parser`` (``MAX_SECTIONS``), ו-RST דרך - התקרה שהכלי מעביר במפורש (``_ceiling.MAX_SYMBOLS``, מאז #3429), כי - ברירת המחדל של ``rst_parser`` היא ללא תקרה. טסט מחזיק את שני המספרים - שווים. **ומה שאף תקרה כאן אינה עוצרת:** Markdown עוין של שורות-תבליט בלי + הקובץ חוצה את תקרת הכותרות — 50,000 בשני המסלולים, ובאותה דרך: ברירת + המחדל של ``max_sections`` בשני הפארסרים היא ``MAX_SECTIONS`` של + ``services.doc_sections``, והכלי אינו מעביר תקרה משלו. (עד #3420 ברירת + המחדל של ``rst_parser`` הייתה ללא תקרה, והכלי העביר לו את + ``_ceiling.MAX_SYMBOLS`` במפורש מאז #3429; היישור נעשה אחרי מדידה על כל + 208 קובצי ה-RST — הקובץ העשיר ביותר נושא 50 סקשנים, אחד לאלף מהתקרה — + ופלט הכלי על כולם זהה לפני ואחרי.) התקרה של סורק האאוטליין, + ``_ceiling.MAX_SYMBOLS``, היא אותו מספר, וטסט מחזיק את השניים שווים. + **ומה שאף תקרה כאן אינה עוצרת:** Markdown עוין של שורות-תבליט בלי כותרות — 500KB עולים 141MiB ו-2.3 שניות, ו-``MAX_SECTIONS`` סופר כותרות ולכן אינו נוגע בו. הפער הזה פתוח ושייך ל-#3391, לא ל-#3429; ראו "מודל הריצה של הכלים" למעלה. diff --git a/docs/whats-new.rst b/docs/whats-new.rst index ef65c8cd0..d515e8404 100644 --- a/docs/whats-new.rst +++ b/docs/whats-new.rst @@ -4,6 +4,7 @@ What's New 2026-09-20 ---------- +- **fix (parsers): ‏``rst_parser.parse_document`` בודק את הקלט בכניסה, וברירת המחדל של ``max_sections`` מיושרת לזו של ``md_parser`` (#3421, #3420).** עד היום ``rst_parser.parse_document(None)`` החזיר מסמך ריק **בשקט** — מפה ריקה שמתחזה למפה של קובץ בלי כותרות — ו-``17`` נפל ב-``AttributeError`` גולמי, בעוד ``md_parser`` זרק ``TypeError`` שאומר מה התקבל. מעכשיו שני הפארסרים עוברים באותה בדיקה, ``doc_sections.require_str``, ומרימים את אותה חריגה עם אותן מילים; אף קורא לא נשען על הצורה הישנה (המטפל, הסורק והסקריפטים מעבירים תמיד מחרוזת). ובאותו PR ההכרעה על ברירת המחדל: ``max_sections`` ב-``rst_parser`` היה ``None`` (בלי תקרה) כי הפרמטר נוסף לפארסר שכבר רץ בייצור, ואילו ב-``md_parser`` הוא ``MAX_SECTIONS`` — כלומר קורא ששכח להעביר תקרה קיבל הגנה מהפארסר האחד ולא מהשני. עכשיו שניהם על אותה ברירת מחדל, ``MAX_SECTIONS`` שעבר ל-``services.doc_sections`` ומיוצא משני הפארסרים, ו-``codekeeper_docs_get_section`` אינו מעביר תקרה לאף אחד מהם. **נמדד, לא הונח:** על כל 208 קובצי ה-RST ב-``docs/`` הקובץ העשיר ביותר נושא 50 סקשנים מול תקרה של 50,000 (אחד לאלף), ופלט הכלי על כולם — 5,931 רשומות — זהה בית-בית לפני ואחרי. סורק האאוטליין ממשיך להעביר את התקרה שלו במפורש, וטסט מקבע זאת. ראו "שני סירובים שמגיעים מהפרסור עצמו" ב-:doc:`mcp-server`. - **feat: ``codekeeper_docs_get_section`` קורא גם קובצי Markdown, ולכל ריפו מדיניות נתיבים משלו.** עד היום הכלי ידע לקרוא ``.rst`` בלבד, ורק מתחת ל-``docs/``. הפארסר של Markdown נכתב ונמדד כבר קודם — ואף אחד לא קרא לו; זה השינוי שמחווט אותו. מעכשיו הסיומת בוחרת את הפארסר, ולכל ריפו מוצהר **איפה התיעוד שלו יושב ובאיזה פורמט**: ``CodeBot`` נשאר ``docs/`` עם ``.rst`` ללא שינוי, ו-``amir-bug-patterns`` מגיש ``.md`` משורש הריפו. **וזו הרחבה מכוונת של גבול הקריאה** — הכלי ציבורי, כלומר כל משתמש מאומת מפעיל אותו בלי אדמין, והריפו שנוסף ציבורי ב-GitHub. מה ש**לא** השתנה: מדיניות סינון הסודות חלה על המסלול הזה במלואה, ולכן ``path="secrets"`` מחזיר ``path_denied`` ולא ``not_found``, וגם ``secrets.md`` ו-``credentials.md`` חסומים; ותקרת 500KB של שירות המראה נשארת ההגנה על הפרסור, כי הכלי קורא את הקובץ כולו בלי ``lines``. **שני שערים ולא אחד:** ``MCP_DOCS_REPO`` מתיר בזמן ריצה, טבלת המדיניות בקוד יודעת לקרוא, וריפו שנמצא במשתנה הסביבה ואין לו מדיניות נדחה ב-``repo_not_configured`` במקום ליפול לברירת מחדל מתירנית. **ובקשה לפורמט שהריפו אינו מגיש נדחית בשמה** — ``suffix_not_allowed`` עם רשימת הסיומות שכן מוגשות, במקום להשלים סיומת בשקט מעל הקיימת ולחזור כ"לא נמצא" על קובץ שקיים. שני סירובים חדשים מגיעים מהפרסור עצמו: ``inconsistent_line_endings`` על ``\r`` בודד (CRLF עובד במלואו), ו-``too_many_sections`` על קובץ שחוצה את תקרת 50,000 הכותרות — Markdown דרך ברירת המחדל של הפרסר, ו-RST דרך התקרה שהכלי מעביר מאז #3429; סירוב שהפארסר מספק לו מספר שורה נושא גם ``line`` — תמיד ב-Markdown, ולא ב-``too_many_sections`` של RST, כי ``rst_parser`` מרים אותה בלי מספר. ``includes`` הוא שדה של RST ולכן בעמוד ``.md`` הוא תמיד ריק. **ומסלול ה-RST לא זז**: אפס דיף מדוד על 5,920 רשומות מ-208 קובצי RST, לפני ואחרי. ראו :ref:`mcp-docs-path-policy`. - **fix: מאגר הקריאות של ה-MCP נגזר ממכסת הזיכרון של הקונטיינר, ולא ממספר המעבדים של המארח (#3391).** כלי הקריאה רצים ב-``asyncio.to_thread``, כלומר ב-executor ברירת המחדל של הלולאה, שגודלו ``min(32, os.cpu_count() + 4)`` — ו-``os.cpu_count()`` בקונטיינר הוא מספר הליבות של **המארח**, כפי ש-CPython מתעד במפורש. שורת הקיבולת מהייצור אמרה את זה במספרים: ``read pool 12 threads (os.cpu_count=8, usable=8)`` על מכסה של ``0.50 cpu``. לקריאת מונגו זה לא מזיק; לפרסור בפייתון טהור אין מקביליות ב-CPU בגלל ה-GIL, ולכן 12 חוטים היו 12 עצי פרסור בזיכרון בו-זמנית — יותר ממה שנכנס ב-512MiB — ורגע לפני שהפרסור הופך לכלי ציבורי. מעכשיו ``attach_read_pool`` מתקין בעליית ה-lifespan של אפליקציית ה-ASGI מאגר משלו, בגודל ``(מגבלת הזיכרון − קו בסיס − שוליים) ÷ עלות פרסור אחד``: המגבלה נקראת מ-cgroup בזמן ריצה (``memory.max`` ב-v2, ‏``memory.limit_in_bytes`` ב-v1) ולא מקובעת בקוד, קו הבסיס והשוליים הם מדידות מהשירות בייצור עם תאריך, ועלות הפרסור נגזרת מ-``MAX_FILE_SIZE_FOR_DISPLAY`` כי הכלי דוחה ``too_large`` לפני שהוא מפרסר. על תוכנית הייצור זה **10** חוטים במקום 12 — עלות הפרסור נמדדה מחדש על הפרסר הנעוץ (``scripts/measure_md_parse_cost.py``; הקבוע נמדד על ``services.md_parser`` כהכנה לכלי ה-Markdown של PR 5, בעוד הכלי הציבורי היום מפרסר RST ב-``services.rst_parser``, שעולה כ-6 בתים לבית גם על העמוד הצפוף ביותר — והסקריפט מודד את שניהם), והיא תלויה בצפיפות הטוקנים של המסמך ולא בגודלו: המסמך הצפוף ביותר בקורפוס הוא הקבוע, וצורה עוינת של שורות-תבליט בודדות עולה פי ארבעה ממנו — מה ששום רוחב מאגר אינו סופג, ומחכה לתקרת טוקנים בפרסר עצמו. **ובעקבות הסקירה:** ``codekeeper_docs_get_section`` מעביר לפרסר את תקרת הסקשנים של האאוטליין (50,000) ומחזיר ``too_many_sections`` במקום לפרסר את הקובץ כולו — צורה עוינת של RST (כותרת בת תו אחד בכל שורה) עלתה 44.0MiB לפרסור, יותר מ-35.2MiB שהמאגר מקצה לחוט, ועכשיו נעצרת ב-20.1MiB; אף עמוד אמיתי אינו מתקרב לתקרה. רצפה של 2 (קריאה תקועה אחת לא משביתה את השאר), תקרה של 12, הרוחב שהייצור כבר הריץ (מעבר לתוכנית גדולה לא מרחיב את המאגר בלי החלטה; הגרסה הראשונה נקבה ב-32, והסקירה של #3429 הורידה אותה כי המחלק מתאר את הפרסור ולא את קריאות הטווח של האדמין; חסימת הקריאה במקור — #3433), וכשאין מגבלה קריאה — הרצפה, **לא** ``cpu_count + 4``, עם WARNING שאומר שזה קרה. **ושורת הקיבולת מפסיקה לחשב בעצמה:** היא נרשמת מתוך ה-lifespan, קוראת את ``_max_workers`` של המאגר שהותקן בפועל, ומדווחת גם את מגבלת הזיכרון לצד מכסת ה-CPU — כי רשומה שמתארת מצב ומתעדכנת בנפרד ממנו היא בדיוק מה שמשקר בשקט. ה-seam הוא עטיפת ``router.lifespan_context``, אותו seam שניקוז ה-PostHog כבר משתמש בו, כי ``FastMCP.streamable_http_app()`` בונה את Starlette עם ``lifespan=`` משלה ו-``on_startup`` אינו רץ. הטסט המרכזי נכנס ל-lifespan של האפליקציה האמיתית ומוכיח שקריאת ``to_thread`` נוחתת על חוט של המאגר החדש — לא שפונקציה נקראה; שורת הקיבולת נבדקת בתת-תהליך נקי דרך הפלט האמיתי ולא דרך ``caplog``. ראו "מודל הריצה של הכלים" ב-:doc:`mcp-server`. - **feat: ``codekeeper_docs_get_section`` מוצא סעיף גם לפי המזהה שלו, ולא רק לפי השם המלא.** הסוכנים מפנים זה לזה לפי מזהה — "קרא K11", "ראו U3" — ועד היום ``section="K11"`` החזיר ``section_not_found`` עם רשימת הצעות **ריקה**, כי ההתאמה הייתה שוויון מלא ו-``difflib`` עם ``cutoff=0.5`` אינו מוצא קרבה בין שלושה תווים לכותרת עברית ארוכה. כלומר הזרימה המרכזית של הכלי לא עבדה. מעכשיו, **ורק אחרי שהשוויון המלא לא מצא כלום**, שאילתה שבנויה כמזהה (עד שלוש אותיות ואחריהן עד שלוש ספרות, עם נקודה אופציונלית) מותאמת לכותרת שנפתחת באותו מזהה. **והמזהה נקרא כיחידה ולא כקידומת של מחרוזת**, ולכן ``K1`` מחזיר את ``K1`` בלבד ולעולם לא את ``K10`` עד ``K15`` — הצורה השבורה של הבדיקה הזאת היא אותה מחלקה בדיוק שבה גבול נתיב נבדק כרצף תווים. שאילתה שאינה בנויה כמזהה אינה עוברת במסלול הזה כלל, ולכן ``section="איך"`` ממשיך להחזיר אפס התאמות גם בעמוד שיש בו חמש-עשרה כותרות שמתחילות במילה הזאת. מזהה שחוזר פעמיים הוא ``ambiguous_section`` עם מועמדים, כמו כל כותרת כפולה, ושאילתת מזהה שלא נמצאה מקבלת ב-``suggestions`` קודם כול כותרת קרובה, אם יש כזו בעמוד, **ורק כשאין אף כותרת קרובה** את המזהים שכן קיימים — עד 50, ועם ``suggestions_truncated`` כשהיו עוד. **והתקרה גבוהה בכוונה**, כי רשימת מזהים אינה מדורגת: לשאלה "אלה שכן קיימים" אין תשובה "החמש הטובות", וחיתוך שלה מסתיר פריטים בלי קריטריון ובלי דרך לבקש את השאר. **והדגל הזה אומר את אותו דבר בשני המסלולים**: גם רשימת מזהים שנחתכה וגם רשימת כותרות קרובות שנחתכה מדליקות אותו. שדה בשם ``truncated`` שמשמעותו "נחתך" במסלול אחד ו"שקט" בשני הוא בדיוק ההפתעה שאנחנו מנסים למנוע. **ונקודה עוד יותר קלה לפספס: הנקודה שפותחת תת-מספור אינה גבול.** בכותרת ``K11. טקסט`` הנקודה **מסיימת** את המזהה, ובכותרת ``K11.1 טקסט`` היא **מפרידה** בתוך מזהה ארוך יותר — ולכן ``K11`` לעולם אינו מחזיר את ``K11.1``, וכותרת בתת-מספור נגישה בשמה המלא בלבד. זו אותה מחלקה בדיוק כמו ``K1`` שתופס את ``K10``: גבול שנבדק כתו בודד, בלי לשאול מה בא אחריו. **ההתנהגות על התיעוד כאן לא זזה, וזה נמדד ולא הונח:** הפלט של הכלי על כל קובצי ה-RST — כל כותרת, בכל צורותיה — זהה בית-בית לפני ואחרי. וההנחה שהכול נשען עליה, שאף כותרת בעמודים כאן אינה נפתחת במזהה, **אינה ספירה שנרשמה פעם אחת אלא טסט שרץ בכל CI**: ``tests/test_docs_headings_carry_no_identifier.py`` סורק את כל העמודים ונופל על כותרת שנפתחת במזהה, עם דוגמת autodoc אמיתית שמוכיחה שהוא מסוגל ליפול. **ומה שלא נבדק בקורפוס הזה נבדק בבקרה סינתטית**, כי סוללה שכל תשובותיה "לא נמצא" אינה מבדילה בין ענף כבוי לבין פרובים עיוורים. שני הדברים שמפתיעים קורא — קיצור המזהה, וכותרת שנושאת בקטיקים ולכן דורשת אותם בשאילתה — עברו לתיאור הפרמטר ``section``, שאינו נחתך אצל הלקוח כמו תיאור הכלי. ראו :ref:`mcp-docs-section`. diff --git a/mcp_server/docs_handlers.py b/mcp_server/docs_handlers.py index 8f13e5b8a..e5de0bdf2 100644 --- a/mcp_server/docs_handlers.py +++ b/mcp_server/docs_handlers.py @@ -25,7 +25,6 @@ from services import doc_sections, md_parser, rst_parser from .handlers import _clamp -from .outline_scanners import _ceiling logger = logging.getLogger(__name__) @@ -387,16 +386,13 @@ def docs_get_section( # ``resolved.suffix`` ולא גזירה שנייה מ-``file_path``: ראו :class:`_ResolvedPath`. parser = _PARSERS[resolved.suffix] # לעולם לא KeyError: ראו _validate_policy_tables - # **תקרת הסקשנים עוברת ל-RST בלבד, ו-Markdown נשאר על ברירת המחדל של - # הפרסר שלו.** שני המסלולים מוגבלים לאותו מספר — ``_ceiling.MAX_SYMBOLS`` - # ו-``md_parser.MAX_SECTIONS`` שניהם 50,000, וטסט קושר ביניהם — אבל הדרך - # שונה בכוונה: ל-``rst_parser`` ברירת המחדל היא ``None`` (קוראים אחרים - # שלו אינם מוגנים ויודעים זאת), ולכן הכלי מעביר את התקרה במפורש; ל- - # ``md_parser`` ברירת המחדל **היא** התקרה, והעברה מפורשת מכאן הייתה עותק - # שני של החלטה ש-PR הפארסר כבר הכריע. ``parser is rst_parser`` שואל על - # הפרסר ולא על הסיומת — זו תכונה של המודול, לא מקום שלישי שבו סמנטיקת - # הסיומת חיה. התקרה נקראת מהמודול **בזמן הקריאה**, כדי שהטסט יוכל - # להקטין אותה במקום להציף אותה. + # **שני הפרסרים רצים על ברירת המחדל שלהם, והכלי אינו מעביר תקרה.** + # ברירת המחדל של ``max_sections`` בשניהם היא ``doc_sections.MAX_SECTIONS`` + # (50,000; מיושרת מאז #3420 — בין #3429 ל-#3420 ברירת המחדל של + # ``rst_parser`` הייתה ``None`` והכלי העביר לו את ``_ceiling.MAX_SYMBOLS`` + # במפורש). העברה מפורשת מכאן הייתה עותק שני של החלטה שהפארסר כבר הכריע, + # ו-``parser is rst_parser`` היה מקום שלישי שבו סמנטיקת הסיומת חיה. הטסט + # שמקטין את התקרה עושה זאת בפרסר (``functools.partial``), לא כאן. # # מה שהתקרה עוצרת ב-RST (סקירת #3429): כותרת בת תו אחד בכל שורה ב-500KB # עולה 44.0MiB לפרסור אחד — יותר מ-35.2MiB שמאגר הקריאות מקצה לחוט @@ -406,7 +402,6 @@ def docs_get_section( # **ומה שאף תקרה כאן אינה עוצרת, ונשאר פתוח:** Markdown עוין של שורות- # תבליט בלי כותרות — 500KB עולים 141MiB ו-2.3 שניות, ו-``MAX_SECTIONS`` # סופר כותרות ולכן אינו נוגע בו. זה אישו #3391 ולא #3429. - parse_kwargs = {"max_sections": _ceiling.MAX_SYMBOLS} if parser is rst_parser else {} # **ה-``try`` הזה אינו ``K11``, וזה נכתב כדי שסקירה עתידית לא תגזור זאת # מחדש.** ``parse_document`` מתועד כ"ערוץ הכשל הוא חריגה בלבד": הוא אינו @@ -419,10 +414,11 @@ def docs_get_section( # מייצא אותה, וייצוא משם היה מצהיר על סירוב שאינו קיים. התפיסה אינה # מותנית במי שפרסר, כי תנאי כזה היה רשימה שנייה לסנכרן. # - # **ומה שלא נתפס כאן, בכוונה:** ``TypeError`` ו-``RuntimeError`` של - # ``md_parser``. שניהם אומרים "חוזה נשבר" ולא "הקלט נדחה", ועטיפתם - # הייתה בדיוק ``widened-exception-scope``. ``content`` הוא תמיד מחרוזת - # במסלול הזה, כי ``binary`` ו-``too_large`` נחסמו למעלה. + # **ומה שלא נתפס כאן, בכוונה:** ``TypeError`` של שני הפרסרים (מאז #3421 + # גם ``rst_parser`` מרים אותה, דרך ``doc_sections.require_str``) ו- + # ``RuntimeError`` של ``md_parser``. שניהם אומרים "חוזה נשבר" ולא "הקלט + # נדחה", ועטיפתם הייתה בדיוק ``widened-exception-scope``. ``content`` הוא + # תמיד מחרוזת במסלול הזה, כי ``binary`` ו-``too_large`` נחסמו למעלה. # # תקרת המקביליות על הפרסור אינה כאן ואינה צריכה להיות: היא נגזרת מגודל # מאגר הקריאות, ומקומה ב-lifespan של השרת — נחת ב-#3429. @@ -436,17 +432,16 @@ def docs_get_section( # ``_line_of`` ולא ``exc.args[0]`` ישירות: חריגה שתיבנה מחר בלי # ארגומנט לא תפיל כאן ``IndexError`` באמצע בקשה. # - # ``"max"`` הוא ``_ceiling.MAX_SYMBOLS`` בשני המסלולים, וזה נכון ל-Markdown - # רק מפני שהטסט שקושר את שתי התקרות מחזיק אותן שוות — בלעדיו המספר - # שמדווח על סירוב Markdown היה יכול להיות תקרה של פרסר אחר. + # ``"max"`` הוא ``doc_sections.MAX_SECTIONS`` — המספר שהפרסר באמת השתמש + # בו, בשני המסלולים, ולא עותק שלו ממודול אחר. try: - doc = parser.parse_document(content, **parse_kwargs) + doc = parser.parse_document(content) except doc_sections.InconsistentLineEndings as exc: return {"ok": False, "error": "inconsistent_line_endings", **context, **_line_of(exc)} except doc_sections.TooManySections as exc: return {"ok": False, "error": "too_many_sections", - "max": _ceiling.MAX_SYMBOLS, **context, **_line_of(exc)} + "max": doc_sections.MAX_SECTIONS, **context, **_line_of(exc)} toc_items, toc_truncated = _toc(doc) diff --git a/mcp_server/outline_scanners/_ceiling.py b/mcp_server/outline_scanners/_ceiling.py index ec29818e5..94606a6a8 100644 --- a/mcp_server/outline_scanners/_ceiling.py +++ b/mcp_server/outline_scanners/_ceiling.py @@ -62,9 +62,9 @@ #: **מי שמשנה את המספר צריך למדוד מחדש את שתיהן.** העלאה בלי המדידה #: הראשונה מחזירה את מחלקת הכשל; הורדה בלי השנייה חוסמת קובץ אמיתי. #: -#: **ויש כאן עותק שני של אותו מספר:** ``services/md_parser.py::MAX_SECTIONS``. -#: הוא אינו מיובא מכאן, כי ``services`` אינו מייבא מ-``mcp_server``, ולכן -#: הערך מוקלד פעמיים. מה שמונע סחיפה הוא +#: **ויש כאן עותק שני של אותו מספר:** ``services/doc_sections.py::MAX_SECTIONS``, +#: ברירת המחדל של ``max_sections`` בשני הפארסרים. הוא אינו מיובא מכאן, כי +#: ``services`` אינו מייבא מ-``mcp_server``, ולכן הערך מוקלד פעמיים. מה שמונע סחיפה הוא #: ``tests/test_md_parser.py::test_the_two_ceilings_are_the_same_number``, #: שקורא את שניהם ומשווה — כלומר שינוי כאן לבדו מפיל את ה-CI. MAX_SYMBOLS = 50_000 diff --git a/mcp_server/outline_scanners/rst.py b/mcp_server/outline_scanners/rst.py index 1b6abec6f..38ec5b48c 100644 --- a/mcp_server/outline_scanners/rst.py +++ b/mcp_server/outline_scanners/rst.py @@ -120,6 +120,13 @@ def extract(text: str) -> dict[str, Any]: קו בלי כותרת, בלוק שלא נסגר — והוא מחזיר את מה שכן זיהה. זה נמדד על כל קובצי ה-RST בריפו ועל שתי טבלאות צורות. לכן אין כאן ``except`` רחב: באג שלנו חייב ליפול בקול ולא להתחפש ל"אין אאוטליין" על קובץ תקין. + (``TypeError`` על ``text`` שאינו מחרוזת, מאז #3421, הוא בדיוק כזה: + ``text`` כאן תמיד מחרוזת מפוענחת, ולכן זה חוזה שנשבר ולא קלט פגום.) + + **והתקרה מועברת כאן במפורש — ``_ceiling.MAX_SYMBOLS`` — ולא נשענת על + ברירת המחדל של הפארסר**, גם אחרי ש-#3420 יישר אותה לאותו מספר: המספר + כאן הוא של המפה (כותרות ותוויות יחד, ``Capped``), והסורק אומר אותו + בעצמו. טסט מקבע שהקריאה נושאת אותו. התקרה נוסעת פנימה כחריגה ויוצאת כאן כערך החזרה — הנימוק ב- ``_ceiling.TooManySymbols`` — וה-``except`` תופס **את שתי החריגות diff --git a/scripts/measure_md_parse_cost.py b/scripts/measure_md_parse_cost.py index f064db5d2..866350b54 100644 --- a/scripts/measure_md_parse_cost.py +++ b/scripts/measure_md_parse_cost.py @@ -25,7 +25,10 @@ לתקרת הקריאה ``MAX_FILE_SIZE_FOR_DISPLAY``); המסמך הצפוף ביותר בו משוכפל עד התקרה — זה המועמד לקבוע, כי זה הגרוע ביותר שהכלי באמת מגיש; וצורה עוינת (Markdown: שורות-תבליט בודדות, RST: כותרת בת תו אחד בכל שורה) שהיא הגבול - העליון של הפרסר ושהקבוע במפורש אינו מכסה. ל-RST נמדד גם מסלול ה-outline + העליון של הפרסר ושהקבוע במפורש אינו מכסה. שלוש הצורות רצות על ברירת + המחדל של ``max_sections`` — ``MAX_SECTIONS`` בשני הפרסרים מאז #3420 — כלומר + בדיוק כמו הכלי; הצורה העוינת של RST נעצרת לכן על התקרה, והמחיר בלי + התקרה נמדד רק אם מעבירים ``max_sections=None`` במפורש. ל-RST נמדד גם מסלול ה-outline של ``codekeeper_get_repo_file``: המסמך הצפוף ביותר משוכפל עד ``RANGE_READ_MAX_BYTES`` ומפורסר עם ``max_sections`` של ``MAX_SYMBOLS``, כי זו ההחזקה הגדולה ביותר שחוט קריאה נושא היום, והיא גדולה מעלות הפרסור diff --git a/services/doc_sections.py b/services/doc_sections.py index e8bc87f79..43edc8244 100644 --- a/services/doc_sections.py +++ b/services/doc_sections.py @@ -29,29 +29,35 @@ ``services/rst_parser.py`` ו-``services/md_parser.py``. שניהם מייצאים מחדש את השמות שכאן, ולכן ``rst_parser.Section`` ו-``md_parser.Section`` הם **אותו אובייקט מחלקה** — וזה מה שמאפשר לצרכן לבחור מודול לפי סיומת -ולקרוא לאותם שמות. **בדיוק אותם שמות — למעט שניים:** ``md_parser`` -מייצא גם ``MAX_SECTIONS`` ו-``InconsistentLineEndings``, ש-``rst_parser`` -אינו מרים ואינו מגדיר. חריגות הסירוב נתפסות לכן דרך המודול הזה, לא דרך -הפארסר — ראו את ה-docstring של :class:`InconsistentLineEndings`. - -**ומה שהם כן עושים אחרת, כי זה נראה כמו סתירה למי שלא יודע:** ברירת -המחדל של ``max_sections``. ב-``rst_parser`` היא ``None`` וב-``md_parser`` -היא התקרה, וההבדל מנומק בשני ה-docstrings. +ולקרוא לאותם שמות. **בדיוק אותם שמות — למעט אחד:** ``md_parser`` מייצא +גם ``InconsistentLineEndings``, ש-``rst_parser`` אינו מרים ואינו מגדיר. +חריגות הסירוב נתפסות לכן דרך המודול הזה, לא דרך הפארסר — ראו את +ה-docstring של :class:`InconsistentLineEndings`. + +**ואותה ברירת מחדל ל-``max_sections`` בשניהם — :data:`MAX_SECTIONS`, שמוגדר +כאן.** עד #3420 היא הייתה ``None`` ב-``rst_parser`` — מה ששמר על +``docs_get_section`` זהה בית-בית כשהפרמטר נוסף לפארסר שכבר רץ בייצור — ו- +``MAX_SECTIONS`` ב-``md_parser``, כלומר קורא ששכח להעביר תקרה קיבל הגנה +מהפארסר האחד ולא מהשני. היישור נעשה אחרי מדידה: הקובץ העשיר ביותר ב-``docs/`` +נושא 50 סקשנים מול תקרה של 50,000, ופלט ``docs_get_section`` על כל 208 קובצי +ה-RST זהה לפני ואחרי. ההיגיון של ברירת המחדל כתוב ליד הקבוע. **ומי הבעלים של שני ערוצי הסירוב: המתקשר.** ``mcp_server/docs_handlers.py`` -בוחר פארסר לפי סיומת, מעביר ל-``rst_parser.parse_document`` את תקרת הסקשנים -של הסורק (מאז #3429) ומשאיר את ``md_parser`` על ברירת המחדל שלו, ו**תופס את -שתי החריגות** — ``TooManySections`` ו-``InconsistentLineEndings`` — מהמודול -הזה ולא מהפארסר, כך שהתפיסה אינה מותנית במי שפרסר. שתיהן הופכות לתשובת -``error`` של MCP עם מספר השורה שהן נושאות. ההמרה הזאת היא עבודה של המתקשר -— לא של המודול הזה, שאינו יודע דבר על MCP. - -**ובדיקת הכניסה עצמה עדיין אינה זהה בין השניים.** נמדד: -``rst_parser.parse_document(None)`` מחזיר ``Document`` ריק **בשקט**, -ו-``rst_parser.parse_document(17)`` זורק ``AttributeError`` גולמי; שניהם -ב-``md_parser`` הם ``TypeError`` שאומר מה התקבל. הפארסר החדש הוא זה -שמתנהג נכון, ולכן אין כאן מה להחליש — אבל יישור של הוותיק הוא שינוי -התנהגות על מסלול חי, ולכן הוא **מתועד באישו #3421** ואינו נעשה כאן. +בוחר פארסר לפי סיומת, משאיר את שניהם על ברירת המחדל שלהם (בין #3429 ל-#3420 +הוא העביר ל-``rst_parser`` את תקרת הסורק במפורש, כי ברירת המחדל שם הייתה +``None``), ו**תופס את שתי החריגות** — ``TooManySections`` ו- +``InconsistentLineEndings`` — מהמודול הזה ולא מהפארסר, כך שהתפיסה אינה +מותנית במי שפרסר. שתיהן הופכות לתשובת ``error`` של MCP עם מספר השורה שהן +נושאות. ההמרה הזאת היא עבודה של המתקשר — לא של המודול הזה, שאינו יודע דבר +על MCP. + +**ובדיקת הכניסה זהה בשניהם — :func:`require_str`, שמוגדרת כאן.** עד #3421 +``rst_parser.parse_document(None)`` החזיר ``Document`` ריק **בשקט** — מפה +ריקה שמתחזה למפה של קובץ בלי כותרות — ו-``rst_parser.parse_document(17)`` +זרק ``AttributeError`` גולמי, בעוד ``md_parser`` זרק ``TypeError`` שאומר +מה התקבל. עכשיו שניהם עוברים באותה בדיקה ומרימים את אותה חריגה עם אותן +מילים; אף קורא לא נשען על הצורה הישנה (נבדק: המטפל, הסורק והסקריפטים +מעבירים תמיד מחרוזת). **ייבוא קל בלבד** — ``re``, ``dataclasses``, ``difflib``, ``typing``. הכלל מנומק ב-``mcp_server/__init__.py``: ``import database.schemas`` מריץ @@ -68,6 +74,57 @@ from difflib import get_close_matches from typing import List, NamedTuple, Optional, Tuple +#: מקסימום יחידות עבודה לפרסור אחד, ומעליו הפרסור נעצר ב-:class:`TooManySections`. +#: זו **ברירת המחדל** של ``max_sections`` בשני הפארסרים (מאז #3420), ו-``None`` +#: מכבה אותה במפורש. +#: +#: **מה נספר תלוי בפארסר, וזה ההבדל שצריך להכיר.** ב-``rst_parser`` נספרים +#: סקשנים; ב-``md_parser`` נספרת כל **כותרת** שהפרסור נתקל בה, כולל כותרת +#: בתוך ציטוט או בתוך פריט רשימה שאינה נכנסת למפה בכלל. הקבוע מגביל +#: **עבודה** ולא **תוצאה**: מה שמייקר הוא הכותרת עצמה, ולא השאלה אם היא +#: נספרת כסעיף בסוף. נמדד: 500KB של ``> ## h`` הם ~64,000 כותרות שאף +#: אחת מהן אינה סעיף, וספירה לפי ``level == 0`` הייתה רואה בהן אפס ולא +#: עוצרת לעולם. הטענה "לא יותר מ-``MAX_SECTIONS`` סעיפים" נשארת נכונה — היא +#: פשוט לא כל מה שהקבוע אוכף. שם מדויק יותר לקבוע הוא שינוי בפני עצמו +#: והוא אישו נפרד. +#: +#: **ולמה ברירת מחדל ולא ``None``:** קורא ששכח להעביר תקרה מקבל הגנה ולא +#: את היעדרה — אותו כלל fail-closed שכבר מנומק ב-``_declares_write`` +#: וב-``_ceiling.py``. המחיר שלה על קלט אמיתי הוא אפס: הקובץ העשיר ביותר +#: ב-``docs/`` נושא 50 סקשנים, אחד לאלף מהתקרה (נמדד על כל 208 קובצי +#: ה-RST, 2026-09-20), ופלט ``docs_get_section`` על כולם זהה בית-בית +#: לפני היישור ואחריו. +#: +#: **אותו ערך בדיוק כמו ``MAX_SYMBOLS`` ב- +#: ``mcp_server/outline_scanners/_ceiling.py``**, ושם כתובות שתי +#: המדידות שקבעו אותו — השיא שהתקרה מרשה, והיחס לקובץ אמיתי. מי +#: שמשנה את אחד מהשניים צריך לפתוח את השני: הם מתארים את אותו גבול על +#: אותה מפה, וסורק האאוטליין מעביר את זה במקום את זה. השוויון +#: מקובע ב-``tests/test_md_parser.py::test_the_two_ceilings_are_the_same_number``. +#: +#: ``_ceiling.MAX_SYMBOLS`` אינו מיובא לכאן, כי ``services`` אינו מייבא +#: מ-``mcp_server`` — הכיוון חד-סטרי ומנומק ב-``TooManySections``. +MAX_SECTIONS = 50_000 + + +def require_str(text: object) -> str: + """בדיקת הכניסה של שני הפארסרים: ``text`` חייב להיות מחרוזת. + + הטקסט מגיע מחוץ לתהליך (קובץ מהמראה, גוף בקשה), ו-``.replace`` על ערך + שאינו מחרוזת היה מפיל ``AttributeError`` ממקום שלא מסביר כלום — או, גרוע + מזה, ``None`` שהופך ל-``""`` ומחזיר מפה ריקה שמתחזה למפה של קובץ בלי + כותרות (זו הצורה שהייתה ב-``rst_parser`` עד #3421). החריגה אומרת מה + התקבל, כי זה מה שמבדיל בין "הקלט פסול" ל"הקוד הקורא שבור". + + **פונקציה אחת לשני הפארסרים ולא שתי שורות זהות בכל אחד**, כדי שהמילים + יישארו אותן מילים: ``TypeError`` היא חוזה שהמטפל מצהיר עליו ("חוזה + נשבר" ולא "הקלט נדחה", ולכן אינה נתפסת ב-``docs_get_section``), ושני + עותקים של אותה בדיקה הם מה שנסחף בתיקון הבא. + """ + if not isinstance(text, str): + raise TypeError(f"parse_document expects str, got {type(text).__name__}") + return text + @dataclass class Section: @@ -126,11 +183,12 @@ class TooManySections(Exception): סקשנים, וב-``md_parser`` נספרות **כותרות** — כולל כותרת בתוך ציטוט או פריט רשימה, שאינה נכנסת למפה בכלל. בשני המקרים התקרה מגבילה את העבודה שהפרסור עושה, ובמסלול ה-Markdown כותרת בתוך מכל עולה בדיוק - כמו כל אחרת — ההנמקה המלאה ב-``services/md_parser.py::MAX_SECTIONS``. + כמו כל אחרת — ההנמקה המלאה ליד :data:`MAX_SECTIONS` למעלה. המחלקה אחת כי מה שהקורא צריך לדעת זהה: הקלט גדול מדי, ואין מפה. **חריגה, ולא ערך החזרה ולא ``Document`` חלקי עם דגל "נקטע".** המתקשר - היחיד שמעביר תקרה הוא סורק האאוטליין, ושם הכלל כבר נקבע ומנומק + היחיד שמעביר תקרה במפורש הוא סורק האאוטליין (השאר על ברירת המחדל), + ושם הכלל כבר נקבע ומנומק ב-``mcp_server/outline_scanners/_ceiling.py``: מפה חלקית שמתחזה למלאה היא בדיוק הכשל שהתקרה קיימת כדי למנוע. דגל דורש בדיקה בכל אתר קריאה, ובדיקה אחת שנשכחת מחזירה תוכן עניינים שחסרות בו כותרות בלי diff --git a/services/md_parser.py b/services/md_parser.py index cf5ca530d..1bb56cbf7 100644 --- a/services/md_parser.py +++ b/services/md_parser.py @@ -61,6 +61,7 @@ from mdit_py_plugins.front_matter import front_matter_plugin from .doc_sections import ( + MAX_SECTIONS, Document, InconsistentLineEndings, Section, @@ -72,6 +73,7 @@ find_sections, neighbors, normalize_title, + require_str, section_bounds, section_text, suggest, @@ -86,17 +88,17 @@ # ``docs_handlers`` יוכל לבחור מודול לפי סיומת ולקרוא לשמות האלה בלי אף # ``if`` נוסף במסלול. # -# **ומה שאינו משותף, כדי שהטענה לא תיקרא רחבה ממה שהיא:** ``MAX_SECTIONS`` -# ו-``InconsistentLineEndings`` מיוצאים רק מכאן. ל-``rst_parser`` אין -# תקרה קבועה (ברירת המחדל שלו היא ``None``), והוא **אינו מרים** את -# החריגה השנייה לעולם — ייצוא שלה משם היה מצהיר על סירוב שלא קיים. -# לכן המטפל ב-PR 5 מייבא את **שתי** חריגות הסירוב מ-``doc_sections``, -# לא ``parser.X``, כפי שכתוב ב-docstring של ``InconsistentLineEndings``. +# **ומה שאינו משותף, כדי שהטענה לא תיקרא רחבה ממה שהיא:** +# ``InconsistentLineEndings`` מיוצאת רק מכאן. ``rst_parser`` **אינו מרים** +# אותה לעולם — ייצוא שלה משם היה מצהיר על סירוב שלא קיים. לכן המטפל מייבא +# את **שתי** חריגות הסירוב מ-``doc_sections``, לא ``parser.X``, כפי שכתוב +# ב-docstring של ``InconsistentLineEndings``. (``MAX_SECTIONS`` היה גם הוא +# רק כאן עד #3420; מאז הוא מוגדר ב-``doc_sections`` ומיוצא משני הפארסרים.) # ההפרש בין שני ה-``__all__`` מקובע ב- -# ``tests/test_md_parser.py::test_the_two_parsers_export_the_same_names_but_two``. +# ``tests/test_md_parser.py::test_the_two_parsers_export_the_same_names_but_one``. __all__ = [ # שם היסטורי: הוא סופר כותרות, גם כאלה שאינן נכנסות למפה. ההנמקה - # המלאה ב-``#:`` שמעל ההגדרה. + # המלאה ב-``#:`` שמעל ההגדרה ב-``doc_sections``. "MAX_SECTIONS", "Document", "InconsistentLineEndings", @@ -114,27 +116,9 @@ "suggest", ] -#: מקסימום **כותרות** לקובץ, ומעליו הפרסור נעצר. -#: -#: **וכותרות ולא סעיפים, וזה ההבדל שצריך להכיר.** נספרת כל כותרת -#: שהפרסור נתקל בה, כולל כותרת בתוך ציטוט או בתוך פריט רשימה שאינה -#: נכנסת למפה בכלל. הקבוע מגביל **עבודה** ולא **תוצאה**: מה שמייקר -#: הוא הכותרת עצמה, ולא השאלה אם היא נספרת כסעיף בסוף. נמדד: 500KB -#: של ``> ## h`` הם ~64,000 כותרות שאף אחת מהן אינה סעיף, וספירה לפי -#: ``level == 0`` הייתה רואה בהן אפס ולא עוצרת לעולם. הטענה "לא יותר -#: מ-``MAX_SECTIONS`` סעיפים" נשארת נכונה — היא פשוט לא כל מה שהקבוע -#: אוכף. שם מדויק יותר לקבוע הוא שינוי בפני עצמו והוא אישו נפרד. -#: -#: **אותו ערך בדיוק כמו ``MAX_SYMBOLS`` ב- -#: ``mcp_server/outline_scanners/_ceiling.py``**, ושם כתובות שתי -#: המדידות שקבעו אותו — השיא שהתקרה מרשה, והיחס לקובץ אמיתי. מי -#: שמשנה את אחד מהשניים צריך לפתוח את השני: הם מתארים את אותו גבול על -#: אותה מפה, והסורק שיגיע לכאן בשלב 2 יעביר את זה במקום את זה. השוויון -#: מקובע ב-``tests/test_md_parser.py::test_the_two_ceilings_are_the_same_number``. -#: -#: ``_ceiling.MAX_SYMBOLS`` אינו מיובא לכאן, כי ``services`` אינו מייבא -#: מ-``mcp_server`` — הכיוון חד-סטרי ומנומק ב-``TooManySections``. -MAX_SECTIONS = 50_000 +# ``MAX_SECTIONS`` מוגדר ב-``doc_sections`` (מאז #3420, כשהפך לברירת המחדל +# של שני הפארסרים) ומיובא לכאן. מה שהוא סופר **במסלול הזה** — כותרות ולא +# סעיפים — מנומק ליד הקבוע שם וב-``_SectionCounter`` למטה. #: ``\r`` שאינו חלק מ-``\r\n``. ההגדרה חוזרת כאן ואינה מיובאת מ- #: ``mcp_server/outline.py::_CR_WITHOUT_LF``, מאותה סיבה: הכיוון חד-סטרי. @@ -323,9 +307,10 @@ def parse_document(text: str, *, max_sections: Optional[int] = MAX_SECTIONS) -> **סדר הבדיקות בכניסה, והוא אינו שרירותי:** - 1. ``isinstance(text, str)`` — הטקסט מגיע מחוץ לתהליך (קובץ מהמראה, - גוף בקשה), ו-``.replace`` על ערך שאינו מחרוזת היה מפיל - ``AttributeError`` ממקום שלא מסביר כלום. + 1. :func:`~services.doc_sections.require_str` — הטקסט מגיע מחוץ + לתהליך (קובץ מהמראה, גוף בקשה), ו-``.replace`` על ערך שאינו מחרוזת + היה מפיל ``AttributeError`` ממקום שלא מסביר כלום. אותה בדיקה + בדיוק, ואותן מילים, כמו ב-``rst_parser`` (מאז #3421). 2. הסרת ``U+FEFF`` (BOM) **אחד, בתחילת המחרוזת בלבד** — ההנמקה בהערה ליד הקוד. לפני בדיקת ה-``\\r``, כדי שמספר השורה שהחריגה נושאת יתאים לטקסט שהפארסר באמת רואה. @@ -345,20 +330,12 @@ def parse_document(text: str, *, max_sections: Optional[int] = MAX_SECTIONS) -> הקלט ששלב 3 קיים בשבילו. :param max_sections: התקרה, **במספר כותרות ולא במספר סעיפים** — - ראו :data:`MAX_SECTIONS`. ברירת המחדל היא :data:`MAX_SECTIONS`, + ראו :data:`~services.doc_sections.MAX_SECTIONS`. ברירת המחדל היא + :data:`~services.doc_sections.MAX_SECTIONS` — אותה ברירת מחדל + כמו ב-``services.rst_parser.parse_document`` (מיושר מאז #3420; + לפני כן היא הייתה ``None`` שם, וההבדל היה מוצהר בשני המקומות) — ו-``None`` מכבה אותה במפורש. - .. important:: - - **ברירת המחדל כאן הפוכה מזו של** - ``services.rst_parser.parse_document``\\ **, שם היא ``None``.** - שם הפרמטר נוסף לפארסר שכבר היה בייצור, וברירת מחדל שאינה - ``None`` הייתה משנה את התנהגות ``docs_get_section`` באותו - קומיט; כאן אין התנהגות קודמת לשמר, ולכן נבחרה ההנחה היקרה — - קורא ששכח להעביר תקרה מקבל הגנה ולא את היעדרה. ההבדל מוצהר - בשני המקומות בכוונה, ויישור של ``rst_parser`` נשקל בנפרד כי - הוא שינוי התנהגות על מסלול חי. - :raises TypeError: ``text`` אינו מחרוזת. :raises ~services.doc_sections.InconsistentLineEndings: יש ``\\r`` שאינו חלק מ-``\\r\\n``. @@ -366,8 +343,7 @@ def parse_document(text: str, *, max_sections: Optional[int] = MAX_SECTIONS) -> כותרות מהתקרה. הארגומנט הוא מספר השורה שבה נעצרנו — מה שמבדיל עצירה בתוך הפרסור מסינון של פלט אחריו. """ - if not isinstance(text, str): - raise TypeError(f"parse_document expects str, got {type(text).__name__}") + text = require_str(text) # **BOM אחד בתחילת הקובץ מוסר, ולא יותר מזה.** בלי ההסרה, ``# Title`` # שלפניו ``U+FEFF`` אינו כותרת אצל ``markdown-it`` — הוא פסקה שמתחילה # בתו בלתי נראה — והפארסר מחזיר **מפה ריקה בלי חריגה** על קובץ שיש diff --git a/services/rst_parser.py b/services/rst_parser.py index 011738342..990cd8b09 100644 --- a/services/rst_parser.py +++ b/services/rst_parser.py @@ -25,6 +25,7 @@ from typing import List, Optional from .doc_sections import ( + MAX_SECTIONS, Document, Section, Suggestions, @@ -35,6 +36,7 @@ find_sections, neighbors, normalize_title, + require_str, section_bounds, section_text, suggest, @@ -57,6 +59,7 @@ #: מסתיר את האזהרה במקום להצהיר על הכוונה; ``__all__`` מצהיר עליה, וגם עונה #: לכל כלי שקורא ייצוא-מחדש במפורש. __all__ = [ + "MAX_SECTIONS", "Document", "Section", "Suggestions", @@ -262,7 +265,7 @@ def _skip_paragraph_block(lines: List[str], i: int, n: int) -> int: return i -def parse_document(text: str, *, max_sections: int | None = None) -> Document: +def parse_document(text: str, *, max_sections: int | None = MAX_SECTIONS) -> Document: """מפרסר טקסט RST לעץ סקשנים. עמיד ל-literal/code blocks ולדירקטיבות מוזחות. **סיומות השורה מנורמלות כאן, בגבול הקלט, ו-``split("\\n")`` נשאר יחידת @@ -283,12 +286,25 @@ def parse_document(text: str, *, max_sections: int | None = None) -> Document: ``\\v``, ``\\f``, NBSP, ``U+2028`` — אינו נעלם כאן. ההגנה עליו יושבת ב-``_is_title_text``, שם ההגדרה של "שורה ריקה" חייבת להיות אחת. - **``max_sections`` — תקרה על מספר הסקשנים, וברירת המחדל היא ללא תקרה.** - ``None`` פירושו התנהגות זהה בית-בית לזו שקדמה לפרמטר. ``docs_get_section`` - — הצרכן השני של המודול הזה — לא העביר תקרה עד הסקירה של #3429, ומאז הוא - מעביר את אותה תקרה של הסורק ומתרגם את החריגה לתשובת ``too_many_sections``; - ברירת המחדל נשארת ``None`` לכל קורא אחר. כשהתקרה מועברת ונחצית מורמת - ``TooManySections`` **בזמן הפרסור**, ולא אחריו. + **הכניסה: ``text`` חייב להיות מחרוזת, ואחרת ``TypeError`` שאומר מה + התקבל** — :func:`~services.doc_sections.require_str`, אותה בדיקה ואותן + מילים כמו ב-``md_parser`` (מאז #3421). עד אז ``None`` הפך כאן ל-``""`` + והחזיר ``Document`` ריק **בשקט** — מפה ריקה שמתחזה למפה של קובץ בלי + כותרות — ו-``17`` נפל ב-``AttributeError`` גולמי מתוך ``.replace``. + אף קורא לא נשען על הצורה הישנה: המטפל, הסורק והסקריפטים מעבירים תמיד + מחרוזת (נבדק), ולכן היישור לא שינה אף תשובה קיימת. + + **``max_sections`` — תקרה על מספר הסקשנים, וברירת המחדל היא + :data:`~services.doc_sections.MAX_SECTIONS` (50,000), כמו ב-``md_parser``.** + ``None`` מכבה אותה במפורש. כשהתקרה נחצית מורמת ``TooManySections`` + **בזמן הפרסור**, ולא אחריו. ההיסטוריה, כי היא מסבירה למה זה נראה כמו + שינוי: הפרמטר נוסף לפארסר שכבר רץ בייצור, ו-``None`` היה מה ששמר על + ``docs_get_section`` זהה בית-בית באותו קומיט; ``docs_get_section`` העביר + את תקרת הסורק במפורש מאז הסקירה של #3429, וכל קורא אחר נשאר בלי הגנה. + היישור ב-#3420 נעשה אחרי מדידה על כל 208 קובצי ה-RST ב-``docs/``: הקובץ + העשיר ביותר נושא 50 סקשנים, אחד לאלף מהתקרה, ופלט ``docs_get_section`` + על כולם זהה לפני ואחרי — כלומר ברירת המחדל מגינה על מי ששכח, ואינה + נוגעת באף קלט אמיתי. **למה בזמן הפרסור, במספרים שנמדדו על קלט של 10MB:** כותרת בת תו אחד בכל שתי שורות נותנת 2.6 מיליון סקשנים, ובלי התקרה זה **18.5 שניות @@ -303,17 +319,13 @@ def parse_document(text: str, *, max_sections: int | None = None) -> Document: הוא תקרת הבתים של הקריאה. **מתועדת באישו #3379** — שם גם הכיוון שמשלים אותה, ומספר השורות ממילא מחושב כאן בראש הפונקציה. - .. important:: - - **ברירת המחדל כאן הפוכה מזו של** ``services.md_parser.parse_document``\\ **, - שם היא** ``MAX_SECTIONS``. ההבדל מכוון ומוצהר בשני המקומות: כאן - ``None`` הוא מה ששמר על ``docs_get_section`` זהה בית-בית כשהפרמטר - נוסף לפארסר שכבר רץ בייצור, ושם אין התנהגות קודמת לשמר ולכן נבחרה - ההנחה היקרה. המשמעות שצריך להכיר: **קורא שאינו מעביר תקרה מקבל - הגנה מהפארסר האחד ולא מהשני.** יישור של הפונקציה הזאת נשקל בנפרד, - כי הוא שינוי התנהגות על מסלול חי. + **ומה שהתקרה עוצרת כאן נמדד ולא הונח:** כותרת בת תו אחד בכל שורה + ב-500KB עולה 44.0MiB לפרסור אחד בלי תקרה, ואיתה נעצרת ב-20.1MiB + (סקירת #3429). קורא שרוצה את המחיר בלי התקרה — הסקריפט שמודד את + עלות הפרסור, למשל — מעביר ``max_sections=None`` ואומר זאת. """ - lines = (text or "").replace("\r\n", "\n").split("\n") + text = require_str(text) + lines = text.replace("\r\n", "\n").split("\n") n = len(lines) sections: List[Section] = [] includes: List[str] = [] diff --git a/tests/test_mcp_docs_handlers.py b/tests/test_mcp_docs_handlers.py index fbe329d81..4c5c30b71 100644 --- a/tests/test_mcp_docs_handlers.py +++ b/tests/test_mcp_docs_handlers.py @@ -295,25 +295,29 @@ def test_an_overlined_heading_is_one_level_deeper_than_the_same_character_underl assert out["toc"][1]["breadcrumb"] == ["כותרת א", "כותרת ב"] -def test_the_docs_reader_passes_the_shared_section_ceiling_and_refuses_above_it(monkeypatch): - """הצרכן בייצור מעביר את תקרת הסקשנים של הסורק, ומעליה מסרב — לא מפרסר את הכול. - - זה היפוך מכוון של הבדיקה שישבה כאן מ-#3378 ("הצרכן בייצור אינו מוגבל - בתקרה"). הנימוק שלה — שקובץ תיעוד גדול לא ייחסם — נמדד ונמצא ריק - בתקרת הקריאה של הכלי: 500KB של העמוד הצפוף ביותר בריפו (6.5 סקשנים - ל-KB) הם כ-3,300 סקשנים, פי 15 מתחת לתקרה. מה שהתקרה כן עוצרת הוא - הצורה העוינת — כותרת בת תו אחד בכל שורה — שבלעדיה עולה 44.0MiB לפרסור - אחד של 500KB, יותר מ-35.2MiB שמאגר הקריאות מקצה לחוט - (``_PARSE_COST_BYTES`` ב-``mcp_server/server.py``), ואיתה נעצרת - ב-20.1MiB. סקירת #3429. - - **ונבדק בלי להציף את התקרה, מאותה סיבה שנימקה הבדיקה הקודמת:** בניית - ``MAX_SYMBOLS + 1`` סקשנים אמיתיים קושרת את מחיר הבדיקה לקבוע שהיא - מגנה עליו (819KB ו-71MB בכל ריצה, ופי עשר מזה כשהתקרה תעלה). הכלי - קורא את התקרה מהמודול בזמן הקריאה, ולכן היא מוקטנת כאן ל-2, והפרסר - האמיתי הוא שמרים את החריגה — על שלושה סקשנים. ברירת המחדל של הפרסר - נשארת ``None``, וזה עדיין נבדק, כי קורא אחר שאינו מעביר תקרה חייב - לדעת שאינו מוגן. +def test_the_rst_reader_is_capped_by_the_parser_default_and_refuses_above_it(monkeypatch): + """הצרכן בייצור אינו מעביר תקרה, וברירת המחדל של הפרסר **היא** התקרה (#3420). + + הגלגול השלישי של הבדיקה שיושבת כאן. #3378: "הצרכן בייצור אינו מוגבל + בתקרה". סקירת #3429 הפכה אותה: הכלי מעביר את ``_ceiling.MAX_SYMBOLS`` + במפורש, כי ברירת המחדל של ``rst_parser`` הייתה ``None`` וכל קורא אחר + נשאר בלי הגנה. #3420 יישר את ברירת המחדל ל-``MAX_SECTIONS`` של + ``doc_sections`` — אחרי מדידה על כל 208 קובצי ה-RST (הקובץ העשיר ביותר + נושא 50 סקשנים, אחד לאלף מהתקרה) ואפס-דיף על פלט הכלי — ומאז הכלי + אינו מעביר דבר, בדיוק כמו במסלול ה-Markdown: העברה מפורשת מכאן הייתה + עותק שני של החלטה שהפארסר כבר הכריע. + + **ומה שנשמר משתי הגרסאות הקודמות:** הסירוב מעל התקרה בא כ-``error`` + ולא כחריגה שבורחת, קובץ שיושב בדיוק על התקרה עובר במלואו, ו-``"max"`` + בתשובה הוא המספר שהפרסר השתמש בו. מה שהתקרה עוצרת — כותרת בת תו אחד + בכל שורה, 44.0MiB לפרסור של 500KB בלעדיה ו-20.1MiB איתה — נמדד בסקירת + #3429 ולא זז. + + **ונבדק בלי להציף את התקרה, מאותה סיבה שנימקו שתי הקודמות:** בניית + ``MAX_SECTIONS + 1`` סקשנים אמיתיים קושרת את מחיר הבדיקה לקבוע שהיא + מגנה עליו. ברירת המחדל קפואה בחתימה, ולכן היא מוקטנת ב-``partial`` + על הפרסר עצמו — וזה עובד רק מפני שהכלי אינו מעביר ארגומנט שדורס + אותה, שזו הטענה הראשונה כאן. """ passed: dict[str, object] = {} real = rst_parser.parse_document @@ -328,14 +332,14 @@ def spy(text, **kwargs): assert out["ok"] and out["mode"] == "toc" assert out["section_count"] == 2 - assert passed.get("max_sections") == docs_handlers._ceiling.MAX_SYMBOLS, ( - f"הכלי לא העביר את תקרת הסורק לפרסור: {passed}" - ) - assert inspect.signature(real).parameters["max_sections"].default is None, ( - "ברירת המחדל של max_sections השתנתה — ההגנה כאן באה מהכלי, לא מהפרסר" - ) + assert passed == {}, f"הכלי העביר תקרה לפרסור: {passed}" + assert (inspect.signature(real).parameters["max_sections"].default + is doc_sections.MAX_SECTIONS), ( + "ברירת המחדל של max_sections ב-rst_parser אינה MAX_SECTIONS — " + "ההגנה במסלול הזה באה מהפרסר, וכשהיא זזה שם היא זזה גם כאן") - monkeypatch.setattr(docs_handlers._ceiling, "MAX_SYMBOLS", 2) + monkeypatch.setattr(docs_handlers.rst_parser, "parse_document", + functools.partial(real, max_sections=2)) at_the_ceiling = docs_handlers.docs_get_section(_TextBackend("א\n=\n\nב\n=\n\n"), path="x") assert at_the_ceiling["ok"] and at_the_ceiling["section_count"] == 2, ( @@ -346,9 +350,12 @@ def spy(text, **kwargs): above = docs_handlers.docs_get_section(_TextBackend(three), path="x") assert {k: above[k] for k in ("ok", "error", "max", "repo", "path", "ref", "resolved_commit")} == { - "ok": False, "error": "too_many_sections", "max": 2, + "ok": False, "error": "too_many_sections", "max": doc_sections.MAX_SECTIONS, "repo": "CodeBot", "path": "docs/x.rst", "ref": "HEAD", "resolved_commit": "c0ffee", } + # ``"max"`` הוא הקבוע ולא ה-2 של ה-``partial``: הכלי מדווח את התקרה + # שהפרסר מצהיר עליה, והטסט הוא שהנמיך אותה מאחורי גבו. + # # **ואין ``line``, וזה לא חסר אלא נכון.** ``rst_parser`` מרים # ``TooManySections`` בלי ארגומנט (``raise TooManySections``, נמדד), בעוד # ``md_parser`` מרים אותה עם השורה. ``_line_of`` מוסיף את השדה רק כשיש @@ -724,11 +731,11 @@ def test_the_parser_table_holds_modules_so_a_monkeypatch_on_the_module_is_seen( monkeypatch): """הטבלה מחזיקה **מודולים**, ולכן החלפת ``parse_document`` עליהם נתפסת. - **וזה מה ש-``test_the_docs_reader_passes_the_shared_section_ceiling_and_refuses_above_it`` אינו + **וזה מה ש-``test_the_rst_reader_is_capped_by_the_parser_default_and_refuses_above_it`` אינו תופס.** אילו הטבלה הייתה מחזיקה את הפונקציה עצמה, היא הייתה קופאת על המקורית, ה-spy היה נעקף **בשקט**, ו-``passed`` שם היה נשאר ריק — - כלומר ``passed.get("max_sections") is None`` היה ממשיך לעבור. שומר - שעובר משתי סיבות שונות אינו שומר. + כלומר ``passed == {}`` היה ממשיך לעבור. שומר שעובר משתי סיבות שונות + אינו שומר. """ calls = [] real = rst_parser.parse_document @@ -767,7 +774,7 @@ def test_a_markdown_file_over_the_heading_ceiling_is_refused_by_name(both_repos, של ``MAX_SECTIONS + 1`` כותרות אמיתיות הוא מאות מגה-בייט בכל ריצת CI, והמחיר שלו צמוד לקבוע שהוא מגן עליו — כלומר הוא מתדרדר בדיוק כשהמערכת גדלה. אותו נימוק בדיוק כתוב ב- - ``test_the_docs_reader_passes_the_shared_section_ceiling_and_refuses_above_it`` שמעליו. + ``test_the_rst_reader_is_capped_by_the_parser_default_and_refuses_above_it`` שמעליו. """ real = md_parser.parse_document monkeypatch.setattr(md_parser, "parse_document", @@ -785,11 +792,14 @@ def test_the_two_refusals_are_mapped_whichever_parser_raised_them(monkeypatch): התניה על ``parser is md_parser`` הייתה רשימה שנייה לסנכרן: כשהתקרה נוספה למסלול ה-RST (#3429), הסירוב היה בורח מהכלי כחריגה גולמית. - **ה-``partial`` שהיה כאן כבר אינו עובד, וזו לא תקלה של הטסט:** הכלי - מעביר עכשיו ``max_sections`` במפורש, וארגומנט מפורש בקריאה דורס את - זה שב-``partial``. לכן מקטינים את התקרה במקום שהכלי קורא אותה. + **התקרה מונמכת ב-``partial`` על הפרסר, כמו במסלול ה-Markdown.** בין + #3429 ל-#3420 זה לא עבד כאן — הכלי העביר ``max_sections`` במפורש, + וארגומנט מפורש בקריאה דורס את זה שב-``partial`` — ולכן הטסט הנמיך אז + את ``_ceiling.MAX_SYMBOLS``. מאז #3420 הכלי אינו מעביר דבר לאף פארסר, + וה-``partial`` הוא הדרך היחידה להנמיך, בשני המסלולים. """ - monkeypatch.setattr(docs_handlers._ceiling, "MAX_SYMBOLS", 1) + monkeypatch.setattr(docs_handlers.rst_parser, "parse_document", + functools.partial(rst_parser.parse_document, max_sections=1)) out = docs_handlers.docs_get_section(_TextBackend("א\n=\n\nב\n=\n"), path="x.rst", repo="CodeBot") assert out["ok"] is False and out["error"] == "too_many_sections" @@ -814,11 +824,11 @@ def test_the_reader_asks_for_the_whole_file_and_so_keeps_the_display_ceiling(bot def test_the_markdown_reader_is_capped_by_the_parser_default(both_repos, monkeypatch): """התקרה האפקטיבית של מסלול ה-Markdown היא ``MAX_SECTIONS``. - **התמונה ההפוכה של ``test_the_docs_reader_passes_the_shared_section_ceiling_and_refuses_above_it``**, + **אותה תמונה כמו ``test_the_rst_reader_is_capped_by_the_parser_default_and_refuses_above_it``**, ושתי הטענות מאותו סוג: הכלי אינו מעביר תקרה, וברירת המחדל בחתימה **היא** התקרה. העברה מפורשת של ``md_parser.MAX_SECTIONS`` מה-handler - הייתה עותק שני של אותה החלטה, ו-PR הפארסר כבר הכריע אותה ונימק - למה ברירת המחדל שם הפוכה מזו של RST. + הייתה עותק שני של אותה החלטה, ו-PR הפארסר כבר הכריע אותה — ומאז + #3420 גם מסלול ה-RST על אותה ברירת מחדל בדיוק. """ passed: dict = {} real = md_parser.parse_document @@ -1088,14 +1098,17 @@ def _boom(text, **k): def test_the_two_section_ceilings_are_the_same_number(): - """``_ceiling.MAX_SYMBOLS`` ו-``md_parser.MAX_SECTIONS`` הם אותו מספר. - - RST מקבל את התקרה מהכלי (``_ceiling``), Markdown מברירת המחדל של - הפרסר שלו (``MAX_SECTIONS``). שני מודולים, מספר אחד, בלי ייבוא ביניהם - — זה ``duplicate-rule-second-copy`` §2 בצורתו המדויקת, והתיקון שהכלל - מבקש כשהכפילות מוצדקת בגבול מודול הוא **טסט שקורא את שני המקורות - ומשווה**. שני דברים נשענים על השוויון הזה: התיעוד שאומר "שני המסלולים - מוגבלים ל-50,000", ו-``"max"`` בסירוב, שמדווח את ``_ceiling`` גם על - קובץ Markdown. + """``_ceiling.MAX_SYMBOLS`` ו-``doc_sections.MAX_SECTIONS`` הם אותו מספר. + + שני הפארסרים מקבלים את התקרה מברירת המחדל שלהם — ``MAX_SECTIONS`` של + ``doc_sections``, אותו אובייקט בדיוק דרך שני הייצואים-מחדש — וסורק + האאוטליין מ-``_ceiling.MAX_SYMBOLS``. שני מודולים, מספר אחד, בלי ייבוא + ביניהם — זה ``duplicate-rule-second-copy`` §2 בצורתו המדויקת, והתיקון + שהכלל מבקש כשהכפילות מוצדקת בגבול מודול הוא **טסט שקורא את שני + המקורות ומשווה**. מה שנשען על השוויון: התיעוד שאומר "50,000 בשני + המסלולים ובסורק", והמדידות ב-``_ceiling`` שקבעו את המספר לשניהם. """ - assert docs_handlers._ceiling.MAX_SYMBOLS == md_parser.MAX_SECTIONS + from mcp_server.outline_scanners import _ceiling + + assert rst_parser.MAX_SECTIONS is md_parser.MAX_SECTIONS is doc_sections.MAX_SECTIONS + assert _ceiling.MAX_SYMBOLS == doc_sections.MAX_SECTIONS diff --git a/tests/test_mcp_outline.py b/tests/test_mcp_outline.py index 0561e71f9..c64ad7d79 100644 --- a/tests/test_mcp_outline.py +++ b/tests/test_mcp_outline.py @@ -2819,6 +2819,33 @@ def counting(**fields): assert len(built) <= 21, f"נבנו {len(built)} סקשנים — הפרסור לא נעצר על התקרה" +def test_the_rst_scanner_passes_its_own_ceiling_and_does_not_lean_on_the_parser_default(monkeypatch): + """הסורק אומר את התקרה שלו בעצמו — ``_ceiling.MAX_SYMBOLS`` — בכל קריאה לפארסר. + + **פין, לא תיקון (#3420):** כשברירת המחדל של ``max_sections`` ב-``rst_parser`` + יושרה ל-``MAX_SECTIONS``, האישו דרש שהסורק יישאר לא-מושפע, וש"זה ייאמר + בטסט ולא בהיגיון". המספר כאן הוא של המפה — כותרות ותוויות יחד, דרך + ``Capped`` — ולכן הוא עובר במפורש גם כששני המספרים שווים. מוטציה שמוחקת + את הארגומנט מפילה את זה; ריצה על הקוד שלפני #3420 עוברת, וזה מכוון. + """ + from services import rst_parser + + passed: list[dict] = [] + real = rst_parser.parse_document + + def spy(text, **kwargs): + passed.append(dict(kwargs)) + return real(text, **kwargs) + + monkeypatch.setattr(rst_parser, "parse_document", spy) + + names = [row["name"] for row in extract_outline("a\n=\n\nb\n-\n\n", "a.rst")["symbols"]] + + assert names == ["a", "a.b"] + assert passed == [{"max_sections": _ceiling.MAX_SYMBOLS}], ( + f"הסורק נשען על ברירת המחדל של הפארסר במקום לומר את התקרה שלו: {passed}") + + def test_the_tool_reports_the_ceiling_through_the_real_path(monkeypatch): """דרך ``repo_handlers.get_repo_file`` ולא רק דרך ``extract_outline``. diff --git a/tests/test_md_parser.py b/tests/test_md_parser.py index ef351ec15..8d1df81da 100644 --- a/tests/test_md_parser.py +++ b/tests/test_md_parser.py @@ -306,6 +306,7 @@ def test_the_default_ceiling_is_the_documented_constant(): import inspect assert md_parser.MAX_SECTIONS == 50_000 + assert md_parser.MAX_SECTIONS is doc_sections.MAX_SECTIONS, "ייצוא-מחדש, לא עותק" default = inspect.signature(md_parser.parse_document).parameters["max_sections"].default assert default is md_parser.MAX_SECTIONS, "ברירת המחדל היא התקרה, לא None" @@ -654,19 +655,21 @@ def test_the_two_exceptions_come_from_the_same_module(): assert md_parser.InconsistentLineEndings is doc_sections.InconsistentLineEndings -def test_the_two_parsers_export_the_same_names_but_two(): +def test_the_two_parsers_export_the_same_names_but_one(): """"בני-החלפה" היא טענה בת-בדיקה, לא משאלה. כל שם ש-``rst_parser`` מייצא קיים גם ב-``md_parser``, וההפרש הוא - **בדיוק** שני השמות שהפרוזה מונה. שם שיתווסף לאחד מהם בלי השני, + **בדיוק** השם היחיד שהפרוזה מונה. שם שיתווסף לאחד מהם בלי השני, או ייצוא של ``InconsistentLineEndings`` מ-``rst_parser`` (שאינו מרים - אותה), מפיל את זה. + אותה), מפיל את זה. עד #3420 ההפרש היה שניים — ``MAX_SECTIONS`` היה + רק כאן; מאז הוא מוגדר ב-``doc_sections`` ומיוצא משני הפארסרים, כי + הוא ברירת המחדל של שניהם. """ from services import rst_parser rst, md = set(rst_parser.__all__), set(md_parser.__all__) assert rst <= md, f"שמות שרק ב-rst_parser: {sorted(rst - md)}" - assert md - rst == {"MAX_SECTIONS", "InconsistentLineEndings"} + assert md - rst == {"InconsistentLineEndings"} @pytest.mark.parametrize( diff --git a/tests/test_rst_parser.py b/tests/test_rst_parser.py index f18a7cabd..97f3b3e11 100644 --- a/tests/test_rst_parser.py +++ b/tests/test_rst_parser.py @@ -1,13 +1,14 @@ """טסטים לפארסר ה-RST — על קבצי RST אמיתיים מהריפו + מקרי קצה סינתטיים ממוקדים.""" import ast +import inspect import json import re from pathlib import Path import pytest -from services import rst_parser +from services import doc_sections, rst_parser _ENV_RST = Path(__file__).resolve().parents[1] / "docs" / "environment-variables.rst" @@ -1168,6 +1169,65 @@ def test_the_section_ceiling_refuses_above_the_line_and_not_on_it(): rst_parser.parse_document(three + "d\n=\n\n", max_sections=3) +# ---- בדיקת הכניסה, וברירת המחדל של התקרה (#3421, #3420) ---- +# +# שני הפארסרים מתועדים כבני-החלפה, ועל קלט שאינו מחרוזת הם התנהגו אחרת: +# ``rst_parser.parse_document(None)`` החזיר מסמך ריק **בשקט** — מפה ריקה +# שמתחזה למפה של קובץ בלי כותרות — ו-``17`` נפל ב-``AttributeError`` גולמי +# מתוך ``.replace``. וברירת המחדל של ``max_sections`` הייתה ``None`` כאן +# ו-``MAX_SECTIONS`` שם, כלומר קורא ששכח להעביר תקרה קיבל הגנה מהפארסר +# האחד ולא מהשני. + +_NOT_STRINGS = {"None": None, "int": 17, "bytes": b"x"} + + +@pytest.mark.parametrize("kind", sorted(_NOT_STRINGS)) +def test_input_that_is_not_a_string_is_refused_by_name(kind): + """``TypeError`` שאומר מה התקבל — ולא מסמך ריק, ולא ``AttributeError`` מבפנים. + + ``match`` על שם הטיפוס הוא מה שמבדיל את זה מהצורה הישנה גם במקרה של + ``bytes``, שנפל אז ב-``TypeError`` **אחר** ("a bytes-like object is + required") מתוך ``.replace``: אותו טיפוס חריגה, הודעה שאינה מסבירה כלום. + """ + with pytest.raises(TypeError, match=f"parse_document expects str, got {kind}"): + rst_parser.parse_document(_NOT_STRINGS[kind]) + + +@pytest.mark.parametrize("kind", sorted(_NOT_STRINGS)) +def test_both_parsers_refuse_a_non_string_with_the_same_words(kind): + """בדיקה אחת לשניהם (``doc_sections.require_str``), ולכן אותן מילים בדיוק. + + שתי שורות זהות בשני מודולים הן מה שנסחף בתיקון הבא; המילים נבדקות + כאן כי הן החוזה שהמטפל מצהיר עליו — ``TypeError`` היא "חוזה נשבר" + ואינה נתפסת ב-``docs_get_section``. + """ + from services import md_parser + + with pytest.raises(TypeError) as from_rst: + rst_parser.parse_document(_NOT_STRINGS[kind]) + with pytest.raises(TypeError) as from_md: + md_parser.parse_document(_NOT_STRINGS[kind]) + assert str(from_rst.value) == str(from_md.value) + + +def test_the_default_ceiling_is_the_shared_constant(): + """טענת O(1) על הערך, כמו הטסט המקביל ב-``test_md_parser`` — לא 50,001 סקשנים בכל CI. + + ברירת המחדל היא **אותו אובייקט** ``doc_sections.MAX_SECTIONS`` שגם + ``md_parser`` מחזיק, וגם מיוצא מחדש מכאן, כדי שקורא שכותב + ``rst_parser.MAX_SECTIONS`` יקבל את המספר שהפארסר באמת משתמש בו. + המנגנון עצמו — עצירה בזמן הפרסור, גבול "מעל ולא על" — מקובע בטסטים + שמעל עם ערכים קטנים מפורשים, ו-``None`` מכבה אותו במפורש. + """ + from services import md_parser + + default = inspect.signature(rst_parser.parse_document).parameters["max_sections"].default + assert default is doc_sections.MAX_SECTIONS, "ברירת המחדל היא התקרה, לא None" + assert rst_parser.MAX_SECTIONS is md_parser.MAX_SECTIONS is doc_sections.MAX_SECTIONS + assert "MAX_SECTIONS" in rst_parser.__all__ + assert len(rst_parser.parse_document("a\n=\n\n" * 3, max_sections=None).sections) == 3 + + def test_no_heading_site_reaches_the_section_list_around_the_funnel(): """כל אתר כותרת עובר ב-``add_section``, ואין דרך עקיפה — נבדק על המקור. From f8cdf9cf53c9fd0c238a1ab770b223f2e099668f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 23:38:17 +0000 Subject: [PATCH 4/9] =?UTF-8?q?fix(mirror):=20get=5Ffile=5Fat=5Fcommit=20?= =?UTF-8?q?=D7=91=D7=95=D7=93=D7=A7=20=D7=90=D7=AA=20=D7=92=D7=95=D7=93?= =?UTF-8?q?=D7=9C=20=D7=94=D7=90=D7=95=D7=91=D7=99=D7=99=D7=A7=D7=98=20?= =?UTF-8?q?=D7=9C=D7=A4=D7=A0=D7=99=20git=20show,=20=D7=95=D7=A7=D7=95?= =?UTF-8?q?=D7=91=D7=A5=20=D7=9E=D7=A2=D7=9C=20=D7=94=D7=AA=D7=A7=D7=A8?= =?UTF-8?q?=D7=94=20=D7=90=D7=99=D7=A0=D7=95=20=D7=A0=D7=98=D7=A2=D7=9F=20?= =?UTF-8?q?=D7=9B=D7=9C=D7=9C=20(#3433,=20=D7=A4=D7=A8=D7=99=D7=98=209)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit עד היום git show נטען כולו לזיכרון (capture_output=True) ורק אז max_size נבדק, כלומר התקרה הייתה בדיקה בדיעבד ולא חסם על הזיכרון: קובץ של 12MB שנדחה עלה 20MiB שיא בחוט הקריאה לפני שהתשובה הייתה file_too_large. זה השורש של WARN-003 בסקירת #3429, ומה שהניח את "קריאה אחת עולה לכל היותר X" כהערה ולא כתכונה של הקוד. - _object_size: git cat-file -s : מחזיר את גודל האובייקט מהמאגר בלי לקרוא אותו. שתי הפקודות פונות לאותו sha שנפתר פעם אחת ב-_validate_ref_with_git, ולכן אין חלון בין הבדיקה לקריאה שסנכרון של המראה יכול להיכנס בו (TOCTOU נבחן ונדחה: אובייקט בקומיט נתון אינו משתנה). - כשל בבדיקת הגודל הוא סירוב באותה מפה של git show, לא נפילה לקריאה בלי תקרה; פלט שאינו מספר הוא כשל ולא אפס (U3). מיפוי ה-stderr אוחד ל-_object_read_error, כי git 2.43 מדפיס את אותן הודעות לשתי הפקודות על נתיב חסר ועל קומיט שאינו מכיל אותו (נמדד). - החסם על מה שמוחזר נשאר: לנתיב של תיקייה cat-file -s מודד את אובייקט העץ ואילו git show מדפיס רשימה, ומה שחוזר לעולם אינו גדול מ-max_size. - אותה תשובה ואותו size בסירוב (גודל הבלוב), ואותה תשובה מתחת לתקרה. נמדד (VmHWM, תהליך נקי, מראה bare עם קבצי טקסט): 12MB מול תקרה של 500KB ושל 10MiB — 20.2 ו-20.4MiB שיא לפני, 0.0 אחרי; 7MB מתחת לתקרה — 14.2 לפני ו-14.5 אחרי, כי אותו כן קוראים. טסטים על מראה git אמיתית ב-tmp_path עם מרגל על subprocess.run: ארבעה נופלים על origin/main (הסירוב לפני git show, אותו sha לשתי הפקודות והסדר ביניהן, כשל בבדיקה שאינו נופל לקריאה, גודל שאינו מספר), ושלושה עוברים שם בכוונה כבקרות (מתחת לתקרה, נתיב חסר, החסם על מה שמוחזר); מוטציה שמוחקת את החסם השני מפילה את הפין שלו. התיעוד וההערות שתיארו את "טוענת את ה-blob כולו לפני בדיקת הגודל" עודכנו. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SfJTSpDAhDr2yhtmpFkwTx --- docs/mcp-server.rst | 10 +- docs/whats-new.rst | 1 + mcp_server/repo_backend.py | 6 +- mcp_server/server.py | 5 +- services/git_mirror_service.py | 83 +++++++++++++---- tests/test_git_mirror_service.py | 154 +++++++++++++++++++++++++++++++ 6 files changed, 237 insertions(+), 22 deletions(-) diff --git a/docs/mcp-server.rst b/docs/mcp-server.rst index e8948f355..288684444 100644 --- a/docs/mcp-server.rst +++ b/docs/mcp-server.rst @@ -115,8 +115,9 @@ usable=8)`` מול ``cpu quota cgroup v2: 0.50 cpu``, כלומר 12 חוטים המספרים מחדש **לשני הפרסרים** — מריצים אותו כשאחד מהם משתנה. - **ההחזקה הגדולה ביותר של חוט קריאה אינה הפרסור, וזו החלטה מתועדת.** כלי האדמין ``codekeeper_get_repo_file`` עם ``outline=true`` או ``lines=`` קורא עד - ``RANGE_READ_MAX_BYTES`` (‏10MiB), והמראה טוענת את ה-blob כולו לזיכרון - **לפני** בדיקת הגודל. נמדד (2026-09-20): outline של קובץ RST בגודל 10MB + ``RANGE_READ_MAX_BYTES`` (‏10MiB), והמראה טוענת blob **שמתחת לתקרה** כולו + לזיכרון לפני הפענוח והחיתוך (blob שמעליה נדחה מ-``git cat-file -s`` בלי + להיקרא — ראו #3433 למטה). נמדד (2026-09-20): outline של קובץ RST בגודל 10MB מחזיק 61–77MiB לאורך המסלול כולו — הטקסט של 10MB בזיכרון ועליו הפרסור; שורת ``outline_densest_real`` של הסקריפט מראה את הפרסור לבדו מעל השיא שלפניו, 51MiB — וקריאת ``lines=`` של ``webapp/static/js/md_preview.bundle.js`` @@ -128,7 +129,10 @@ usable=8)`` מול ``cpu quota cgroup v2: 0.50 cpu``, כלומר 12 חוטים (הגדול ביותר כאן 169KB). תמחור לפי ``RANGE_READ_MAX_BYTES`` היה מצמצם את המסלול הציבורי ל-4 חוטים (לפי 77MiB) או ל-9 (לפי 37MiB) בגלל צורה שאין לה מקור. מה שכן השתנה בגלל זה הוא התקרה (למטה); והשורש — לחסום את הקריאה - במקור, בבדיקת גודל לפני ``git show`` — נמצא ב-#3433. + במקור — נסגר ב-#3433: ``get_file_at_commit`` שואל את מאגר האובייקטים לגודל + (``git cat-file -s :``) לפני ``git show``, ולכן ``max_size`` הוא + חסם על מה שנקרא ולא בדיקה בדיעבד. נמדד: קובץ של 12MB שנדחה עלה 20MiB שיא + לפני, ואפס אחרי; קובץ של 7MB שמתחת לתקרה נשאר 14MiB, כי אותו כן קוראים. - **רצפה** (``_READ_POOL_FLOOR``, ‏2): כדי שקריאת מונגו תקועה אחת לא תשבית את השאר. שניים הוא הגודל הקטן ביותר עם התכונה הזאת, וכל יחידה מעליו היא זיכרון שהחשבון אמר שאין. **תקרה** (``_READ_POOL_CAP``, ‏12): הרוחב שהייצור diff --git a/docs/whats-new.rst b/docs/whats-new.rst index ef65c8cd0..86a14edff 100644 --- a/docs/whats-new.rst +++ b/docs/whats-new.rst @@ -4,6 +4,7 @@ What's New 2026-09-20 ---------- +- **fix (mirror): ‏``get_file_at_commit`` בודק את גודל הקובץ במאגר האובייקטים לפני ``git show``, ולכן קובץ מעל התקרה אינו נטען לזיכרון כלל (#3433, פריט 9).** עד היום ``git show`` נטען כולו לזיכרון ורק אז ``max_size`` נבדק — כלומר התקרה הייתה בדיקה בדיעבד: קובץ של 12MB שנדחה עלה 20MiB שיא בחוט הקריאה לפני שהתשובה הייתה ``file_too_large``. מעכשיו ``git cat-file -s :`` מחזיר את גודל האובייקט בלי לקרוא אותו, שתי הפקודות פונות לאותו קומיט שנפתר פעם אחת (אין חלון לסנכרון להיכנס בו), וקובץ מעל התקרה נדחה לפני שנקרא בית אחד — אותה תשובה, אותו ``size``, אפס זיכרון. כשל בבדיקת הגודל אינו נופל לקריאה בלי תקרה, ומה שמוחזר עדיין נבדק מול התקרה בנפרד, לכל סוג אובייקט. נמדד: 12MB מעל תקרה של 500KB ושל 10MiB — 20.2 ו-20.4MiB לפני, אפס אחרי; 7MB מתחת לתקרה — 14MiB לפני ואחרי, כי אותו כן קוראים. ראו "מודל הריצה של הכלים" ב-:doc:`mcp-server`. - **feat: ``codekeeper_docs_get_section`` קורא גם קובצי Markdown, ולכל ריפו מדיניות נתיבים משלו.** עד היום הכלי ידע לקרוא ``.rst`` בלבד, ורק מתחת ל-``docs/``. הפארסר של Markdown נכתב ונמדד כבר קודם — ואף אחד לא קרא לו; זה השינוי שמחווט אותו. מעכשיו הסיומת בוחרת את הפארסר, ולכל ריפו מוצהר **איפה התיעוד שלו יושב ובאיזה פורמט**: ``CodeBot`` נשאר ``docs/`` עם ``.rst`` ללא שינוי, ו-``amir-bug-patterns`` מגיש ``.md`` משורש הריפו. **וזו הרחבה מכוונת של גבול הקריאה** — הכלי ציבורי, כלומר כל משתמש מאומת מפעיל אותו בלי אדמין, והריפו שנוסף ציבורי ב-GitHub. מה ש**לא** השתנה: מדיניות סינון הסודות חלה על המסלול הזה במלואה, ולכן ``path="secrets"`` מחזיר ``path_denied`` ולא ``not_found``, וגם ``secrets.md`` ו-``credentials.md`` חסומים; ותקרת 500KB של שירות המראה נשארת ההגנה על הפרסור, כי הכלי קורא את הקובץ כולו בלי ``lines``. **שני שערים ולא אחד:** ``MCP_DOCS_REPO`` מתיר בזמן ריצה, טבלת המדיניות בקוד יודעת לקרוא, וריפו שנמצא במשתנה הסביבה ואין לו מדיניות נדחה ב-``repo_not_configured`` במקום ליפול לברירת מחדל מתירנית. **ובקשה לפורמט שהריפו אינו מגיש נדחית בשמה** — ``suffix_not_allowed`` עם רשימת הסיומות שכן מוגשות, במקום להשלים סיומת בשקט מעל הקיימת ולחזור כ"לא נמצא" על קובץ שקיים. שני סירובים חדשים מגיעים מהפרסור עצמו: ``inconsistent_line_endings`` על ``\r`` בודד (CRLF עובד במלואו), ו-``too_many_sections`` על קובץ שחוצה את תקרת 50,000 הכותרות — Markdown דרך ברירת המחדל של הפרסר, ו-RST דרך התקרה שהכלי מעביר מאז #3429; סירוב שהפארסר מספק לו מספר שורה נושא גם ``line`` — תמיד ב-Markdown, ולא ב-``too_many_sections`` של RST, כי ``rst_parser`` מרים אותה בלי מספר. ``includes`` הוא שדה של RST ולכן בעמוד ``.md`` הוא תמיד ריק. **ומסלול ה-RST לא זז**: אפס דיף מדוד על 5,920 רשומות מ-208 קובצי RST, לפני ואחרי. ראו :ref:`mcp-docs-path-policy`. - **fix: מאגר הקריאות של ה-MCP נגזר ממכסת הזיכרון של הקונטיינר, ולא ממספר המעבדים של המארח (#3391).** כלי הקריאה רצים ב-``asyncio.to_thread``, כלומר ב-executor ברירת המחדל של הלולאה, שגודלו ``min(32, os.cpu_count() + 4)`` — ו-``os.cpu_count()`` בקונטיינר הוא מספר הליבות של **המארח**, כפי ש-CPython מתעד במפורש. שורת הקיבולת מהייצור אמרה את זה במספרים: ``read pool 12 threads (os.cpu_count=8, usable=8)`` על מכסה של ``0.50 cpu``. לקריאת מונגו זה לא מזיק; לפרסור בפייתון טהור אין מקביליות ב-CPU בגלל ה-GIL, ולכן 12 חוטים היו 12 עצי פרסור בזיכרון בו-זמנית — יותר ממה שנכנס ב-512MiB — ורגע לפני שהפרסור הופך לכלי ציבורי. מעכשיו ``attach_read_pool`` מתקין בעליית ה-lifespan של אפליקציית ה-ASGI מאגר משלו, בגודל ``(מגבלת הזיכרון − קו בסיס − שוליים) ÷ עלות פרסור אחד``: המגבלה נקראת מ-cgroup בזמן ריצה (``memory.max`` ב-v2, ‏``memory.limit_in_bytes`` ב-v1) ולא מקובעת בקוד, קו הבסיס והשוליים הם מדידות מהשירות בייצור עם תאריך, ועלות הפרסור נגזרת מ-``MAX_FILE_SIZE_FOR_DISPLAY`` כי הכלי דוחה ``too_large`` לפני שהוא מפרסר. על תוכנית הייצור זה **10** חוטים במקום 12 — עלות הפרסור נמדדה מחדש על הפרסר הנעוץ (``scripts/measure_md_parse_cost.py``; הקבוע נמדד על ``services.md_parser`` כהכנה לכלי ה-Markdown של PR 5, בעוד הכלי הציבורי היום מפרסר RST ב-``services.rst_parser``, שעולה כ-6 בתים לבית גם על העמוד הצפוף ביותר — והסקריפט מודד את שניהם), והיא תלויה בצפיפות הטוקנים של המסמך ולא בגודלו: המסמך הצפוף ביותר בקורפוס הוא הקבוע, וצורה עוינת של שורות-תבליט בודדות עולה פי ארבעה ממנו — מה ששום רוחב מאגר אינו סופג, ומחכה לתקרת טוקנים בפרסר עצמו. **ובעקבות הסקירה:** ``codekeeper_docs_get_section`` מעביר לפרסר את תקרת הסקשנים של האאוטליין (50,000) ומחזיר ``too_many_sections`` במקום לפרסר את הקובץ כולו — צורה עוינת של RST (כותרת בת תו אחד בכל שורה) עלתה 44.0MiB לפרסור, יותר מ-35.2MiB שהמאגר מקצה לחוט, ועכשיו נעצרת ב-20.1MiB; אף עמוד אמיתי אינו מתקרב לתקרה. רצפה של 2 (קריאה תקועה אחת לא משביתה את השאר), תקרה של 12, הרוחב שהייצור כבר הריץ (מעבר לתוכנית גדולה לא מרחיב את המאגר בלי החלטה; הגרסה הראשונה נקבה ב-32, והסקירה של #3429 הורידה אותה כי המחלק מתאר את הפרסור ולא את קריאות הטווח של האדמין; חסימת הקריאה במקור — #3433), וכשאין מגבלה קריאה — הרצפה, **לא** ``cpu_count + 4``, עם WARNING שאומר שזה קרה. **ושורת הקיבולת מפסיקה לחשב בעצמה:** היא נרשמת מתוך ה-lifespan, קוראת את ``_max_workers`` של המאגר שהותקן בפועל, ומדווחת גם את מגבלת הזיכרון לצד מכסת ה-CPU — כי רשומה שמתארת מצב ומתעדכנת בנפרד ממנו היא בדיוק מה שמשקר בשקט. ה-seam הוא עטיפת ``router.lifespan_context``, אותו seam שניקוז ה-PostHog כבר משתמש בו, כי ``FastMCP.streamable_http_app()`` בונה את Starlette עם ``lifespan=`` משלה ו-``on_startup`` אינו רץ. הטסט המרכזי נכנס ל-lifespan של האפליקציה האמיתית ומוכיח שקריאת ``to_thread`` נוחתת על חוט של המאגר החדש — לא שפונקציה נקראה; שורת הקיבולת נבדקת בתת-תהליך נקי דרך הפלט האמיתי ולא דרך ``caplog``. ראו "מודל הריצה של הכלים" ב-:doc:`mcp-server`. - **feat: ``codekeeper_docs_get_section`` מוצא סעיף גם לפי המזהה שלו, ולא רק לפי השם המלא.** הסוכנים מפנים זה לזה לפי מזהה — "קרא K11", "ראו U3" — ועד היום ``section="K11"`` החזיר ``section_not_found`` עם רשימת הצעות **ריקה**, כי ההתאמה הייתה שוויון מלא ו-``difflib`` עם ``cutoff=0.5`` אינו מוצא קרבה בין שלושה תווים לכותרת עברית ארוכה. כלומר הזרימה המרכזית של הכלי לא עבדה. מעכשיו, **ורק אחרי שהשוויון המלא לא מצא כלום**, שאילתה שבנויה כמזהה (עד שלוש אותיות ואחריהן עד שלוש ספרות, עם נקודה אופציונלית) מותאמת לכותרת שנפתחת באותו מזהה. **והמזהה נקרא כיחידה ולא כקידומת של מחרוזת**, ולכן ``K1`` מחזיר את ``K1`` בלבד ולעולם לא את ``K10`` עד ``K15`` — הצורה השבורה של הבדיקה הזאת היא אותה מחלקה בדיוק שבה גבול נתיב נבדק כרצף תווים. שאילתה שאינה בנויה כמזהה אינה עוברת במסלול הזה כלל, ולכן ``section="איך"`` ממשיך להחזיר אפס התאמות גם בעמוד שיש בו חמש-עשרה כותרות שמתחילות במילה הזאת. מזהה שחוזר פעמיים הוא ``ambiguous_section`` עם מועמדים, כמו כל כותרת כפולה, ושאילתת מזהה שלא נמצאה מקבלת ב-``suggestions`` קודם כול כותרת קרובה, אם יש כזו בעמוד, **ורק כשאין אף כותרת קרובה** את המזהים שכן קיימים — עד 50, ועם ``suggestions_truncated`` כשהיו עוד. **והתקרה גבוהה בכוונה**, כי רשימת מזהים אינה מדורגת: לשאלה "אלה שכן קיימים" אין תשובה "החמש הטובות", וחיתוך שלה מסתיר פריטים בלי קריטריון ובלי דרך לבקש את השאר. **והדגל הזה אומר את אותו דבר בשני המסלולים**: גם רשימת מזהים שנחתכה וגם רשימת כותרות קרובות שנחתכה מדליקות אותו. שדה בשם ``truncated`` שמשמעותו "נחתך" במסלול אחד ו"שקט" בשני הוא בדיוק ההפתעה שאנחנו מנסים למנוע. **ונקודה עוד יותר קלה לפספס: הנקודה שפותחת תת-מספור אינה גבול.** בכותרת ``K11. טקסט`` הנקודה **מסיימת** את המזהה, ובכותרת ``K11.1 טקסט`` היא **מפרידה** בתוך מזהה ארוך יותר — ולכן ``K11`` לעולם אינו מחזיר את ``K11.1``, וכותרת בתת-מספור נגישה בשמה המלא בלבד. זו אותה מחלקה בדיוק כמו ``K1`` שתופס את ``K10``: גבול שנבדק כתו בודד, בלי לשאול מה בא אחריו. **ההתנהגות על התיעוד כאן לא זזה, וזה נמדד ולא הונח:** הפלט של הכלי על כל קובצי ה-RST — כל כותרת, בכל צורותיה — זהה בית-בית לפני ואחרי. וההנחה שהכול נשען עליה, שאף כותרת בעמודים כאן אינה נפתחת במזהה, **אינה ספירה שנרשמה פעם אחת אלא טסט שרץ בכל CI**: ``tests/test_docs_headings_carry_no_identifier.py`` סורק את כל העמודים ונופל על כותרת שנפתחת במזהה, עם דוגמת autodoc אמיתית שמוכיחה שהוא מסוגל ליפול. **ומה שלא נבדק בקורפוס הזה נבדק בבקרה סינתטית**, כי סוללה שכל תשובותיה "לא נמצא" אינה מבדילה בין ענף כבוי לבין פרובים עיוורים. שני הדברים שמפתיעים קורא — קיצור המזהה, וכותרת שנושאת בקטיקים ולכן דורשת אותם בשאילתה — עברו לתיאור הפרמטר ``section``, שאינו נחתך אצל הלקוח כמו תיאור הכלי. ראו :ref:`mcp-docs-section`. diff --git a/mcp_server/repo_backend.py b/mcp_server/repo_backend.py index 4eb348f4c..c9e82cd66 100644 --- a/mcp_server/repo_backend.py +++ b/mcp_server/repo_backend.py @@ -38,11 +38,13 @@ #: ``too_large`` עם הפרמטר בדיוק כמו בלעדיו (#3317). ``webapp/app.py`` בן #: 805,594 הבייטים, הקובץ שהכי הרבה עובדים עליו, היה בלתי קריא דרך הכלי. #: -#: **למה תקרה אחרת ולא ביטול.** הבלוב עדיין נקרא ומפוענח במלואו לפני +#: **למה תקרה אחרת ולא ביטול.** בלוב שמתחת לתקרה נקרא ומפוענח במלואו לפני #: החיתוך, אז "בלי תקרה" פירושו שקובץ פתולוגי בריפו יגיע ל-RAM כמו שהוא. #: נמדד: קריאה ופענוח צורכים כפי שלושה מגודל הקובץ — 6.6MB הגיעו ל-35.7MB #: שיא. תקרה של 10MB חוסמת את זה בערך ב-30MB, ונותנת פי 12 מרווח מעל -#: הקובץ הגדול ביותר שבאמת קוראים. +#: הקובץ הגדול ביותר שבאמת קוראים. ומאז #3433 התקרה נבדקת מול גודל +#: האובייקט במאגר **לפני** ``git show``, ולכן קובץ שמעליה אינו נטען כלל — +#: החסם הוא על מה שנקרא, לא בדיקה בדיעבד. #: #: זו החלטת מדיניות של שכבת ה-MCP, לא של שירות המראה — ולכן היא כאן ולא #: שם, ואינה מייתרת את 500KB שממשיכה לחול על קריאה מלאה ועל הוובאפ. diff --git a/mcp_server/server.py b/mcp_server/server.py index ee9094438..3c383025f 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -750,7 +750,10 @@ def _memory_limit() -> tuple[int | None, str]: #: Pricing by ``RANGE_READ_MAX_BYTES`` would have cut the public path to 4 #: threads (at 77MiB) or 9 (at 37MiB) for a shape without a source. What did #: change because of this is :data:`_READ_POOL_CAP`; the root fix — bounding -#: the read at its source with a size probe before ``git show`` — is #3433. +#: the read at its source with a size probe before ``git show`` (#3433) — has +#: landed in ``get_file_at_commit``: a blob over ``max_size`` is refused from +#: ``git cat-file -s`` without being read (12MB: 20MiB peak before, 0 after, +#: measured), so the hold above is now only what a file *under* the cap costs. _PARSE_COST_BYTES = _PARSE_RSS_PER_INPUT_BYTE * MAX_FILE_SIZE_FOR_DISPLAY diff --git a/services/git_mirror_service.py b/services/git_mirror_service.py index 12654aa1b..99856ee65 100644 --- a/services/git_mirror_service.py +++ b/services/git_mirror_service.py @@ -1573,6 +1573,41 @@ def get_file_history( self.logger.error(f"Error getting file history: {e}") return {"error": "internal_error", "message": "שגיאה פנימית"} + def _object_size( + self, mirror_path: Path, resolved_commit: str, safe_file_path: str, timeout: float = 10 + ) -> Dict[str, Any]: + """גודל האובייקט ``:`` מהמאגר, בלי לקרוא את תוכנו — ``git cat-file -s``. + + מחזיר ``{"size": int}`` או ``{"error": ..., "message": ...}`` באותה מפה + של ``git show`` (:meth:`_object_read_error`), כי git 2.43 מדפיס את + **אותן** הודעות לשתי הפקודות על נתיב חסר (``path 'x' does not exist + in ''``) ועל קומיט שאינו מכיל אותו (``exists on disk, but not + in``) — נמדד, לא הונח. הפלט הוא קלט חיצוני (U3): מספר שאינו מתפענח + הוא כשל ולא אפס, כדי שלא ייקרא כ"קטן מהתקרה". ``TimeoutExpired`` עולה + לקורא, שכבר ממפה אותה ל-``timeout``. + """ + result = subprocess.run( + ["git", "-C", str(mirror_path), "cat-file", "-s", f"{resolved_commit}:{safe_file_path}"], + capture_output=True, + timeout=timeout, + ) + if result.returncode != 0: + return self._object_read_error(result.stderr) + try: + return {"size": int(result.stdout.decode("ascii", errors="replace").strip())} + except ValueError: + self.logger.warning("git cat-file -s returned a non-numeric size for %s", safe_file_path) + return {"error": "git_error", "message": "git לא החזיר גודל אובייקט תקין"} + + def _object_read_error(self, stderr_bytes: bytes) -> Dict[str, Any]: + """ממפה stderr של ``git show`` ושל ``git cat-file`` לתשובת שגיאה — הגדרה אחת לשתיהן.""" + stderr = self._sanitize_output(stderr_bytes.decode('utf-8', errors='replace')) + if "does not exist" in stderr or "exists on disk" in stderr: + return {"error": "file_not_in_commit", "message": "הקובץ לא קיים ב-commit זה"} + if "unknown revision" in stderr or "bad revision" in stderr: + return {"error": "invalid_commit", "message": "Commit לא נמצא"} + return {"error": "git_error", "message": stderr} + def get_file_at_commit( self, repo_name: str, @@ -1587,7 +1622,10 @@ def get_file_at_commit( repo_name: שם הריפו file_path: נתיב הקובץ commit: Hash, tag, branch או expression - max_size: גודל מקסימלי להחזרה (bytes) + max_size: תקרה בבתים. **נבדקת מול גודל האובייקט במאגר לפני + ``git show``** (#3433), ולכן קובץ גדול ממנה אינו נטען לזיכרון + כלל — ``max_size`` הוא חסם על מה שנקרא, לא בדיקה בדיעבד. ונבדקת + שוב על מה שמוחזר, כדי שהחסם יחול על כל סוג אובייקט. Returns: Dict עם תוכן הקובץ או שגיאה @@ -1615,6 +1653,29 @@ def get_file_at_commit( resolved_commit = ref_validation["resolved_sha"] try: + # **הגודל נבדק מול מאגר האובייקטים לפני שנקרא בית אחד של תוכן (#3433).** + # עד כאן ``git show`` נטען כולו לזיכרון (``capture_output=True``) ורק + # אז ``max_size`` נבדק — כלומר התקרה הייתה בדיקה בדיעבד ולא חסם על + # הזיכרון: קובץ של 12MB נטען במלואו (20MiB שיא, נמדד) לפני שנדחה. + # ``git cat-file -s`` מחזיר את גודל האובייקט מהמאגר בלי לקרוא אותו, + # ושתי הפקודות פונות לאותו ``:`` שנפתר פעם אחת למעלה — + # אובייקט בקומיט נתון אינו משתנה, ולכן אין חלון בין הבדיקה לקריאה + # שסנכרון של המראה יכול להיכנס בו (TOCTOU נבחן ונדחה). + probe = self._object_size(mirror_path, resolved_commit, safe_file_path) + if "error" in probe: + # כשל בבדיקה אינו נופל לקריאה בלי תקרה — זו בדיוק הנפילה-לאחור + # השקטה למסלול הגרוע (``silent-fallback-to-worse-path``). הסירוב + # עולה כמות שהוא, באותה מפה שהייתה ל-``git show``. + return probe + file_size = probe["size"] + if file_size > max_size: + return { + "error": "file_too_large", + "message": f"הקובץ גדול מדי ({file_size:,} bytes)", + "size": file_size, + "max_size": max_size + } + # שליפת תוכן כ-bytes (לא text) לזיהוי בינארי נכון cmd = [ "git", "-C", str(mirror_path), @@ -1629,25 +1690,15 @@ def get_file_at_commit( ) if result.returncode != 0: - stderr = result.stderr.decode('utf-8', errors='replace') - stderr = self._sanitize_output(stderr) - - if "does not exist" in stderr or "exists on disk" in stderr: - return { - "error": "file_not_in_commit", - "message": "הקובץ לא קיים ב-commit זה" - } - if "unknown revision" in stderr or "bad revision" in stderr: - return { - "error": "invalid_commit", - "message": "Commit לא נמצא" - } - return {"error": "git_error", "message": stderr} + return self._object_read_error(result.stderr) raw_content = result.stdout file_size = len(raw_content) - # בדיקת גודל + # **החסם על מה שמוחזר, בנוסף לחסם על מה שנקרא.** לבלוב שני + # המספרים זהים; לנתיב של תיקייה ``cat-file -s`` מודד את אובייקט + # העץ ואילו ``git show`` מדפיס רשימה מעוצבת — ומה שחוזר מכאן + # לעולם אינו גדול מ-``max_size``, יהיה סוג האובייקט אשר יהיה. if file_size > max_size: return { "error": "file_too_large", diff --git a/tests/test_git_mirror_service.py b/tests/test_git_mirror_service.py index 5c1e48000..faf8884f2 100644 --- a/tests/test_git_mirror_service.py +++ b/tests/test_git_mirror_service.py @@ -249,3 +249,157 @@ def test_constructor_token_overrides_map(tmp_path, monkeypatch): svc = GitMirrorService(base_path=str(tmp_path), github_token="ghp_EXPLICIT") # טוקן שהוזרק במפורש ל-constructor גובר על הכל assert svc._token_for_url("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/Campaign-AI4U/campaign-ai.git") == "ghp_EXPLICIT" + + +# --------------------------------------------------------------------------- +# get_file_at_commit: התקרה נבדקת מול מאגר האובייקטים לפני git show (#3433, פריט 9) +# --------------------------------------------------------------------------- +# +# עד #3433 ``git show`` נטען כולו לזיכרון ורק אז ``max_size`` נבדק — קובץ של +# 12MB שנדחה עלה 20MiB שיא. הטסטים כאן רצים על מראה git אמיתית שנבנית +# ב-``tmp_path`` (bare clone, כמו בייצור), ומרגלים על ``subprocess.run`` כדי +# לראות **אילו** פקודות git רצו — כי התכונה הנבדקת היא מה לא נקרא, ואת זה +# רואים רק ברשימת הפקודות. + +import subprocess # noqa: E402 + +from services import git_mirror_service as _gms # noqa: E402 + + +def _bare_mirror(tmp_path, files): + """מראה bare בשם ``probe`` תחת ``tmp_path``, עם הקבצים הנתונים בקומיט אחד; מחזיר את השירות ואת ה-sha.""" + src = tmp_path / "src" + src.mkdir() + for name, data in files.items(): + target = src / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + env = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@t", "PATH": __import__("os").environ["PATH"]} + for cmd in (["git", "init", "-q"], ["git", "add", "-A"], ["git", "commit", "-qm", "init"]): + subprocess.run(cmd, cwd=src, check=True, env=env, capture_output=True) + sha = subprocess.run(["git", "rev-parse", "HEAD"], cwd=src, check=True, env=env, + capture_output=True, text=True).stdout.strip() + subprocess.run(["git", "clone", "-q", "--bare", str(src), str(tmp_path / "probe.git")], + check=True, env=env, capture_output=True) + return GitMirrorService(base_path=str(tmp_path)), sha + + +def _spy_on_git(monkeypatch, override=None): + """מרגל שמעביר כל קריאה ל-``subprocess.run`` האמיתי ורושם את ה-argv; ``override(argv)`` יכול להחזיר תוצאה מזויפת.""" + calls = [] + real = subprocess.run + + def spy(cmd, *args, **kwargs): + calls.append(list(cmd)) + if override is not None: + faked = override(list(cmd)) + if faked is not None: + return faked + return real(cmd, *args, **kwargs) + + monkeypatch.setattr(_gms.subprocess, "run", spy) + return calls + + +def _subcommands(calls): + return [cmd[cmd.index("-C") + 2] if "-C" in cmd else cmd[1] for cmd in calls] + + +def test_a_file_over_the_cap_is_refused_before_git_show_reads_it(tmp_path, monkeypatch): + """הסירוב מגיע מ-``git cat-file -s``, ו-``git show`` אינו רץ כלל — אפס בתים נקראים.""" + svc, _ = _bare_mirror(tmp_path, {"big.bin": b"x" * 3000}) + calls = _spy_on_git(monkeypatch) + + out = svc.get_file_at_commit("probe", "big.bin", "HEAD", max_size=1000) + + assert out["error"] == "file_too_large" and out["size"] == 3000 and out["max_size"] == 1000 + assert "cat-file" in _subcommands(calls) + assert "show" not in _subcommands(calls), f"git show רץ על קובץ שמעל התקרה: {calls}" + + +def test_a_file_under_the_cap_is_read_exactly_as_before(tmp_path, monkeypatch): + """הבקרה: מתחת לתקרה התשובה זהה לזו של קודם — תוכן, קידוד, גודל, שורות, sha.""" + svc, sha = _bare_mirror(tmp_path, {"a.txt": "שלום\nעולם\n".encode("utf-8")}) + calls = _spy_on_git(monkeypatch) + + out = svc.get_file_at_commit("probe", "a.txt", "HEAD", max_size=1000) + + assert out["success"] and out["content"] == "שלום\nעולם\n" and out["is_binary"] is False + assert out["encoding"] == "utf-8" and out["size"] == len("שלום\nעולם\n".encode("utf-8")) + assert out["lines"] == 3 and out["resolved_commit"] == sha + assert "show" in _subcommands(calls), "מתחת לתקרה הקובץ כן נקרא" + + +def test_a_missing_path_is_still_file_not_in_commit_through_the_probe(tmp_path): + """git מדפיס את אותה הודעה ל-``cat-file -s`` ול-``show`` על נתיב חסר, והמפה אחת לשתיהן.""" + svc, _ = _bare_mirror(tmp_path, {"a.txt": b"x"}) + out = svc.get_file_at_commit("probe", "nope.txt", "HEAD") + assert out == {"error": "file_not_in_commit", "message": "הקובץ לא קיים ב-commit זה"} + + +def test_both_commands_name_the_same_resolved_commit(tmp_path, monkeypatch): + """בלי חלון בין הבדיקה לקריאה: שתי הפקודות פונות ל-``:`` שנפתר פעם אחת, לא ל-``HEAD``.""" + svc, sha = _bare_mirror(tmp_path, {"a.txt": b"x"}) + calls = _spy_on_git(monkeypatch) + + svc.get_file_at_commit("probe", "a.txt", "HEAD") + + # ``rev-parse`` של אימות ה-ref רץ גם הוא עם ``-C``; כאן מעניינות רק שתי פקודות האובייקט. + targets = {sub: cmd[-1] for cmd in calls if "-C" in cmd + for sub in [cmd[cmd.index("-C") + 2]] if sub in ("cat-file", "show")} + assert targets == {"cat-file": f"{sha}:a.txt", "show": f"{sha}:a.txt"} + assert [s for s in _subcommands(calls) if s in ("cat-file", "show")] == ["cat-file", "show"], ( + "הבדיקה קודמת לקריאה — זה הסדר שהופך את max_size לחסם על מה שנקרא") + + +def test_a_failed_size_probe_never_falls_back_to_an_unbounded_read(tmp_path, monkeypatch): + """כשל בבדיקת הגודל הוא סירוב, לא קריאה בלי תקרה — ``silent-fallback-to-worse-path``.""" + svc, _ = _bare_mirror(tmp_path, {"a.txt": b"x" * 3000}) + + def fail_the_probe(cmd): + if "cat-file" in cmd: + return subprocess.CompletedProcess(cmd, 128, stdout=b"", stderr=b"fatal: object store unreadable") + return None + + calls = _spy_on_git(monkeypatch, override=fail_the_probe) + out = svc.get_file_at_commit("probe", "a.txt", "HEAD", max_size=10) + + assert out["error"] == "git_error" and "unreadable" in out["message"] + assert "show" not in _subcommands(calls) + + +def test_a_non_numeric_size_is_a_failure_and_not_a_small_file(tmp_path, monkeypatch): + """הפלט של git הוא קלט חיצוני (U3): "lots" אינו אפס, והוא אינו עובר את התקרה בשקט.""" + svc, _ = _bare_mirror(tmp_path, {"a.txt": b"x" * 3000}) + + def garble_the_probe(cmd): + if "cat-file" in cmd: + return subprocess.CompletedProcess(cmd, 0, stdout=b"lots\n", stderr=b"") + return None + + calls = _spy_on_git(monkeypatch, override=garble_the_probe) + out = svc.get_file_at_commit("probe", "a.txt", "HEAD", max_size=10) + + assert out["error"] == "git_error" + assert "show" not in _subcommands(calls) + + +def test_what_comes_back_never_exceeds_the_cap_whatever_the_probe_said(tmp_path, monkeypatch): + """החסם השני, על מה שמוחזר: גם אם המאגר דיווח גודל קטן, מה ש-``git show`` הדפיס נבדק שוב. + + פין של תכונה שהייתה קיימת — הבדיקה בדיעבד — ונשארת בכוונה, כי לנתיב + של תיקייה ``cat-file -s`` מודד את אובייקט העץ ואילו ``git show`` מדפיס + רשימה מעוצבת. הדרך היחידה לבודד אותה היא מאגר שמשקר. + """ + svc, _ = _bare_mirror(tmp_path, {"a.txt": b"x" * 3000}) + + def understate_the_probe(cmd): + if "cat-file" in cmd: + return subprocess.CompletedProcess(cmd, 0, stdout=b"10\n", stderr=b"") + return None + + _spy_on_git(monkeypatch, override=understate_the_probe) + out = svc.get_file_at_commit("probe", "a.txt", "HEAD", max_size=1000) + + assert out["error"] == "file_too_large" and out["size"] == 3000 From be911e08cc43a469861920f9dddc5f08cc900510 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 23:46:12 +0000 Subject: [PATCH 5/9] =?UTF-8?q?chore(mcp):=20=D7=90=D7=A8=D7=91=D7=A2?= =?UTF-8?q?=D7=94=20=D7=A4=D7=A8=D7=99=D7=98=D7=99=D7=9D=20=D7=A7=D7=98?= =?UTF-8?q?=D7=A0=D7=99=D7=9D=20=D7=9E=D7=A1=D7=A7=D7=99=D7=A8=D7=AA=20#34?= =?UTF-8?q?29=20=E2=80=94=20SUGG-014,=20SUGG-001,=20SUGG-004,=20SUGG-010?= =?UTF-8?q?=20(#3433)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SUGG-004: שורת הקיבולת קוראת את ThreadPoolExecutor._max_workers הפרטי דרך _installed_width, וכשהמאפיין ייעלם היא מדפיסה את הרוחב שהתבקש עם "(requested; installed width unreadable)" ו-WARNING שאומר למה — במקום שהשירות לא יעלה בגלל שורת לוג, ובמקום להדהד את הבקשה כאילו היא המצב (state-record-without-state-change). אותו כלל גם ב-WARNING של ה-fallback. - SUGG-010: md_parser.token_count — פונקציה ציבורית ומתועדת שסופרת טוקנים על _MD, המופע שהכלי מריץ — במקום שהסקריפט יקרא ל-_build_parser הפרטית ויבנה פרסר חדש לכל קובץ. מחוץ ל-__all__ בכוונה: הרשימה שם היא החוזה של "שני פארסרים בני-החלפה", וטסט מקבע את ההפרש בינה לבין זו של rst_parser. - SUGG-014: שני טסטים לקובץ memory.max שקיים אבל ריק (IndexError) או לא-מספרי (ValueError) — שניהם נופלים ל-cgroup v1 ומחזירים את הערך שלו. כיסוי השורות היה מלא; התרחיש חסר. מוטציה שמסירה כל אחת מהחריגות מה-except מפילה את הטסט שלה. - SUGG-001: האסרשן שלא יכול היה ליפול הוחלף: os.cpu_count מוצמד ל-16 כדי ש"cpu_count + 4" יהיה 20, מספר שהרצפה לעולם אינה — ומוטציה שמחזירה את ה-fallback ל-min(32, cpu_count + 4) מפילה אותו. על origin/main: שלושה טסטים נופלים (הרוחב הלא-קריא, token_count, הסקריפט דרך הפונקציה הציבורית); שלושת האחרים עוברים שם ומוכחים במוטציות. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SfJTSpDAhDr2yhtmpFkwTx --- docs/whats-new.rst | 1 + mcp_server/server.py | 42 +++++++++++-- scripts/measure_md_parse_cost.py | 2 +- services/md_parser.py | 19 ++++++ tests/test_mcp_to_thread.py | 72 ++++++++++++++++++++-- tests/test_md_parser.py | 13 ++++ tests/test_measure_md_parse_cost_script.py | 13 ++++ 7 files changed, 152 insertions(+), 10 deletions(-) diff --git a/docs/whats-new.rst b/docs/whats-new.rst index ef65c8cd0..1670fbad4 100644 --- a/docs/whats-new.rst +++ b/docs/whats-new.rst @@ -4,6 +4,7 @@ What's New 2026-09-20 ---------- +- **chore (MCP): ארבעה פריטים קטנים מסקירת #3429 (#3433).** שורת הקיבולת של מאגר הקריאות קוראת את ``ThreadPoolExecutor._max_workers`` הפרטי דרך ``_installed_width``, וכשהמאפיין ייעלם היא אומרת "unreadable" לצד הרוחב שהתבקש עם WARNING, במקום שהשירות לא יעלה בגלל שורת לוג (SUGG-004); ‏``scripts/measure_md_parse_cost.py`` מודד צפיפות דרך ``md_parser.token_count`` הציבורית — על המופע שהכלי מריץ — במקום לקרוא ל-``_build_parser`` הפרטית ולבנות פרסר לכל קובץ (SUGG-010); שני טסטים חדשים מקבעים שקובץ ``memory.max`` שקיים אבל ריק או לא-מספרי נופל ל-cgroup v1 (SUGG-014); ואסרשן שלא יכול היה ליפול הוחלף בטסט שמצמיד ``os.cpu_count`` ל-16 כדי שה-fallback ייבדק מול 20 ולא מול עצמו (SUGG-001). - **feat: ``codekeeper_docs_get_section`` קורא גם קובצי Markdown, ולכל ריפו מדיניות נתיבים משלו.** עד היום הכלי ידע לקרוא ``.rst`` בלבד, ורק מתחת ל-``docs/``. הפארסר של Markdown נכתב ונמדד כבר קודם — ואף אחד לא קרא לו; זה השינוי שמחווט אותו. מעכשיו הסיומת בוחרת את הפארסר, ולכל ריפו מוצהר **איפה התיעוד שלו יושב ובאיזה פורמט**: ``CodeBot`` נשאר ``docs/`` עם ``.rst`` ללא שינוי, ו-``amir-bug-patterns`` מגיש ``.md`` משורש הריפו. **וזו הרחבה מכוונת של גבול הקריאה** — הכלי ציבורי, כלומר כל משתמש מאומת מפעיל אותו בלי אדמין, והריפו שנוסף ציבורי ב-GitHub. מה ש**לא** השתנה: מדיניות סינון הסודות חלה על המסלול הזה במלואה, ולכן ``path="secrets"`` מחזיר ``path_denied`` ולא ``not_found``, וגם ``secrets.md`` ו-``credentials.md`` חסומים; ותקרת 500KB של שירות המראה נשארת ההגנה על הפרסור, כי הכלי קורא את הקובץ כולו בלי ``lines``. **שני שערים ולא אחד:** ``MCP_DOCS_REPO`` מתיר בזמן ריצה, טבלת המדיניות בקוד יודעת לקרוא, וריפו שנמצא במשתנה הסביבה ואין לו מדיניות נדחה ב-``repo_not_configured`` במקום ליפול לברירת מחדל מתירנית. **ובקשה לפורמט שהריפו אינו מגיש נדחית בשמה** — ``suffix_not_allowed`` עם רשימת הסיומות שכן מוגשות, במקום להשלים סיומת בשקט מעל הקיימת ולחזור כ"לא נמצא" על קובץ שקיים. שני סירובים חדשים מגיעים מהפרסור עצמו: ``inconsistent_line_endings`` על ``\r`` בודד (CRLF עובד במלואו), ו-``too_many_sections`` על קובץ שחוצה את תקרת 50,000 הכותרות — Markdown דרך ברירת המחדל של הפרסר, ו-RST דרך התקרה שהכלי מעביר מאז #3429; סירוב שהפארסר מספק לו מספר שורה נושא גם ``line`` — תמיד ב-Markdown, ולא ב-``too_many_sections`` של RST, כי ``rst_parser`` מרים אותה בלי מספר. ``includes`` הוא שדה של RST ולכן בעמוד ``.md`` הוא תמיד ריק. **ומסלול ה-RST לא זז**: אפס דיף מדוד על 5,920 רשומות מ-208 קובצי RST, לפני ואחרי. ראו :ref:`mcp-docs-path-policy`. - **fix: מאגר הקריאות של ה-MCP נגזר ממכסת הזיכרון של הקונטיינר, ולא ממספר המעבדים של המארח (#3391).** כלי הקריאה רצים ב-``asyncio.to_thread``, כלומר ב-executor ברירת המחדל של הלולאה, שגודלו ``min(32, os.cpu_count() + 4)`` — ו-``os.cpu_count()`` בקונטיינר הוא מספר הליבות של **המארח**, כפי ש-CPython מתעד במפורש. שורת הקיבולת מהייצור אמרה את זה במספרים: ``read pool 12 threads (os.cpu_count=8, usable=8)`` על מכסה של ``0.50 cpu``. לקריאת מונגו זה לא מזיק; לפרסור בפייתון טהור אין מקביליות ב-CPU בגלל ה-GIL, ולכן 12 חוטים היו 12 עצי פרסור בזיכרון בו-זמנית — יותר ממה שנכנס ב-512MiB — ורגע לפני שהפרסור הופך לכלי ציבורי. מעכשיו ``attach_read_pool`` מתקין בעליית ה-lifespan של אפליקציית ה-ASGI מאגר משלו, בגודל ``(מגבלת הזיכרון − קו בסיס − שוליים) ÷ עלות פרסור אחד``: המגבלה נקראת מ-cgroup בזמן ריצה (``memory.max`` ב-v2, ‏``memory.limit_in_bytes`` ב-v1) ולא מקובעת בקוד, קו הבסיס והשוליים הם מדידות מהשירות בייצור עם תאריך, ועלות הפרסור נגזרת מ-``MAX_FILE_SIZE_FOR_DISPLAY`` כי הכלי דוחה ``too_large`` לפני שהוא מפרסר. על תוכנית הייצור זה **10** חוטים במקום 12 — עלות הפרסור נמדדה מחדש על הפרסר הנעוץ (``scripts/measure_md_parse_cost.py``; הקבוע נמדד על ``services.md_parser`` כהכנה לכלי ה-Markdown של PR 5, בעוד הכלי הציבורי היום מפרסר RST ב-``services.rst_parser``, שעולה כ-6 בתים לבית גם על העמוד הצפוף ביותר — והסקריפט מודד את שניהם), והיא תלויה בצפיפות הטוקנים של המסמך ולא בגודלו: המסמך הצפוף ביותר בקורפוס הוא הקבוע, וצורה עוינת של שורות-תבליט בודדות עולה פי ארבעה ממנו — מה ששום רוחב מאגר אינו סופג, ומחכה לתקרת טוקנים בפרסר עצמו. **ובעקבות הסקירה:** ``codekeeper_docs_get_section`` מעביר לפרסר את תקרת הסקשנים של האאוטליין (50,000) ומחזיר ``too_many_sections`` במקום לפרסר את הקובץ כולו — צורה עוינת של RST (כותרת בת תו אחד בכל שורה) עלתה 44.0MiB לפרסור, יותר מ-35.2MiB שהמאגר מקצה לחוט, ועכשיו נעצרת ב-20.1MiB; אף עמוד אמיתי אינו מתקרב לתקרה. רצפה של 2 (קריאה תקועה אחת לא משביתה את השאר), תקרה של 12, הרוחב שהייצור כבר הריץ (מעבר לתוכנית גדולה לא מרחיב את המאגר בלי החלטה; הגרסה הראשונה נקבה ב-32, והסקירה של #3429 הורידה אותה כי המחלק מתאר את הפרסור ולא את קריאות הטווח של האדמין; חסימת הקריאה במקור — #3433), וכשאין מגבלה קריאה — הרצפה, **לא** ``cpu_count + 4``, עם WARNING שאומר שזה קרה. **ושורת הקיבולת מפסיקה לחשב בעצמה:** היא נרשמת מתוך ה-lifespan, קוראת את ``_max_workers`` של המאגר שהותקן בפועל, ומדווחת גם את מגבלת הזיכרון לצד מכסת ה-CPU — כי רשומה שמתארת מצב ומתעדכנת בנפרד ממנו היא בדיוק מה שמשקר בשקט. ה-seam הוא עטיפת ``router.lifespan_context``, אותו seam שניקוז ה-PostHog כבר משתמש בו, כי ``FastMCP.streamable_http_app()`` בונה את Starlette עם ``lifespan=`` משלה ו-``on_startup`` אינו רץ. הטסט המרכזי נכנס ל-lifespan של האפליקציה האמיתית ומוכיח שקריאת ``to_thread`` נוחתת על חוט של המאגר החדש — לא שפונקציה נקראה; שורת הקיבולת נבדקת בתת-תהליך נקי דרך הפלט האמיתי ולא דרך ``caplog``. ראו "מודל הריצה של הכלים" ב-:doc:`mcp-server`. - **feat: ``codekeeper_docs_get_section`` מוצא סעיף גם לפי המזהה שלו, ולא רק לפי השם המלא.** הסוכנים מפנים זה לזה לפי מזהה — "קרא K11", "ראו U3" — ועד היום ``section="K11"`` החזיר ``section_not_found`` עם רשימת הצעות **ריקה**, כי ההתאמה הייתה שוויון מלא ו-``difflib`` עם ``cutoff=0.5`` אינו מוצא קרבה בין שלושה תווים לכותרת עברית ארוכה. כלומר הזרימה המרכזית של הכלי לא עבדה. מעכשיו, **ורק אחרי שהשוויון המלא לא מצא כלום**, שאילתה שבנויה כמזהה (עד שלוש אותיות ואחריהן עד שלוש ספרות, עם נקודה אופציונלית) מותאמת לכותרת שנפתחת באותו מזהה. **והמזהה נקרא כיחידה ולא כקידומת של מחרוזת**, ולכן ``K1`` מחזיר את ``K1`` בלבד ולעולם לא את ``K10`` עד ``K15`` — הצורה השבורה של הבדיקה הזאת היא אותה מחלקה בדיוק שבה גבול נתיב נבדק כרצף תווים. שאילתה שאינה בנויה כמזהה אינה עוברת במסלול הזה כלל, ולכן ``section="איך"`` ממשיך להחזיר אפס התאמות גם בעמוד שיש בו חמש-עשרה כותרות שמתחילות במילה הזאת. מזהה שחוזר פעמיים הוא ``ambiguous_section`` עם מועמדים, כמו כל כותרת כפולה, ושאילתת מזהה שלא נמצאה מקבלת ב-``suggestions`` קודם כול כותרת קרובה, אם יש כזו בעמוד, **ורק כשאין אף כותרת קרובה** את המזהים שכן קיימים — עד 50, ועם ``suggestions_truncated`` כשהיו עוד. **והתקרה גבוהה בכוונה**, כי רשימת מזהים אינה מדורגת: לשאלה "אלה שכן קיימים" אין תשובה "החמש הטובות", וחיתוך שלה מסתיר פריטים בלי קריטריון ובלי דרך לבקש את השאר. **והדגל הזה אומר את אותו דבר בשני המסלולים**: גם רשימת מזהים שנחתכה וגם רשימת כותרות קרובות שנחתכה מדליקות אותו. שדה בשם ``truncated`` שמשמעותו "נחתך" במסלול אחד ו"שקט" בשני הוא בדיוק ההפתעה שאנחנו מנסים למנוע. **ונקודה עוד יותר קלה לפספס: הנקודה שפותחת תת-מספור אינה גבול.** בכותרת ``K11. טקסט`` הנקודה **מסיימת** את המזהה, ובכותרת ``K11.1 טקסט`` היא **מפרידה** בתוך מזהה ארוך יותר — ולכן ``K11`` לעולם אינו מחזיר את ``K11.1``, וכותרת בתת-מספור נגישה בשמה המלא בלבד. זו אותה מחלקה בדיוק כמו ``K1`` שתופס את ``K10``: גבול שנבדק כתו בודד, בלי לשאול מה בא אחריו. **ההתנהגות על התיעוד כאן לא זזה, וזה נמדד ולא הונח:** הפלט של הכלי על כל קובצי ה-RST — כל כותרת, בכל צורותיה — זהה בית-בית לפני ואחרי. וההנחה שהכול נשען עליה, שאף כותרת בעמודים כאן אינה נפתחת במזהה, **אינה ספירה שנרשמה פעם אחת אלא טסט שרץ בכל CI**: ``tests/test_docs_headings_carry_no_identifier.py`` סורק את כל העמודים ונופל על כותרת שנפתחת במזהה, עם דוגמת autodoc אמיתית שמוכיחה שהוא מסוגל ליפול. **ומה שלא נבדק בקורפוס הזה נבדק בבקרה סינתטית**, כי סוללה שכל תשובותיה "לא נמצא" אינה מבדילה בין ענף כבוי לבין פרובים עיוורים. שני הדברים שמפתיעים קורא — קיצור המזהה, וכותרת שנושאת בקטיקים ולכן דורשת אותם בשאילתה — עברו לתיאור הפרמטר ``section``, שאינו נחתך אצל הלקוח כמו תיאור הכלי. ראו :ref:`mcp-docs-section`. diff --git a/mcp_server/server.py b/mcp_server/server.py index ee9094438..2f82c0724 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -803,6 +803,28 @@ def _read_pool_size(memory_limit: int | None) -> _ReadPoolSizing: return _ReadPoolSizing(int(allowed), "memory", f"sized from memory: {arithmetic}") +def _installed_width(pool: ThreadPoolExecutor) -> int | None: + """The width the executor actually has — or ``None`` when this Python no longer exposes it. + + ``ThreadPoolExecutor._max_workers`` is private to CPython, and it is read + on purpose (``state-record-without-state-change``: the capacity line + describes the pool that exists, not the one that was requested). Private + means it can go away. When it does, the answer here is ``None`` — not the + requested width, which would be exactly the echo the line exists to avoid + — and the callers print that they do not know, with a warning, instead of + failing the startup of the service over a log line (#3433, SUGG-004). + """ + return getattr(pool, "_max_workers", None) + + +def _width_label(pool: ThreadPoolExecutor, sizing: _ReadPoolSizing) -> str: + """What the capacity line prints for the read pool: the installed width, or an honest "unreadable".""" + width = _installed_width(pool) + if width is not None: + return str(width) + return f"{sizing.workers} (requested; installed width unreadable)" + + def _log_dispatch_capacity( read_pool: ThreadPoolExecutor, memory_display: str, sizing: _ReadPoolSizing ) -> None: @@ -821,7 +843,10 @@ def _log_dispatch_capacity( number the pool is sized from. Every value is computed before the call rather than inside it, so a level - guard or a deleted line takes the line and nothing else with it. + guard or a deleted line takes the line and nothing else with it. And the + private attribute is read through :func:`_installed_width`: should it + vanish, the line says "unreadable" beside the requested width and a + warning names the reason — a log line never fails the startup. """ detected = os.cpu_count() or 1 try: @@ -829,10 +854,17 @@ def _log_dispatch_capacity( except (AttributeError, OSError): usable = detected quota = _cpu_budget() + if _installed_width(read_pool) is None: + logger.warning( + "mcp read pool width is not readable on this Python " + "(ThreadPoolExecutor._max_workers is gone); the capacity line reports " + "the requested width %d instead of the installed one", + sizing.workers, + ) logger.info( - "mcp dispatch capacity: read pool %d threads (%s; os.cpu_count=%d, " + "mcp dispatch capacity: read pool %s threads (%s; os.cpu_count=%d, " "usable=%d), write pool 1 thread, cpu quota %s, memory limit %s", - read_pool._max_workers, + _width_label(read_pool, sizing), sizing.detail, detected, usable, @@ -904,9 +936,9 @@ async def _lifespan_with_read_pool(scope_app: Any): # path nobody reports is the one that stays. The capacity line says # the size; this says that it was not chosen. logger.warning( - "mcp read pool fell back to %d threads: memory limit %s — reads are " + "mcp read pool fell back to %s threads: memory limit %s — reads are " "narrower than the plan allows until the cgroup limit is readable", - pool._max_workers, + _width_label(pool, sizing), memory_display, ) async with original(scope_app) as state: diff --git a/scripts/measure_md_parse_cost.py b/scripts/measure_md_parse_cost.py index f064db5d2..5a208c5ed 100644 --- a/scripts/measure_md_parse_cost.py +++ b/scripts/measure_md_parse_cost.py @@ -160,7 +160,7 @@ def density(text: str, parser: str = "md") -> float: if parser == "md": from services import md_parser - return len(md_parser._build_parser().parse(text)) * 1024 / size + return md_parser.token_count(text) * 1024 / size from services import rst_parser return len(rst_parser.parse_document(text).sections) * 1024 / size diff --git a/services/md_parser.py b/services/md_parser.py index cf5ca530d..90578afa9 100644 --- a/services/md_parser.py +++ b/services/md_parser.py @@ -318,6 +318,25 @@ def _build_parser() -> MarkdownIt: _MD = _build_parser() +def token_count(text: str) -> int: + """מספר הטוקנים שהפרסר של הכלי מייצר לטקסט — מדד צפיפות, לא מפה. + + קיים בשביל ``scripts/measure_md_parse_cost.py``, שמדרג מסמכים לפי + צפיפות טוקני-בלוק ל-KB, וקרא עד #3433 ל-``_build_parser`` הפרטית — + תלות סמויה בפנימי של המודול, ובנייה של פרסר חדש לכל קובץ. הפונקציה + הזאת היא התלות בגלוי: על ``_MD``, המופע שהכלי מריץ, בלי ``env`` ולכן + בלי תקרה (``_ceiling_rule`` מקבל ``None`` ומחזיר ``False``), ובלי + טוקני inline (``disable("inline")``) — כלומר בדיוק מה שהפרסור האמיתי + סופר. + + **ומחוץ ל-``__all__`` בכוונה:** הרשימה שם היא החוזה של "שני פארסרים + בני-החלפה", וטסט מקבע את ההפרש בינה לבין זו של ``rst_parser``. מדד + למדידה אינו חלק מהחוזה הזה ואינו קיים ל-RST, ולכן הוא ציבורי (בלי קו + תחתון, מתועד) אך אינו מיוצא. + """ + return len(_MD.parse(text)) + + def parse_document(text: str, *, max_sections: Optional[int] = MAX_SECTIONS) -> Document: """בונה ``Document`` מטקסט Markdown. diff --git a/tests/test_mcp_to_thread.py b/tests/test_mcp_to_thread.py index 74d9e50b1..1bb90fbae 100644 --- a/tests/test_mcp_to_thread.py +++ b/tests/test_mcp_to_thread.py @@ -962,6 +962,29 @@ def test_startup_names_the_read_pool_the_cpu_quota_and_the_memory_limit(caplog): assert expected in line, (expected, line) +def test_a_pool_without_a_readable_width_does_not_fail_startup_and_says_so(caplog): + """``_max_workers`` is CPython-private; if it goes, the line says so instead of the service not starting. + + A record that describes state reads the state — and when the state cannot + be read, it says *that*, rather than echoing the request as if it were + the state. The pool here is an object with no ``_max_workers`` at all, + which is what a future Python would look like to this code (#3433, + SUGG-004). + """ + import logging as _logging + import types as _types + + from mcp_server.server import _log_dispatch_capacity, _read_pool_size + + with caplog.at_level(_logging.INFO, logger="mcp_server.server"): + _log_dispatch_capacity(_types.SimpleNamespace(), "cgroup v2: 512.0MiB", _read_pool_size(512 * _MiB)) + + info = [r.getMessage() for r in caplog.records if r.levelno == _logging.INFO] + warnings = [r.getMessage() for r in caplog.records if r.levelno == _logging.WARNING] + assert any("read pool 10 (requested; installed width unreadable) threads" in m for m in info), info + assert any("_max_workers" in m for m in warnings), warnings + + def test_the_pool_is_sized_from_the_memory_budget(): """``(limit - baseline - margin) // cost of one parse`` — and a different limit gives a different answer. @@ -1009,20 +1032,25 @@ def test_the_pool_never_rises_above_the_cap(): assert sizing.detail.startswith("cap 12:"), sizing.detail -def test_no_readable_memory_limit_falls_back_to_the_floor_and_not_to_cpu_count(): +def test_no_readable_memory_limit_falls_back_to_the_floor_and_not_to_cpu_count(monkeypatch): """The fallback is the conservative constant, and it is not ``cpu_count + 4``. ``cpu_count + 4`` is exactly the number this sizing exists to stop relying - on, so the test pins the fallback to the floor on any machine — on a - 16-core box the old default would have given 20. + on. ``os.cpu_count`` is pinned to 16 here so that the old default, + ``min(32, 16 + 4) = 20``, is a number the floor can never be — a fallback + that consulted the host's cores would answer 20 and fail. The line this + replaced (``workers != min(32, cpu_count + 4) or workers == 2``) could not + fail after the assertion before it had pinned ``workers == 2`` (#3433, + SUGG-001). """ import os from mcp_server.server import _READ_POOL_FLOOR, _read_pool_size + monkeypatch.setattr(os, "cpu_count", lambda: 16) + sizing = _read_pool_size(None) assert (sizing.workers, sizing.source) == (_READ_POOL_FLOOR, "fallback"), sizing - assert sizing.workers != min(32, (os.cpu_count() or 1) + 4) or sizing.workers == 2 assert "no memory limit readable" in sizing.detail, sizing.detail @@ -1100,6 +1128,42 @@ def test_the_memory_limit_is_read_from_cgroup_v1_when_v2_is_absent(tmp_path, mon assert server_module._memory_limit() == (268435456, "cgroup v1: 256.0MiB") +def test_an_empty_cgroup_v2_file_falls_through_to_v1(tmp_path, monkeypatch): + """A ``memory.max`` that exists but holds nothing is not a limit of zero — v1 answers. + + ``split()`` of an empty file is ``[]`` and ``raw[0]`` is the ``IndexError`` + the reader's ``except`` names; until now only a *missing* v2 file proved + the fall-through (#3433, SUGG-014). Line coverage was complete, the + scenario was not. + """ + import mcp_server.server as server_module + + _fake_cgroup( + monkeypatch, + tmp_path, + { + "/sys/fs/cgroup/memory.max": "", + "/sys/fs/cgroup/memory/memory.limit_in_bytes": "268435456\n", + }, + ) + assert server_module._memory_limit() == (268435456, "cgroup v1: 256.0MiB") + + +def test_a_non_numeric_cgroup_v2_file_falls_through_to_v1(tmp_path, monkeypatch): + """``memory.max`` with a word that is not ``max`` is the ``ValueError`` branch — v1 answers.""" + import mcp_server.server as server_module + + _fake_cgroup( + monkeypatch, + tmp_path, + { + "/sys/fs/cgroup/memory.max": "lots\n", + "/sys/fs/cgroup/memory/memory.limit_in_bytes": "268435456\n", + }, + ) + assert server_module._memory_limit() == (268435456, "cgroup v1: 256.0MiB") + + def test_the_cgroup_v1_unlimited_sentinel_is_no_limit(tmp_path, monkeypatch): """v1 has no word for unlimited: it reads back ``LONG_MAX`` rounded to a page. diff --git a/tests/test_md_parser.py b/tests/test_md_parser.py index ef351ec15..3b28fe0f2 100644 --- a/tests/test_md_parser.py +++ b/tests/test_md_parser.py @@ -430,6 +430,19 @@ def spy(self, chainName=""): ) +def test_token_count_is_the_tool_parsers_own_count_and_not_an_export(): + """המדד למדידה סופר על ``_MD`` — המופע שהכלי מריץ — ונשאר מחוץ לחוזה של ``__all__``. + + הסקריפט שמודד את עלות הפרסור קרא ל-``_build_parser`` הפרטית ובנה פרסר + חדש לכל קובץ; ``token_count`` היא אותה תלות בגלוי (#3433, SUGG-010). + """ + text = "---\na: 1\n---\n\n# כותרת\n\n> ## בציטוט\n\n- פריט\n\n| a | b |\n|---|---|\n| 1 | 2 |\n" + + assert md_parser.token_count(text) == len(md_parser._MD.parse(text)) + assert md_parser.token_count("") == 0 + assert "token_count" not in md_parser.__all__ + + def test_the_ceiling_anchor_fails_loudly_when_the_plugin_is_not_registered(): """העוגן ``before("front_matter", ...)`` קונה כשל מיידי — ועל מה בדיוק. diff --git a/tests/test_measure_md_parse_cost_script.py b/tests/test_measure_md_parse_cost_script.py index a2320c987..9137fc386 100644 --- a/tests/test_measure_md_parse_cost_script.py +++ b/tests/test_measure_md_parse_cost_script.py @@ -128,6 +128,19 @@ def test_tiling_stops_at_the_ceiling_and_is_longer_than_its_input(): assert 997 <= len(script.tiled_to_ceiling(text, 1000).encode("utf-8")) <= 1000 +def test_density_goes_through_the_public_token_count_and_not_the_private_builder(monkeypatch): + """הסקריפט אינו קורא ל-``md_parser._build_parser`` (#3433, SUGG-010): התלות היא ``token_count``.""" + from services import md_parser + + script = _load_script() + + def _forbidden(): + raise AssertionError("הסקריפט קרא ל-_build_parser הפרטית") + + monkeypatch.setattr(md_parser, "_build_parser", _forbidden) + assert script.density("- a\n" * 10, "md") > 0 + + def test_density_ranks_a_dense_document_above_a_sparse_one_of_the_same_size(): """הצפיפות היא מה שמדרג: טוקני בלוק ל-KB ב-Markdown, סקשנים ל-KB ב-RST.""" script = _load_script() From 09cae3215e65284e5d475cb1762795aa30aa0912 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 00:00:00 +0000 Subject: [PATCH 6/9] =?UTF-8?q?refactor:=20=D7=A9=D7=9C=D7=95=D7=A9=D7=94?= =?UTF-8?q?=20=D7=9B=D7=9C=D7=9C=D7=99=D7=9D=20=D7=A9=D7=A0=D7=9B=D7=AA?= =?UTF-8?q?=D7=91=D7=95=20=D7=A4=D7=A2=D7=9E=D7=99=D7=99=D7=9D=20=D7=97?= =?UTF-8?q?=D7=96=D7=A8=D7=95=20=D7=9C=D7=9E=D7=A7=D7=95=D7=9D=20=D7=90?= =?UTF-8?q?=D7=97=D7=93=20=E2=80=94=20CR=20=D7=91=D7=95=D7=93=D7=93,=20?= =?UTF-8?q?=D7=92=D7=91=D7=95=D7=9C=20front=20matter,=20=D7=AA=D7=95=D7=95?= =?UTF-8?q?=D7=99=D7=9D=20=D7=A0=D7=A1=D7=AA=D7=A8=D7=99=D7=9D=20(#3419,?= =?UTF-8?q?=20#3427)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - כלל ה-\r הבודד (סיומת שורה שאינה חד-משמעית) ישב גם ב-mcp_server/outline.py וגם ב-services/md_parser.py, ושני העותקים היו צריכים להסכים לנצח (R6). עכשיו הוא במודול עלה חדש, services/line_endings.py (CR_WITHOUT_LF, find_lone_cr), ושני הצרכנים קוראים לו; הכיוון חד-סטרי (mcp_server מייבא מ-services). טסט מוכיח מול markdown-it-py שהכלל נכון, וטסט מבני מוכיח ששני הצרכנים מייבאים אותו ואף אחד מהם אינו מחזיק עותק. - scripts/generate_ai_map.py::_body_start כתב ביד את גבול ה-front matter, ונמדד ב-#3418 שהוא חולק על התוסף ש-MyST מריץ בחמש מתוך שתים-עשרה צורות. עכשיו הוא קורא את הגבול מהפארסר של הכלי — md_parser.front_matter_end, דרך mdit_py_plugins.front_matter על אותו מופע — והסקריפט מוסיף את שורש הריפו ל-sys.path כדי לרוץ לבדו כמו קודם. אפס-דיף: AI-MAP.md שנוצר זהה בית-בית לזה שבריפו (וגם לפלט הקוד הישן). הטסט שקיבע את הפער בכוונה (test_the_front_matter_rules_still_disagree_as_measured) נערך יחד עם הסגירה, כפי שה-docstring שלו דרש: הטבלה נשארה עם עמודת אמת אחת (התוסף), ושני הקוראים נבדקים מולה; הטסט שהצמיד את המספר "חמש מתוך שתים-עשרה" לפרוזה הוסר יחד עם הפער, והפרוזה (md_parser, requirements/base.txt) מספרת עכשיו את ההיסטוריה. - #3427: known_hex4 (utils) ו-_KNOWN_ESCAPE_HEX4 (CodeNormalizer) — שתי רשימות זהות של 16 קודים שנבדקו לפני בדיקת הקטגוריה, וכל 16 הם Cf, כלומר הרשימה לא הוסיפה דבר. שתיהן נמחקו, ושתי הפונקציות אוחדו ל-strip_hidden_escapes אחת בשכבת הדומיין (טהורה, בלי I/O); utils.normalize_code מייבא אותה במפורש (הייבוא האופציונלי של הדומיין הפך לייבוא רגיל — מסלול ישן שרץ בלעדיו היה עותק שני מחדש). ענף ה-Variation Selectors (Mn, לא Cf) נשאר נפרד ונדלק רק עם remove_variation_selectors=True, כמו קודם. אפס-דיף מדוד: 39 קלטים של רצפי בריחה × 6 צירופי אפשרויות, שני הנרמולים, זהים לפני ואחרי. טסטים: מה שנוגע בשמות החדשים נופל על origin/main (line_endings, front_matter_end, strip_hidden_escapes); טסטי ההתנהגות עוברים שם בכוונה (אפס-דיף) ומוכחים במוטציות — בדיקת הקטגוריה שנמחקת מפילה את 16 טסטי הקודים, ועותק פרטי של כלל ה-CR בפארסר מפיל את הטסט המבני. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SfJTSpDAhDr2yhtmpFkwTx --- docs/whats-new.rst | 1 + mcp_server/outline.py | 22 +-- requirements/base.txt | 9 +- scripts/generate_ai_map.py | 44 ++--- services/line_endings.py | 42 +++++ services/md_parser.py | 45 +++-- src/domain/services/code_normalizer.py | 99 ++++++----- tests/test_ai_map_generator.py | 13 ++ tests/test_md_parser.py | 168 +++++++------------ tests/test_utils_normalize_hidden_escapes.py | 56 +++++++ utils.py | 71 ++------ 11 files changed, 308 insertions(+), 262 deletions(-) create mode 100644 services/line_endings.py diff --git a/docs/whats-new.rst b/docs/whats-new.rst index ef65c8cd0..15e2ef353 100644 --- a/docs/whats-new.rst +++ b/docs/whats-new.rst @@ -4,6 +4,7 @@ What's New 2026-09-20 ---------- +- **refactor: שלושה כללים שנכתבו פעמיים חזרו למקום אחד (#3419, #3427).** ‏(1) כלל ה-``\r`` הבודד — סיומת שורה שאינה חד-משמעית — ישב גם במנתב האאוטליין וגם בפארסר ה-Markdown, ושני העותקים היו צריכים להסכים לנצח; עכשיו הוא במודול עלה חדש, ``services/line_endings.py``, ושני הצרכנים קוראים לו. ‏(2) ``scripts/generate_ai_map.py`` כתב ביד את גבול ה-front matter ונמדד שהוא חולק על התוסף ש-MyST מריץ בחמש מתוך שתים-עשרה צורות; עכשיו הוא קורא את הגבול מהפארסר של הכלי (``md_parser.front_matter_end``, דרך ``mdit_py_plugins.front_matter``), ו-``AI-MAP.md`` שנוצר זהה בית-בית לזה שבריפו — הטסט שקיבע את הפער בכוונה נערך יחד עם הסגירה. ‏(3) שתי רשימות זהות של 16 קודי תווים נסתרים — ב-``utils.normalize_code`` וב-``CodeNormalizer`` — לא הוסיפו דבר על בדיקת ``unicodedata.category == "Cf"`` שישבה מתחתן (כל 16 הערכים הם ``Cf``); הרשימות נמחקו, שתי הפונקציות אוחדו ל-``strip_hidden_escapes`` אחת בשכבת הדומיין, וענף ה-Variation Selectors (``Mn``, לא ``Cf``) נשאר נפרד ונדלק רק לפי בקשה. לשני הנרמולים אפס-דיף מדוד על קורפוס של רצפי בריחה, בכל צירוף אפשרויות. - **feat: ``codekeeper_docs_get_section`` קורא גם קובצי Markdown, ולכל ריפו מדיניות נתיבים משלו.** עד היום הכלי ידע לקרוא ``.rst`` בלבד, ורק מתחת ל-``docs/``. הפארסר של Markdown נכתב ונמדד כבר קודם — ואף אחד לא קרא לו; זה השינוי שמחווט אותו. מעכשיו הסיומת בוחרת את הפארסר, ולכל ריפו מוצהר **איפה התיעוד שלו יושב ובאיזה פורמט**: ``CodeBot`` נשאר ``docs/`` עם ``.rst`` ללא שינוי, ו-``amir-bug-patterns`` מגיש ``.md`` משורש הריפו. **וזו הרחבה מכוונת של גבול הקריאה** — הכלי ציבורי, כלומר כל משתמש מאומת מפעיל אותו בלי אדמין, והריפו שנוסף ציבורי ב-GitHub. מה ש**לא** השתנה: מדיניות סינון הסודות חלה על המסלול הזה במלואה, ולכן ``path="secrets"`` מחזיר ``path_denied`` ולא ``not_found``, וגם ``secrets.md`` ו-``credentials.md`` חסומים; ותקרת 500KB של שירות המראה נשארת ההגנה על הפרסור, כי הכלי קורא את הקובץ כולו בלי ``lines``. **שני שערים ולא אחד:** ``MCP_DOCS_REPO`` מתיר בזמן ריצה, טבלת המדיניות בקוד יודעת לקרוא, וריפו שנמצא במשתנה הסביבה ואין לו מדיניות נדחה ב-``repo_not_configured`` במקום ליפול לברירת מחדל מתירנית. **ובקשה לפורמט שהריפו אינו מגיש נדחית בשמה** — ``suffix_not_allowed`` עם רשימת הסיומות שכן מוגשות, במקום להשלים סיומת בשקט מעל הקיימת ולחזור כ"לא נמצא" על קובץ שקיים. שני סירובים חדשים מגיעים מהפרסור עצמו: ``inconsistent_line_endings`` על ``\r`` בודד (CRLF עובד במלואו), ו-``too_many_sections`` על קובץ שחוצה את תקרת 50,000 הכותרות — Markdown דרך ברירת המחדל של הפרסר, ו-RST דרך התקרה שהכלי מעביר מאז #3429; סירוב שהפארסר מספק לו מספר שורה נושא גם ``line`` — תמיד ב-Markdown, ולא ב-``too_many_sections`` של RST, כי ``rst_parser`` מרים אותה בלי מספר. ``includes`` הוא שדה של RST ולכן בעמוד ``.md`` הוא תמיד ריק. **ומסלול ה-RST לא זז**: אפס דיף מדוד על 5,920 רשומות מ-208 קובצי RST, לפני ואחרי. ראו :ref:`mcp-docs-path-policy`. - **fix: מאגר הקריאות של ה-MCP נגזר ממכסת הזיכרון של הקונטיינר, ולא ממספר המעבדים של המארח (#3391).** כלי הקריאה רצים ב-``asyncio.to_thread``, כלומר ב-executor ברירת המחדל של הלולאה, שגודלו ``min(32, os.cpu_count() + 4)`` — ו-``os.cpu_count()`` בקונטיינר הוא מספר הליבות של **המארח**, כפי ש-CPython מתעד במפורש. שורת הקיבולת מהייצור אמרה את זה במספרים: ``read pool 12 threads (os.cpu_count=8, usable=8)`` על מכסה של ``0.50 cpu``. לקריאת מונגו זה לא מזיק; לפרסור בפייתון טהור אין מקביליות ב-CPU בגלל ה-GIL, ולכן 12 חוטים היו 12 עצי פרסור בזיכרון בו-זמנית — יותר ממה שנכנס ב-512MiB — ורגע לפני שהפרסור הופך לכלי ציבורי. מעכשיו ``attach_read_pool`` מתקין בעליית ה-lifespan של אפליקציית ה-ASGI מאגר משלו, בגודל ``(מגבלת הזיכרון − קו בסיס − שוליים) ÷ עלות פרסור אחד``: המגבלה נקראת מ-cgroup בזמן ריצה (``memory.max`` ב-v2, ‏``memory.limit_in_bytes`` ב-v1) ולא מקובעת בקוד, קו הבסיס והשוליים הם מדידות מהשירות בייצור עם תאריך, ועלות הפרסור נגזרת מ-``MAX_FILE_SIZE_FOR_DISPLAY`` כי הכלי דוחה ``too_large`` לפני שהוא מפרסר. על תוכנית הייצור זה **10** חוטים במקום 12 — עלות הפרסור נמדדה מחדש על הפרסר הנעוץ (``scripts/measure_md_parse_cost.py``; הקבוע נמדד על ``services.md_parser`` כהכנה לכלי ה-Markdown של PR 5, בעוד הכלי הציבורי היום מפרסר RST ב-``services.rst_parser``, שעולה כ-6 בתים לבית גם על העמוד הצפוף ביותר — והסקריפט מודד את שניהם), והיא תלויה בצפיפות הטוקנים של המסמך ולא בגודלו: המסמך הצפוף ביותר בקורפוס הוא הקבוע, וצורה עוינת של שורות-תבליט בודדות עולה פי ארבעה ממנו — מה ששום רוחב מאגר אינו סופג, ומחכה לתקרת טוקנים בפרסר עצמו. **ובעקבות הסקירה:** ``codekeeper_docs_get_section`` מעביר לפרסר את תקרת הסקשנים של האאוטליין (50,000) ומחזיר ``too_many_sections`` במקום לפרסר את הקובץ כולו — צורה עוינת של RST (כותרת בת תו אחד בכל שורה) עלתה 44.0MiB לפרסור, יותר מ-35.2MiB שהמאגר מקצה לחוט, ועכשיו נעצרת ב-20.1MiB; אף עמוד אמיתי אינו מתקרב לתקרה. רצפה של 2 (קריאה תקועה אחת לא משביתה את השאר), תקרה של 12, הרוחב שהייצור כבר הריץ (מעבר לתוכנית גדולה לא מרחיב את המאגר בלי החלטה; הגרסה הראשונה נקבה ב-32, והסקירה של #3429 הורידה אותה כי המחלק מתאר את הפרסור ולא את קריאות הטווח של האדמין; חסימת הקריאה במקור — #3433), וכשאין מגבלה קריאה — הרצפה, **לא** ``cpu_count + 4``, עם WARNING שאומר שזה קרה. **ושורת הקיבולת מפסיקה לחשב בעצמה:** היא נרשמת מתוך ה-lifespan, קוראת את ``_max_workers`` של המאגר שהותקן בפועל, ומדווחת גם את מגבלת הזיכרון לצד מכסת ה-CPU — כי רשומה שמתארת מצב ומתעדכנת בנפרד ממנו היא בדיוק מה שמשקר בשקט. ה-seam הוא עטיפת ``router.lifespan_context``, אותו seam שניקוז ה-PostHog כבר משתמש בו, כי ``FastMCP.streamable_http_app()`` בונה את Starlette עם ``lifespan=`` משלה ו-``on_startup`` אינו רץ. הטסט המרכזי נכנס ל-lifespan של האפליקציה האמיתית ומוכיח שקריאת ``to_thread`` נוחתת על חוט של המאגר החדש — לא שפונקציה נקראה; שורת הקיבולת נבדקת בתת-תהליך נקי דרך הפלט האמיתי ולא דרך ``caplog``. ראו "מודל הריצה של הכלים" ב-:doc:`mcp-server`. - **feat: ``codekeeper_docs_get_section`` מוצא סעיף גם לפי המזהה שלו, ולא רק לפי השם המלא.** הסוכנים מפנים זה לזה לפי מזהה — "קרא K11", "ראו U3" — ועד היום ``section="K11"`` החזיר ``section_not_found`` עם רשימת הצעות **ריקה**, כי ההתאמה הייתה שוויון מלא ו-``difflib`` עם ``cutoff=0.5`` אינו מוצא קרבה בין שלושה תווים לכותרת עברית ארוכה. כלומר הזרימה המרכזית של הכלי לא עבדה. מעכשיו, **ורק אחרי שהשוויון המלא לא מצא כלום**, שאילתה שבנויה כמזהה (עד שלוש אותיות ואחריהן עד שלוש ספרות, עם נקודה אופציונלית) מותאמת לכותרת שנפתחת באותו מזהה. **והמזהה נקרא כיחידה ולא כקידומת של מחרוזת**, ולכן ``K1`` מחזיר את ``K1`` בלבד ולעולם לא את ``K10`` עד ``K15`` — הצורה השבורה של הבדיקה הזאת היא אותה מחלקה בדיוק שבה גבול נתיב נבדק כרצף תווים. שאילתה שאינה בנויה כמזהה אינה עוברת במסלול הזה כלל, ולכן ``section="איך"`` ממשיך להחזיר אפס התאמות גם בעמוד שיש בו חמש-עשרה כותרות שמתחילות במילה הזאת. מזהה שחוזר פעמיים הוא ``ambiguous_section`` עם מועמדים, כמו כל כותרת כפולה, ושאילתת מזהה שלא נמצאה מקבלת ב-``suggestions`` קודם כול כותרת קרובה, אם יש כזו בעמוד, **ורק כשאין אף כותרת קרובה** את המזהים שכן קיימים — עד 50, ועם ``suggestions_truncated`` כשהיו עוד. **והתקרה גבוהה בכוונה**, כי רשימת מזהים אינה מדורגת: לשאלה "אלה שכן קיימים" אין תשובה "החמש הטובות", וחיתוך שלה מסתיר פריטים בלי קריטריון ובלי דרך לבקש את השאר. **והדגל הזה אומר את אותו דבר בשני המסלולים**: גם רשימת מזהים שנחתכה וגם רשימת כותרות קרובות שנחתכה מדליקות אותו. שדה בשם ``truncated`` שמשמעותו "נחתך" במסלול אחד ו"שקט" בשני הוא בדיוק ההפתעה שאנחנו מנסים למנוע. **ונקודה עוד יותר קלה לפספס: הנקודה שפותחת תת-מספור אינה גבול.** בכותרת ``K11. טקסט`` הנקודה **מסיימת** את המזהה, ובכותרת ``K11.1 טקסט`` היא **מפרידה** בתוך מזהה ארוך יותר — ולכן ``K11`` לעולם אינו מחזיר את ``K11.1``, וכותרת בתת-מספור נגישה בשמה המלא בלבד. זו אותה מחלקה בדיוק כמו ``K1`` שתופס את ``K10``: גבול שנבדק כתו בודד, בלי לשאול מה בא אחריו. **ההתנהגות על התיעוד כאן לא זזה, וזה נמדד ולא הונח:** הפלט של הכלי על כל קובצי ה-RST — כל כותרת, בכל צורותיה — זהה בית-בית לפני ואחרי. וההנחה שהכול נשען עליה, שאף כותרת בעמודים כאן אינה נפתחת במזהה, **אינה ספירה שנרשמה פעם אחת אלא טסט שרץ בכל CI**: ``tests/test_docs_headings_carry_no_identifier.py`` סורק את כל העמודים ונופל על כותרת שנפתחת במזהה, עם דוגמת autodoc אמיתית שמוכיחה שהוא מסוגל ליפול. **ומה שלא נבדק בקורפוס הזה נבדק בבקרה סינתטית**, כי סוללה שכל תשובותיה "לא נמצא" אינה מבדילה בין ענף כבוי לבין פרובים עיוורים. שני הדברים שמפתיעים קורא — קיצור המזהה, וכותרת שנושאת בקטיקים ולכן דורשת אותם בשאילתה — עברו לתיאור הפרמטר ``section``, שאינו נחתך אצל הלקוח כמו תיאור הכלי. ראו :ref:`mcp-docs-section`. diff --git a/mcp_server/outline.py b/mcp_server/outline.py index e102628cb..63987cb98 100644 --- a/mcp_server/outline.py +++ b/mcp_server/outline.py @@ -19,29 +19,19 @@ from __future__ import annotations import logging -import re from collections.abc import Callable from typing import Any +from services.line_endings import find_lone_cr + from .outline_scanners import css as _css from .outline_scanners import html as _html from .outline_scanners import python as _python from .outline_scanners import rst as _rst -#: ``\r`` שאינו חלק מ-``\r\n``. ה-lookahead השלילי הוא כל ההבחנה: CRLF -#: הוא המקרה הנפוץ ושתי ספירות השורות מסכימות עליו, ולכן הוא חייב להמשיך -#: לעבוד. רק CR בודד — הפורמט של Mac שלפני 2001 — מפריד ביניהן. -#: -#: ``search`` ולא ``replace``: הוא עוצר על ההתאמה הראשונה ואינו מקצה עותק -#: של הטקסט, שיכול להיות 10MB לפי ``RANGE_READ_MAX_BYTES``. -#: -#: **עותק שני של אותו כלל קיים ב-**``services/md_parser.py::_CR_WITHOUT_LF``. -#: הוא אינו מיובא משם ולא לשם, כי ``services`` אינו מייבא מ-``mcp_server`` -#: והמסלול השני אינו עובר דרך המנתב הזה בכלל. השקילות אינה תקווה: -#: ``tests/test_md_parser.py::test_the_lone_cr_rule_matches_markdown_it`` -#: מריץ את שניהם מול ההתנהגות של ``markdown-it-py`` עצמו. איחוד השניים -#: בשכבת ``services`` הוא אישו נפרד. -_CR_WITHOUT_LF = re.compile(r"\r(?!\n)") +# כלל ה-``\r`` הבודד יושב ב-``services/line_endings.py`` (מאז #3419), משותף +# למנתב הזה ול-``services/md_parser.py`` — עותק אחד במקום שניים שטסט השווה +# ביניהם. הנימוק המלא, ו"למה ``search`` ולא ``replace``", שם. #: **השורה היחידה שהמסלול הזה כותב, והיא נכתבת כאן ולא בסורקים.** כאן יש גם #: את הנתיב וגם את גודל הקלט, ולכן זו הנקודה היחידה שבה שורה אחת מזהה מה @@ -128,7 +118,7 @@ def extract_outline(text: str, path: str, symbol: str | None = None) -> dict[str # # CRLF **אינו** מושפע ועובד במלואו: שם שתי הספירות זהות, וזה המקרה # הנפוץ. מה שנדחה הוא ``\r`` בודד בלבד. - if _CR_WITHOUT_LF.search(text): + if find_lone_cr(text): return {"status": "no_outline", "reason": "inconsistent_line_endings"} # ``chars`` ולא ``bytes``, כי ``len`` על ``str`` מודד תווים. השם הקודם diff --git a/requirements/base.txt b/requirements/base.txt index ee826d15b..45dc2196f 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -164,10 +164,11 @@ rich==14.2.0 markdown-it-py==3.0.0 # התוסף שמזהה front matter. הוא ההגדרה ש-MyST משתמש בה, ולכן הוא הכלל # שקובע מהו front matter גם בקובצי ה-.md שתחת docs/ בריפו הזה. הוא נרשם -# על מופע הפארסר במקום שנכתוב את הכלל ביד: נמדד שכלל שכתבנו בעצמנו חולק -# על התוסף בחמש מתוך שתים-עשרה צורות — `---` מוזח, סוגר מוזח בארבעה -# רווחים, ארבעה מקפים, סוגר ארוך מהפותח, ופותח שיש אחריו טקסט. הטבלה -# עצמה ב-`tests/test_md_parser.py`, והמספר נגזר ממנה ולא מוקלד פעמיים. +# על מופע הפארסר במקום שנכתוב את הכלל ביד: נמדד שכלל שנכתב ביד +# (`scripts/generate_ai_map.py`, עד #3419) חלק על התוסף בחמש מתוך +# שתים-עשרה צורות — `---` מוזח, סוגר מוזח בארבעה רווחים, ארבעה מקפים, סוגר +# ארוך מהפותח, ופותח שיש אחריו טקסט. מאז #3419 גם הסקריפט קורא את הגבול +# מהפארסר, והטבלה ב-`tests/test_md_parser.py` מקבעת שהשניים מסכימים. mdit-py-plugins==0.4.2 typer==0.20.0 diff --git a/scripts/generate_ai_map.py b/scripts/generate_ai_map.py index b06b917d2..a9ed17d56 100644 --- a/scripts/generate_ai_map.py +++ b/scripts/generate_ai_map.py @@ -35,6 +35,11 @@ import yaml ROOT = Path(__file__).resolve().parent.parent +# הסקריפט רץ גם לבדו (``python3 scripts/generate_ai_map.py``), ואז ``sys.path[0]`` +# הוא ``scripts/`` ולא שורש הריפו; גבול ה-front matter נקרא מ-``services``. +sys.path.insert(0, str(ROOT)) + +from services import md_parser # noqa: E402 DOCS = ROOT / "docs" OUTPUT = ROOT / "AI-MAP.md" SUMMARY_CAP = 220 @@ -126,33 +131,20 @@ def _toctree_blocks(lines: list[str]): def _body_start(lines: list[str]) -> int: - """האינדקס שאחרי ה-front matter, או 0 אם אין. - - לפי ``markdown_it``/MyST, front matter הוא בלוק שנפתח ב-``---`` בשורה - הראשונה ממש ונסגר ב-``---`` או ``...``. - - .. warning:: - - **הכלל כאן אינו מדויק מול התוסף שהוא מתאר, וזה נמדד.** - ``mdit_py_plugins.front_matter`` — שהוא מה ש-MyST באמת מריץ, וש- - ``services/md_parser.py`` רושם על מופע הפארסר שלו במקום לכתוב את - הכלל ביד — חולק על הפונקציה הזאת בחמש צורות: ``---`` מוזח (כאן - מתקבל, שם נדחה), ``----`` בן ארבעה מקפים, סוגר ארוך מהפותח, פותח - שיש אחריו טקסט, וסוגר מוזח בארבעה רווחים שהוא בלוק קוד. בקורפוס - של היום אין לאף אחת מהן מופע — 23 מתוך 34 קובצי ה-``.md`` תחת - ``docs/`` פותחים ב-``---`` מדויק — ולכן המפה שנוצרת כאן נכונה. - - הפער מקובע ב- - ``tests/test_md_parser.py::test_the_front_matter_rules_still_disagree_as_measured`` - כדי שלא ייסחף בשקט, והיעד — שהפונקציה הזאת תשתמש בתוסף — הוא - אישו נפרד. + """האינדקס שאחרי ה-front matter, או 0 אם אין — לפי התוסף, לא לפי כלל שנכתב ביד. + + ``services.md_parser.front_matter_end`` שואל את ``mdit_py_plugins.front_matter`` + שרשום על הפארסר של הכלי — ההגדרה ש-MyST באמת מריץ על קובצי ה-``.md`` + שתחת ``docs/``. עד #3419 הפונקציה הזאת כתבה את הכלל ביד, ונמדד שהיא + חלקה על התוסף בחמש מתוך שתים-עשרה צורות (``---`` מוזח, סוגר מוזח + בארבעה רווחים, ארבעה מקפים, סוגר ארוך מהפותח, פותח שיש אחריו טקסט). + בקורפוס לא היה מופע לאף אחת מהן, ולכן המפה שנוצרה הייתה נכונה — וזה + מה שאפס-הדיף על ``AI-MAP.md`` מקבע גם אחרי המעבר. + + ``"\\n".join`` מחזיר את הטקסט ש-``_read`` פיצל ב-``split("\\n")``, ולכן + האינדקסים של הפרסר הם האינדקסים של ``lines``. """ - if not lines or lines[0].strip() != "---": - return 0 - for i in range(1, len(lines)): - if lines[i].strip() in ("---", "..."): - return i + 1 - return 0 # לא נסגר — MyST גם לא היה מזהה אותו כ-front matter + return md_parser.front_matter_end("\n".join(lines)) def _title(lines: list[str], path: Path) -> str: diff --git a/services/line_endings.py b/services/line_endings.py new file mode 100644 index 000000000..d74707c78 --- /dev/null +++ b/services/line_endings.py @@ -0,0 +1,42 @@ +"""כלל אחד ל-``\\r`` בודד — סיומת שורה שאינה חד-משמעית — לשני הצרכנים שלו (#3419). + +**למה מודול משלו.** הכלל נכתב פעמיים: ``mcp_server/outline.py`` (המנתב מחזיר +``no_outline`` עם ``inconsistent_line_endings``) ו-``services/md_parser.py`` +(הפארסר מרים ``InconsistentLineEndings``). שני העותקים היו נכונים, וטסט השווה +אותם מול ``markdown-it-py`` — אבל עותק שני של כלל הוא מה שנסחף בתיקון הבא +(R6, ``duplicate-rule-second-copy``). ``services`` אינו מייבא מ-``mcp_server``, +הכיוון חד-סטרי, ולכן ההגדרה יושבת כאן ושני הצרכנים קוראים לה — אותו תקדים +כמו ``webapp/size_format.py``, שנולד אחרי שפורמוט גודל נמצא בחמישה מקומות. + +**מה הכלל אומר.** ה-lookahead השלילי הוא כל ההבחנה: CRLF הוא המקרה הנפוץ +ושתי ספירות השורות מסכימות עליו, ולכן הוא חייב להמשיך לעבוד. רק CR בודד — +הפורמט של Mac שלפני 2001 — מפריד ביניהן: ``markdown-it-py`` מנרמל +``\\r\\n?|\\n`` (``rules_core/normalize.py``), כלומר ``\\r`` בודד הוא אצלו שורה +חדשה, בעוד שדות ה-``lines`` של ה-MCP נספרים ב-``split("\\n")``. בקובץ כזה +המפה מצביעה לשורה שקריאת טווח לא מגיעה אליה — ומפה שמצביעה לשומקום גרועה +ממפה שאין. ``tests/test_md_parser.py::test_the_lone_cr_rule_matches_markdown_it`` +מריץ את הכלל מול הספרייה עצמה על טבלת קלטים. + +**``search`` ולא ``replace``**: עוצר על ההתאמה הראשונה ואינו מקצה עותק של +הטקסט, שיכול להיות 10MB לפי ``RANGE_READ_MAX_BYTES``. + +ייבוא קל בלבד — ``re``; בזמן ייבוא רצה רק קומפילציה של הביטוי. +""" + +from __future__ import annotations + +import re +from typing import Optional + +#: ``\\r`` שאינו חלק מ-``\\r\\n``. +CR_WITHOUT_LF = re.compile(r"\r(?!\n)") + + +def find_lone_cr(text: str) -> Optional["re.Match[str]"]: + """ההתאמה הראשונה של ``\\r`` בודד, או ``None`` כשאין. + + מחזירה את ההתאמה ולא ``bool``, כי הצרכן שמרים חריגה גוזר ממנה את מספר + השורה (``text.count("\\n", 0, match.start()) + 1``), והצרכן שמחזיר סירוב + צריך רק לדעת שיש. + """ + return CR_WITHOUT_LF.search(text) diff --git a/services/md_parser.py b/services/md_parser.py index cf5ca530d..1a91f2bfc 100644 --- a/services/md_parser.py +++ b/services/md_parser.py @@ -53,13 +53,13 @@ from __future__ import annotations -import re from typing import List, Optional from markdown_it import MarkdownIt from markdown_it.token import Token from mdit_py_plugins.front_matter import front_matter_plugin +from .line_endings import find_lone_cr from .doc_sections import ( Document, InconsistentLineEndings, @@ -136,14 +136,8 @@ #: מ-``mcp_server`` — הכיוון חד-סטרי ומנומק ב-``TooManySections``. MAX_SECTIONS = 50_000 -#: ``\r`` שאינו חלק מ-``\r\n``. ההגדרה חוזרת כאן ואינה מיובאת מ- -#: ``mcp_server/outline.py::_CR_WITHOUT_LF``, מאותה סיבה: הכיוון חד-סטרי. -#: השקילות בין השניים אינה תקווה — ``tests/test_md_parser.py`` משווה את -#: שניהם מול ההתנהגות של ``markdown-it-py`` עצמו על טבלת קלטים אחת. -#: -#: ``search`` ולא ``replace``: הוא עוצר על ההתאמה הראשונה ואינו מקצה -#: עותק של הטקסט. -_CR_WITHOUT_LF = re.compile(r"\r(?!\n)") +# כלל ה-``\r`` הבודד יושב ב-``services/line_endings.py`` (מאז #3419), משותף +# למנתב האאוטליין ולפארסר הזה — עותק אחד, לא שניים שצריכים להסכים לנצח. #: המפתח שתחתיו יושב מונה ה**כותרות** ב-``env`` של פרסור בודד. #: המחרוזת עצמה נשארה ``section`` כשמשמעות המונה השתנתה, כי היא @@ -242,10 +236,11 @@ def _build_parser() -> MarkdownIt: בקובצי ה-``.md`` שתחת ``docs/`` בריפו הזה. נמדד שהיא **אינה מזיזה אף מספר שורה** באף אחת מעשר הצורות שנבדקו — כולל בלוק שאינו נסגר, ``---`` מוזח, ו-``---`` עם רווח בסוף — כי הטקסט עצמו אינו משתנה - כלל. וכלל שהיינו כותבים בעצמנו כן היה סוטה ממנה: נמדד שפרוטוטיפ - שמשווה ``---`` מדויק חולק עליה ב**חמש מתוך שתים-עשרה** צורות. - המספר נגזר מהטבלה ב-``tests/test_md_parser.py`` ומושווה למשפט - הזה ב-``test_the_measured_front_matter_gap_matches_the_prose``. + כלל. וכלל שהיינו כותבים בעצמנו כן היה סוטה ממנה: נמדד שהכלל שנכתב + ביד ב-``scripts/generate_ai_map.py`` חלק עליה בחמש מתוך שתים-עשרה + צורות. מאז #3419 הסקריפט קורא את הגבול מהמופע הזה, דרך + :func:`front_matter_end`, והטבלה ב-``tests/test_md_parser.py`` + מקבעת שהשניים מסכימים על כל צורה. 3. **``disable("inline")``** — הכלל ה**ליבתי** בשם הזה, שהוא זה שמפרק את תוכן הכותרת לטוקנים פנימיים. נמדד: עד 40% חיסכון בזמן ו-37% בזיכרון, **ואפס שינוי בזיהוי הכותרות ובטווחים** — כי השם @@ -392,7 +387,7 @@ def parse_document(text: str, *, max_sections: Optional[int] = MAX_SECTIONS) -> # **החריגה נושאת את מספר השורה**, כמו ``TooManySections``. המיקום # כבר מחושב כאן — ``search`` מחזיר אובייקט התאמה — והוא ההבדל בין # "יש ``\r`` איפשהו בקובץ" לבין משהו שאפשר לפתוח ולתקן. - lone_cr = _CR_WITHOUT_LF.search(text) + lone_cr = find_lone_cr(text) if lone_cr: raise InconsistentLineEndings(text.count("\n", 0, lone_cr.start()) + 1) @@ -526,3 +521,25 @@ def _title_of(tokens: List[Token], heading_index: int) -> str: else (part if index == 0 else part.lstrip(" \t")) for index, part in enumerate(parts) ) + + +def front_matter_end(text: str) -> int: + """אינדקס השורה שאחרי בלוק ה-front matter — או 0 כשאין בלוק, כולל בלוק שלא נסגר. + + לפי התוסף ``mdit_py_plugins.front_matter`` שרשום על ``_MD`` — ההגדרה + ש-MyST מריץ — ולא לפי כלל שנכתב ביד. ``scripts/generate_ai_map.py`` כתב + עד #3419 כלל משלו, ונמדד שהוא חלק על התוסף בחמש מתוך שתים-עשרה צורות: + ``---`` מוזח, סוגר מוזח בארבעה רווחים, ארבעה מקפים, סוגר ארוך מהפותח, + ופותח שיש אחריו טקסט. הטוקן ``front_matter`` הוא תמיד הראשון (התוסף + מזהה בלוק רק בשורה 0) ונושא ``map = [0, end]``, ו-``end`` הוא בדיוק + האינדקס שהסקריפט צריך. + + פרסור מלא של הטקסט, בלי ``env`` ולכן בלי תקרה — הקלט הוא עמודי + תיעוד. **מחוץ ל-``__all__`` בכוונה**, מאותו נימוק שכתוב ליד + :func:`token_count` אם הוא קיים כאן: הרשימה שם היא החוזה של "שני + פארסרים בני-החלפה", וגבול front matter אינו קיים ל-RST. + """ + tokens = _MD.parse(text) + if tokens and tokens[0].type == "front_matter" and tokens[0].map: + return tokens[0].map[1] + return 0 diff --git a/src/domain/services/code_normalizer.py b/src/domain/services/code_normalizer.py index 7b25df1b4..7a16a4ea0 100644 --- a/src/domain/services/code_normalizer.py +++ b/src/domain/services/code_normalizer.py @@ -50,12 +50,6 @@ class CodeNormalizer: "\u2069", # PDI } - _KNOWN_ESCAPE_HEX4 = { - "200B", "200C", "200D", "2060", "FEFF", # zero-width set - "200E", "200F", "202A", "202B", "202C", "202D", "202E", # directional - "2066", "2067", "2068", "2069", # directional isolates - } - def normalize(self, text: Any) -> str: """Normalize code text. @@ -69,7 +63,7 @@ def normalize(self, text: Any) -> str: # Handle sequences like "\\u200B" and "\\U0001F600" that represent hidden/format chars literally if ("\\u" in out) or ("\\U" in out): - out = self._strip_hidden_escapes(out) + out = strip_hidden_escapes(out) # 1) Strip BOM at start if out.startswith("\ufeff"): @@ -115,37 +109,62 @@ def _keep_char(ch: str) -> bool: return out - def _strip_hidden_escapes(self, s: str) -> str: - """Remove literal unicode escape sequences for hidden/format characters. + # ``_strip_hidden_escapes`` היה כאן כמתודה, עם ``_KNOWN_ESCAPE_HEX4`` — עותק + # שני של אותה פונקציה ואותה רשימה מ-``utils.normalize_code`` (#3427). + # ההגדרה האחת היא :func:`strip_hidden_escapes` למטה, ושני הצרכנים קוראים לה. - - Removes escapes in KNOWN sets or with Unicode category Cf - - Handles both \\uXXXX and \\UXXXXXXXX forms - """ - def _strip_if_hidden_u4(m: "re.Match[str]") -> str: - hexcode = m.group(1).upper() - if hexcode in self._KNOWN_ESCAPE_HEX4: - return "" - try: - ch = chr(int(hexcode, 16)) - cat = unicodedata.category(ch) - if cat == "Cf": - return "" - # Variation Selectors (FE00..FE0F) are kept by default (compatibility) - except Exception: - pass - return m.group(0) - - def _strip_if_hidden_u8(m: "re.Match[str]") -> str: - hexcode = m.group(1).upper() - try: - ch = chr(int(hexcode, 16)) - if unicodedata.category(ch) == "Cf": - return "" - # Ideographic Variation Selectors (E0100..E01EF) are kept by default - except Exception: - pass - return m.group(0) - - s = re.sub(r"\\u([0-9a-fA-F]{4})", _strip_if_hidden_u4, s) - s = re.sub(r"\\U([0-9a-fA-F]{8})", _strip_if_hidden_u8, s) - return s + +# תווי Variation Selector: ``Mn`` ולא ``Cf``, ולכן בדיקת הקטגוריה אינה תופסת +# אותם והם צריכים תנאי משלהם — שנדלק רק לפי בקשה. +_VS_HEX4 = range(0xFE00, 0xFE0F + 1) +_IDEOGRAPHIC_VS = range(0xE0100, 0xE01EF + 1) + +_ESCAPE_U4 = re.compile(r"\\u([0-9a-fA-F]{4})") +_ESCAPE_U8 = re.compile(r"\\U([0-9a-fA-F]{8})") + + +def strip_hidden_escapes(s: str, *, remove_variation_selectors: bool = False) -> str: + """מסיר רצפי בריחה טקסטואליים (``\\uXXXX``, ``\\UXXXXXXXX``) שמייצגים תווי פורמט. + + **ההגדרה היחידה, לשני הצרכנים** — :meth:`CodeNormalizer.normalize` ו- + ``utils.normalize_code`` (#3427). עד אז כל אחד מהם החזיק עותק של אותה + פונקציה, ולפניה רשימה של 16 קודים "ידועים" (``_KNOWN_ESCAPE_HEX4`` / + ``known_hex4``) שנבדקה לפני בדיקת הקטגוריה. נמדד: כל 16 הערכים הם + ``Cf``, כלומר הרשימה לא הוסיפה דבר על ``unicodedata.category`` — מסלול + מהיר שאיש לא מדד, בשני עותקים שהיו צריכים להסכים לנצח. ומי שהיה מוסיף + קוד לרשימה באחד הקבצים לא היה משנה דבר בפועל, ומסיק שהתיקון עבד. + **רשימה היא העתק של ידע שצריך להסכים עם עצמו; קטגוריה היא הגדרה + שמתעדכנת עם התקן** — ולכן נשארה הקטגוריה לבדה. + + - ``Cf`` (format) מוסר תמיד, בשתי הצורות. + - Variation Selectors (``U+FE00``–``U+FE0F``, ובצורה הארוכה + ``U+E0100``–``U+E01EF``) הם ``Mn`` ולא ``Cf``, ולכן הם ענף נפרד שנדלק + רק עם ``remove_variation_selectors=True`` — ברירת המחדל שומרת אותם, + כמו קודם בשני הצרכנים. + - רצף שאינו מתפענח לקוד תו (``\\uZZZZ`` אינו תואם את הביטוי; ``chr`` + מעבר לטווח) נשאר כמות שהוא. + """ + + def _strip_if_hidden_u4(m: "re.Match[str]") -> str: + code = int(m.group(1), 16) + if unicodedata.category(chr(code)) == "Cf": + return "" + if remove_variation_selectors and code in _VS_HEX4: + return "" + return m.group(0) + + def _strip_if_hidden_u8(m: "re.Match[str]") -> str: + code = int(m.group(1), 16) + try: + hidden = unicodedata.category(chr(code)) == "Cf" + except (ValueError, OverflowError): + return m.group(0) # מעבר לטווח Unicode — לא תו, נשאר כמות שהוא + if hidden: + return "" + if remove_variation_selectors and code in _IDEOGRAPHIC_VS: + return "" + return m.group(0) + + s = _ESCAPE_U4.sub(_strip_if_hidden_u4, s) + s = _ESCAPE_U8.sub(_strip_if_hidden_u8, s) + return s diff --git a/tests/test_ai_map_generator.py b/tests/test_ai_map_generator.py index 866cea5d7..89e13f021 100644 --- a/tests/test_ai_map_generator.py +++ b/tests/test_ai_map_generator.py @@ -204,6 +204,19 @@ def test_declared_summary_is_capped(): assert got.endswith("…") +def test_body_start_is_read_from_the_parser_and_not_computed_here(monkeypatch): + """הגבול של ה-front matter מגיע מ-``services.md_parser.front_matter_end`` (#3419) — לא מכלל שנכתב כאן. + + ערך שאף כלל ידני לא היה מחזיר על הקלט הזה מוכיח שהפונקציה מאצילה ולא + מחשבת; ההסכמה עם התוסף על כל צורה נבדקת ב-``tests/test_md_parser.py``. + """ + from services import md_parser + + g = _load_generator() + monkeypatch.setattr(md_parser, "front_matter_end", lambda text: 7) + assert g._body_start(["# not front matter at all"]) == 7 + + def test_front_matter_does_not_become_the_title(): """ה-``---`` הסוגר של front matter אינו קו-תחתון של כותרת setext. diff --git a/tests/test_md_parser.py b/tests/test_md_parser.py index ef351ec15..63aa863b5 100644 --- a/tests/test_md_parser.py +++ b/tests/test_md_parser.py @@ -906,134 +906,96 @@ def _load_ai_map_generator(): @pytest.mark.parametrize("name, text", _LINE_ENDING_INPUTS) def test_the_lone_cr_rule_matches_markdown_it(name, text): - """שני העותקים של כלל ה-``\\r`` הבודד, מול ההתנהגות שהם מתארים. + """הכלל המשותף של ה-``\\r`` הבודד, מול ההתנהגות שהוא מתאר. - אמת המידה אינה אחד מהם אלא ``markdown-it-py``: הכלל שלו, + אמת המידה אינה הכלל אלא ``markdown-it-py``: הכלל שלו, ``rules_core/normalize.py`` עם ``\\r\\n?|\\n``, הוא מה שהופך ``\\r`` בודד לשורה חדשה — ומשם הפער מול ``split("\\n")`` שכל שדות ה- - ``lines`` ב-MCP נספרים בו. - - הטסט מריץ את הביטוי משני המקומות ואת הספרייה, ודורש שהשלושה יסכימו - על כל קלט. פיצול עתידי של אחד מהם ייפול כאן. + ``lines`` ב-MCP נספרים בו. עד #3419 היו כאן שני עותקים (המנתב + והפארסר) שהטסט השווה ביניהם; מאז יש הגדרה אחת ב-``services/line_endings``, + והטסט שאחרי זה מוכיח ששני הצרכנים באמת קוראים לה. """ - from mcp_server import outline as outline_router + from services import line_endings - by_router = bool(outline_router._CR_WITHOUT_LF.search(text)) - by_parser = bool(md_parser._CR_WITHOUT_LF.search(text)) + by_rule = bool(line_endings.find_lone_cr(text)) # האמת: האם ``markdown-it-py`` סופר שורות אחרת מ-``split("\n")``? normalized_by_library = re.sub(r"\r\n?|\n", "\n", text) normalized_by_us = text.replace("\r\n", "\n") by_library = normalized_by_library != normalized_by_us - assert by_router == by_parser == by_library, { - "מנתב": by_router, - "פארסר": by_parser, - "הספרייה": by_library, - } - - -#: הפער שנמדד בין ``_body_start`` לבין התוסף, צורה ← האם התוסף רואה כאן -#: front matter. ``True`` פירושו שהתוסף מזהה בלוק והפונקציה הישנה לא, או -#: להפך — הפירוט בעמודה השלישית. -_FRONT_MATTER_SHAPES = [ - # (שם, טקסט, האם התוסף מזהה בלוק, האם _body_start מזהה בלוק) - ("רגיל", "---\na: 1\n---\n\n# כותרת\n", True, True), - ("סוגר ...", "---\na: 1\n...\n\n# כותרת\n", True, True), - ("לא נסגר", "---\na: 1\n\n# כותרת\n", False, False), - ("פותח 4 סוגר 3", "----\na: 1\n---\n\n# כותרת\n", False, False), - ("ריק", "", False, False), - # ── צורות שנראות חשודות ובכל זאת **מסכימות** — כאן בכוונה, כי - # "בדקנו ואין פער" הוא מידע בדיוק כמו פער ── - ("פותח רווח בסוף", "--- \na: 1\n---\n\n# כותרת\n", True, True), - ("סוגר מוזח 2 רווחים", "---\na: 1\n ---\n\n# כותרת\n", True, True), - # ── והצורות שבהן הם באמת חלוקים. המספר כאן אינו מוקלד בפרוזה: - # ``test_the_measured_front_matter_gap_matches_the_prose`` גוזר - # אותו מהטבלה ומשווה למה שכתוב ב-``md_parser`` ── - ("פותח מוזח", " ---\na: 1\n---\n\n# כותרת\n", False, True), - # ארבעה רווחים הם בלוק קוד לפי CommonMark, ולכן התוסף אינו רואה - # סוגר — ואילו ``_body_start`` עושה ``.strip()`` ומקבל. הצורה - # הזאת נמדדה, הוזכרה ב-``generate_ai_map`` ונשמטה מהטבלה. - ("סוגר מוזח 4 רווחים", "---\na: 1\n ---\n\n# כותרת\n", False, True), - ("ארבעה מקפים", "----\na: 1\n----\n\n# כותרת\n", True, False), - ("פותח 3 סוגר 4", "---\na: 1\n----\n\n# כותרת\n", True, False), - ("פותח עם טקסט", "---title\na: 1\n---\n\n# כותרת\n", True, False), -] - -#: מספר הצורות שבהן התוסף ו-``_body_start`` חלוקים, **נגזר מהטבלה**. -#: הפרוזה ב-``services/md_parser.py`` וב-``requirements/base.txt`` -#: מצטטת אותו, וטסט משווה — כדי שתוספת צורה בלי עדכון הפרוזה תפיל. -_FRONT_MATTER_DISAGREEMENTS = sum( - 1 for _, _, plugin_sees, body_start_sees in _FRONT_MATTER_SHAPES - if plugin_sees is not body_start_sees -) - -#: מילות המספר שהפרוזה משתמשת בהן. טווח קטן בכוונה: אם הטבלה תגדל מעבר -#: לו, הטסט ייפול על ``KeyError`` ויכריח מבט — וזה עדיף על השלמה שקטה. -_HEBREW_NUMBERS = { - 3: "שלוש", 4: "ארבע", 5: "חמש", 6: "שש", 7: "שבע", 8: "שמונה", - 9: "תשע", 10: "עשר", 11: "אחת-עשרה", 12: "שתים-עשרה", - 13: "שלוש-עשרה", 14: "ארבע-עשרה", 15: "חמש-עשרה", -} + assert by_rule == by_library, {"הכלל": by_rule, "הספרייה": by_library} -def test_the_measured_front_matter_gap_matches_the_prose(): - """הפער שנמדד בטבלה הוא מה שכתוב בפרוזה, בשני המקומות שמצטטים אותו. +def test_both_consumers_use_the_shared_lone_cr_rule_and_neither_keeps_a_copy(): + """המנתב והפארסר מייבאים את הכלל מ-``services.line_endings`` — ואף אחד מהם אינו מגדיר אותו מחדש. - **למה טסט ולא סתם לתקן את המספר.** זה בדיוק מה שכבר קרה: הטבלה - גדלה, והפרוזה נשארה על "חמש מתוך שלוש-עשרה" בזמן שבטבלה היו - אחת-עשרה שורות וארבע חלוקות — שלוש רשימות שונות של "חמש הצורות" - חיו בריפו בו-זמנית. מספר שמוקלד ביד במקום שני מתיישן בשקט; מספר - שנגזר מהקוד לא יכול. + הבדיקה היא על המקור ולא על התנהגות, כי התנהגות זהה היא בדיוק מה ששני + עותקים מספקים ביום הראשון (R6): מה שצריך לתפוס הוא העותק שיחזור מחר. """ - disagreements = _HEBREW_NUMBERS[_FRONT_MATTER_DISAGREEMENTS] - total = _HEBREW_NUMBERS[len(_FRONT_MATTER_SHAPES)] - expected = f"{disagreements} מתוך {total}" - - parser_source = (_REPO / "services" / "md_parser.py").read_text(encoding="utf-8") - base = (_REPO / "requirements" / "base.txt").read_text(encoding="utf-8") - - for name, text in (("services/md_parser.py", parser_source), ("requirements/base.txt", base)): - assert expected in text, ( - f"{name} אינו אומר {expected!r}. הטבלה מכילה עכשיו " - f"{len(_FRONT_MATTER_SHAPES)} צורות ובהן {_FRONT_MATTER_DISAGREEMENTS} " - "חלוקות — עדכן את הפרוזה יחד עם הטבלה." - ) + import ast + from mcp_server import outline as outline_router + from services import line_endings + + rule = r"\r(?!\n)" + assert line_endings.CR_WITHOUT_LF.pattern == rule + for module in (outline_router, md_parser): + source = Path(module.__file__).read_text(encoding="utf-8") + assert rule not in source, f"{module.__name__} מחזיק עותק משלו של כלל ה-CR הבודד" + imported = { + alias.name + for node in ast.walk(ast.parse(source)) + if isinstance(node, ast.ImportFrom) and (node.module or "").endswith("line_endings") + for alias in node.names + } + assert "find_lone_cr" in imported, f"{module.__name__} אינו מייבא את find_lone_cr" + + +#: הצורות שבהן נמדד, ב-#3418, מה התוסף רואה — כולל חמש שכלל שנכתב ביד +#: (``scripts/generate_ai_map.py::_body_start``, עד #3419) חלק עליו בהן. +#: העמודה השלישית היא האמת: האם ``mdit_py_plugins.front_matter`` מזהה בלוק. +_FRONT_MATTER_SHAPES = [ + # (שם, טקסט, האם התוסף מזהה בלוק) + ("רגיל", "---\na: 1\n---\n\n# כותרת\n", True), + ("סוגר ...", "---\na: 1\n...\n\n# כותרת\n", True), + ("לא נסגר", "---\na: 1\n\n# כותרת\n", False), + ("פותח 4 סוגר 3", "----\na: 1\n---\n\n# כותרת\n", False), + ("ריק", "", False), + ("פותח רווח בסוף", "--- \na: 1\n---\n\n# כותרת\n", True), + ("סוגר מוזח 2 רווחים", "---\na: 1\n ---\n\n# כותרת\n", True), + # ── חמש הצורות שהכלל שנכתב ביד חלק עליהן, עד #3419 ── + ("פותח מוזח", " ---\na: 1\n---\n\n# כותרת\n", False), + # ארבעה רווחים הם בלוק קוד לפי CommonMark, ולכן התוסף אינו רואה סוגר. + ("סוגר מוזח 4 רווחים", "---\na: 1\n ---\n\n# כותרת\n", False), + ("ארבעה מקפים", "----\na: 1\n----\n\n# כותרת\n", True), + ("פותח 3 סוגר 4", "---\na: 1\n----\n\n# כותרת\n", True), + ("פותח עם טקסט", "---title\na: 1\n---\n\n# כותרת\n", True), +] -@pytest.mark.parametrize("name, text, plugin_sees, body_start_sees", _FRONT_MATTER_SHAPES) -def test_the_front_matter_rules_still_disagree_as_measured( - name, text, plugin_sees, body_start_sees -): - """**הטסט הזה מקבע פער מדוד, ולא התנהגות רצויה.** - - אמת המידה היא ``mdit_py_plugins.front_matter`` — מה ש-MyST באמת - מריץ, ומה ש-``md_parser`` רושם על מופע הפארסר שלו. - ``scripts/generate_ai_map.py::_body_start`` הוא **הסוטה**: הוא - כותב את אותו כלל ביד ו-``.strip()`` שבו מקבל ``---`` מוזח שהתוסף - דוחה, ופוסל ``----`` וסוגר ארוך מהפותח שהתוסף מקבל. - - .. warning:: - **נפילה של הטסט הזה אחרי שהפער ייסגר היא תוצאה מכוונת.** היעד - הוא ש-``_body_start`` יעבור להשתמש בתוסף; ביום שזה יקרה, הטסט - הזה **חייב** להיערך יחד עם השינוי ועם האישו שמתעד אותו. אל - "תתקן" אותו כאילו היה באג — הוא הדבר היחיד שמונע מהפער להיסחף - בשקט לכיוון שלישי. +@pytest.mark.parametrize("name, text, plugin_sees", _FRONT_MATTER_SHAPES) +def test_front_matter_end_is_the_plugins_boundary_on_every_shape(name, text, plugin_sees): + """``md_parser.front_matter_end`` אומר בדיוק מה שהתוסף אומר — גם בחמש הצורות שהכלל הידני חלק עליהן. - בקורפוס של היום אין לאף אחת מחמש הצורות החלוקות מופע: 23 מתוך 34 - קובצי ה-``.md`` תחת ``docs/`` פותחים ב-``---`` מדויק, ואפס במוזח או - עם רווח. כלומר המפה שנוצרת היום נכונה, והפער הוא חוב ולא תקלה. + **הגלגול השני של הטסט שישב כאן.** הקודם — ``..._still_disagree_as_measured`` + — קיבע פער מדוד בין ``_body_start`` של ``generate_ai_map`` לבין התוסף, + והכריז שנפילתו אחרי סגירת הפער היא תוצאה מכוונת. הפער נסגר ב-#3419: + הסקריפט קורא את הגבול מהפארסר, והטסט נערך יחד עם השינוי, כפי שה-docstring + שלו דרש. אמת המידה לא זזה: התוסף עצמו, על פרסר בקרה שנבנה כאן. """ from mdit_py_plugins.front_matter import front_matter_plugin probe = MarkdownIt("commonmark").use(front_matter_plugin).disable("inline") - saw_block = any(t.type == "front_matter" for t in probe.parse(text, {})) + tokens = probe.parse(text, {}) + saw_block = any(t.type == "front_matter" for t in tokens) assert saw_block is plugin_sees, f"התנהגות התוסף השתנתה בצורה {name!r}" + expected = next((t.map[1] for t in tokens if t.type == "front_matter"), 0) + assert md_parser.front_matter_end(text) == expected, name generator = _load_ai_map_generator() - assert bool(generator._body_start(text.split("\n"))) is body_start_sees, ( - f"‏_body_start השתנה בצורה {name!r} — עדכן את הטבלה יחד עם האישו" + assert generator._body_start(text.split("\n")) == expected, ( + f"‏_body_start חולק על התוסף בצורה {name!r} — הפער שנסגר ב-#3419 נפתח מחדש" ) @@ -1044,7 +1006,7 @@ def test_the_parser_never_invents_a_section_where_the_plugin_sees_front_matter() ה"מומצאת" היא התנהגות CommonMark נכונה ולא באג שלנו, ולכן היא מוצהרת כאן במפורש. """ - for name, text, plugin_sees, _old in _FRONT_MATTER_SHAPES: + for name, text, plugin_sees in _FRONT_MATTER_SHAPES: if not text: continue titles = [s.title for s in md_parser.parse_document(text).sections] diff --git a/tests/test_utils_normalize_hidden_escapes.py b/tests/test_utils_normalize_hidden_escapes.py index c317279c7..1870da5be 100644 --- a/tests/test_utils_normalize_hidden_escapes.py +++ b/tests/test_utils_normalize_hidden_escapes.py @@ -43,3 +43,59 @@ def test_normalize_code_disable_strip_literal_escapes(): out = normalize_code(text, remove_escaped_format_escapes=False) assert "\\u200B" in out assert "\\U000E0001" in out + + +# --------------------------------------------------------------------------- +# #3427: רשימה אחת נמחקה משני מקומות, ופונקציה אחת במקום שתיים +# --------------------------------------------------------------------------- + +#: 16 הקודים שישבו ב-``known_hex4`` (utils) וב-``_KNOWN_ESCAPE_HEX4`` (הדומיין) — +#: זהים תו-בתו, ונמדד שכולם ``Cf``, כלומר הרשימה לא הוסיפה דבר על הקטגוריה. +_FORMERLY_LISTED = [ + "200B", "200C", "200D", "2060", "FEFF", + "200E", "200F", "202A", "202B", "202C", "202D", "202E", + "2066", "2067", "2068", "2069", +] + + +def test_the_two_normalizers_share_one_hidden_escape_rule_and_no_list(): + """הגדרה אחת בשכבת הדומיין, ושני הצרכנים קוראים לה — בלי רשימה באף אחד מהם.""" + import inspect + + import utils + from src.domain.services import code_normalizer + + assert utils._strip_hidden_escapes is code_normalizer.strip_hidden_escapes + assert not hasattr(code_normalizer.CodeNormalizer, "_KNOWN_ESCAPE_HEX4") + assert not hasattr(code_normalizer.CodeNormalizer, "_strip_hidden_escapes") + assert "known_hex4" not in inspect.getsource(utils.normalize_code) + + +@pytest.mark.parametrize("hexcode", _FORMERLY_LISTED) +def test_every_code_the_deleted_lists_named_is_cf_and_is_still_stripped_on_both_paths(hexcode): + """המדידה מהאישו, כטסט: כל קוד שהרשימה תפסה נתפס על ידי הקטגוריה — בשני הנרמולים.""" + from src.domain.services.code_normalizer import CodeNormalizer + + assert unicodedata.category(chr(int(hexcode, 16))) == "Cf" + text = f"a\\u{hexcode}b" + assert CodeNormalizer().normalize(text) == "ab" + assert normalize_code(text, strip_bom=False) == "ab", "המסלול הישן של utils (אפשרות שאינה ברירת מחדל)" + + +def test_variation_selectors_stay_a_separate_branch_because_they_are_mn_not_cf(): + """‏``U+FE0F`` הוא ``Mn``: הקטגוריה לא תופסת אותו, ולכן הוא ענף משלו שנדלק רק לפי בקשה.""" + from src.domain.services.code_normalizer import CodeNormalizer, strip_hidden_escapes + + assert unicodedata.category("\uFE0F") == "Mn" + assert strip_hidden_escapes("a\\uFE0Fb") == "a\\uFE0Fb" + assert strip_hidden_escapes("a\\uFE0Fb", remove_variation_selectors=True) == "ab" + assert CodeNormalizer().normalize("a\\uFE0Fb") == "a\\uFE0Fb" + assert normalize_code("a\\uFE0Fb", strip_bom=False) == "a\\uFE0Fb" + assert normalize_code("a\\uFE0Fb", strip_bom=False, remove_variation_selectors=True) == "ab" + + +def test_an_escape_beyond_unicode_is_left_exactly_as_written(): + """‏``\\U00110000`` אינו תו (מעל U+10FFFF): לא ``Cf``, לא שגיאה — נשאר כמות שהוא, כמו קודם.""" + from src.domain.services.code_normalizer import strip_hidden_escapes + + assert strip_hidden_escapes("a\\U00110000b") == "a\\U00110000b" diff --git a/utils.py b/utils.py index 03951e906..e26f1eaf8 100644 --- a/utils.py +++ b/utils.py @@ -25,12 +25,14 @@ from typing import Any, Callable, Dict, List, Optional, Tuple, Union from zoneinfo import ZoneInfo -# Try to use the new domain CodeNormalizer when available (backwards compatible) -try: # pragma: no cover - optional import during gradual refactor - from src.domain.services.code_normalizer import CodeNormalizer as _DomainCodeNormalizer # type: ignore - _DOMAIN_NORMALIZER = _DomainCodeNormalizer() -except Exception: # pragma: no cover - keep legacy path if domain not present - _DOMAIN_NORMALIZER = None # type: ignore +# The domain CodeNormalizer is pure Python with no I/O (``src/domain/services/ +# code_normalizer.py``), and it is the single home of the hidden-escape rule +# that both normalizers use (#3427) — so it is a plain import, not an optional +# one: a legacy path that silently ran without it would be a second copy again. +from src.domain.services.code_normalizer import CodeNormalizer as _DomainCodeNormalizer +from src.domain.services.code_normalizer import strip_hidden_escapes as _strip_hidden_escapes + +_DOMAIN_NORMALIZER = _DomainCodeNormalizer() # Optional telegram import with safe fallback for web-only environments try: @@ -1538,60 +1540,11 @@ def normalize_code(text: str, # Fallback to legacy logic below on any error pass - # Handle sequences like "\u200B" that represent hidden/format chars literally - # We do NOT decode arbitrary escapes; only strip escapes that would decode to Cf/hidden sets + # Handle sequences like "\u200B" that represent hidden/format chars literally. + # One definition for both normalizers (#3427): Cf by category, and the + # Variation-Selector branch (Mn, not Cf) only when asked for. if remove_escaped_format_escapes and ("\\u" in out or "\\U" in out): - try: - import re as _re - # Known hidden/format codepoints we target explicitly - known_hex4 = { - "200B", "200C", "200D", "2060", "FEFF", # zero-width set - "200E", "200F", "202A", "202B", "202C", "202D", "202E", # directional - "2066", "2067", "2068", "2069", # directional isolates - } - - def _strip_if_hidden(m: 're.Match[str]') -> str: - hexcode = m.group(1).upper() - # Quick allowlist: only remove if in known set or Unicode category Cf - if hexcode in known_hex4: - return "" - try: - ch = chr(int(hexcode, 16)) - cat = unicodedata.category(ch) - if cat == 'Cf': - return "" - # Remove Unicode Variation Selectors (U+FE00..U+FE0F) - if remove_variation_selectors: - v = int(hexcode, 16) - if 0xFE00 <= v <= 0xFE0F: - return "" - except Exception: - pass - return m.group(0) # keep original escape - - # Replace \uXXXX sequences - out = _re.sub(r"\\u([0-9a-fA-F]{4})", _strip_if_hidden, out) - - # Replace \UXXXXXXXX sequences (rare for these marks, but safe) - def _strip_if_hidden_u8(m: 're.Match[str]') -> str: - hexcode = m.group(1).upper() - try: - ch = chr(int(hexcode, 16)) - if unicodedata.category(ch) == 'Cf': - return "" - # Remove Ideographic Variation Selectors (U+E0100..U+E01EF) - if remove_variation_selectors: - v = int(hexcode, 16) - if 0xE0100 <= v <= 0xE01EF: - return "" - except Exception: - pass - return m.group(0) - - out = _re.sub(r"\\U([0-9a-fA-F]{8})", _strip_if_hidden_u8, out) - except Exception: - # Best-effort: ignore on failure - pass + out = _strip_hidden_escapes(out, remove_variation_selectors=remove_variation_selectors) # Strip BOM at start if strip_bom and out.startswith("\ufeff"): From 4b9a06146a84bdde330027976368fd3cef24bb46 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 00:13:12 +0000 Subject: [PATCH 7/9] =?UTF-8?q?chore(mcp):=20=D7=99=D7=AA=D7=A8=D7=AA=20?= =?UTF-8?q?=D7=9E=D7=9E=D7=A6=D7=90=D7=99=20=D7=94=D7=A1=D7=A7=D7=99=D7=A8?= =?UTF-8?q?=D7=94=20=D7=A2=D7=9C=20#3428=20=E2=80=94=20=D7=A9=D7=A0=D7=99?= =?UTF-8?q?=20=D7=A1=D7=99=D7=A8=D7=95=D7=91=D7=99=D7=9D=20=D7=91=D7=A9?= =?UTF-8?q?=D7=9E=D7=9D,=20=D7=98=D7=91=D7=9C=D7=90=D7=95=D7=AA=20=D7=A7?= =?UTF-8?q?=D7=A4=D7=95=D7=90=D7=95=D7=AA,=20=D7=96=D7=A0=D7=91=20=D7=AA?= =?UTF-8?q?=D7=A9=D7=95=D7=91=D7=94=20=D7=A0=D7=A4=D7=A8=D7=93,=20=D7=95?= =?UTF-8?q?=D7=98=D7=A1=D7=98=D7=99=D7=9D=20=D7=A9=D7=97=D7=A1=D7=A8=D7=95?= =?UTF-8?q?=20(#3432)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit שמונה מאחת-עשרה ההצעות ממומשות, שלוש מוכרעות במפורש: - SUGG-013: path_outside_root — נתיב תקין בצורתו שפותר אל מחוץ לשורש התיעוד (docs/../secrets, /etc/hosts, ../README.md בשורש ריק) נדחה בשמו, עם root בתשובה; missing_path נשאר לנתיב ריק או עם NUL בלבד. - SUGG-011: repo_not_mirrored — מראה שאין למארח (repo_not_found מהשירות) אינה not_found; invalid_commit נשאר not_found (הריפו קיים, ה-ref הוא מה שהקורא יכול לשנות); בזמן sync שניהם sync_in_progress כמו קודם. עובר גם דרך codekeeper_docs_get_section עם repo ו-path. - SUGG-021: _PARSERS ו-DOCS_PATH_POLICY מיוצאים כ-MappingProxyType — הוולידציה בייבוא היא הבטחה רק אם הטבלה אינה משתנה אחריה; _PARSER_TABLE ו- _DOCS_PATH_POLICY_TABLE הם התפר לטסטים (monkeypatch.setitem). - SUGG-020: זנב עיצוב התשובה של docs_get_section נפרד ל-_answer_from_document (ארבע צורות התשובה מתוך מסמך שכבר נפרסר). אפס-דיף: 5,935 רשומות על אותו קורפוס, 5,933 זהות בית-בית; השתיים ששונות הן בדיוק שני הפרובים של SUGG-013 (missing_path ← path_outside_root). - SUGG-009: חמישה טסטים לשתי בדיקות הנרמול ב-_validate_policy_tables (סיומת ברישיות/בלי נקודה, שורש מוחלט/עם לוכסן סוגר/עם ./). - SUGG-010: טסט caplog לשורת ה-WARNING על repo_not_configured. - SUGG-004: טסט ההסכמה בין חיפוש לקריאה אינו דורש עוד returned == allowed — repo_policy מצהיר ש-is_denied נשאר שכבה אחרונה, ושוויון מדויק דרש את ההפך; נשאר "אפס חסומים בתוצאות" + "הקובץ המותר כן חוזר" נגד ריקנות. - SUGG-005: _require_git אחד לשני הטסטים תלויי-git — דילוג בלי הבינארי במקום skip באחד וקריסה על check=True בשני. - SUGG-022 (הכרעה, מתועדת ב-docs/mcp-server.rst): סירוב הוא תשובה של הפרוטוקול ולא תקרית; אף מסלול סירוב בשכבת המטפלים אינו רושם שורה, בכל הקבצים באותה מידה; לוג למה שהמפעיל צריך לדעת, תשובה למה שהקורא צריך; ספירת סירובים — ב-PostHog. - SUGG-012 (נדחה בנימוק): תצוגת התצורה ב-services אינה יכולה לייבא את mcp_server.docs_handlers — הכיוון חד-סטרי ומוצהר בכמה מקומות; התקלה כבר קולנית בזמן ריצה (WARNING לכל קריאה + repo_not_configured). - SUGG-018 (נדחה בנימוק): שני חצאי מדיניות הסודות נשארים בשתי שפות כי אין מנוע אחד לשניהם; מה שמחזיק אותם יחד — רשימת תבניות אחת, טסט אינטגרציה על מראה אמיתית, וטסט ישיר על ארבע הצורות. טסטים: 17 נופלים על origin/main (הקודים החדשים, הפרוקסי, הוולידטור, המראה החסרה), 7 עוברים שם כפינים ומוכחים במוטציות — מחיקת ה-WARNING, טבלאות כ-dict רגיל, ומחיקת בדיקת הסיומת מהוולידטור מפילות כל אחת את הטסט שלה. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SfJTSpDAhDr2yhtmpFkwTx --- docs/mcp-server.rst | 26 +++++++++++ docs/whats-new.rst | 4 ++ mcp_server/docs_handlers.py | 61 ++++++++++++++++++++++--- mcp_server/repo_backend.py | 10 ++++- mcp_server/server.py | 5 ++- tests/test_mcp_docs_handlers.py | 79 ++++++++++++++++++++++++++++++--- tests/test_mcp_repo_backend.py | 21 +++++++++ tests/test_mcp_repo_policy.py | 30 ++++++++++--- 8 files changed, 215 insertions(+), 21 deletions(-) diff --git a/docs/mcp-server.rst b/docs/mcp-server.rst index e8948f355..016ef631b 100644 --- a/docs/mcp-server.rst +++ b/docs/mcp-server.rst @@ -1051,6 +1051,17 @@ Claude Desktop ההבדל בין שני הקודים אינו סגנוני: ``not_found`` אומר "אין קובץ כזה" ומזמין לנסות שם אחר, ו-``path_denied`` אומר "יש, ולא תקבל". +**ועוד שני סירובים שנוקבים בסיבתם, משתי סיבות שהתחזו לאחרות (#3432).** +``path_outside_root`` — הנתיב תקין בצורתו ופותר אל מחוץ לשורש התיעוד של +הריפו (``docs/../secrets``, ``/etc/hosts``, ``../README.md`` בריפו ששורשו +ריק); התשובה נושאת את ``root``, שממילא כתוב בתיאור הכלי. עד היום זה חזר +כ-``missing_path``, והקורא לא ידע אם שכח נתיב או ניסה לצאת מהשורש; +``missing_path`` נשאר לנתיב ריק בלבד. ו-``repo_not_mirrored`` — למארח אין +עותק של הריפו כלל (הכלי ``codekeeper_get_repo_file`` מחזיר אותו באותו מצב). +עד היום גם זה חזר כ-``not_found``, וסוכן שביקש עמוד אמיתי ניסה שם אחר בזמן +שהבעיה הייתה אצל המפעיל. אם sync רץ באותו רגע, התשובה היא +``sync_in_progress`` כמו תמיד. + התנהגות בזמן sync ~~~~~~~~~~~~~~~~~~ @@ -2302,8 +2313,23 @@ JSON גדול, או כלל אחד מתוך מסמך, מושך היום את הק לפניה. **היעדר שורה של כלי אינו סימן שהכלי לא נקרא**: קריאות אינן רושמות שורה לכל קריאה — רק כתיבות (זמן ההמתנה בתור וזמן הריצה) — ומדידת השימוש עוברת ב-PostHog + **וגם סירוב אינו רושם שורה, וזו החלטה ולא פער** (#3432, SUGG-022): + תשובת ``{"ok": false, "error": ...}`` היא תשובה תקינה של הפרוטוקול — + הקורא ביקש משהו שאין, או שאסור — ולא תקרית של השרת; ‏78 מסלולי סירוב + בשכבת המטפלים אינם רושמים דבר, בכל הקבצים באותה מידה. השורה היחידה + שנרשמת היא על תקלת תצורה שרק המפעיל יכול לתקן + (``repo_not_configured``), וזה הקו: לוג למה שהמפעיל צריך לדעת, תשובה + למה שהקורא צריך לדעת. מי שמחפש כמה סירובים היו ומאיזה סוג — ב-PostHog, + שמודד כל קריאה * - ``repo_or_ref_not_found`` - ה-mirror עוד לא שוכפל מקומית (המתינו למעבר autosync) או שאין דיסק/``REPO_MIRROR_PATH`` + * - ``repo_not_mirrored`` בקריאת קובץ או עמוד תיעוד + - אותו מצב, מהצד של ``get_file``: למארח אין עותק של הריפו. זה אינו שם + קובץ שגוי — אל תנסו נתיב אחר; בדקו את המראה (``REPO_MIRROR_PATH``, + autosync). בזמן sync התשובה היא ``sync_in_progress`` + * - ``path_outside_root`` ב-``codekeeper_docs_get_section`` + - הנתיב פותר אל מחוץ לשורש התיעוד של הריפו (``root`` בתשובה). נתיב + מלא נלקח כמות שהוא; slug בלי ``/`` מעוגן לשורש * - הסוכן שמר קובץ ולא הגיעה התראה - לפי הסדר: ‏(1) יש מנוי Push פעיל בדפדפן? נרשמים ב-``/settings``, ובלי מנוי האירועים נפסלים מיד. ‏(2) ``MCP_PUSH_NOTIFICATIONS_ENABLED`` כבוי diff --git a/docs/whats-new.rst b/docs/whats-new.rst index ef65c8cd0..12ba2bad3 100644 --- a/docs/whats-new.rst +++ b/docs/whats-new.rst @@ -2,6 +2,10 @@ What's New ========== :summary: יומן השינויים של הבוט וה-WebApp לפי תאריך — מה נוסף, מה השתנה ומה תוקן בכל עדכון, עם קישורים ל-Issues הרלוונטיים. +2026-09-21 +---------- +- **chore (MCP): יתרת ממצאי הסקירה על #3428 (#3432).** שני סירובים חדשים שנוקבים בסיבתם: ``path_outside_root`` ב-``codekeeper_docs_get_section`` (עד היום נתיב שיצא מהשורש חזר כ-``missing_path``, ו-``missing_path`` נשאר לנתיב ריק בלבד) ו-``repo_not_mirrored`` מ-``RepoBackend.get_file`` (עד היום מארח בלי עותק של הריפו חזר כ-``not_found``, וסוכן ניסה שם אחר בזמן שהבעיה אצל המפעיל). טבלאות המדיניות של הכלי (``DOCS_PATH_POLICY``, ``_PARSERS``) מיוצאות כ-``MappingProxyType``, כי הוולידציה בייבוא היא הבטחה רק אם הטבלה אינה משתנה אחריה; זנב עיצוב התשובה של ``docs_get_section`` נפרד ל-``_answer_from_document`` (אפס-דיף מדוד על 208 קובצי ה-RST); שתי בדיקות הנרמול של הוולידטור ושורת הלוג על ``repo_not_configured`` קיבלו טסטים; טסט ההסכמה בין חיפוש לקריאה כבר אינו דורש שוויון מדויק (המדיניות מצהירה ש-``is_denied`` נשאר שכבה אחרונה), ושני הטסטים תלויי-git מדלגים בלי git באותה צורה. **החלטה מפורשת על לוגים לסירובים:** סירוב הוא תשובה של הפרוטוקול ולא תקרית, ואף מסלול סירוב בשכבת המטפלים אינו רושם שורה — מתועד ב-:doc:`mcp-server`. שני ממצאים נדחו בנימוק: תצוגת התצורה אינה יכולה לייבא את ``docs_handlers`` (הכיוון ``services`` ← ``mcp_server`` חד-סטרי), ושני חצאי מדיניות הסודות נשארים בשתי שפות כי אין מנוע אחד לשניהם — מה שמחזיק אותם יחד הוא רשימת תבניות אחת ושני טסטים. + 2026-09-20 ---------- - **feat: ``codekeeper_docs_get_section`` קורא גם קובצי Markdown, ולכל ריפו מדיניות נתיבים משלו.** עד היום הכלי ידע לקרוא ``.rst`` בלבד, ורק מתחת ל-``docs/``. הפארסר של Markdown נכתב ונמדד כבר קודם — ואף אחד לא קרא לו; זה השינוי שמחווט אותו. מעכשיו הסיומת בוחרת את הפארסר, ולכל ריפו מוצהר **איפה התיעוד שלו יושב ובאיזה פורמט**: ``CodeBot`` נשאר ``docs/`` עם ``.rst`` ללא שינוי, ו-``amir-bug-patterns`` מגיש ``.md`` משורש הריפו. **וזו הרחבה מכוונת של גבול הקריאה** — הכלי ציבורי, כלומר כל משתמש מאומת מפעיל אותו בלי אדמין, והריפו שנוסף ציבורי ב-GitHub. מה ש**לא** השתנה: מדיניות סינון הסודות חלה על המסלול הזה במלואה, ולכן ``path="secrets"`` מחזיר ``path_denied`` ולא ``not_found``, וגם ``secrets.md`` ו-``credentials.md`` חסומים; ותקרת 500KB של שירות המראה נשארת ההגנה על הפרסור, כי הכלי קורא את הקובץ כולו בלי ``lines``. **שני שערים ולא אחד:** ``MCP_DOCS_REPO`` מתיר בזמן ריצה, טבלת המדיניות בקוד יודעת לקרוא, וריפו שנמצא במשתנה הסביבה ואין לו מדיניות נדחה ב-``repo_not_configured`` במקום ליפול לברירת מחדל מתירנית. **ובקשה לפורמט שהריפו אינו מגיש נדחית בשמה** — ``suffix_not_allowed`` עם רשימת הסיומות שכן מוגשות, במקום להשלים סיומת בשקט מעל הקיימת ולחזור כ"לא נמצא" על קובץ שקיים. שני סירובים חדשים מגיעים מהפרסור עצמו: ``inconsistent_line_endings`` על ``\r`` בודד (CRLF עובד במלואו), ו-``too_many_sections`` על קובץ שחוצה את תקרת 50,000 הכותרות — Markdown דרך ברירת המחדל של הפרסר, ו-RST דרך התקרה שהכלי מעביר מאז #3429; סירוב שהפארסר מספק לו מספר שורה נושא גם ``line`` — תמיד ב-Markdown, ולא ב-``too_many_sections`` של RST, כי ``rst_parser`` מרים אותה בלי מספר. ``includes`` הוא שדה של RST ולכן בעמוד ``.md`` הוא תמיד ריק. **ומסלול ה-RST לא זז**: אפס דיף מדוד על 5,920 רשומות מ-208 קובצי RST, לפני ואחרי. ראו :ref:`mcp-docs-path-policy`. diff --git a/mcp_server/docs_handlers.py b/mcp_server/docs_handlers.py index 8f13e5b8a..9efe8995b 100644 --- a/mcp_server/docs_handlers.py +++ b/mcp_server/docs_handlers.py @@ -20,8 +20,8 @@ import os import posixpath from dataclasses import dataclass -from types import ModuleType -from typing import Any, NamedTuple +from types import MappingProxyType, ModuleType +from typing import Any, Mapping, NamedTuple from services import doc_sections, md_parser, rst_parser from .handlers import _clamp @@ -67,7 +67,13 @@ #: קופאת על הפונקציה המקורית וה-spy היה נעקף **בשקט** — בלי שאף טסט קיים #: ייפול. ``tests/test_mcp_docs_handlers.py::test_the_parser_table_holds_modules_ #: so_a_monkeypatch_on_the_module_is_seen`` מקבע את זה. -_PARSERS: dict[str, ModuleType] = {".rst": rst_parser, ".md": md_parser} +_PARSER_TABLE: dict[str, ModuleType] = {".rst": rst_parser, ".md": md_parser} +#: **מה שמיוצא אינו ניתן לשינוי** (#3432, SUGG-021): ``_validate_policy_tables`` +#: רץ פעם אחת בייבוא, ושינוי של הטבלה אחריו עוקף אותו בשקט. ``MappingProxyType`` +#: הופך את ההבטחה לתכונה — ``_PARSERS[".txt"] = x`` הוא ``TypeError`` — והטבלה +#: שמתחת, ``_PARSER_TABLE``, נשארת התפר לטסטים (``monkeypatch.setitem`` עליה +#: נראה דרך הפרוקסי מיד, ומוחזר בסוף הטסט). +_PARSERS: Mapping[str, ModuleType] = MappingProxyType(_PARSER_TABLE) @dataclass(frozen=True) @@ -121,10 +127,14 @@ class _DocsPathPolicy: #: מה ש**לא** משתנה: ``mcp_server.repo_policy.is_denied`` הוא ההוראה #: הראשונה ב-``RepoBackend.get_file`` וחל על המסלול הזה במלואו, כך #: ש-``secrets.md``, ``credentials.md`` ו-``.env*`` חסומים גם כאן. -DOCS_PATH_POLICY: dict[str, _DocsPathPolicy] = { +_DOCS_PATH_POLICY_TABLE: dict[str, _DocsPathPolicy] = { "CodeBot": _DocsPathPolicy(root="docs", suffix=".rst"), "amir-bug-patterns": _DocsPathPolicy(root="", suffix=".md"), } +#: קריאה בלבד, מאותו נימוק שכתוב ליד ``_PARSERS``: הוולידציה בייבוא היא הבטחה +#: רק אם הטבלה אינה משתנה אחריה. הטסטים שמוסיפים ריפו סינתטי עושים זאת על +#: ``_DOCS_PATH_POLICY_TABLE``. +DOCS_PATH_POLICY: Mapping[str, _DocsPathPolicy] = MappingProxyType(_DOCS_PATH_POLICY_TABLE) def _validate_policy_tables() -> None: @@ -164,7 +174,11 @@ class _ResolvedPath(NamedTuple): ``str | None`` הספיק כל עוד הייתה סיבת דחייה אחת. הצורה ``value-or-error-string`` שב-``repo_backend.normalize_line_range`` אינה עובדת כאן, כי הנתיב וקוד השגיאה שניהם ``str`` ו-``isinstance`` אינו - מבדיל ביניהם. + מבדיל ביניהם. ארבעה קודים חיים כאן היום, אחד לכל דחייה: ``missing_path`` + (אין נתיב — ריק או עם NUL), ``path_too_long``, ``suffix_not_allowed``, + ו-``path_outside_root`` (הנתיב תקין בצורתו ופותר אל מחוץ לשורש התיעוד; + עד #3432 הוא חלק קוד עם ``missing_path``, והקורא לא ידע אם שכח נתיב או + ניסה לצאת מהשורש). **ו-``suffix`` נישא ולא נגזר מחדש, וזה תיקון לבאג שנתפס בבדיקה.** הגרסה הראשונה קראה ל-``_suffix_of`` פעם שנייה על הנתיב המוגמר כדי @@ -253,7 +267,8 @@ def _resolve_docs_path(path: str, policy: _DocsPathPolicy) -> _ResolvedPath: ל-``docs/secrets.rst``, **ויגיש אותו**. 4. ``normpath`` — כאן, ורק כאן. 5. הגבול מול השורש. זו הנקודה השנייה: ``startswith`` חשוף הוא - ``K16``, ולכן הוא עטוף ב-``_is_under``. + ``K16``, ולכן הוא עטוף ב-``_is_under`` — ונתיב שנופל בה נדחה + ב-``path_outside_root``, בשמו, ולא כ"נתיב חסר". """ p = (path or "").strip().strip("/") if not p or "\x00" in p: @@ -283,7 +298,7 @@ def _resolve_docs_path(path: str, policy: _DocsPathPolicy) -> _ResolvedPath: norm = posixpath.normpath(p) if _is_under(norm, policy.root): return _ResolvedPath(norm, suffix, None) - return _ResolvedPath(None, None, "missing_path") + return _ResolvedPath(None, None, "path_outside_root") def _toc(doc: doc_sections.Document) -> tuple[list, bool]: @@ -353,6 +368,11 @@ def docs_get_section( if resolved.error == "path_too_long": return {"ok": False, "error": "path_too_long", "repo": repo_name, "max_chars": MAX_PATH_CHARS, "actual_chars": len(path or "")} + if resolved.error == "path_outside_root": + # השורש כבר כתוב בתיאור הכלי לכל ריפו, ולכן אמירתו כאן אינה מגלה + # דבר — היא רק חוסכת לקורא לחפש את הטעות בשם הקובץ. + return {"ok": False, "error": "path_outside_root", "repo": repo_name, + "root": policy.root} if not resolved.path: return {"ok": False, "error": "missing_path"} file_path = resolved.path @@ -448,6 +468,33 @@ def docs_get_section( return {"ok": False, "error": "too_many_sections", "max": _ceiling.MAX_SYMBOLS, **context, **_line_of(exc)} + return _answer_from_document(doc, context=context, section=section, + include_subsections=include_subsections, + max_chars=max_chars, offset=offset) + + +def _answer_from_document( + doc: doc_sections.Document, + *, + context: dict[str, Any], + section: str | None, + include_subsections: bool, + max_chars: int, + offset: int, +) -> dict[str, Any]: + """ארבע צורות התשובה של הכלי, מתוך מסמך שכבר נפרסר. + + **מה שנפרד מ-``docs_get_section`` (#3432, SUGG-020):** הפונקציה ההיא + פותרת ריפו, פותרת נתיב, קוראת קובץ ובוחרת פארסר — ארבע החלטות שכל אחת + מהן יכולה לסרב — ומכאן והלאה יש רק מסמך ושאלה. הזנב הזה הוא החלק שנפרד + הכי נקי, ואפס-דיף על 208 קובצי ה-RST (``scripts/docs_section_zero_diff.py``) + הוא מה שמוכיח שהוא רק זז. + + ארבע הצורות: ``toc`` כשאין ``section``; ``section_not_found`` עם הצעות; + ``ambiguous_section`` עם מועמדים; ו-``section`` עם התוכן, השכנים + ותת-הסקשנים. ``context`` הוא ``repo``/``path``/``ref``/``resolved_commit`` + שכל תשובה נושאת. + """ toc_items, toc_truncated = _toc(doc) # ``includes`` הוא שדה של פארסר: ``rst_parser`` ממלא אותו מיעדי diff --git a/mcp_server/repo_backend.py b/mcp_server/repo_backend.py index 4eb348f4c..5206ca04a 100644 --- a/mcp_server/repo_backend.py +++ b/mcp_server/repo_backend.py @@ -538,7 +538,15 @@ def get_file( return {"ok": False, "error": "invalid_input"} # repo_not_found / invalid_commit / git_error / timeout / internal_error: # possibly a transient race with a running sync — say so if it is. - fallback = "not_found" if err in ("repo_not_found", "invalid_commit") else "read_failed" + # + # A mirror the host does not have is its own code (#3432, SUGG-011): + # ``not_found`` invites the agent to try another file name, when what + # is missing is the whole repository — an operator's matter, not the + # caller's. ``invalid_commit`` stays ``not_found``: the repo is there, + # and the ref is what the caller can change. + if err == "repo_not_found": + return self._transient_error(repo, "repo_not_mirrored") + fallback = "not_found" if err == "invalid_commit" else "read_failed" return self._transient_error(repo, fallback) def search( diff --git a/mcp_server/server.py b/mcp_server/server.py index ee9094438..35d38f315 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -224,7 +224,10 @@ def _build_docs_path_doc() -> str: "repo_not_configured. A path longer than " f"{docs_handlers.MAX_PATH_CHARS} characters is refused with " "path_too_long before anything reads it — the longest real path in " - "any served repo is under 120 characters." + "any served repo is under 120 characters. A path that resolves outside " + "the repo's docs root is refused with path_outside_root (the root is " + "in the answer), and a repo this host has no mirror of with " + "repo_not_mirrored — an operator matter, not a wrong file name." ) diff --git a/tests/test_mcp_docs_handlers.py b/tests/test_mcp_docs_handlers.py index fbe329d81..7e2b11dfe 100644 --- a/tests/test_mcp_docs_handlers.py +++ b/tests/test_mcp_docs_handlers.py @@ -210,7 +210,7 @@ def test_explicit_repo_in_allowlist_allowed(monkeypatch): def test_path_outside_docs_rejected(bad_path): be = _FsBackend() out = docs_handlers.docs_get_section(be, path=bad_path) - assert out["ok"] is False and out["error"] == "missing_path" + assert out["ok"] is False and out["error"] == "path_outside_root" assert be.calls == [] # נחסם ב-handler לפני ה-backend @@ -578,7 +578,7 @@ def test_the_root_anchor_is_decided_before_normalisation(): """ be = _RecordingBackend() out = docs_handlers.docs_get_section(be, path="docs/../secrets", repo="CodeBot") - assert out == {"ok": False, "error": "missing_path"} + assert out == {"ok": False, "error": "path_outside_root", "repo": "CodeBot", "root": "docs"} assert be.kwargs == [] @@ -591,7 +591,7 @@ def test_traversal_out_of_the_repo_is_refused_even_at_the_repo_root(both_repos, """שורש ריק אינו "הכול" — הוא "כל דבר **בתוך** הריפו".""" be = _RecordingBackend() out = docs_handlers.docs_get_section(be, path=bad_path, repo="amir-bug-patterns") - assert out == {"ok": False, "error": "missing_path"} + assert out == {"ok": False, "error": "path_outside_root", "repo": "amir-bug-patterns", "root": ""} assert be.kwargs == [] @@ -630,11 +630,11 @@ def test_a_root_is_matched_as_a_path_unit_and_not_as_a_prefix(monkeypatch): כזה. ``norm.startswith("docs")`` בלי המפריד מקבל אותו. """ monkeypatch.setenv("MCP_DOCS_REPO", "CodeBot,synthetic") - monkeypatch.setitem(docs_handlers.DOCS_PATH_POLICY, "synthetic", + monkeypatch.setitem(docs_handlers._DOCS_PATH_POLICY_TABLE, "synthetic", docs_handlers._DocsPathPolicy(root="docs", suffix=".rst")) be = _RecordingBackend() out = docs_handlers.docs_get_section(be, path="docsecret/x", repo="synthetic") - assert out == {"ok": False, "error": "missing_path"} + assert out == {"ok": False, "error": "path_outside_root", "repo": "synthetic", "root": "docs"} assert be.kwargs == [] @@ -852,12 +852,79 @@ def test_every_suffix_a_repo_policy_names_has_a_parser(monkeypatch): for repo, policy in docs_handlers.DOCS_PATH_POLICY.items(): assert policy.suffix in docs_handlers._PARSERS, f"{repo}: {policy.suffix}" - monkeypatch.setitem(docs_handlers.DOCS_PATH_POLICY, "broken", + monkeypatch.setitem(docs_handlers._DOCS_PATH_POLICY_TABLE, "broken", docs_handlers._DocsPathPolicy(root="", suffix=".txt")) with pytest.raises(RuntimeError, match="_PARSERS"): docs_handlers._validate_policy_tables() +@pytest.mark.parametrize("policy, message", [ + (docs_handlers._DocsPathPolicy(root="docs", suffix="RST"), "סיומת לא מנורמלת"), + (docs_handlers._DocsPathPolicy(root="docs", suffix="rst"), "סיומת לא מנורמלת"), + (docs_handlers._DocsPathPolicy(root="/docs", suffix=".rst"), "שורש לא מנורמל"), + (docs_handlers._DocsPathPolicy(root="docs/", suffix=".rst"), "שורש לא מנורמל"), + (docs_handlers._DocsPathPolicy(root="./docs", suffix=".rst"), "שורש לא מנורמל"), +]) +def test_the_validator_refuses_an_unnormalised_suffix_or_root(monkeypatch, policy, message): + """שתי בדיקות הנרמול ב-``_validate_policy_tables``, שהיו בלי טסט (#3432, SUGG-009). + + כל אחת מהצורות כאן הייתה עוברת בשקט את ``_suffix_of``/``_is_under`` ומחזירה + ``suffix_not_allowed`` או ``path_outside_root`` על כל קריאה — הכלי מת בלי + שאיש ידע למה. הוולידטור הוא מה שהופך הקלדה בקבוע לשרת שלא עולה. + """ + monkeypatch.setitem(docs_handlers._DOCS_PATH_POLICY_TABLE, "bad", policy) + with pytest.raises(RuntimeError, match=message): + docs_handlers._validate_policy_tables() + + +def test_the_policy_tables_cannot_be_changed_after_import_but_the_seam_can(monkeypatch): + """הוולידציה בייבוא היא הבטחה רק אם הטבלאות אינן משתנות אחריה (#3432, SUGG-021). + + ``MappingProxyType`` דוחה כתיבה ישירה, והטבלה שמתחת נשארת התפר לטסטים — + שינוי בה נראה דרך הפרוקסי מיד, ו-``monkeypatch`` מחזיר אותו בסוף. + """ + policy = docs_handlers._DocsPathPolicy(root="", suffix=".md") + with pytest.raises(TypeError): + docs_handlers.DOCS_PATH_POLICY["seam"] = policy # type: ignore[index] + with pytest.raises(TypeError): + docs_handlers._PARSERS[".txt"] = docs_handlers.md_parser # type: ignore[index] + + monkeypatch.setitem(docs_handlers._DOCS_PATH_POLICY_TABLE, "seam", policy) + assert docs_handlers.DOCS_PATH_POLICY["seam"] is policy + + +def test_a_repo_without_a_policy_is_logged_for_the_operator(monkeypatch, caplog): + """התשובה מגיעה לסוכן; השורה מגיעה למפעיל — ומחיקתה הייתה משאירה הכול ירוק (#3432, SUGG-010).""" + import logging as _logging + + monkeypatch.setenv("MCP_DOCS_REPO", "CodeBot,ghost-repo") + with caplog.at_level(_logging.WARNING, logger="mcp_server.docs_handlers"): + out = docs_handlers.docs_get_section(_RecordingBackend(), path="anything", repo="ghost-repo") + assert out["error"] == "repo_not_configured" + warnings = [r.getMessage() for r in caplog.records if r.levelno == _logging.WARNING] + assert any("ghost-repo" in m and "MCP_DOCS_REPO" in m for m in warnings), warnings + + +@pytest.mark.parametrize("path", ["", " ", "a\x00b"]) +def test_no_usable_path_is_still_missing_path(path): + """‏``missing_path`` נשאר לדחייה שהוא נבנה בשבילה — אין נתיב — וזו כבר אינה גם "יצאת מהשורש" (#3432, SUGG-013).""" + be = _RecordingBackend() + out = docs_handlers.docs_get_section(be, path=path, repo="CodeBot") + assert out == {"ok": False, "error": "missing_path"} + assert be.kwargs == [] + + +def test_a_host_without_the_mirror_is_passed_through_as_repo_not_mirrored_with_context(): + """‏``repo_not_mirrored`` מהמראה עובר לקורא עם הריפו והנתיב — לא כ-``not_found`` (#3432, SUGG-011).""" + + class _NoMirror: + def get_file(self, **kwargs): + return {"ok": False, "error": "repo_not_mirrored"} + + out = docs_handlers.docs_get_section(_NoMirror(), path="mcp-server", repo="CodeBot") + assert out == {"ok": False, "error": "repo_not_mirrored", "repo": "CodeBot", "path": "docs/mcp-server.rst"} + + def test_the_default_repo_has_a_path_policy(): """הריפו שמוגש כשאיש לא נקב בריפו חייב להיות כזה שהכלי יודע לקרוא. diff --git a/tests/test_mcp_repo_backend.py b/tests/test_mcp_repo_backend.py index 39c766748..0faec0f85 100644 --- a/tests/test_mcp_repo_backend.py +++ b/tests/test_mcp_repo_backend.py @@ -280,6 +280,27 @@ def test_get_file_not_found_and_denied(): assert mirror.calls == [] +def test_get_file_names_a_missing_mirror_and_keeps_not_found_for_a_bad_ref(): + """‏``repo_not_found`` מהמראה הוא ``repo_not_mirrored`` — עניין של המפעיל, לא שם קובץ שגוי (#3432, SUGG-011). + + ‏``invalid_commit`` נשאר ``not_found``: הריפו קיים, וה-ref הוא מה שהקורא + יכול לשנות. ובזמן sync שניהם ``sync_in_progress``, כמו קודם. + """ + no_mirror = RepoBackend(db=_repos_db(), mirror=_Mirror(file_result={"error": "repo_not_found"}), + search_service=_Search()) + assert no_mirror.get_file(repo="alpha", path="a.py") == {"ok": False, "error": "repo_not_mirrored"} + + bad_ref = RepoBackend(db=_repos_db(), mirror=_Mirror(file_result={"error": "invalid_commit"}), + search_service=_Search()) + assert bad_ref.get_file(repo="alpha", path="a.py") == {"ok": False, "error": "not_found"} + + syncing = _DB(repos=[{"repo_name": "alpha", "default_branch": "main"}], + jobs=[{"repo_name": "alpha", "status": "running"}]) + mid_sync = RepoBackend(db=syncing, mirror=_Mirror(file_result={"error": "repo_not_found"}), + search_service=_Search()) + assert mid_sync.get_file(repo="alpha", path="a.py")["error"] == "sync_in_progress" + + def test_get_file_sync_in_progress_instead_of_not_found(): db = _DB( repos=[{"repo_name": "alpha", "default_branch": "main"}], diff --git a/tests/test_mcp_repo_policy.py b/tests/test_mcp_repo_policy.py index c588d4bd2..ef380d16d 100644 --- a/tests/test_mcp_repo_policy.py +++ b/tests/test_mcp_repo_policy.py @@ -1,5 +1,6 @@ """Unit tests for the repo secrets-path policy (mandatory, fail-closed).""" +import shutil from pathlib import Path import pytest @@ -7,6 +8,19 @@ from mcp_server import repo_policy from mcp_server.repo_policy import is_denied + +def _require_git() -> None: + """הטסטים שמריצים git מדלגים בלי git — כולם באותה צורה (#3432, SUGG-005). + + ``pytest.importorskip("services.git_mirror_service")`` בודק מודול פייתון, + לא את הבינארי; עד כאן טסט אחד דילג על ``returncode`` וטסט שני קרס על + ``check=True``. אותו אידיום כמו ``_GIT = shutil.which("git")`` ב- + ``tests/test_mcp_search_total.py``. + """ + if shutil.which("git") is None: + pytest.skip("git אינו מותקן") + + _ROOT = Path(__file__).resolve().parent.parent @@ -166,10 +180,11 @@ def test_the_policy_blocks_exactly_the_known_files_in_this_repository(): """ import subprocess + _require_git() proc = subprocess.run(["git", "ls-files", "-z"], cwd=str(_ROOT), capture_output=True, text=True) if proc.returncode != 0: - pytest.skip("אין git או שזו אינה עבודה מגיט") + pytest.skip("זו אינה עבודה מגיט") paths = [p for p in proc.stdout.split("\0") if p] assert len(paths) > 1000, "רשימת הקבצים קצרה מדי — הבדיקה איבדה את הקורפוס שלה" assert any(" " in p for p in paths), ( @@ -191,6 +206,7 @@ def _seeded_mirror(tmp_path): """ import subprocess + _require_git() work = tmp_path / "work" seeded = { "config/.env/README.md": "SEEDTOKEN\n", @@ -229,7 +245,11 @@ def test_the_search_side_skips_every_path_the_read_side_denies(tmp_path): כי הם דורשים שהנתיב **יסתיים** ב-``.pem``. הטענה כאן היא על ההסכמה עצמה ולא על רשימה מוקלדת: כל נתיב שהקריאה - חוסמת חייב להיעדר מתוצאות החיפוש, וההפך. + חוסמת חייב להיעדר מתוצאות החיפוש. **ולא ההפך** — ``repo_policy`` מצהיר + ש-``is_denied`` נשאר השכבה האחרונה, כלומר תבנית שהמנוע אינו יכול לבטא + (או מבטא רחב יותר) עדיין נחסמת בקריאה; שוויון מדויק בין שני החצאים היה + דורש את ההפך ממה שמתועד (#3432, SUGG-004). מה שכן נדרש: החיפוש אינו + ריק, אחרת "אפס חסומים בתוצאות" היה נכון גם על מנוע שבור. """ pytest.importorskip("services.git_mirror_service") from services.git_mirror_service import GitMirrorService @@ -241,14 +261,12 @@ def test_the_search_side_skips_every_path_the_read_side_denies(tmp_path): returned = {r["path"] for r in out.get("results", [])} denied = {p for p in seeded if is_denied(p)} - allowed = set(seeded) - denied assert denied, "אף נתיב שנזרע אינו נחסם — הזריעה איבדה את הנושא שלה" assert returned & denied == set(), ( f"החיפוש החזיר נתיבים שהקריאה חוסמת: {sorted(returned & denied)}") - assert returned == allowed, ( - f"החיפוש ותשובת הקריאה אינם מסכימים: חיפוש={sorted(returned)}, " - f"מותר={sorted(allowed)}") + assert "docs/ok.md" in returned, ( + f"החיפוש לא החזיר את הקובץ המותר — התוצאות ריקות מסיבה אחרת: {sorted(returned)}") # =========================================================================== From 21a478874c542ad9ecfd7b4b8e3b8f012ee95e61 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 04:00:39 +0000 Subject: [PATCH 8/9] =?UTF-8?q?fix(mcp):=20=D7=AA=D7=A7=D7=A8=D7=AA=20?= =?UTF-8?q?=D7=94=D7=92=D7=95=D7=A3=20=D7=9E=D7=A4=D7=A1=D7=99=D7=A7=D7=94?= =?UTF-8?q?=20=D7=9C=D7=A7=D7=A8=D7=95=D7=90=20=D7=92=D7=95=D7=A3=20=D7=90?= =?UTF-8?q?=D7=A0=D7=95=D7=A0=D7=99=D7=9E=D7=99=20=D7=9C=D7=A4=D7=A0=D7=99?= =?UTF-8?q?=20=D7=94=D7=90=D7=99=D7=9E=D7=95=D7=AA,=20=D7=95=D7=99=D7=AA?= =?UTF-8?q?=D7=A8=D7=AA=20=D7=9E=D7=9E=D7=A6=D7=90=D7=99=20=D7=A1=D7=A7?= =?UTF-8?q?=D7=99=D7=A8=D7=AA=20=D7=A9=D7=91=D7=A2=D7=AA=20=D7=94-PR=D7=99?= =?UTF-8?q?=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit סגירת הממצאים מסקירת Han על #3434–#3441 (הדוח: code-review-7prs-3434-3441.md ב-CodeKeeper). SEC-001 (רגרסיה של #3431), שלושה חלקים, כל אחד עם טסט שנפל לפני התיקון: - Content-Length תקין (ספרות ASCII, בלי Transfer-Encoding) עובר בלי קריאה — השרת תוחם את הגוף. נמדד על האפליקציה במצב OAuth: 5 קריאות receive() לפני ה-401 → 0. - גוף בלי אורך מוצהר נקרא עד התקרה תחת דדליין של 30 שניות על הלולאה כולה (anyio.fail_after), ואז 408 body_read_timeout עם כמה נקרא. נמדד מול uvicorn אמיתי: לפני — אין תשובה אחרי 35 שניות; אחרי — 408 אחרי 30.005 שניות, Connection: close, והשרת סוגר את החיבור. - רק POST/PUT/PATCH נבדקות (WARN-002): GET /healthz עם Content-Length מזויף מחזיר 200 במקום 413, בשני מצבי האימות. שני הסירובים נושאים Connection: close. טסט סדר-התקנה למצב OAuth לצד זה של מצב PAT, ותיקון המשפט "הפטור מבני" בתיעוד, ב-docstring ובהערה. WARN-001: טסט בתת-תהליך שמייבא את mcp_server.app כמו uvicorn, בשני המצבים, עם ריצת בקרה — שני משתני הסביבה ו-MAX_CODE_SIZE מגיעים לאפליקציה הבנויה. WARN-003: הוחלט — התיעוד אומר שסירובי מכסה אינם נספרים ב-PostHog (ואף סירוב אינו נספר לפי סוג), והעיצוב פתוח באישו #3442. WARN-004: הצורה העוינת במדידת הפרסור רצה עם max_sections=None במפורש. הצעות שנכנסו: SUGG-022 (התקרה נגזרת מ-MAX_CODE_SIZE ב-request_bytes_for, גם בעליית השירות; טסט מצמיד ומודד שקובץ עברי מקסימלי נכנס), SUGG-009 (הסירוב הוא CallToolResult שלם — כלי עם טיפוס החזרה ושם לא מוכר מקבלים rate_limited), SUGG-023 (is None במקום or), SUGG-024 (25 קריאות מקבילות, תקציב 2 — בדיוק 2 עוברות), SUGG-011 (RateLimiter אינו יוצר רשומה לשאלה בלבד; פנקס האזהרות מנוקה), SUGG-019 (token_count ו-front_matter_end עוברים בשער הכניסה של parse_document), SUGG-013, SUGG-002, SUGG-020, SUGG-021, SUGG-005, SUGG-006, SUGG-010, SUGG-001. YAGNI-001 נמחק, YAGNI-002 נשאר ומתועד כהחלטה סגורה. עשר ההצעות שנדחו — אישו #3442. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SfJTSpDAhDr2yhtmpFkwTx --- docs/environment-variables.rst | 2 +- docs/mcp-server.rst | 102 ++-- docs/whats-new.rst | 3 +- mcp_server/app.py | 9 +- mcp_server/docs_handlers.py | 7 +- mcp_server/handlers.py | 13 +- mcp_server/limits.py | 269 ++++++++--- mcp_server/server.py | 96 +++- rate_limiter.py | 19 +- scripts/generate_ai_map.py | 10 +- scripts/measure_md_parse_cost.py | 36 +- services/md_parser.py | 96 ++-- tests/test_mcp_app_wiring.py | 136 ++++++ tests/test_mcp_limits.py | 533 ++++++++++++++++++++- tests/test_mcp_to_thread.py | 8 +- tests/test_md_parser.py | 35 ++ tests/test_measure_md_parse_cost_script.py | 16 +- utils.py | 5 +- webapp/push_api.py | 2 +- 19 files changed, 1214 insertions(+), 183 deletions(-) create mode 100644 tests/test_mcp_app_wiring.py diff --git a/docs/environment-variables.rst b/docs/environment-variables.rst index 644cae099..b5aae9844 100644 --- a/docs/environment-variables.rst +++ b/docs/environment-variables.rst @@ -2577,7 +2577,7 @@ - ``120`` - MCP * - ``MCP_MAX_REQUEST_BYTES`` - - תקרת גודל (בתים) לגוף בקשה לשרת ה-MCP, לכל הנתיבים ולכל הכלים. גוף גדול יותר — לפי ``Content-Length`` או לפי מה שבאמת מגיע — נדחה ב-``413`` עם ``{"error": "body_too_large", "max_bytes": ...}`` **לפני** שהטרנספורט מפענח JSON. מינימום ``65536``; ערך שאינו מספר — ברירת המחדל, עם WARNING. ראו "גבולות הבקשה" ב-:doc:`mcp-server`. + - תקרת גודל (בתים) לגוף בקשה לשרת ה-MCP, במתודות שנושאות גוף (``POST``/``PUT``/``PATCH``) ולכל הכלים. ברירת המחדל **נגזרת** מ-``MAX_CODE_SIZE`` (``request_bytes_for`` ב-``mcp_server/limits.py``: פי שישה ועוד מעטפת, מעוגל ל-MiB שלם — 1MiB על ברירת המחדל של ``MAX_CODE_SIZE``), והמשתנה הזה רק מעלה אותה בלי דיפלוי; הוא אינו kill switch — ``0`` אינו מכבה, המינימום ``65536`` חל. ``Content-Length`` מעל התקרה נדחה ב-``413`` עם ``{"error": "body_too_large", "max_bytes": ...}`` בלי שנקרא בית; גוף בלי אורך מוצהר נספר עד התקרה תחת דדליין של 30 שניות (``408 body_read_timeout``). ערך שאינו מספר — ברירת המחדל, עם WARNING. ראו "גבולות הבקשה" ב-:doc:`mcp-server`. - לא - ``1048576`` - ``2097152`` diff --git a/docs/mcp-server.rst b/docs/mcp-server.rst index 5ca0ea144..9562392fa 100644 --- a/docs/mcp-server.rst +++ b/docs/mcp-server.rst @@ -412,8 +412,9 @@ Starlette עם ``lifespan=`` משלה, ולכן ``on_startup`` אינו רץ כ - תקציב הבתים של תשובה אחת * - ``DEFAULT_MAX_REQUEST_BYTES`` - 1,048,576 - - גוף בקשה אחת, לכל הנתיבים, ב-``mcp_server/limits.py``; ‏``MCP_MAX_REQUEST_BYTES`` - מכוון אותו. ראו "גבולות הבקשה" + - גוף בקשה אחת במתודות שנושאות גוף, ב-``mcp_server/limits.py``. נגזר + מ-``MAX_CODE_SIZE`` (``request_bytes_for``) ולא מוקלד לצידו; + ‏``MCP_MAX_REQUEST_BYTES`` מעלה אותו. ראו "גבולות הבקשה" * - ``DEFAULT_RATE_LIMIT_PER_MINUTE`` - 60 - קריאות כלים לזהות אחת בדקה, ב-``mcp_server/limits.py``; @@ -563,20 +564,47 @@ Starlette עם ``lifespan=`` משלה, ולכן ``on_startup`` אינו רץ כ **גודל גוף הבקשה — במידלוור ASGI, לפני שהטרנספורט מפענח JSON.** הטרנספורט של ה-SDK קורא את הגוף כולו ומפענח אותו לפני שאף כלי רואה ארגומנט, ולכן התקרה -הזאת היא הגבול היחיד על **כל** ארגומנט של **כל** כלי, קיים ועתידי. ברירת המחדל -היא 1MiB: הארגומנט הלגיטימי הגדול ביותר הוא ``content`` של -``codekeeper_save_file``, שחסום ב-``MAX_CODE_SIZE`` (100,000 תווים), ולקוח -שמקודד JSON עם ``ensure_ascii`` הופך כל תו עברי לשישה בתים — כלומר כ-600KB -במקרה הקיצוני, ו-1MiB משאיר לזה מרווח. ‏``Content-Length`` שמצהיר על יותר מזה -נדחה לפני שנקרא בית אחד; כותרת חסרה או מכזבת נתפסת בספירה של מה שבאמת מגיע. -הסירוב נוקב בסיבתו, באותה צורה של ה-401 של האימות: +הזאת היא הגבול היחיד על **כל** ארגומנט של **כל** כלי, קיים ועתידי. **התקרה +נגזרת ולא מוקלדת:** הארגומנט הלגיטימי הגדול ביותר הוא ``code`` של +``codekeeper_save_file``, שחסום ב-``MAX_CODE_SIZE`` (תווים), ולקוח שמקודד JSON +עם ``ensure_ascii`` הופך כל תו עברי לשישה בתים; ``request_bytes_for`` ב- +``mcp_server/limits.py`` מחשב ``MAX_CODE_SIZE × 6`` ועוד מעטפת של 64KiB (שם +הכלי, שם הקובץ, שפה, ותיאור בתקרתו) ומעגל כלפי מעלה ל-MiB שלם — על ברירת המחדל +של התצורה זה 1MiB. השירות גוזר את הערך בעלייה מהתצורה שהוא באמת רץ איתה +(``max_code_size()``), כך שהעלאת ``MAX_CODE_SIZE`` לעולם אינה משאירה את התקרה +מאחור, וטסט בונה את בקשת השמירה הגדולה ביותר (קובץ עברי בגודל המקסימלי, בקידוד +היקר) ומודד שהיא נכנסת. + +**מה נקרא ומה לא, ולמה זה חשוב.** התקרה מסתכלת רק על מתודות שנושאות גוף +(``POST``, ‏``PUT``, ‏``PATCH``); ``GET`` ודומיה עוברות כמות שהן, ולכן +``Content-Length`` מזויף על ``GET /healthz`` אינו 413 (עד סקירת שבעת ה-PRים הוא +כן היה, מול uvicorn אמיתי). ``Content-Length`` תקין — ספרות בלבד, כמו ש-h11 +מקבל — שמצהיר על יותר מהתקרה נדחה לפני שנקרא בית אחד; תקין ומתחת לתקרה, בלי +``Transfer-Encoding`` לצידו, **עובר בלי שהמידלוור קורא בית**, כי השרת (uvicorn/h11) +תוחם את הגוף לאורך המוצהר ואי אפשר להעביר דרכו יותר — נמדד. מה שנשאר לספירה +הוא גוף בלי אורך מוצהר (chunked, או ``Content-Length`` לצד ``Transfer-Encoding``, +ששם h11 תוחם לפי chunked): הוא נקרא עד התקרה, וכל הלולאה תחת דדליין של 30 +שניות (``DEFAULT_BODY_READ_SECONDS``), כי ל-uvicorn אין timeout לגוף בקשה +וההמתנה לנתח הבא הייתה אחרת בלתי חסומה. הסירוב נוקב בסיבתו, באותה צורה של +ה-401 של האימות, ונושא ``Connection: close`` — הוא הגיע לפני שהגוף נגמר, ואין +טעם להמשיך לקרוא אותו: .. code-block:: json {"error": "body_too_large", "max_bytes": 1048576, "content_length": 20971520} -במצב PAT האימות נשאר מחוץ לתקרה (בקשה בלי טוקן היא 401 גם כשהיא ענקית); במצב -OAuth התקרה היא השכבה החיצונית ברמת האפליקציה, ומסרבת לפני שנבדק אישור כלשהו. +.. code-block:: json + + {"error": "body_read_timeout", "read_timeout_seconds": 30.0, "received_bytes": 4096} + +במצב PAT האימות נשאר מחוץ לתקרה (בקשה בלי טוקן היא 401 גם כשהיא ענקית). במצב +OAuth התקרה היא השכבה החיצונית ברמת האפליקציה, והאימות של ה-SDK יושב בתוך +ה-mount של ``/mcp``: אורך מוצהר מעל התקרה נדחה לפני שנבדק אישור כלשהו, ואורך +מוצהר תקין עובר בלי קריאה, כך שה-401 של ה-SDK עדיין מגיע בלי שנקרא בית מהגוף. +**זו הייתה רגרסיה של #3431** (SEC-001 בסקירת שבעת ה-PRים): הגרסה הראשונה קראה +כל גוף אנונימי עד התקרה לפני ה-401 — נמדדו 5 קריאות ``receive()`` לפני התשובה +על גוף של חמישה נתחים, גם עם אורך מוצהר — ובלי דדליין. היום: 0 קריאות לאורך +מוצהר, וגוף chunked אנונימי, המקרה שנשאר, חסום בתקרה ובדדליין. **קצב — לפי זהות, בנקודת השיגור של הכלים.** הזהות שאפשר לספור לפיה קיימת במידלוור רק במצב PAT; במצב OAuth (הייצור) ה-SDK מאמת בתוך ``/mcp``, והמקום היחיד @@ -586,7 +614,9 @@ OAuth התקרה היא השכבה החיצונית ברמת האפליקציה, מהמאגר ולפני שגוף אסינכרוני רץ על הלולאה: קריאה שנדחתה אינה עולה עובד, ומה שאינו קריאת כלי (``initialize``, ‏``tools/list``) אינו נספר. הוא בנוי מעל ``rate_limiter.RateLimiter`` — אותו חלון מתגלגל של דקה שהבוט משתמש בו, בזיכרון -ובלי Redis, וזה מספיק כי השירות רץ במופע אחד. ברירת המחדל היא 60 קריאות בדקה +ובלי Redis, וזה מספיק כי השירות רץ במופע אחד — הגדרה של השירות ב-Render +(``numInstances: 1``), מחוץ לריפו; מי שמוסיף מופע מאבד את הזיכרון המשותף, וזה +המקום להתחיל ממנו. ברירת המחדל היא 60 קריאות בדקה לזהות, ומאיפה המספר: הבקשה הציבורית היקרה ביותר היום היא עמוד RST של 500KB — ‏0.24 שניות מעבד עם תקרת הסקשנים; במסלול ה-Markdown המסמך הצפוף האמיתי עולה 0.47 שניות והצורה העוינת 2.3; המכסה היא 0.5 מעבד, כלומר 30 שניות-מעבד בדקה. @@ -599,21 +629,34 @@ OAuth התקרה היא השכבה החיצונית ברמת האפליקציה, {"ok": false, "error": "rate_limited", "limit_per_minute": 60, "retry_after_seconds": 17} -**הפטור לנתיבי הדופק הוא מבני, לא רשימה.** ‏``/healthz`` אינו קריאת כלי ולכן -לעולם אינו מגיע למגביל, וכ-``GET`` אין לו גוף לתקרה. זו המלכודת שמתוארת +**הפטור לנתיב הדופק — שני חצאים, ורק אחד מהם מבני.** מהמגביל ``/healthz`` פטור +במבנה: הוא אינו קריאת כלי ולכן לעולם אינו מגיע למגביל. זו המלכודת שמתוארת ב-``blanket-policy-silent-block`` §7 — מגבלה גורפת שמחזירה ``429`` למוניטור -החיצוני ומכריזה על שירות בריא כמנותק — והיא נסגרת כאן על ידי המבנה, ומקובעת -בטסט על האפליקציה האמיתית: מאה דגימות של ``/healthz`` תחת מגבלה של קריאה אחת -בדקה מחזירות מאה ``200``. - -**מה נרשם בלוג.** בעלייה — שורה אחת עם שני הגבולות שנקבעו; סירוב על גודל — -WARNING עם הגודל והנתיב (לא הגוף ולא הטוקן); סירוב על קצב — WARNING אחד לזהות -לחלון, כדי שסוכן שממשיך לדפוק לא יהפוך את הלוג לתור. - -**כיוון.** ‏``MCP_MAX_REQUEST_BYTES`` (מינימום 65,536) ו-``MCP_RATE_LIMIT_PER_MINUTE`` -(‏``0`` מכבה במפורש, עם WARNING). שניהם נקראים ב-``create_app`` — בכניסה לשירות, -לא בזמן ייבוא — וערך שאינו מספר מחזיר את ברירת המחדל עם WARNING; בשום מסלול -טעות תצורה אינה הופכת לגבול רחב יותר. +החיצוני ומכריזה על שירות בריא כמנותק — והיא נסגרת כאן על ידי המבנה. מתקרת +הגוף הוא פטור **בכלל**, לא במבנה: ``GET`` אינה מתודה שנושאת גוף, ולכן התקרה +אינה מסתכלת עליה — עד סקירת שבעת ה-PRים היא כן הסתכלה, ו-``GET /healthz`` עם +``Content-Length`` מזויף מעל התקרה ענה ``413``. שני החצאים מקובעים בטסטים על +האפליקציה האמיתית, בשני מצבי האימות: מאה דגימות של ``/healthz`` תחת מגבלה של +קריאה אחת בדקה מחזירות מאה ``200``, ו-``GET /healthz`` עם אורך מזויף מחזיר ``200``. + +**מה נרשם בלוג.** בעלייה — שורה אחת עם שני הגבולות שנקבעו; סירוב על גודל או על +הדדליין — WARNING עם הסיבה, המספרים והנתיב (לא הגוף ולא הטוקן); סירוב על קצב — +WARNING אחד לזהות לחלון, כדי שסוכן שממשיך לדפוק לא יהפוך את הלוג לתור. **וסירובי +המכסה אינם נספרים ב-PostHog**: המדידה עוטפת את ``_tool_manager.call_tool`` של +ה-SDK, וההכרעה על המכסה קורית לפניו, ולכן קריאה שסורבה אינה נראית שם כלל; וגם +סירוב שכן עובר במדידה (תשובת ``{"ok": false, ...}`` מגוף כלי) נרשם כהצלחה, כי +``is_error`` מסומן רק על חריגה. האות היחיד על סירובי מכסה הוא ה-WARNING ההוא, +שאי אפשר לסכם. ההחלטה מפורטת באישו #3442, ושם גם מה שנשאר לעצב. + +**כיוון.** ‏``MCP_MAX_REQUEST_BYTES`` ו-``MCP_RATE_LIMIT_PER_MINUTE`` (‏``0`` מכבה +במפורש, עם WARNING). שניהם נקראים ב-``create_app`` — בכניסה לשירות, לא בזמן +ייבוא — וערך שאינו מספר מחזיר את ברירת המחדל עם WARNING; בשום מסלול טעות תצורה +אינה הופכת לגבול רחב יותר. **``MCP_MAX_REQUEST_BYTES`` אינו kill switch, וזו +החלטה סגורה** (YAGNI-002 בסקירת שבעת ה-PRים): ``0`` אינו מכבה אותו — הרצפה של +65,536 בתים חלה — והוא קיים כדי להעלות את התקרה בלי דיפלוי כשבקשה לגיטימית +נדחית ב-``body_too_large``, למשל אחרי שכלי יקבל ארגומנט גדול מ-``MAX_CODE_SIZE``. +הדדליין על גוף בלי אורך מוצהר (30 שניות) אינו ניתן לכיוון מהסביבה: אין לו +קורא שמצדיק זאת. .. _mcp-update-description: @@ -2405,8 +2448,11 @@ JSON גדול, או כלל אחד מתוך מסמך, מושך היום את הק בשכבת המטפלים אינם רושמים דבר, בכל הקבצים באותה מידה. השורה היחידה שנרשמת היא על תקלת תצורה שרק המפעיל יכול לתקן (``repo_not_configured``), וזה הקו: לוג למה שהמפעיל צריך לדעת, תשובה - למה שהקורא צריך לדעת. מי שמחפש כמה סירובים היו ומאיזה סוג — ב-PostHog, - שמודד כל קריאה + למה שהקורא צריך לדעת. **ואין היום מקום שסופר סירובים לפי סוג:** PostHog + מודד כל קריאה שהגיעה לגוף כלי, אבל סירוב מגוף כלי נרשם שם כהצלחה + (``is_error`` מסומן רק על חריגה), וסירוב מכסה (``rate_limited``) אינו + מגיע למדידה כלל — הוא מוכרע לפני התפר שהיא עוטפת. הפער מתועד באישו + #3442; ראו גם "מה נרשם בלוג" ב-:ref:`mcp-request-limits` * - ``repo_or_ref_not_found`` - ה-mirror עוד לא שוכפל מקומית (המתינו למעבר autosync) או שאין דיסק/``REPO_MIRROR_PATH`` * - ``repo_not_mirrored`` בקריאת קובץ או עמוד תיעוד diff --git a/docs/whats-new.rst b/docs/whats-new.rst index b11dabffe..728d3fbba 100644 --- a/docs/whats-new.rst +++ b/docs/whats-new.rst @@ -4,6 +4,7 @@ What's New 2026-09-21 ---------- +- **fix (MCP): סגירת ממצאי סקירת שבעת ה-PRים #3434–#3441 — תקרת הגוף מפסיקה לקרוא גוף אנונימי לפני האימות, ועוד ארבע-עשרה הצעות.** **הרגרסיה (SEC-001):** תקרת הגוף מ-#3431 קראה כל גוף עד 1MiB לפני שהשכבה הבאה ראתה את הבקשה — במצב OAuth גם גוף אנונימי שהאימות של ה-SDK דוחה מהכותרות בלבד, ובלי דדליין (ל-uvicorn אין timeout לגוף בקשה): נמדדו 5 קריאות ``receive()`` לפני ה-401 על גוף של חמישה נתחים, גם עם ``Content-Length``. שלושה כללים, כל אחד עם טסט שנפל לפני שנכתב: ``Content-Length`` תקין (ספרות בלבד, בלי ``Transfer-Encoding``) עובר בלי קריאה, כי השרת תוחם את הגוף לאורך המוצהר — 0 קריאות לפני ה-401; גוף בלי אורך מוצהר נקרא עד התקרה תחת דדליין של 30 שניות על הלולאה כולה, ואז ``408 body_read_timeout`` שאומר כמה נקרא; ורק ``POST``/``PUT``/``PATCH`` נבדקות, ולכן ``GET /healthz`` עם ``Content-Length`` מזויף מחזיר ``200`` ולא ``413`` (WARN-002). שני הסירובים נושאים ``Connection: close``, וטסט סדר-התקנה למצב OAuth עומד ליד זה של מצב PAT. **התקרה נגזרת:** ``DEFAULT_MAX_REQUEST_BYTES`` היה מוקלד לצד הערה שגוזרת אותו ביד; עכשיו ``request_bytes_for(MAX_CODE_SIZE)`` — פי שישה ועוד מעטפת, מעוגל ל-MiB — גם בעליית השירות מהתצורה בפועל, וטסט בונה את בקשת השמירה הגדולה ביותר (קובץ עברי בגודל המקסימלי) ומודד שהיא נכנסת (SUGG-022). **המגביל:** הסירוב הוא ``CallToolResult`` שלם ולא ערך שעובר ב-``convert_result`` של הכלי, ולכן כלי עם טיפוס החזרה מוצהר או שם כלי לא מוכר מקבלים ``rate_limited`` ולא ``ValidationError`` או שגיאת SDK (SUGG-009); ``is None`` במקום ``or`` בבחירת המגביל, כדי ש-``ToolRateLimiter(0)`` לא יוחלף בשקט ב-60 בדקה ברגע שיתווסף ``__len__`` (SUGG-023); טסט מקבילי — 25 קריאות בו-זמנית, תקציב 2, בדיוק 2 עוברות (SUGG-024); ``RateLimiter`` אינו יוצר עוד רשומה לזהות ששאלו עליה בלבד, ופנקס האזהרות מנוקה (SUGG-011); ``ToolRateLimiter.enabled`` נמחק (YAGNI-001) ו-``MCP_MAX_REQUEST_BYTES`` נשאר כהחלטה סגורה — לא kill switch אלא דרך להעלות את התקרה בלי דיפלוי (YAGNI-002). **החיווט נבדק:** טסט בתת-תהליך מייבא את ``mcp_server.app`` כמו uvicorn, בשני מצבי האימות ועם ריצת בקרה, ומוודא ששני משתני הסביבה ו-``MAX_CODE_SIZE`` מגיעים לאפליקציה הבנויה (WARN-001). **ומה שלא נספר:** סירובי מכסה אינם נספרים ב-PostHog — ההכרעה קורית לפני התפר שהמדידה עוטפת, וגם סירוב מגוף כלי נרשם שם כהצלחה — התיעוד אומר זאת עכשיו במפורש, והעיצוב פתוח באישו #3442 (WARN-003). **קטנות באותו PR:** ``token_count`` ו-``front_matter_end`` עוברים באותו שער כניסה כמו ``parse_document`` (BOM, ``\r`` בודד, טיפוס), ולכן ``generate_ai_map`` אינו יכול עוד לקבל גבול front matter שחולק על ``split("\n")`` (SUGG-019, SUGG-013); הצורה העוינת בסקריפט מדידת הפרסור רצה עם ``max_sections=None`` במפורש, כי עם התקרה היא מדדה עצירה (41.0 בתים לבית) ולא את הפרסר (90.1) (WARN-004); ``_CANDIDATES_MAX`` מיובא מ-``doc_sections`` במקום להיות מוקלד (SUGG-002); תנאי מת ב-``utils.normalize_code`` ומספר מוקלד בטסט של שורת הקיבולת הוסרו (SUGG-021, SUGG-020); והערות עם מספרים שהתיישנו אחרי המעבר לפרנקפורט אומרות זאת בתאריך (SUGG-005, SUGG-006, SUGG-010). עשר ההצעות שנדחו — באישו #3442. ראו :ref:`mcp-request-limits`. - **chore (MCP): יתרת ממצאי הסקירה על #3428 (#3432).** שני סירובים חדשים שנוקבים בסיבתם: ``path_outside_root`` ב-``codekeeper_docs_get_section`` (עד היום נתיב שיצא מהשורש חזר כ-``missing_path``, ו-``missing_path`` נשאר לנתיב ריק בלבד) ו-``repo_not_mirrored`` מ-``RepoBackend.get_file`` (עד היום מארח בלי עותק של הריפו חזר כ-``not_found``, וסוכן ניסה שם אחר בזמן שהבעיה אצל המפעיל). טבלאות המדיניות של הכלי (``DOCS_PATH_POLICY``, ``_PARSERS``) מיוצאות כ-``MappingProxyType``, כי הוולידציה בייבוא היא הבטחה רק אם הטבלה אינה משתנה אחריה; זנב עיצוב התשובה של ``docs_get_section`` נפרד ל-``_answer_from_document`` (אפס-דיף מדוד על 208 קובצי ה-RST); שתי בדיקות הנרמול של הוולידטור ושורת הלוג על ``repo_not_configured`` קיבלו טסטים; טסט ההסכמה בין חיפוש לקריאה כבר אינו דורש שוויון מדויק (המדיניות מצהירה ש-``is_denied`` נשאר שכבה אחרונה), ושני הטסטים תלויי-git מדלגים בלי git באותה צורה. **החלטה מפורשת על לוגים לסירובים:** סירוב הוא תשובה של הפרוטוקול ולא תקרית, ואף מסלול סירוב בשכבת המטפלים אינו רושם שורה — מתועד ב-:doc:`mcp-server`. שני ממצאים נדחו בנימוק: תצוגת התצורה אינה יכולה לייבא את ``docs_handlers`` (הכיוון ``services`` ← ``mcp_server`` חד-סטרי), ושני חצאי מדיניות הסודות נשארים בשתי שפות כי אין מנוע אחד לשניהם — מה שמחזיק אותם יחד הוא רשימת תבניות אחת ושני טסטים. 2026-09-20 @@ -12,7 +13,7 @@ What's New - **fix (MCP): תקרת גודל לגוף בקשה והגבלת קצב לפי זהות, עם סירוב שנוקב בסיבתו (#3431).** עד עכשיו המידלוור היחיד היה האימות, וכל משתמש מאומת יכול היה לשלוח בקשות בכל גודל ובכל תדירות. שני גבולות חדשים ב-``mcp_server/limits.py``: גוף בקשה מעל 1MiB (``MCP_MAX_REQUEST_BYTES``) נדחה ב-``413 body_too_large`` במידלוור ASGI, לפני שהטרנספורט מפענח JSON — לפי ``Content-Length`` ואם היא חסרה או מכזבת, לפי מה שבאמת מגיע; וקריאות כלים מוגבלות ל-60 בדקה לזהות (``MCP_RATE_LIMIT_PER_MINUTE``) בנקודת השיגור של הכלים — המקום היחיד שרואה את הזהות גם במצב OAuth — ולפני שנתפס חוט, עם ``rate_limited`` ו-``retry_after_seconds`` בתשובה. המגביל הוא ``rate_limiter.RateLimiter`` הקיים של הבוט, לא עותק. ‏``/healthz`` פטור מבנית משניהם (``blanket-policy-silent-block`` §7), וזה מקובע בטסט. המספרים נגזרו מהמדידות ב-#3431: 0.24 שניות מעבד לעמוד RST עוין של 500KB, 0.47 למסמך ה-Markdown הצפוף, 2.3 לצורה העוינת, מול מכסה של 0.5 מעבד. ראו "גבולות הבקשה" ב-:doc:`mcp-server`. - **fix (mirror): ‏``get_file_at_commit`` בודק את גודל הקובץ במאגר האובייקטים לפני ``git show``, ולכן קובץ מעל התקרה אינו נטען לזיכרון כלל (#3433, פריט 9).** עד היום ``git show`` נטען כולו לזיכרון ורק אז ``max_size`` נבדק — כלומר התקרה הייתה בדיקה בדיעבד: קובץ של 12MB שנדחה עלה 20MiB שיא בחוט הקריאה לפני שהתשובה הייתה ``file_too_large``. מעכשיו ``git cat-file -s :`` מחזיר את גודל האובייקט בלי לקרוא אותו, שתי הפקודות פונות לאותו קומיט שנפתר פעם אחת (אין חלון לסנכרון להיכנס בו), וקובץ מעל התקרה נדחה לפני שנקרא בית אחד — אותה תשובה, אותו ``size``, אפס זיכרון. כשל בבדיקת הגודל אינו נופל לקריאה בלי תקרה, ומה שמוחזר עדיין נבדק מול התקרה בנפרד, לכל סוג אובייקט. נמדד: 12MB מעל תקרה של 500KB ושל 10MiB — 20.2 ו-20.4MiB לפני, אפס אחרי; 7MB מתחת לתקרה — 14MiB לפני ואחרי, כי אותו כן קוראים. ראו "מודל הריצה של הכלים" ב-:doc:`mcp-server`. - **chore (MCP): ארבעה פריטים קטנים מסקירת #3429 (#3433).** שורת הקיבולת של מאגר הקריאות קוראת את ``ThreadPoolExecutor._max_workers`` הפרטי דרך ``_installed_width``, וכשהמאפיין ייעלם היא אומרת "unreadable" לצד הרוחב שהתבקש עם WARNING, במקום שהשירות לא יעלה בגלל שורת לוג (SUGG-004); ‏``scripts/measure_md_parse_cost.py`` מודד צפיפות דרך ``md_parser.token_count`` הציבורית — על המופע שהכלי מריץ — במקום לקרוא ל-``_build_parser`` הפרטית ולבנות פרסר לכל קובץ (SUGG-010); שני טסטים חדשים מקבעים שקובץ ``memory.max`` שקיים אבל ריק או לא-מספרי נופל ל-cgroup v1 (SUGG-014); ואסרשן שלא יכול היה ליפול הוחלף בטסט שמצמיד ``os.cpu_count`` ל-16 כדי שה-fallback ייבדק מול 20 ולא מול עצמו (SUGG-001). -- **fix (parsers): ‏``rst_parser.parse_document`` בודק את הקלט בכניסה, וברירת המחדל של ``max_sections`` מיושרת לזו של ``md_parser`` (#3421, #3420).** עד היום ``rst_parser.parse_document(None)`` החזיר מסמך ריק **בשקט** — מפה ריקה שמתחזה למפה של קובץ בלי כותרות — ו-``17`` נפל ב-``AttributeError`` גולמי, בעוד ``md_parser`` זרק ``TypeError`` שאומר מה התקבל. מעכשיו שני הפארסרים עוברים באותה בדיקה, ``doc_sections.require_str``, ומרימים את אותה חריגה עם אותן מילים; אף קורא לא נשען על הצורה הישנה (המטפל, הסורק והסקריפטים מעבירים תמיד מחרוזת). ובאותו PR ההכרעה על ברירת המחדל: ``max_sections`` ב-``rst_parser`` היה ``None`` (בלי תקרה) כי הפרמטר נוסף לפארסר שכבר רץ בייצור, ואילו ב-``md_parser`` הוא ``MAX_SECTIONS`` — כלומר קורא ששכח להעביר תקרה קיבל הגנה מהפארסר האחד ולא מהשני. עכשיו שניהם על אותה ברירת מחדל, ``MAX_SECTIONS`` שעבר ל-``services.doc_sections`` ומיוצא משני הפארסרים, ו-``codekeeper_docs_get_section`` אינו מעביר תקרה לאף אחד מהם. **נמדד, לא הונח:** על כל 208 קובצי ה-RST ב-``docs/`` הקובץ העשיר ביותר נושא 50 סקשנים מול תקרה של 50,000 (אחד לאלף), ופלט הכלי על כולם — 5,931 רשומות — זהה בית-בית לפני ואחרי. סורק האאוטליין ממשיך להעביר את התקרה שלו במפורש, וטסט מקבע זאת. ראו "שני סירובים שמגיעים מהפרסור עצמו" ב-:doc:`mcp-server`. +- **fix (parsers): ‏``rst_parser.parse_document`` בודק את הקלט בכניסה, וברירת המחדל של ``max_sections`` מיושרת לזו של ``md_parser`` (#3421, #3420).** עד היום ``rst_parser.parse_document(None)`` החזיר מסמך ריק **בשקט** — מפה ריקה שמתחזה למפה של קובץ בלי כותרות — ו-``17`` נפל ב-``AttributeError`` גולמי, בעוד ``md_parser`` זרק ``TypeError`` שאומר מה התקבל. מעכשיו שני הפארסרים עוברים באותה בדיקה, ``doc_sections.require_str``, ומרימים את אותה חריגה עם אותן מילים; אף קורא לא נשען על הצורה הישנה (המטפל, הסורק והסקריפטים מעבירים תמיד מחרוזת). ובאותו PR ההכרעה על ברירת המחדל: ``max_sections`` ב-``rst_parser`` היה ``None`` (בלי תקרה) כי הפרמטר נוסף לפארסר שכבר רץ בייצור, ואילו ב-``md_parser`` הוא ``MAX_SECTIONS`` — כלומר קורא ששכח להעביר תקרה קיבל הגנה מהפארסר האחד ולא מהשני. עכשיו שניהם על אותה ברירת מחדל, ``MAX_SECTIONS`` שעבר ל-``services.doc_sections`` ומיוצא משני הפארסרים, ו-``codekeeper_docs_get_section`` אינו מעביר תקרה לאף אחד מהם — וזה מחליף את מה שכתוב ברשומת הקריאה של Markdown שלמטה ("RST דרך התקרה שהכלי מעביר מאז #3429"): מאז השינוי הזה הכלי אינו מעביר תקרה, וברירת המחדל של הפרסר היא שעוצרת. **נמדד, לא הונח:** על כל 208 קובצי ה-RST ב-``docs/`` הקובץ העשיר ביותר נושא 50 סקשנים מול תקרה של 50,000 (אחד לאלף), ופלט הכלי על כולם — 5,931 רשומות — זהה בית-בית לפני ואחרי. סורק האאוטליין ממשיך להעביר את התקרה שלו במפורש, וטסט מקבע זאת. ראו "שני סירובים שמגיעים מהפרסור עצמו" ב-:doc:`mcp-server`. - **refactor: שלושה כללים שנכתבו פעמיים חזרו למקום אחד (#3419, #3427).** ‏(1) כלל ה-``\r`` הבודד — סיומת שורה שאינה חד-משמעית — ישב גם במנתב האאוטליין וגם בפארסר ה-Markdown, ושני העותקים היו צריכים להסכים לנצח; עכשיו הוא במודול עלה חדש, ``services/line_endings.py``, ושני הצרכנים קוראים לו. ‏(2) ``scripts/generate_ai_map.py`` כתב ביד את גבול ה-front matter ונמדד שהוא חולק על התוסף ש-MyST מריץ בחמש מתוך שתים-עשרה צורות; עכשיו הוא קורא את הגבול מהפארסר של הכלי (``md_parser.front_matter_end``, דרך ``mdit_py_plugins.front_matter``), ו-``AI-MAP.md`` שנוצר זהה בית-בית לזה שבריפו — הטסט שקיבע את הפער בכוונה נערך יחד עם הסגירה. ‏(3) שתי רשימות זהות של 16 קודי תווים נסתרים — ב-``utils.normalize_code`` וב-``CodeNormalizer`` — לא הוסיפו דבר על בדיקת ``unicodedata.category == "Cf"`` שישבה מתחתן (כל 16 הערכים הם ``Cf``); הרשימות נמחקו, שתי הפונקציות אוחדו ל-``strip_hidden_escapes`` אחת בשכבת הדומיין, וענף ה-Variation Selectors (``Mn``, לא ``Cf``) נשאר נפרד ונדלק רק לפי בקשה. לשני הנרמולים אפס-דיף מדוד על קורפוס של רצפי בריחה, בכל צירוף אפשרויות. - **feat: ``codekeeper_docs_get_section`` קורא גם קובצי Markdown, ולכל ריפו מדיניות נתיבים משלו.** עד היום הכלי ידע לקרוא ``.rst`` בלבד, ורק מתחת ל-``docs/``. הפארסר של Markdown נכתב ונמדד כבר קודם — ואף אחד לא קרא לו; זה השינוי שמחווט אותו. מעכשיו הסיומת בוחרת את הפארסר, ולכל ריפו מוצהר **איפה התיעוד שלו יושב ובאיזה פורמט**: ``CodeBot`` נשאר ``docs/`` עם ``.rst`` ללא שינוי, ו-``amir-bug-patterns`` מגיש ``.md`` משורש הריפו. **וזו הרחבה מכוונת של גבול הקריאה** — הכלי ציבורי, כלומר כל משתמש מאומת מפעיל אותו בלי אדמין, והריפו שנוסף ציבורי ב-GitHub. מה ש**לא** השתנה: מדיניות סינון הסודות חלה על המסלול הזה במלואה, ולכן ``path="secrets"`` מחזיר ``path_denied`` ולא ``not_found``, וגם ``secrets.md`` ו-``credentials.md`` חסומים; ותקרת 500KB של שירות המראה נשארת ההגנה על הפרסור, כי הכלי קורא את הקובץ כולו בלי ``lines``. **שני שערים ולא אחד:** ``MCP_DOCS_REPO`` מתיר בזמן ריצה, טבלת המדיניות בקוד יודעת לקרוא, וריפו שנמצא במשתנה הסביבה ואין לו מדיניות נדחה ב-``repo_not_configured`` במקום ליפול לברירת מחדל מתירנית. **ובקשה לפורמט שהריפו אינו מגיש נדחית בשמה** — ``suffix_not_allowed`` עם רשימת הסיומות שכן מוגשות, במקום להשלים סיומת בשקט מעל הקיימת ולחזור כ"לא נמצא" על קובץ שקיים. שני סירובים חדשים מגיעים מהפרסור עצמו: ``inconsistent_line_endings`` על ``\r`` בודד (CRLF עובד במלואו), ו-``too_many_sections`` על קובץ שחוצה את תקרת 50,000 הכותרות — Markdown דרך ברירת המחדל של הפרסר, ו-RST דרך התקרה שהכלי מעביר מאז #3429; סירוב שהפארסר מספק לו מספר שורה נושא גם ``line`` — תמיד ב-Markdown, ולא ב-``too_many_sections`` של RST, כי ``rst_parser`` מרים אותה בלי מספר. ``includes`` הוא שדה של RST ולכן בעמוד ``.md`` הוא תמיד ריק. **ומסלול ה-RST לא זז**: אפס דיף מדוד על 5,920 רשומות מ-208 קובצי RST, לפני ואחרי. ראו :ref:`mcp-docs-path-policy`. - **fix: מאגר הקריאות של ה-MCP נגזר ממכסת הזיכרון של הקונטיינר, ולא ממספר המעבדים של המארח (#3391).** כלי הקריאה רצים ב-``asyncio.to_thread``, כלומר ב-executor ברירת המחדל של הלולאה, שגודלו ``min(32, os.cpu_count() + 4)`` — ו-``os.cpu_count()`` בקונטיינר הוא מספר הליבות של **המארח**, כפי ש-CPython מתעד במפורש. שורת הקיבולת מהייצור אמרה את זה במספרים: ``read pool 12 threads (os.cpu_count=8, usable=8)`` על מכסה של ``0.50 cpu``. לקריאת מונגו זה לא מזיק; לפרסור בפייתון טהור אין מקביליות ב-CPU בגלל ה-GIL, ולכן 12 חוטים היו 12 עצי פרסור בזיכרון בו-זמנית — יותר ממה שנכנס ב-512MiB — ורגע לפני שהפרסור הופך לכלי ציבורי. מעכשיו ``attach_read_pool`` מתקין בעליית ה-lifespan של אפליקציית ה-ASGI מאגר משלו, בגודל ``(מגבלת הזיכרון − קו בסיס − שוליים) ÷ עלות פרסור אחד``: המגבלה נקראת מ-cgroup בזמן ריצה (``memory.max`` ב-v2, ‏``memory.limit_in_bytes`` ב-v1) ולא מקובעת בקוד, קו הבסיס והשוליים הם מדידות מהשירות בייצור עם תאריך, ועלות הפרסור נגזרת מ-``MAX_FILE_SIZE_FOR_DISPLAY`` כי הכלי דוחה ``too_large`` לפני שהוא מפרסר. על תוכנית הייצור זה **10** חוטים במקום 12 — עלות הפרסור נמדדה מחדש על הפרסר הנעוץ (``scripts/measure_md_parse_cost.py``; הקבוע נמדד על ``services.md_parser`` כהכנה לכלי ה-Markdown של PR 5, בעוד הכלי הציבורי היום מפרסר RST ב-``services.rst_parser``, שעולה כ-6 בתים לבית גם על העמוד הצפוף ביותר — והסקריפט מודד את שניהם), והיא תלויה בצפיפות הטוקנים של המסמך ולא בגודלו: המסמך הצפוף ביותר בקורפוס הוא הקבוע, וצורה עוינת של שורות-תבליט בודדות עולה פי ארבעה ממנו — מה ששום רוחב מאגר אינו סופג, ומחכה לתקרת טוקנים בפרסר עצמו. **ובעקבות הסקירה:** ``codekeeper_docs_get_section`` מעביר לפרסר את תקרת הסקשנים של האאוטליין (50,000) ומחזיר ``too_many_sections`` במקום לפרסר את הקובץ כולו — צורה עוינת של RST (כותרת בת תו אחד בכל שורה) עלתה 44.0MiB לפרסור, יותר מ-35.2MiB שהמאגר מקצה לחוט, ועכשיו נעצרת ב-20.1MiB; אף עמוד אמיתי אינו מתקרב לתקרה. רצפה של 2 (קריאה תקועה אחת לא משביתה את השאר), תקרה של 12, הרוחב שהייצור כבר הריץ (מעבר לתוכנית גדולה לא מרחיב את המאגר בלי החלטה; הגרסה הראשונה נקבה ב-32, והסקירה של #3429 הורידה אותה כי המחלק מתאר את הפרסור ולא את קריאות הטווח של האדמין; חסימת הקריאה במקור — #3433), וכשאין מגבלה קריאה — הרצפה, **לא** ``cpu_count + 4``, עם WARNING שאומר שזה קרה. **ושורת הקיבולת מפסיקה לחשב בעצמה:** היא נרשמת מתוך ה-lifespan, קוראת את ``_max_workers`` של המאגר שהותקן בפועל, ומדווחת גם את מגבלת הזיכרון לצד מכסת ה-CPU — כי רשומה שמתארת מצב ומתעדכנת בנפרד ממנו היא בדיוק מה שמשקר בשקט. ה-seam הוא עטיפת ``router.lifespan_context``, אותו seam שניקוז ה-PostHog כבר משתמש בו, כי ``FastMCP.streamable_http_app()`` בונה את Starlette עם ``lifespan=`` משלה ו-``on_startup`` אינו רץ. הטסט המרכזי נכנס ל-lifespan של האפליקציה האמיתית ומוכיח שקריאת ``to_thread`` נוחתת על חוט של המאגר החדש — לא שפונקציה נקראה; שורת הקיבולת נבדקת בתת-תהליך נקי דרך הפלט האמיתי ולא דרך ``caplog``. ראו "מודל הריצה של הכלים" ב-:doc:`mcp-server`. diff --git a/mcp_server/app.py b/mcp_server/app.py index c3d507193..13bc341a6 100644 --- a/mcp_server/app.py +++ b/mcp_server/app.py @@ -51,13 +51,14 @@ ) from .backend import ProductionBackend +from .handlers import max_code_size from .limits import ( - DEFAULT_MAX_REQUEST_BYTES, DEFAULT_RATE_LIMIT_PER_MINUTE, MAX_REQUEST_BYTES_ENV, MIN_MAX_REQUEST_BYTES, RATE_LIMIT_ENV, limit_from_env, + request_bytes_for, ) from .server import build_app from .token_store import MCPTokenStore @@ -125,8 +126,12 @@ def create_app(): backend = ProductionBackend(db_manager=db_manager, mongo_db=mongo) name = os.getenv("MCP_SERVER_NAME", "CodeKeeper") # Request limits (#3431) — read here, at the service entry, not at import. + # The body cap is derived from the code-size ceiling the service actually + # runs with (``MAX_CODE_SIZE`` from the config, or the handlers' fallback), + # so raising one can never leave the other behind; ``MCP_MAX_REQUEST_BYTES`` + # is the way to raise the cap further without a deploy, not a kill switch. max_request_bytes = limit_from_env( - MAX_REQUEST_BYTES_ENV, DEFAULT_MAX_REQUEST_BYTES, minimum=MIN_MAX_REQUEST_BYTES + MAX_REQUEST_BYTES_ENV, request_bytes_for(max_code_size()), minimum=MIN_MAX_REQUEST_BYTES ) rate_limit_per_minute = limit_from_env( RATE_LIMIT_ENV, DEFAULT_RATE_LIMIT_PER_MINUTE, minimum=1, zero_disables=True diff --git a/mcp_server/docs_handlers.py b/mcp_server/docs_handlers.py index 6bc2b4534..a1c2e1e5b 100644 --- a/mcp_server/docs_handlers.py +++ b/mcp_server/docs_handlers.py @@ -64,9 +64,10 @@ #: בתים ושיא הקצאה של 12.6MB — על שאילתה בת שלושה תווים, וזה היה השדה היחיד #: בתשובה בלי גבול (``toc`` ב-``_TOC_MAX``, ‏``suggestions`` #: ב-``MAX_IDENTIFIER_SUGGESTIONS``). הענף נדלק רק מכותרות שנושאות מזהה, ומאז -#: #3428 הקורפוס שנושא מזהים (``amir-bug-patterns``) מוגש. השוויון בין שני -#: המספרים מקובע בטסט, כמו שני גבולות האאוטליין והפארסר. אישו #3426. -_CANDIDATES_MAX = 50 +#: #3428 הקורפוס שנושא מזהים (``amir-bug-patterns``) מוגש. אותו מספר ממש — +#: מיובא ולא מוקלד שוב (הכיוון ``services`` ← ``mcp_server`` מותר; ההפוך לא), +#: והטסט שומר שלא יוקלד מחדש. אישו #3426. +_CANDIDATES_MAX = doc_sections.MAX_IDENTIFIER_SUGGESTIONS #: הסיומת ← **המודול** שמפרסר אותה. זהו המקום היחיד שאומר איזה פורמט הכלי #: יודע לקרוא בכלל, ו-``DOCS_PATH_POLICY`` למטה אומר מי מהם מוגש בכל ריפו. diff --git a/mcp_server/handlers.py b/mcp_server/handlers.py index 77dfd7d09..def5c4278 100644 --- a/mcp_server/handlers.py +++ b/mcp_server/handlers.py @@ -461,8 +461,13 @@ def get_collection_items( ) -def _max_code_size() -> int: - """The app's per-file size gate (characters, not bytes), with a safe fallback.""" +def max_code_size() -> int: + """The app's per-file size gate (characters, not bytes), with a safe fallback. + + Public, because ``mcp_server/app.py`` derives the request-body cap from it + (``limits.request_bytes_for``): one lookup of ``MAX_CODE_SIZE`` serves both + gates, so the two cannot drift apart. + """ try: from config import config as _cfg @@ -493,7 +498,7 @@ def save_file( # Reject oversize content (the large-file path is non-versioned; out of scope # here). Mirror the app's own gate, which counts characters, not bytes. - max_size = _max_code_size() + max_size = max_code_size() if len(code) > max_size: return {"ok": False, "error": "code_too_large", "max": max_size} @@ -615,7 +620,7 @@ def _resave_edited( an edit never resets them; the same size gate as ``save_file`` applies to the resulting body. """ - max_size = _max_code_size() + max_size = max_code_size() if len(new_code) > max_size: return {"ok": False, "error": "code_too_large", "max": max_size} return backend.save_file( diff --git a/mcp_server/limits.py b/mcp_server/limits.py index ac7e0b1d3..6454b6949 100644 --- a/mcp_server/limits.py +++ b/mcp_server/limits.py @@ -13,16 +13,52 @@ של 20MB נטען ומפוענח במלואו לפני שאף כלי רואה ארגומנט. התקרה כאן היא לכן הגבול היחיד על **כל** ארגומנט של **כל** כלי, קיים ועתידי. + **מה המידלוור קורא, ומה לא.** הגרסה הראשונה (#3431) קראה כל גוף עד התקרה + לפני שהשכבה הבאה ראתה את הבקשה — גם גוף אנונימי שהאימות היה דוחה מהכותרות + בלבד, ובלי דדליין; זו הייתה רגרסיה (SEC-001 בסקירת שבעת ה-PRים): במצב OAuth + האימות של ה-SDK יושב **בתוך** ה-mount של ``/mcp``, ולכן התקרה, שהיא השכבה + החיצונית, החזיקה עד 1MiB לכל חיבור אנונימי לפני ה-401, וכל עוד הלקוח טפטף + בתים. שלושה כללים סוגרים את זה, וכל אחד מקובע בטסט שנפל לפני שנכתב: + + - **רק מתודות שנושאות גוף נבדקות** (:data:`METHODS_WITH_BODY`). שום route כאן + אינו קורא גוף במתודה אחרת — הטרנספורט קורא ב-``POST``, ו-DCR, token ו-consent + הם ``POST`` — ולכן ``GET /healthz`` עם ``Content-Length`` מזויף, שקודם ענה + 413, עובר בלי שהתקרה מסתכלת עליו. + - **אורך מוצהר תקין עובר בלי קריאה.** ‏``Content-Length`` של ספרות ASCII (מה + ש-h11 מקבל: ``_content_length_re = re.compile(rb"[0-9]+")``, ‏``h11/_headers.py``) + שאין לצידו ``Transfer-Encoding`` הוא תיחום של השרת: uvicorn/h11 מוסרים + לאפליקציה בדיוק את המספר הזה — נמדד: 20 בתים מאחורי ``Content-Length: 10`` + הגיעו כ-10. גדול מהתקרה — 413 בלי שנקרא בית; קטן ממנה — הבקשה עוברת הלאה + כמות שהיא, והשכבה שעונה מהכותרות (ה-401) עונה בלי קריאה. **הסייג הוא + ``Transfer-Encoding``:** לצידו h11 תוחם לפי chunked ומעביר את שתי הכותרות + (RFC 7230 §3.3.3), ונמדד ש-200 בתים מאחורי ``Content-Length: 5`` הגיעו + במלואם — ולכן שם סופרים כמו בלי כותרת. + - **מה שנשאר לספירה — גוף בלי אורך מוצהר — נקרא עד התקרה, ותחת דדליין.** + ל-uvicorn 0.38.0 אין timeout לגוף בקשה (``timeout_keep_alive`` חל אחרי + תשובה), ולכן בלי דדליין ההמתנה לנתח הבא אינה חסומה. הלולאה כולה — לא כל + קריאה בנפרד, כי בית כל 29 שניות היה עובר דדליין-לקריאה — רצה תחת + ``anyio.fail_after`` (:data:`DEFAULT_BODY_READ_SECONDS`), וכשהוא פג התשובה + היא 408 שאומר כמה נקרא. + **ולמה חיץ עד התקרה, ולא חריגה מתוך ``receive``.** אותו ``_handle_post_request`` עוטף את קריאת הגוף ב-``try`` שתופס ``Exception`` ומחזיר שגיאת JSON-RPC משלו — חריגה שהיינו מרימים מתוך ``receive`` הייתה נבלעת שם והופכת ל"שגיאת שרת" בלי סיבה. לכן המידלוור קורא בעצמו את הודעות ``http.request`` עד התקרה, מסרב ב-413 **בעצמו** כשהיא נחצית, ואחרת משדר את מה שכבר נקרא הלאה כמות שהוא. גוף שמתחת לתקרה ממילא מוחזק בשלמותו על ידי ``request.body()``, ולכן החיץ אינו - עולה זיכרון נוסף. ‏``Content-Length`` שמצהיר על יותר מהתקרה נדחה עוד לפני - שנקרא בית אחד; כותרת חסרה או מכזבת נתפסת בספירה של מה שבאמת מגיע. - הפרוטוקול: הודעות ``http.request`` עם ``body`` ו-``more_body``, ו-``http.disconnect`` - — כפי ש-``starlette/requests.py::Request.stream`` (Starlette 1.6.0) קורא אותן. + עולה זיכרון נוסף. הפרוטוקול: הודעות ``http.request`` עם ``body`` ו-``more_body``, + ו-``http.disconnect`` — כפי ש-``starlette/requests.py::Request.stream`` + (Starlette 1.6.0) קורא אותן. + + **סירוב סוגר את החיבור.** שני הסירובים מגיעים לפני שהגוף נגמר, ולכן נושאים + ``Connection: close``: h11 מכבה keep-alive על תשובה שנושאת אותו + (``h11/_connection.py::_keep_alive``), ו-uvicorn סוגר את ה-transport מיד אחרי + התשובה (``h11_impl.py::send`` — ``our_state is MUST_CLOSE``). בלעדיו השרת היה + ממשיך לקרוא ולזרוק את שארית הגוף שסירבנו לו, ומחזיק חיבור פתוח לגוף + שלא הגיע. ל-408 זה גם מה שהתקן מבקש: "the server SHOULD send the 'close' + connection option in the response, since 408 implies that the server has + decided to close the connection rather than continue waiting" (RFC 9110 + §15.5.9, מצוטט כאן לפי MDN). 2. **קצב — בנקודת הכניסה של קריאות הכלים, לא במידלוור.** הזהות שהאישו מציע לספור לפיה ("הטוקן כבר מזוהה במידלוור") קיימת ב-``request.state`` רק במצב PAT; @@ -35,21 +71,26 @@ אסינכרוני לפני שהוא רץ על הלולאה. קריאה שנדחתה אינה עולה עובד. מה שאינו קריאת כלי — ``initialize``, ‏``tools/list``, זרם ה-SSE — זול ואינו נספר. - **הפטור לנתיבי הדופק הוא מבני, לא רשימה.** ‏``/healthz`` (וכל נתיב HTTP אחר) - לעולם אינו מגיע לנקודת השיגור של הכלים, ולכן המוניטור החיצוני שדוגם אותו - בקצב קבוע אינו יכול לקבל 429 — המלכודת של ``blanket-policy-silent-block`` §7. - וגם תקרת הגוף אינה נוגעת בו: ל-``GET`` אין גוף. שניהם מקובעים בטסט על - האפליקציה האמיתית, לא על ההיגיון. + **הפטור לנתיב הדופק — שני חצאים, ורק אחד מהם מבני.** מהמגביל ``/healthz`` פטור + במבנה: הוא אינו קריאת כלי, ולכן לעולם אינו מגיע לנקודת השיגור, והמוניטור + החיצוני שדוגם אותו בקצב קבוע אינו יכול לקבל 429 — המלכודת של + ``blanket-policy-silent-block`` §7. מתקרת הגוף הוא פטור **בכלל**, לא במבנה: + ``GET`` אינה מתודה שנושאת גוף ולכן התקרה אינה מסתכלת עליה — עד SEC-001 היא + כן הסתכלה, ו-``GET /healthz`` עם ``Content-Length`` מזויף ענה 413. שני + החצאים מקובעים בטסט על האפליקציה האמיתית, בשני מצבי האימות. **הסירוב נוקב בסיבתו** — כמו ``path_too_long`` ו-``too_many_sections``: ‏413 עם -``{"error": "body_too_large", "max_bytes": ...}`` באותה צורה של ה-401 ב-``auth.py``, -וקריאת כלי שנחסמה מחזירה ``{"ok": false, "error": "rate_limited", -"limit_per_minute": ..., "retry_after_seconds": ...}`` — תשובת כלי רגילה, כי זה -מה שהסוכן קורא, ועם הזמן שנותר עד שהחלון משתחרר במקום "נסה שוב". - -**המספרים, ומאיפה.** ראו ליד הקבועים. שניהם ניתנים לכיוון במשתני סביבה שנקראים -ב-``create_app`` (בכניסה לשירות, לא בזמן ייבוא), דרך :func:`limit_from_env`, -שלעולם אינו הופך ערך פגום לגבול רחב יותר מברירת המחדל. +``{"error": "body_too_large", "max_bytes": ...}`` ו-408 עם ``{"error": +"body_read_timeout", "read_timeout_seconds": ..., "received_bytes": ...}``, באותה +צורה של ה-401 ב-``auth.py``; וקריאת כלי שנחסמה מחזירה ``{"ok": false, "error": +"rate_limited", "limit_per_minute": ..., "retry_after_seconds": ...}`` — תשובת +כלי רגילה, כי זה מה שהסוכן קורא, ועם הזמן שנותר עד שהחלון משתחרר במקום "נסה +שוב". + +**המספרים, ומאיפה.** ראו ליד הקבועים. תקרת הגוף **נגזרת** מ-``MAX_CODE_SIZE`` +(:func:`request_bytes_for`) ואינה מוקלדת לצידו, והקצב ניתן לכיוון במשתנה +סביבה; שניהם נקראים ב-``create_app`` (בכניסה לשירות, לא בזמן ייבוא), דרך +:func:`limit_from_env`, שלעולם אינו הופך ערך פגום לגבול רחב יותר מברירת המחדל. """ from __future__ import annotations @@ -57,28 +98,75 @@ import logging import math import os +import re import time from typing import Any, Awaitable, Callable +# anyio הוא תלות ישירה של mcp (``anyio>=4.5``) ושל starlette, ומיובא כאן ישירות +# בדיוק כמו ש-starlette עצמו מיובא — בלי נעיצה נפרדת בדרישות. +import anyio from starlette.datastructures import Headers from starlette.responses import JSONResponse from rate_limiter import RateLimiter +from .handlers import DEFAULT_MAX_CODE_SIZE + logger = logging.getLogger(__name__) -#: תקרת גוף הבקשה בבתים. הארגומנט הלגיטימי הגדול ביותר הוא ``content`` של -#: ``codekeeper_save_file``, שחסום ב-``config.MAX_CODE_SIZE`` — 100,000 תווים -#: (``mcp_server/handlers.py::_max_code_size``). לקוח שמקודד JSON עם -#: ``ensure_ascii`` הופך כל תו עברי ל-``\\uXXXX``, שישה בתים, ולכן הגוף הגדול -#: ביותר שיכול להיות נכון הוא כ-600KB ועוד המעטפת. 1MiB משאיר לזה מרווח, -#: ועדיין נמוך בסדר גודל ומעלה מגוף שהטרנספורט היה מפענח בלי התקרה. -DEFAULT_MAX_REQUEST_BYTES = 1_048_576 +#: כמה בתים תו אחד של ``code`` יכול לעלות על החוט. לקוח שמקודד JSON עם +#: ``ensure_ascii`` — ברירת המחדל של ``json.dumps`` — כותב כל תו שאינו ASCII +#: כ-``\\uXXXX``: שישה בתים. עברית היא המקרה הרגיל כאן, וכולה מחוץ ל-ASCII. +JSON_BYTES_PER_CHAR = 6 + +#: כל מה שבבקשת ``tools/call`` אינו ``code``: מעטפת ה-JSON-RPC, שם הכלי, ושאר +#: הארגומנטים של ``codekeeper_save_file`` — שם הקובץ (בלי תקרה בשכבה הזאת), +#: שפה, ותיאור שחסום ב-``FILE_DESCRIPTION_MAX_CHARS`` (500 תווים, כלומר 3,000 +#: בתים בקידוד היקר). ‏64KiB הם סדר גודל מעל כל זה, וטסט בונה את הבקשה הגדולה +#: ביותר ומודד שהיא נכנסת. +REQUEST_ENVELOPE_BYTES = 65_536 + +_MIB = 1024 * 1024 + + +def request_bytes_for(max_code_chars: int) -> int: + """תקרת הגוף שמכילה את הבקשה הלגיטימית הגדולה ביותר לתקרת קוד נתונה. + + הבקשה הגדולה ביותר היא ``codekeeper_save_file`` על קובץ בגודל ``MAX_CODE_SIZE`` + (תווים, לא בתים — ``mcp_server/handlers.py::max_code_size``), ולכן: + ``MAX_CODE_SIZE × JSON_BYTES_PER_CHAR + REQUEST_ENVELOPE_BYTES``, מעוגל כלפי + מעלה ל-MiB שלם. העיגול הוא כדי שהמספר יישאר מוכר ויציב מול שינוי קטן + במרכיבים — תקרה היא תקרה, לא התאמה מדויקת. על ברירת המחדל של התצורה + (100,000 תווים) זה 1MiB; ‏``tests/test_mcp_limits.py`` מצמיד את הגזירה. + """ + needed = int(max_code_chars) * JSON_BYTES_PER_CHAR + REQUEST_ENVELOPE_BYTES + return -(-needed // _MIB) * _MIB + + +#: תקרת גוף הבקשה בבתים על ברירת המחדל של ``MAX_CODE_SIZE`` — 1MiB. הערך +#: שהשירות באמת מריץ נגזר ב-``create_app`` מהתצורה בפועל (``max_code_size()``), +#: וזה כאן הוא ברירת המחדל של ``build_app`` ושל המידלוור, והמספר שהתיעוד מציג. +DEFAULT_MAX_REQUEST_BYTES = request_bytes_for(DEFAULT_MAX_CODE_SIZE) #: הרצפה לערך ממשתנה הסביבה: תקרה של עשרה בתים היא הפסקת שירות, לא הקשחה — #: הודעת ``initialize`` לבדה היא כמה מאות בתים. MIN_MAX_REQUEST_BYTES = 65_536 +#: המתודות שנושאות גוף — ולכן היחידות שהתקרה בודקת. הערך ב-scope הוא כפי +#: שהשרת נותן אותו ("The HTTP method name, uppercased" — מפרט ASGI), ומושווה +#: כמות שהוא, בדיוק כמו ש-Starlette מנתב לפיו. ``PUT`` ו-``PATCH`` אינם בשימוש +#: כאן היום, אבל הם המחלקה: route חדש שיקרא גוף באחת מהן מכוסה בלי לגעת בשער. +METHODS_WITH_BODY = frozenset({"POST", "PUT", "PATCH"}) + +#: כמה זמן הלולאה שקוראת גוף **בלי אורך מוצהר** מוכנה להמתין לו כולו. המסלול +#: הזה רץ רק ללקוח שאינו מצהיר ``Content-Length`` — לקוח MCP רגיל מצהיר, כי הוא +#: שולח הודעת JSON שלמה (httpx, שלקוח ה-SDK בנוי עליו, מוסיף ``Content-Length`` +#: לכל גוף בתים: ``httpx/_content.py::encode_content``). ‏30 שניות לגוף של עד +#: 1MiB הן כ-35KB/s — מתחת לכל קו שלקוח כזה רץ עליו. מחיר טעות לכל כיוון: קצר +#: מדי — 408 שלקוח יכול לנסות שוב עם אורך מוצהר; ארוך מדי — קורוטינה (לא חוט) +#: שמחזיקה חיץ עד התקרה למשך הדדליין, לכל חיבור אנונימי שטורח. +DEFAULT_BODY_READ_SECONDS = 30.0 + #: קריאות כלים לזהות אחת בדקה. נגזר מהמדידות בתגובה ב-#3431 (2026-09-20): #: הבקשה הציבורית היקרה ביותר היום היא עמוד RST של 500KB — ‏0.24 שניות מעבד #: עם תקרת הסקשנים (0.035 לעמוד הצפוף האמיתי); במסלול ה-Markdown (#3428) @@ -87,14 +175,28 @@ #: זהות אחת יכולה לכל היותר למלא דקת מעבד אחת משלה, ולא יותר; קריאה רגילה #: (10–50ms) הופכת 60 בדקה לאחוז עד שלושה מהמכסה. ומול מאגר של 10 חוטים, #: 60 בדקה הן קריאה אחת בשנייה לזהות — הוגנות, לא חנק. +#: +#: **וגם צד הכתיבה (סקירת שבעת ה-PRים, SUGG-005):** אותן 60 בדקה לזהות עומדות +#: גם מול מאגר הכתיבה של עובד אחד (``mcp_server/server.py::_WRITE_POOL``). +#: המגביל אינו ההגנה על תור כתיבה שאינו מתרוקן — זו ה-WARNING של +#: ``_SLOW_WRITE_QUEUE_WAIT`` — ובמספרים של היום התור מתרוקן הרבה מעל 60 בדקה: +#: בלוגי השירות בפרנקפורט (``srv-dal7o0u1egvs73eqp0pg``, מאז 2026-09-16, נקראו +#: 2026-09-21) 30 שורות ``mcp write``, ‏``queued 0.000s`` בכולן פרט לאחת (0.003s), +#: ו-``ran`` בין 0.012 ל-1.993 שניות. המספרים הישנים על גוף כתיבה (7.5 שניות +#: ב-p95, ‏4.3–4.9 ב-p50) נמדדו לפני המעבר לפרנקפורט והתיישנו. DEFAULT_RATE_LIMIT_PER_MINUTE = 60 MAX_REQUEST_BYTES_ENV = "MCP_MAX_REQUEST_BYTES" RATE_LIMIT_ENV = "MCP_RATE_LIMIT_PER_MINUTE" BODY_TOO_LARGE = "body_too_large" +BODY_READ_TIMEOUT = "body_read_timeout" RATE_LIMITED = "rate_limited" +# מה ש-h11 מקבל כאורך: ``_content_length_re = re.compile(rb"[0-9]+")`` +# (``h11/_headers.py``). ‏``str.isdigit()`` היה מקבל גם ``²`` ונופל על ``int()``. +_CONTENT_LENGTH = re.compile(r"[0-9]+") + def limit_from_env(name: str, default: int, *, minimum: int, zero_disables: bool = False) -> int: """ערך מספרי ממשתנה סביבה, בלי שטעות תצורה תפתח את הגבול. @@ -127,38 +229,75 @@ class BodySizeLimitMiddleware: ASGI טהור ולא ``BaseHTTPMiddleware``, כי הוא צריך את ``receive`` עצמו: ההודעות נקראות כאן, נספרות, ומשודרות הלאה — ו-``BaseHTTPMiddleware`` מסתיר את זה - מאחורי ``Request``. ראו את ה-docstring של המודול על למה חיץ ולא חריגה. + מאחורי ``Request``. ראו את ה-docstring של המודול על מה נקרא כאן ומה לא, + ועל למה חיץ ולא חריגה. + + ``read_timeout`` — הדדליין, בשניות, על קריאת גוף בלי אורך מוצהר; פרמטר כדי + שטסט יוכל להקטין אותו במקום להמתין לו. """ - def __init__(self, app: Any, *, max_bytes: int = DEFAULT_MAX_REQUEST_BYTES) -> None: + def __init__( + self, + app: Any, + *, + max_bytes: int = DEFAULT_MAX_REQUEST_BYTES, + read_timeout: float = DEFAULT_BODY_READ_SECONDS, + ) -> None: self.app = app self.max_bytes = int(max_bytes) + self.read_timeout = float(read_timeout) async def __call__(self, scope: dict, receive: Callable[[], Awaitable[dict]], send: Any) -> None: - if scope.get("type") != "http": + if scope.get("type") != "http" or scope.get("method") not in METHODS_WITH_BODY: await self.app(scope, receive, send) return - # U3: הכותרת היא קלט חיצוני. ספרות בלבד נחשבות מספר; כל צורה אחרת - # נשארת לשרת ולטרנספורט לדחות, והספירה למטה עדיין תופסת מה שבאמת מגיע. - declared = Headers(scope=scope).get("content-length") - if declared is not None and declared.isdigit() and int(declared) > self.max_bytes: - await self._refuse(scope, receive, send, content_length=int(declared)) - return + headers = Headers(scope=scope) + declared = _declared_length(headers) + if declared is not None: + if declared > self.max_bytes: + await self._refuse( + scope, receive, send, + status=413, reason=BODY_TOO_LARGE, max_bytes=self.max_bytes, content_length=declared, + ) + return + if headers.get("transfer-encoding") is None: + # השרת תוחם את הגוף לאורך המוצהר — אין מה לקרוא כאן (ראו את + # ה-docstring של המודול, "אורך מוצהר תקין עובר בלי קריאה"). + await self.app(scope, receive, send) + return buffered: list[dict] = [] received = 0 - while True: - message = await receive() - buffered.append(message) - if message.get("type") != "http.request": - break # ``http.disconnect`` — משודר הלאה כמות שהוא - received += len(message.get("body", b"")) - if received > self.max_bytes: - await self._refuse(scope, receive, send, received_bytes=received) - return - if not message.get("more_body", False): - break + over_cap = False + try: + with anyio.fail_after(self.read_timeout): + while True: + message = await receive() + buffered.append(message) + if message.get("type") != "http.request": + break # ``http.disconnect`` — משודר הלאה כמות שהוא + received += len(message.get("body", b"")) + if received > self.max_bytes: + over_cap = True + break + if not message.get("more_body", False): + break + except TimeoutError: + # ``anyio.fail_after`` מרים ``TimeoutError`` (``anyio/_core/_tasks.py``, + # anyio 4.15.1) אחרי שביטל את ההמתנה ל-``receive``. + await self._refuse( + scope, receive, send, + status=408, reason=BODY_READ_TIMEOUT, + read_timeout_seconds=self.read_timeout, received_bytes=received, + ) + return + if over_cap: + await self._refuse( + scope, receive, send, + status=413, reason=BODY_TOO_LARGE, max_bytes=self.max_bytes, received_bytes=received, + ) + return async def replay() -> dict: if buffered: @@ -167,26 +306,43 @@ async def replay() -> dict: await self.app(scope, replay, send) - async def _refuse(self, scope: dict, receive: Any, send: Any, **detail: int) -> None: - # גודל ונתיב בלבד — לא הגוף ולא הטוקן (K13). + async def _refuse( + self, scope: dict, receive: Any, send: Any, *, status: int, reason: str, **detail: int | float + ) -> None: + # גודל, זמן ונתיב בלבד — לא הגוף ולא הטוקן (K13). logger.warning( - "mcp request refused: body over %d bytes (%s) on %s", - self.max_bytes, + "mcp request refused: %s (%s) on %s", + reason, ", ".join(f"{k}={v}" for k, v in detail.items()), scope.get("path"), ) - response = JSONResponse( - {"error": BODY_TOO_LARGE, "max_bytes": self.max_bytes, **detail}, status_code=413 - ) + # ``Connection: close`` — הסירוב הגיע לפני שהגוף נגמר; ראו את ה-docstring + # של המודול, "סירוב סוגר את החיבור". + response = JSONResponse({"error": reason, **detail}, status_code=status, headers={"connection": "close"}) await response(scope, receive, send) +def _declared_length(headers: Headers) -> int | None: + """‏``Content-Length`` כמספר — ורק כשהוא ספרות ASCII, כמו ש-h11 מקבל אותו (U3). + + כל צורה אחרת אינה אורך מוצהר: מול uvicorn היא נדחתה ב-400 עוד לפני ה-ASGI, + ומול שרת אחר סופרים את מה שבאמת מגיע. + """ + value = headers.get("content-length") + if value is None or _CONTENT_LENGTH.fullmatch(value) is None: + return None + return int(value) + + class ToolRateLimiter: """הגבלת קצב לפי זהות על קריאות כלים, מעל ``rate_limiter.RateLimiter`` הקיים. אותו מגביל שהבוט משתמש בו (R6: לא עותק שני של חלון מתגלגל) — בזיכרון, - ללא Redis, וזה מספיק כי שירות ה-MCP רץ במופע אחד (``numInstances: 1``, - ‏``docs/mcp-server.rst``). ‏``per_minute == 0`` פירושו כבוי במפורש. + ללא Redis, וזה מספיק כי שירות ה-MCP רץ במופע אחד. **המופע היחיד הוא הגדרה + של השירות ב-Render, מחוץ לריפו** (``numInstances: 1``; נבדק מול ה-API של + Render ב-2026-09-21) — ``docs/mcp-server.rst`` מתאר זאת ואינו המקור. מי + שמוסיף מופע מאבד את הזיכרון המשותף, וזה המקום להתחיל ממנו. ‏``per_minute == 0`` + פירושו כבוי במפורש. """ def __init__(self, per_minute: int = DEFAULT_RATE_LIMIT_PER_MINUTE) -> None: @@ -194,10 +350,6 @@ def __init__(self, per_minute: int = DEFAULT_RATE_LIMIT_PER_MINUTE) -> None: self._limiter = RateLimiter(max_per_minute=self.per_minute) if self.per_minute > 0 else None self._warned_at: dict[int, float] = {} - @property - def enabled(self) -> bool: - return self._limiter is not None - async def admit(self, user_id: int) -> dict | None: """‏``None`` כשהקריאה מותרת; אחרת תשובת הסירוב שהכלי מחזיר במקום לרוץ.""" if self._limiter is None: @@ -219,6 +371,9 @@ def _warn_once_per_window(self, user_id: int, retry_after: float) -> None: last = self._warned_at.get(user_id) if last is not None and now - last < 60.0: return + # הפנקס החזיק חותמת לכל זהות שסורבה אי פעם (SUGG-011); חותמות שחלונן + # עבר יורדות במעבר שממילא כותב חותמת חדשה — בלי מעבר נוסף על המסלול החם. + self._warned_at = {uid: at for uid, at in self._warned_at.items() if now - at < 60.0} self._warned_at[user_id] = now logger.warning( "mcp tool rate limit: identity %s exceeded %d calls/min; refused for the next %.0fs", diff --git a/mcp_server/server.py b/mcp_server/server.py index 91bd1d347..aae332970 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -35,7 +35,9 @@ from concurrent.futures import ThreadPoolExecutor from typing import Annotated, Any, NamedTuple +import pydantic_core from mcp.server.fastmcp import Context, FastMCP +from mcp.types import CallToolResult, TextContent from pydantic import Field from mcp.server.transport_security import TransportSecuritySettings from starlette.responses import JSONResponse @@ -523,11 +525,17 @@ def _build_query_doc() -> str: #: How long a write may wait for the queue before the wait is worth a WARNING. #: #: Picked against this service's own numbers rather than a round figure. A -#: ``save_file`` body runs 7.5s at p95, so a threshold below one whole body -#: would fire every time two writes from the same agent arrive together — the -#: ordinary case, and an alert that fires on the ordinary case is noise. Past -#: this, the caller waited for more than one complete write ahead of it, which -#: is the shape of a queue that is not draining rather than of a busy moment. +#: ``save_file`` body ran 7.5s at p95 when this was set (before the service +#: moved to Frankfurt, next to Mongo, on 2026-09-16), so a threshold below one +#: whole body would fire every time two writes from the same agent arrive +#: together — the ordinary case, and an alert that fires on the ordinary case +#: is noise. Past this, the caller waited for more than one complete write +#: ahead of it, which is the shape of a queue that is not draining rather than +#: of a busy moment. **The 7.5s figure is stale:** the Frankfurt service's logs +#: (2026-09-16 to 2026-09-21) show ``ran`` between 0.012s and 1.993s over 30 +#: writes, so today the threshold sits well above five whole bodies — still a +#: queue that is not draining, and not worth lowering until a slow write shows +#: up in those logs. _SLOW_WRITE_QUEUE_WAIT = 10.0 #: ``FastMCP.add_tool``'s own signature, read once at import so that the @@ -1055,9 +1063,12 @@ def _log_write_timing(tool: str, waited: float, ran: float | None) -> None: configures logging at ``INFO`` (``mcp_server/app.py``, from ``LOG_LEVEL``), so a ``debug`` line is not quiet — it is *absent*, which is the failure this whole area was just fixed for. Against that, the volume cannot run away: - one worker runs one write at a time and a write body is 4.3-4.9s at p50, so - this line is bounded at roughly thirteen a minute no matter how much load - arrives. And logging only the slow case would say when the wait is bad + one worker runs one write at a time, so this line is bounded by how many + writes a minute one worker can finish — thirteen at the 4.3-4.9s p50 this + was written against (before the 2026-09-16 move to Frankfurt; the service's + logs since then show 0.012-1.993s, so the bound is higher now, and it is + still one line per write, never more). And logging only the slow case would + say when the wait is bad without ever saying what it normally is, which is the number any decision about the queue's width depends on. @@ -1248,6 +1259,34 @@ def _timed() -> Any: return _run_on_write_pool +def _refusal_result(refusal: dict[str, Any]) -> CallToolResult: + """A rate-limit refusal as the whole ``CallToolResult`` — the one shape every tool can return. + + ``FastMCP.call_tool`` normally returns what the tool's own ``convert_result`` + built for its return type, and the low-level ``tools/call`` handler then + validates structured content against the tool's ``outputSchema``: a bare + content list for a tool that declares one becomes an ``isError`` result + ("outputSchema defined but no structured output returned"). A + ``CallToolResult`` is returned by that handler as it is + (``if isinstance(results, types.CallToolResult): return + types.ServerResult(results)`` — ``mcp/server/lowlevel/server.py``, mcp + 1.28.1), so it serves a ``dict`` tool, a typed tool and an unknown name + alike. Until the seven-PR review (SUGG-009) the refusal went through the + refused tool's ``convert_result``, which turned the throttle of a tool + annotated ``-> list[str]`` into a ``pydantic.ValidationError`` exactly when + it fired, and an unknown name into the SDK's unknown-tool error. + + The text is built the way ``_convert_to_content`` builds it for a ``dict`` + (``mcp/server/fastmcp/utilities/func_metadata.py``: + ``pydantic_core.to_json(result, fallback=str, indent=2)``), so the client + reads the same block a body's own refusal produces. ``isError`` stays + ``False``: a refusal is a regular protocol answer, not a server incident — + the rule ``docs/mcp-server.rst`` states for every refusal. + """ + text = pydantic_core.to_json(refusal, fallback=str, indent=2).decode() + return CallToolResult(content=[TextContent(type="text", text=text)], isError=False) + + class AdminAwareFastMCP(FastMCP): """FastMCP that hides the admin-only tools from non-admin tools/list, and keeps every tool body off the event loop. @@ -1262,7 +1301,13 @@ def __init__( ) -> None: # Set before ``super().__init__`` so the attribute exists whatever the # SDK's constructor does; ``call_tool`` below reads it on every call. - self._tool_rate_limiter = tool_rate_limiter or ToolRateLimiter(DEFAULT_RATE_LIMIT_PER_MINUTE) + # ``is None`` and not ``or``: ``ToolRateLimiter(0)`` is the documented + # kill switch, and ``or`` kept it only because the class defines neither + # ``__bool__`` nor ``__len__`` — the first one added would have swapped a + # switched-off limiter for the 60/min default in silence (K12 §3). + if tool_rate_limiter is None: + tool_rate_limiter = ToolRateLimiter(DEFAULT_RATE_LIMIT_PER_MINUTE) + self._tool_rate_limiter = tool_rate_limiter super().__init__(*args, **kwargs) def add_tool(self, fn: Any, *args: Any, **kwargs: Any) -> Any: # type: ignore[override] @@ -1295,18 +1340,18 @@ async def call_tool(self, name: str, arguments: dict[str, Any]) -> Any: # type: stay exactly what ``add_tool`` built — the routing tests read ``fn.__code__.co_name`` off them, which an outer wrapper would hide. - The refusal goes through the tool's own ``convert_result``, so the - client sees the same shape a body's own refusal has - (``{"ok": false, "error": ...}`` as text content), not a transport error. + The refusal is a whole ``CallToolResult`` (:func:`_refusal_result`), so + the client sees the same text block a body's own refusal has + (``{"ok": false, "error": ...}``), whatever the tool's return type is + and whether or not the name exists — the budget is charged before the + name is looked up, so an identity over budget cannot probe tool names + for free. """ user_id = self._caller_identity() if user_id is not None: refusal = await self._tool_rate_limiter.admit(user_id) if refusal is not None: - tool = self._tool_manager.get_tool(name) - if tool is not None: - return tool.fn_metadata.convert_result(refusal) - # An unknown name is the SDK's own ToolError, unchanged. + return _refusal_result(refusal) return await super().call_tool(name, arguments) def _caller_identity(self) -> int | None: @@ -1318,8 +1363,13 @@ def _caller_identity(self) -> int | None: to count, and the tool bodies keep answering as they do in the tests that call them directly. Inside a request the identity is ``current_user_id`` on the SDK's context — the same function every gate - uses, in both auth modes — and its documented failure, - ``PermissionError``, means the body itself is about to refuse. + uses, in both auth modes. Its documented failure, ``PermissionError`` + (no identity on the request), is unreachable for a ``tools/call`` in + production, because both auth modes reject an unauthenticated request + upstream: ``RequireAuthMiddleware`` on the ``/mcp`` mount in OAuth + mode, ``PATAuthMiddleware`` in PAT mode. So answering ``None`` there is + not a way past the limiter; the test that reaches it does so by + monkeypatching ``current_user_id``. """ try: self._mcp_server.request_context @@ -2290,8 +2340,14 @@ def build_app( # middleware added last is the outermost — the PAT check stays outside, and an # unauthenticated oversized POST is a 401 before it is a 413. In OAuth mode # the SDK's auth wraps only the ``/mcp`` mount, so here the cap is the - # outermost app-level layer and refuses a 20MB body before any credential - # is looked at. ``/healthz`` is a GET: no body, nothing to cap. + # outermost app-level layer: it refuses a declared 20MB body before any + # credential is looked at, and hands a declared body under the cap on + # *unread*, so the SDK's 401 still comes without a byte of it read + # (SEC-001 in the seven-PR review; the drain that remains, for a body with + # no declared length, is bounded by the cap and by a deadline — + # ``mcp_server/limits.py``). ``/healthz`` is a GET, and GET is not a + # method the cap looks at, so a spoofed ``Content-Length`` there is not a + # 413; both modes pin that in ``tests/test_mcp_limits.py``. app.add_middleware(BodySizeLimitMiddleware, max_bytes=max_request_bytes) if oauth: for route in consent_routes or []: diff --git a/rate_limiter.py b/rate_limiter.py index c8d30c116..bf40afcaf 100644 --- a/rate_limiter.py +++ b/rate_limiter.py @@ -1,5 +1,4 @@ import asyncio -from collections import defaultdict from datetime import datetime, timedelta, timezone from typing import Dict, List @@ -13,7 +12,9 @@ class RateLimiter: def __init__(self, max_per_minute: int = 30) -> None: self.max_per_minute = max(1, int(max_per_minute or 30)) - self._requests: Dict[int, List[datetime]] = defaultdict(list) + # מילון רגיל ולא ``defaultdict``: רשומה נוצרת רק כשקריאה אושרה + # (``check_rate_limit``), ולא לכל זהות ששאלו עליה — ראו ``_live_entries``. + self._requests: Dict[int, List[datetime]] = {} self._lock = asyncio.Lock() def _live_entries(self, user_id: int, now: datetime) -> List[datetime]: @@ -23,8 +24,16 @@ def _live_entries(self, user_id: int, now: datetime) -> List[datetime]: עותקים של אותה לולאה שצריכים להסכים זה עם זה לנצח. הרשומות נשמרות בסדר הזמן, ולכן הראשונה שעדיין בחלון היא הגבול, ומה שלפניה נמחק (תיקון off-by-one כאשר כל הערכים פגי-תוקף: אז נמחק הכול). + + **ואינו יוצר רשומה למי ששואלים עליו בלבד.** עם ``defaultdict`` הקריאה + ``self._requests[user_id]`` יצרה מפתח קבוע לכל זהות ש-``get_current_usage_ratio`` + או ``seconds_until_allowed`` נשאלו עליה (סקירת שבעת ה-PRים, SUGG-011); + עכשיו זהות בלי רשומה מקבלת רשימה ריקה שאינה נשמרת, ומפתח שהחלון שלו + התרוקן נמחק. מי ששומר הוא ``check_rate_limit``, וברגע האישור בלבד. """ - entries = self._requests[user_id] + entries = self._requests.get(user_id) + if entries is None: + return [] one_min_ago = now - timedelta(seconds=60) delete_upto = len(entries) for idx, ts in enumerate(entries): @@ -33,6 +42,8 @@ def _live_entries(self, user_id: int, now: datetime) -> List[datetime]: break if delete_upto > 0: del entries[:delete_upto] + if not entries: + del self._requests[user_id] return entries async def check_rate_limit(self, user_id: int) -> bool: @@ -43,6 +54,8 @@ async def check_rate_limit(self, user_id: int) -> bool: if len(entries) >= self.max_per_minute: return False entries.append(now) + # הרשימה עשויה להיות חדשה, או כזו ש-``_live_entries`` ניתק כשהתרוקנה. + self._requests[user_id] = entries return True async def get_current_usage_ratio(self, user_id: int) -> float: diff --git a/scripts/generate_ai_map.py b/scripts/generate_ai_map.py index a9ed17d56..bcaba6e83 100644 --- a/scripts/generate_ai_map.py +++ b/scripts/generate_ai_map.py @@ -142,7 +142,11 @@ def _body_start(lines: list[str]) -> int: מה שאפס-הדיף על ``AI-MAP.md`` מקבע גם אחרי המעבר. ``"\\n".join`` מחזיר את הטקסט ש-``_read`` פיצל ב-``split("\\n")``, ולכן - האינדקסים של הפרסר הם האינדקסים של ``lines``. + האינדקסים של הפרסר הם האינדקסים של ``lines`` — בתנאי שהקלט עבר את שער + הכניסה של הפרסר, ומאז סקירת שבעת ה-PRים (SUGG-019) הוא עובר: קובץ עם + ``\\r`` בודד, ש-markdown-it היה סופר כמעבר שורה ו-``split("\\n")`` לא, + מרים כאן ``InconsistentLineEndings`` עם מספר השורה — כמו בכלי — במקום + להזיז את הגבול בשקט, ו-BOM בתחילת הקובץ מוסר במקום להסתיר את הבלוק. """ return md_parser.front_matter_end("\n".join(lines)) @@ -152,7 +156,9 @@ def _title(lines: list[str], path: Path) -> str: # מדלגים על front matter לפני חיפוש הכותרת: ה-``---`` הסוגר שלו # הוא קו-תחתון חוקי לפי ``_UNDERLINE_RE``, ולכן שורת ``summary:`` # שמעליו נקראה ככותרת setext והכותרת של העמוד יצאה "summary: ...". - for idx, ln in enumerate(lines[_body_start(lines):], start=_body_start(lines)): + # פעם אחת: כל קריאה היא פרסור מלא של העמוד. + start = _body_start(lines) + for idx, ln in enumerate(lines[start:], start=start): if ln.startswith("# "): return ln[2:].strip() if ln.strip() and idx + 1 < len(lines) and _UNDERLINE_RE.match(lines[idx + 1]): diff --git a/scripts/measure_md_parse_cost.py b/scripts/measure_md_parse_cost.py index e161899ea..713b9e343 100644 --- a/scripts/measure_md_parse_cost.py +++ b/scripts/measure_md_parse_cost.py @@ -25,10 +25,13 @@ לתקרת הקריאה ``MAX_FILE_SIZE_FOR_DISPLAY``); המסמך הצפוף ביותר בו משוכפל עד התקרה — זה המועמד לקבוע, כי זה הגרוע ביותר שהכלי באמת מגיש; וצורה עוינת (Markdown: שורות-תבליט בודדות, RST: כותרת בת תו אחד בכל שורה) שהיא הגבול - העליון של הפרסר ושהקבוע במפורש אינו מכסה. שלוש הצורות רצות על ברירת - המחדל של ``max_sections`` — ``MAX_SECTIONS`` בשני הפרסרים מאז #3420 — כלומר - בדיוק כמו הכלי; הצורה העוינת של RST נעצרת לכן על התקרה, והמחיר בלי - התקרה נמדד רק אם מעבירים ``max_sections=None`` במפורש. ל-RST נמדד גם מסלול ה-outline + העליון של הפרסר ושהקבוע במפורש אינו מכסה. הקורפוס והמסמך הצפוף רצים על + ברירת המחדל של ``max_sections`` — ``MAX_SECTIONS`` בשני הפרסרים מאז #3420 — + כלומר בדיוק כמו הכלי. **הצורה העוינת רצה בלי תקרה, ``max_sections=None`` + במפורש:** היא מודדת את הגבול העליון של הפרסר ולא את מה שהכלי מגיש, ועם + ברירת המחדל היא נעצרת על התקרה ומדווחת עלות של עצירה במקום של הפרסר — + נמדד ב-RST 41.0 בתים לבית עם התקרה מול 90.1 בלעדיה (סקירת שבעת ה-PRים, + WARN-004). ל-RST נמדד גם מסלול ה-outline של ``codekeeper_get_repo_file``: המסמך הצפוף ביותר משוכפל עד ``RANGE_READ_MAX_BYTES`` ומפורסר עם ``max_sections`` של ``MAX_SYMBOLS``, כי זו ההחזקה הגדולה ביותר שחוט קריאה נושא היום, והיא גדולה מעלות הפרסור @@ -185,7 +188,9 @@ def real_files(suffix: str) -> list[pathlib.Path]: def rank(files: list[pathlib.Path], parser: str) -> list[tuple[float, pathlib.Path, str]]: - """הקבצים לפי צפיפות, מהצפוף ביותר; קובץ שאינו UTF-8 מדולג בקול ב-stderr.""" + """הקבצים לפי צפיפות, מהצפוף ביותר; קובץ שאינו UTF-8, או עם ``\\r`` בודד, מדולג בקול ב-stderr.""" + from services.doc_sections import InconsistentLineEndings + ranked = [] for p in files: try: @@ -195,7 +200,13 @@ def rank(files: list[pathlib.Path], parser: str) -> list[tuple[float, pathlib.Pa # ביותר יכול לצאת מהדירוג בלי שאיש יידע. print(json.dumps({"skipped": str(p), "reason": f"not utf-8: {exc.reason}"}), file=sys.stderr) continue - ranked.append((density(text, parser), p, text)) + try: + ranked.append((density(text, parser), p, text)) + except InconsistentLineEndings as exc: + # אותו כלל: הפרסר של הכלי מסרב לקובץ עם ``\r`` בודד (מאז SUGG-019 + # גם ``token_count`` עובר בשער הכניסה שלו), והכלי לא היה מגיש אותו. + print(json.dumps({"skipped": str(p), "reason": f"lone CR at line {exc.args[0]}"}), file=sys.stderr) + continue if not ranked: suffix = PARSERS[parser]["suffix"] raise SystemExit(f"no {suffix} file of at least {MIN_DOC_BYTES} bytes under {REPO} — nothing to measure") @@ -244,14 +255,20 @@ def measure(parser: str, work: pathlib.Path) -> list[dict]: "density_per_kb": round(top_density, 1), **peak_cost(tiled, work, parser=parser), }) - # הצורה העוינת — הגבול העליון של הפרסר, שהקבוע במפורש אינו מכסה. + # הצורה העוינת — הגבול העליון של הפרסר, שהקבוע במפורש אינו מכסה. בלי + # תקרה, במפורש: עם ברירת המחדל הפרסר נעצר על ``MAX_SECTIONS`` והמספר + # מתאר עצירה, לא את הפרסר (ראו את ה-docstring של המודול). unit = spec["hostile_unit"] hostile = work / ("hostile" + spec["suffix"]) hostile.write_text( clip_to_bytes(unit * (MAX_FILE_SIZE_FOR_DISPLAY // len(unit) + 1), MAX_FILE_SIZE_FOR_DISPLAY), encoding="utf-8", ) - results.append({"parser": parser, "shape": "hostile", **peak_cost(hostile, work, parser=parser)}) + results.append({ + "parser": parser, + "shape": "hostile", + **peak_cost(hostile, work, parser=parser, kwargs={"max_sections": None}), + }) if parser == "rst": # מסלול ה-outline של codekeeper_get_repo_file: קריאה עד RANGE_READ_MAX_BYTES, # פרסור עם תקרת סקשנים — ההחזקה הגדולה ביותר של חוט קריאה היום. @@ -289,7 +306,8 @@ def main() -> int: } | { "note": ( "the constant is the densest document the tool actually serves; " - "the hostile bound is not a pool problem" + "the hostile bound is the parser's own ceiling, measured with max_sections=None, " + "and is not a pool problem" ), })) return 0 diff --git a/services/md_parser.py b/services/md_parser.py index e7ce99a19..a962e00fb 100644 --- a/services/md_parser.py +++ b/services/md_parser.py @@ -297,6 +297,53 @@ def _build_parser() -> MarkdownIt: _MD = _build_parser() +def _entry_checks(text: object) -> str: + """שער הכניסה המשותף לכל מי שמפרסר כאן: ``parse_document``, ``token_count`` ו-``front_matter_end``. + + שלושה צעדים, בסדר הזה (ההנמקה לסדר ב-docstring של ``parse_document``): + ``require_str``, הסרת BOM אחד בתחילת המחרוזת, ו-``\\r`` בודד → + :class:`~services.doc_sections.InconsistentLineEndings`. הנרמול של + ``\\r\\n`` נשאר ב-``parse_document``, כי רק היא בונה ``lines`` שחייב להסכים + עם המפה; markdown-it מנרמל אותו בעצמו לפני הפרסור. + + עד סקירת שבעת ה-PRים (SUGG-019) שתי הפונקציות הקטנות קראו ל-``_MD.parse`` + ישירות ודילגו על שלושתם, ונמדד מה זה עושה: ``front_matter_end`` על בלוק + שיש בו ``\\r`` בודד החזיר 4 בעוד ה-``---`` הסוגר יושב באינדקס 2 של + ``split("\\n")`` — markdown-it סופר ``\\r`` כמעבר שורה והקורא לא — ועם BOM + החזיר 0, כלומר לא ראה את הבלוק כלל; ערך שאינו מחרוזת נפל עם ההודעה של + markdown-it ולא של המודול. + """ + text = require_str(text) + # **BOM אחד בתחילת הקובץ מוסר, ולא יותר מזה.** בלי ההסרה, ``# Title`` + # שלפניו ``U+FEFF`` אינו כותרת אצל ``markdown-it`` — הוא פסקה שמתחילה + # בתו בלתי נראה — והפארסר מחזיר **מפה ריקה בלי חריגה** על קובץ שיש + # בו כותרות, שזו בדיוק מחלקת הכשל שהמודול הזה מצהיר שאין בו. נמדד. + # + # **וזה יישור ל-cmark-gfm ול-GitHub, לא למפרט.** מימוש הייחוס של + # CommonMark (``commonmark.py`` 0.9.2) **אינו** מסיר BOM ומרנדר + # ``

# Title

`` בדיוק כמו ``markdown-it``; cmark מסיר. + # האורקל שנבחר למודול הזה הוא GitHub, ולכן הולכים אחריו. + # + # **למה זה אינו מזיז מספרי שורות:** התו יושב בעמודה 0 של שורה 1 + # ואינו מעבר שורה, ולכן ``lines`` ומספרי המפה זהים עם ובלי ההסרה — + # אותו תנאי שהתיר את נרמול ה-``\r\n``. חיתוך בתווים, לא בבייטים. + # + # **ורק בתחילת המחרוזת.** ``U+FEFF`` באמצע הטקסט הוא ZERO WIDTH + # NO-BREAK SPACE רגיל — תוכן, לא סימון קידוד — ואיש אינו נוגע בו. + # מסלול הייצור (``git_mirror_service._try_decode_content``) מפענח + # ב-``utf-8-sig`` ולכן לא יעביר BOM לכאן; ההגנה היא בשביל כל קורא + # אחר — הסקריפט, טסט, או הצרכן הבא — שיפענח ב-``utf-8`` רגיל. + if text.startswith(""): + text = text[1:] + # **החריגה נושאת את מספר השורה**, כמו ``TooManySections``. המיקום + # כבר מחושב כאן — ``search`` מחזיר אובייקט התאמה — והוא ההבדל בין + # "יש ``\r`` איפשהו בקובץ" לבין משהו שאפשר לפתוח ולתקן. + lone_cr = find_lone_cr(text) + if lone_cr: + raise InconsistentLineEndings(text.count("\n", 0, lone_cr.start()) + 1) + return text + + def token_count(text: str) -> int: """מספר הטוקנים שהפרסר של הכלי מייצר לטקסט — מדד צפיפות, לא מפה. @@ -306,14 +353,15 @@ def token_count(text: str) -> int: הזאת היא התלות בגלוי: על ``_MD``, המופע שהכלי מריץ, בלי ``env`` ולכן בלי תקרה (``_ceiling_rule`` מקבל ``None`` ומחזיר ``False``), ובלי טוקני inline (``disable("inline")``) — כלומר בדיוק מה שהפרסור האמיתי - סופר. + סופר. ודרך אותו שער כניסה (:func:`_entry_checks`): BOM מוסר, ``\\r`` בודד + נדחה, וערך שאינו מחרוזת מקבל את החריגה של המודול — כמו בכלי. **ומחוץ ל-``__all__`` בכוונה:** הרשימה שם היא החוזה של "שני פארסרים בני-החלפה", וטסט מקבע את ההפרש בינה לבין זו של ``rst_parser``. מדד למדידה אינו חלק מהחוזה הזה ואינו קיים ל-RST, ולכן הוא ציבורי (בלי קו תחתון, מתועד) אך אינו מיוצא. """ - return len(_MD.parse(text)) + return len(_MD.parse(_entry_checks(text))) def parse_document(text: str, *, max_sections: Optional[int] = MAX_SECTIONS) -> Document: @@ -326,7 +374,8 @@ def parse_document(text: str, *, max_sections: Optional[int] = MAX_SECTIONS) -> היה מפיל ``AttributeError`` ממקום שלא מסביר כלום. אותה בדיקה בדיוק, ואותן מילים, כמו ב-``rst_parser`` (מאז #3421). 2. הסרת ``U+FEFF`` (BOM) **אחד, בתחילת המחרוזת בלבד** — ההנמקה - בהערה ליד הקוד. לפני בדיקת ה-``\\r``, כדי שמספר השורה שהחריגה + בהערה ליד הקוד ב-:func:`_entry_checks`, השער המשותף לשלוש הפונקציות + שמפרסרות כאן. לפני בדיקת ה-``\\r``, כדי שמספר השורה שהחריגה נושאת יתאים לטקסט שהפארסר באמת רואה. 3. ``\\r`` בודד → :class:`~services.doc_sections.InconsistentLineEndings`. 4. נרמול ``\\r\\n`` ← ``\\n``. ``splitlines()`` נשקל ונדחה באותו @@ -357,34 +406,7 @@ def parse_document(text: str, *, max_sections: Optional[int] = MAX_SECTIONS) -> כותרות מהתקרה. הארגומנט הוא מספר השורה שבה נעצרנו — מה שמבדיל עצירה בתוך הפרסור מסינון של פלט אחריו. """ - text = require_str(text) - # **BOM אחד בתחילת הקובץ מוסר, ולא יותר מזה.** בלי ההסרה, ``# Title`` - # שלפניו ``U+FEFF`` אינו כותרת אצל ``markdown-it`` — הוא פסקה שמתחילה - # בתו בלתי נראה — והפארסר מחזיר **מפה ריקה בלי חריגה** על קובץ שיש - # בו כותרות, שזו בדיוק מחלקת הכשל שהמודול הזה מצהיר שאין בו. נמדד. - # - # **וזה יישור ל-cmark-gfm ול-GitHub, לא למפרט.** מימוש הייחוס של - # CommonMark (``commonmark.py`` 0.9.2) **אינו** מסיר BOM ומרנדר - # ``

\ufeff# Title

`` בדיוק כמו ``markdown-it``; cmark מסיר. - # האורקל שנבחר למודול הזה הוא GitHub, ולכן הולכים אחריו. - # - # **למה זה אינו מזיז מספרי שורות:** התו יושב בעמודה 0 של שורה 1 - # ואינו מעבר שורה, ולכן ``lines`` ומספרי המפה זהים עם ובלי ההסרה — - # אותו תנאי שהתיר את נרמול ה-``\r\n``. חיתוך בתווים, לא בבייטים. - # - # **ורק בתחילת המחרוזת.** ``U+FEFF`` באמצע הטקסט הוא ZERO WIDTH - # NO-BREAK SPACE רגיל — תוכן, לא סימון קידוד — ואיש אינו נוגע בו. - # מסלול הייצור (``git_mirror_service._try_decode_content``) מפענח - # ב-``utf-8-sig`` ולכן לא יעביר BOM לכאן; ההגנה היא בשביל כל קורא - # אחר — הסקריפט, טסט, או הצרכן הבא — שיפענח ב-``utf-8`` רגיל. - if text.startswith("\ufeff"): - text = text[1:] - # **החריגה נושאת את מספר השורה**, כמו ``TooManySections``. המיקום - # כבר מחושב כאן — ``search`` מחזיר אובייקט התאמה — והוא ההבדל בין - # "יש ``\r`` איפשהו בקובץ" לבין משהו שאפשר לפתוח ולתקן. - lone_cr = find_lone_cr(text) - if lone_cr: - raise InconsistentLineEndings(text.count("\n", 0, lone_cr.start()) + 1) + text = _entry_checks(text) normalized = text.replace("\r\n", "\n") lines = normalized.split("\n") @@ -530,11 +552,15 @@ def front_matter_end(text: str) -> int: האינדקס שהסקריפט צריך. פרסור מלא של הטקסט, בלי ``env`` ולכן בלי תקרה — הקלט הוא עמודי - תיעוד. **מחוץ ל-``__all__`` בכוונה**, מאותו נימוק שכתוב ליד - :func:`token_count` אם הוא קיים כאן: הרשימה שם היא החוזה של "שני - פארסרים בני-החלפה", וגבול front matter אינו קיים ל-RST. + תיעוד. ודרך :func:`_entry_checks`, כי האינדקס שמוחזר חייב להסכים עם + ``split("\\n")`` של הקורא: ``\\r`` בודד (ש-markdown-it סופר כמעבר שורה) + היה מזיז אותו בשקט, ו-BOM היה מסתיר את הבלוק כולו — עכשיו הראשון + נדחה בשם, והשני מוסר, בדיוק כמו ב-``parse_document``. **מחוץ ל-``__all__`` + בכוונה**, מאותו נימוק שכתוב ליד :func:`token_count` אם הוא קיים כאן: + הרשימה שם היא החוזה של "שני פארסרים בני-החלפה", וגבול front matter אינו + קיים ל-RST. """ - tokens = _MD.parse(text) + tokens = _MD.parse(_entry_checks(text)) if tokens and tokens[0].type == "front_matter" and tokens[0].map: return tokens[0].map[1] return 0 diff --git a/tests/test_mcp_app_wiring.py b/tests/test_mcp_app_wiring.py new file mode 100644 index 000000000..b7d7d3b50 --- /dev/null +++ b/tests/test_mcp_app_wiring.py @@ -0,0 +1,136 @@ +"""The service entry point carries the two request limits from the environment into the app it builds. + +``limit_from_env`` is unit-tested and ``build_app`` is tested with explicit +values; the lines that connect them in ``mcp_server/app.py::create_app`` were +exercised by no test (WARN-001 in the seven-PR review). A misspelled variable +name or a dropped keyword would have shipped silently: the service would run +on the defaults while the config inspector shows the operator's value. + +The technique is the one ``tests/test_mcp_logging_visible.py`` uses: a fresh +interpreter imports ``mcp_server.app`` exactly as uvicorn does. What differs is +that ``create_app`` has to *succeed* here, so the probe puts a fake ``database`` +module on ``sys.modules`` before the import (the only place the entry point +touches the database layer is ``from database import db`` inside +``create_app``; every ``mcp_server`` module imports it lazily) and switches the +repo autosync off through its own environment flag. Both auth branches of +``create_app`` are driven — PAT, and OAuth with a strong ``SECRET_KEY`` — and a +control run without the variables shows the defaults, so the runs with them are +measuring the wiring and not a coincidence. + +The body cap is read off the installed ``BodySizeLimitMiddleware`` (Starlette +keeps ``cls``/``kwargs`` on ``app.user_middleware``); the limiter's budget is +observed through a subclass installed on ``mcp_server.server`` before the import, +because ``build_mcp`` constructs ``ToolRateLimiter`` through that module's +global name and the built app does not expose the ``FastMCP`` instance. +""" + +import json +import os +import pathlib +import subprocess +import sys +import textwrap + +import pytest + +pytest.importorskip("mcp") + +from mcp_server import handlers, limits # noqa: E402 + +REPO = str(pathlib.Path(__file__).resolve().parents[1]) +TESTS = str(pathlib.Path(__file__).resolve().parent) + +_PROBE = """ +import json, sys, types +# The repo root first, so ``config`` resolves to the real module the service +# imports and not to the stub ``tests/config.py``; ``tests/`` after it, for the fake. +sys.path.insert(0, {tests!r}) +sys.path.insert(0, {repo!r}) +from _fake_mongo import FakeDB + +# ``from database import db as db_manager`` inside create_app, then +# ``resolve_mongo(db_manager)`` reads ``db_manager.db``: a manager whose ``db`` +# is the in-memory fake, and nothing else from the real package. +fake_database = types.ModuleType("database") +fake_database.db = types.SimpleNamespace(db=FakeDB()) +sys.modules["database"] = fake_database + +import mcp_server.server as server_module + +constructed = [] + + +class _Observed(server_module.ToolRateLimiter): + def __init__(self, per_minute=server_module.DEFAULT_RATE_LIMIT_PER_MINUTE): + constructed.append(per_minute) + super().__init__(per_minute) + + +server_module.ToolRateLimiter = _Observed + +import mcp_server.app as app_module +from mcp_server.limits import BodySizeLimitMiddleware + +caps = [dict(m.kwargs) for m in app_module.app.user_middleware if m.cls is BodySizeLimitMiddleware] +print("WIRING=" + json.dumps({{ + "caps": caps, + "limiters": constructed, + "middleware": [m.cls.__name__ for m in app_module.app.user_middleware], +}}), flush=True) +print("PROBE-DONE", flush=True) +""" + +_STRONG_SECRET = "wiring-test-secret-" + "x" * 48 + + +def _wiring(mode: str, **variables: str) -> dict: + """Import the entry point in a fresh interpreter and return what it wired.""" + env = dict(os.environ) + for key in ("MCP_MAX_REQUEST_BYTES", "MCP_RATE_LIMIT_PER_MINUTE", "MAX_CODE_SIZE", + "MCP_SERVER_URL", "WEBAPP_URL"): + env.pop(key, None) + env["MCP_REPO_AUTOSYNC"] = "0" + if mode == "oauth": + env["MCP_SERVER_URL"] = "https://mcp.test" + env["WEBAPP_URL"] = "https://web.test" + env["SECRET_KEY"] = _STRONG_SECRET + env.update(variables) + proc = subprocess.run( + [sys.executable, "-B", "-c", textwrap.dedent(_PROBE.format(repo=REPO, tests=TESTS))], + capture_output=True, text=True, timeout=180, cwd=REPO, env=env, + ) + assert "PROBE-DONE" in proc.stdout, f"the probe did not finish\nSTDOUT:\n{proc.stdout}\nSTDERR:\n{proc.stderr}" + line = next(ln for ln in proc.stdout.splitlines() if ln.startswith("WIRING=")) + return json.loads(line[len("WIRING="):]) + + +@pytest.mark.parametrize("mode", ["pat", "oauth"]) +def test_the_two_limits_from_the_environment_reach_the_built_app(mode): + wired = _wiring(mode, MCP_MAX_REQUEST_BYTES="131072", MCP_RATE_LIMIT_PER_MINUTE="5") + + assert wired["caps"] == [{"max_bytes": 131072}], wired + assert wired["limiters"] == [5], wired + assert "BodySizeLimitMiddleware" in wired["middleware"] + assert ("PATAuthMiddleware" in wired["middleware"]) == (mode == "pat"), wired["middleware"] + + +def test_without_the_variables_the_app_runs_on_the_documented_defaults(): + """The control: the same probe, nothing set — so the run above measured the variables.""" + wired = _wiring("pat") + + assert wired["caps"] == [{"max_bytes": limits.DEFAULT_MAX_REQUEST_BYTES}], wired + assert wired["limiters"] == [limits.DEFAULT_RATE_LIMIT_PER_MINUTE], wired + + +def test_the_body_cap_follows_the_code_size_ceiling_the_service_runs_with(): + """SUGG-022 at the entry point: ``MAX_CODE_SIZE`` raised in the environment raises the cap with it. + + ``create_app`` derives the cap from ``max_code_size()`` — the config's value, + read at startup — so the two ceilings cannot drift apart in a deployment + either; ``MCP_MAX_REQUEST_BYTES`` then only raises it further. + """ + raised = 3 * handlers.DEFAULT_MAX_CODE_SIZE + wired = _wiring("pat", MAX_CODE_SIZE=str(raised)) + + assert wired["caps"] == [{"max_bytes": limits.request_bytes_for(raised)}], wired + assert wired["caps"][0]["max_bytes"] > limits.DEFAULT_MAX_REQUEST_BYTES diff --git a/tests/test_mcp_limits.py b/tests/test_mcp_limits.py index abbcff1ec..2e580a9f0 100644 --- a/tests/test_mcp_limits.py +++ b/tests/test_mcp_limits.py @@ -3,19 +3,31 @@ שני הגבולות נבדקים בשכבה שבה הם חיים — המידלוור מול הודעות ASGI ממש, והמגביל דרך ``call_tool`` של ה-``FastMCP`` האמיתי — ואז על האפליקציה המלאה דרך ``TestClient``, כי הפטור לנתיבי הדופק והסדר מול האימות הם תכונות של ההרכבה -ולא של אף רכיב לבדו. בלי ``sleep``: התקרה והחלון מוקטנים במקום להמתין להם. +ולא של אף רכיב לבדו. בלי ``sleep``: התקרה, החלון והדדליין מוקטנים במקום +להמתין להם. + +**מה המידלוור קורא, ומה לא (SEC-001 בסקירת שבעת ה-PRים).** הגרסה הראשונה קראה +כל גוף עד התקרה לפני שהשכבה הבאה ראתה את הבקשה — גם גוף אנונימי שהאימות היה +דוחה מהכותרות בלבד, ובלי דדליין. הטסטים כאן מקבעים את שלושת החלקים של התיקון: +אורך מוצהר תקין עובר בלי קריאה (השרת תוחם), הלולאה שנשארה לגוף chunked נסגרת +בדדליין, ורק מתודות שנושאות גוף נבדקות בכלל. """ from __future__ import annotations +import asyncio import contextlib +import importlib.util import json import logging +from datetime import datetime, timedelta, timezone +from pathlib import Path import pytest pytest.importorskip("mcp") +from mcp import types as mcp_types # noqa: E402 from mcp.server.fastmcp import Context # noqa: E402 from mcp.server.lowlevel.server import request_ctx # noqa: E402 from mcp.shared.context import RequestContext # noqa: E402 @@ -24,11 +36,18 @@ from starlette.testclient import TestClient # noqa: E402 import mcp_server.server as srv # noqa: E402 -from mcp_server import limits # noqa: E402 +from mcp_server import handlers, limits # noqa: E402 from mcp_server.limits import BodySizeLimitMiddleware, ToolRateLimiter, limit_from_env # noqa: E402 from mcp_server.server import _READ_ONLY_TOOL, build_app, build_mcp # noqa: E402 from rate_limiter import RateLimiter # noqa: E402 +try: # local `tests` pkg can be shadowed by an unrelated top-level `tests` on sys.path + from tests._fake_mongo import FakeDB # noqa: E402 +except ImportError: # fall back to the sibling module (tests/ is on sys.path under pytest) + from _fake_mongo import FakeDB # noqa: E402 + +_REPO = Path(__file__).resolve().parent.parent + class _Backend: """המשטח הקטן ביותר שהכלי הנבדק נוגע בו: ``get_file`` שמחזיר "לא נמצא".""" @@ -48,17 +67,35 @@ def verify(self, token): def _scope(path="/mcp", method="POST", headers=()): + # ``method`` is uppercased by the server ("The HTTP method name, uppercased" — + # ASGI HTTP connection scope), and the middleware compares it as given. return { "type": "http", + "http_version": "1.1", "method": method, "path": path, + "raw_path": path.encode(), + "root_path": "", "headers": [(k.encode(), v.encode()) for k, v in headers], "query_string": b"", "server": ("test", 80), + "client": ("127.0.0.1", 1), "scheme": "http", } +def _mcp_post(headers=()): + """‏``POST /mcp`` כפי שלקוח MCP שולח אותו: JSON, ומקבל JSON או SSE.""" + return _scope(headers=[("content-type", "application/json"), + ("accept", "application/json, text/event-stream"), *headers]) + + +def _chunks(sizes, byte=b"x"): + sizes = list(sizes) + return [{"type": "http.request", "body": byte * n, "more_body": i < len(sizes) - 1} + for i, n in enumerate(sizes)] + + class _Echo: """אפליקציה שקוראת את הגוף כמו הטרנספורט — דרך ``Request.body()`` — ורושמת מה ראתה.""" @@ -88,10 +125,37 @@ async def send(message): return sent +async def _drive_traced(app, scope, messages, mark=lambda: None): + """כמו ``_drive``, ורושם לכל קריאת ``receive()`` כמה הודעות כבר נשלחו וערך סימון מהטסט. + + ``(0, ...)`` ברשומה פירושו קריאה **לפני** שהתשובה התחילה; ``mark()`` נותן + לטסט לרשום מה שהוא רוצה — למשל אם האפליקציה כבר נכנסה. + """ + queue = list(messages) + sent = [] + trace = [] + + async def receive(): + assert queue, "receive() was called after the last message" + trace.append((len(sent), mark())) + return queue.pop(0) + + async def send(message): + sent.append(message) + + await app(scope, receive, send) + return sent, trace + + def _status(sent): return next(m["status"] for m in sent if m["type"] == "http.response.start") +def _headers(sent): + start = next(m for m in sent if m["type"] == "http.response.start") + return {k.lower(): v for k, v in start.get("headers", [])} + + def _json_body(sent): return json.loads(b"".join(m.get("body", b"") for m in sent if m["type"] == "http.response.body")) @@ -105,24 +169,166 @@ async def test_a_declared_length_over_the_cap_is_refused_before_a_byte_is_read() assert _status(sent) == 413 assert _json_body(sent) == {"error": "body_too_large", "max_bytes": 100, "content_length": 101} assert echo.calls == 0 + assert _headers(sent)[b"connection"] == b"close", "the body was refused unread; the connection is not kept" + + +async def test_a_valid_declared_length_under_the_cap_is_handed_on_without_a_read(): + """SEC-001, חלק 1: ``Content-Length`` תקין ובלי ``Transfer-Encoding`` — המידלוור אינו קורא בית. + + השרת תוחם את הגוף לאורך המוצהר: מול uvicorn 0.38.0 (h11) אמיתי, 20 בתים + מאחורי ``Content-Length: 10`` הגיעו לאפליקציה כ-10. לכן אין כאן מה לספור, + ומי שעונה מהכותרות בלבד — ה-401 של האימות — עונה בלי שנקרא דבר. לפני + התיקון הקריאה הראשונה קרתה לפני שהאפליקציה נכנסה (``echo.calls == 0`` + בזמן הקריאה); עכשיו הקריאה היחידה היא של האפליקציה עצמה. + """ + echo = _Echo() + mw = BodySizeLimitMiddleware(echo, max_bytes=100) + + sent, trace = await _drive_traced(mw, _scope(headers=[("content-length", "50")]), + _chunks([50]), mark=lambda: echo.calls) + + assert _status(sent) == 200 and echo.seen == b"x" * 50 + assert [entered for _, entered in trace] == [1], "the only read belongs to the app, after it was entered" + + +async def test_a_declared_length_beside_transfer_encoding_is_not_trusted(): + """‏``Content-Length`` לצד ``Transfer-Encoding: chunked`` — סופרים, כמו בלי כותרת. + + h11 תוחם לפי chunked ומעביר את **שתי** הכותרות ל-scope (נמדד מול uvicorn + 0.38.0: 200 בתים מאחורי ``Content-Length: 5`` הגיעו במלואם). זו הכותרת + המכזבת היחידה שמסוגלת להעביר יותר ממה שהצהירה, ולכן המסלול המהיר דורש + שאין ``Transfer-Encoding``. הטסט עובר גם על הקוד שלפני התיקון; הוא מפיל + מסלול מהיר שמסתכל על ``Content-Length`` בלבד. + """ + echo = _Echo() + mw = BodySizeLimitMiddleware(echo, max_bytes=100) + + sent = await _drive(mw, _scope(headers=[("content-length", "5"), ("transfer-encoding", "chunked")]), + _chunks([60, 60])) + + assert _status(sent) == 413 + assert _json_body(sent) == {"error": "body_too_large", "max_bytes": 100, "received_bytes": 120} + assert echo.calls == 0 + + +async def test_a_declared_length_that_is_not_ascii_digits_is_not_a_length(): + """‏``Content-Length: ²`` — ``str.isdigit()`` מקבל ספרות-על ו-``int()`` נופל עליהן (U3). + + h11 דוחה כותרת כזאת ב-400 לפני ה-ASGI (``[0-9]+`` בלבד), ולכן מול uvicorn + זה לא מגיע לכאן; מול שרת אחר, או ב-``TestClient``, זה היה 500. ערך שאינו + ספרות ASCII אינו אורך מוצהר: סופרים את מה שבאמת מגיע. + """ + echo = _Echo() + mw = BodySizeLimitMiddleware(echo, max_bytes=100) + scope = _scope() + # latin-1 on the wire, as ``Headers`` decodes it: ``b"\xb2"`` reads back as ``²``. + scope["headers"] = [(b"content-length", b"\xb2")] + + sent = await _drive(mw, scope, _chunks([40])) + + assert _status(sent) == 200 and echo.seen == b"x" * 40 async def test_a_streamed_body_over_the_cap_is_refused_and_the_app_never_runs(): - """בלי ``Content-Length`` (או עם אחת שמכזבת) — הספירה של מה שבאמת מגיע היא שעוצרת.""" + """בלי ``Content-Length`` (גוף chunked) — הספירה של מה שבאמת מגיע היא שעוצרת. + + **הטענה השתנתה ב-SEC-001, בכוונה.** עד אז הטסט שלח ``Content-Length: 90`` + עם 120 בתים, ו"כותרת מכזבת" נתפסה בספירה. אורך מוצהר תקין (בלי + ``Transfer-Encoding``) עובר עכשיו בלי קריאה, כי השרת תוחם את הגוף לאורך + המוצהר ואי אפשר להעביר דרכו יותר (נמדד מול uvicorn/h11 בטסט המסלול + המהיר). הכותרת המכזבת היחידה שמסוגלת להעביר יותר היא זו שלצד + ``Transfer-Encoding``, ויש לה טסט משלה. מה שנשאר לספירה הוא גוף בלי אורך + מוצהר — וזה מה שכאן. + """ echo = _Echo() mw = BodySizeLimitMiddleware(echo, max_bytes=100) - chunks = [ - {"type": "http.request", "body": b"x" * 60, "more_body": True}, - {"type": "http.request", "body": b"y" * 60, "more_body": False}, - ] - sent = await _drive(mw, _scope(headers=[("content-length", "90")]), chunks) + sent = await _drive(mw, _scope(), _chunks([60, 60])) assert _status(sent) == 413 assert _json_body(sent) == {"error": "body_too_large", "max_bytes": 100, "received_bytes": 120} assert echo.calls == 0 +async def test_a_body_that_stops_arriving_is_refused_at_the_deadline_with_what_was_read(): + """SEC-001, חלק 2: גוף chunked אנונימי שנעצר באמצע נסגר ב-408, ולא מוחזק לנצח. + + ל-uvicorn 0.38.0 אין timeout לגוף בקשה (``timeout_keep_alive`` חל אחרי + תשובה), ולכן בלי דדליין ההמתנה כאן אינה חסומה — וזה המקרה שנשאר אחרי + המסלול המהיר: לקוח בלי ``Content-Length``, לפני האימות. הסירוב נוקב + בסיבתו ובכמה נקרא, ונושא ``Connection: close`` — "since 408 implies that + the server has decided to close the connection rather than continue + waiting" (MDN, ‏``408 Request Timeout``). ``asyncio.wait_for`` הוא השומר של + הטסט עצמו: על הקוד שלפני התיקון הוא זה שנופל. + """ + echo = _Echo() + mw = BodySizeLimitMiddleware(echo, max_bytes=100, read_timeout=0.05) + queue = _chunks([40, 40]) # the second chunk never arrives + sent = [] + + async def receive(): + if len(queue) == 2: + return queue.pop(0) + await asyncio.Event().wait() + + async def send(message): + sent.append(message) + + await asyncio.wait_for(mw(_scope(), receive, send), timeout=5) + + assert _status(sent) == 408 + assert _json_body(sent) == {"error": "body_read_timeout", "read_timeout_seconds": 0.05, "received_bytes": 40} + assert _headers(sent)[b"connection"] == b"close" + assert echo.calls == 0 + + +async def test_the_deadline_bounds_the_whole_body_and_not_each_chunk(): + """דדליין על הלולאה כולה: לקוח ששולח בית כל 29 שניות היה עובר דדליין-לקריאה. + + כאן כל נתח מגיע הרבה לפני הדדליין, ויש יותר נתחים ממה שהדדליין מכיל — + מימוש שמודד כל קריאה בנפרד היה ממשיך לקרוא עד התקרה. + """ + mw = BodySizeLimitMiddleware(_Echo(), max_bytes=1000, read_timeout=0.1) + sent = [] + + async def receive(): + await asyncio.sleep(0.03) + return {"type": "http.request", "body": b"x", "more_body": True} + + async def send(message): + sent.append(message) + + await asyncio.wait_for(mw(_scope(), receive, send), timeout=5) + + assert _status(sent) == 408 + assert _json_body(sent)["received_bytes"] >= 2, "more than one chunk was read, each within the deadline" + + +@pytest.mark.parametrize("method", ["GET", "HEAD", "OPTIONS", "DELETE"]) +async def test_a_method_that_carries_no_body_is_neither_capped_nor_read(method): + """SEC-001, חלק 3 (WARN-002): רק מתודות שנושאות גוף נבדקות. + + ‏``GET /healthz`` עם ``Content-Length: 99999999`` מזויף ענה 413 — נמדד מול + uvicorn אמיתי — בניגוד ל"פטור מבני" שהתיעוד הצהיר. שום route כאן אינו + קורא גוף במתודה אחרת: הטרנספורט של ה-SDK קורא ב-``POST``, ו-DCR, token + ו-consent הם ``POST``. התקרה היא לכן תכונה של המתודות שנושאות גוף. + """ + echo = _Echo() + mw = BodySizeLimitMiddleware(echo, max_bytes=100) + + sent = await _drive(mw, _scope(method=method, headers=[("content-length", "99999999")]), _chunks([0])) + + assert _status(sent) == 200 + assert echo.calls == 1, "handed straight to the app; the empty body read is the app's own" + + +@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH"]) +async def test_a_method_that_carries_a_body_is_capped(method): + sent = await _drive(BodySizeLimitMiddleware(_Echo(), max_bytes=100), + _scope(method=method, headers=[("content-length", "101")]), []) + assert _status(sent) == 413 + + async def test_a_body_under_the_cap_reaches_the_app_intact(): echo = _Echo() mw = BodySizeLimitMiddleware(echo, max_bytes=100) @@ -172,7 +378,7 @@ async def app(scope, receive, send): # --------------------------------------------------------------------------- -# האפליקציה המלאה: הסדר מול האימות, והפטור המבני לנתיב הדופק +# האפליקציה המלאה: הסדר מול האימות בשני המצבים, והפטור לנתיב הדופק # --------------------------------------------------------------------------- @@ -198,14 +404,84 @@ def test_on_the_real_app_the_401_comes_before_the_413_and_the_413_names_its_reas assert small.status_code not in (413, 500), "a body under the cap must reach the transport" +def _oauth_app(backend, **kwargs): + """האפליקציה במצב OAuth — הייצור — כפי ש-``tests/test_mcp_oauth_e2e.py`` בונה אותה.""" + from pydantic import AnyHttpUrl + + from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions + from mcp_server.oauth_provider import CodeKeeperOAuthProvider + from mcp_server.oauth_routes import oauth_consent_routes + from mcp_server.oauth_store import OAuthStore + + base = "https://mcp.test" + store = OAuthStore(FakeDB()) + provider = CodeKeeperOAuthProvider( + store=store, pat_verify=lambda t: None, + identify_url=f"{base}/fake-identify", consent_url=f"{base}/oauth/consent", + ) + settings = AuthSettings( + issuer_url=AnyHttpUrl(base), resource_server_url=AnyHttpUrl(base), + client_registration_options=ClientRegistrationOptions( + enabled=True, valid_scopes=["read", "write"], default_scopes=["read"]), + revocation_options=RevocationOptions(enabled=True), required_scopes=[], + ) + return build_app(backend, auth_provider=provider, auth_settings=settings, + consent_routes=oauth_consent_routes(store, "e2e-secret"), **kwargs) + + +async def test_in_oauth_mode_an_anonymous_post_is_a_401_before_a_byte_of_its_body_is_read(): + """טסט סדר-ההתקנה למצב OAuth, אח של הטסט במצב PAT שמעליו. + + במצב OAuth התקרה היא השכבה החיצונית ברמת האפליקציה, וה-``RequireAuthMiddleware`` + של ה-SDK יושב בתוך ה-mount של ``/mcp`` ועונה 401 מהכותרות בלבד + (``mcp/server/auth/middleware/bearer_auth.py``, mcp 1.28.1). לפני SEC-001 + התקרה קראה את הגוף האנונימי כולו לפני ה-401: נמדדו 5 קריאות ``receive()`` + לפני התשובה על גוף של חמישה נתחים, גם עם ``Content-Length`` מוצהר. עכשיו: + אורך מוצהר תקין — אפס קריאות; אורך מוצהר מעל התקרה — 413 בלי קריאה; + וגוף chunked אנונימי הוא המקרה שנשאר — נספר עד התקרה, תחת הדדליין, + והאפליקציה אינה נכנסת. + """ + app = _oauth_app(_Backend(), max_request_bytes=limits.MIN_MAX_REQUEST_BYTES) + body = _chunks([1000] * 5) + + sent, trace = await _drive_traced(app, _mcp_post([("content-length", "5000")]), body) + assert _status(sent) == 401 + assert trace == [], "declared under the cap: the SDK's auth answered before any read" + + sent, trace = await _drive_traced(app, _mcp_post([("content-length", str(2 * limits.MIN_MAX_REQUEST_BYTES))]), body) + assert _status(sent) == 413 and trace == [] + + over = _chunks([limits.MIN_MAX_REQUEST_BYTES // 2 + 1] * 3) + sent, trace = await _drive_traced(app, _mcp_post(), over) + assert _status(sent) == 413 + assert [n for n, _ in trace] == [0, 0], "chunked and anonymous: read up to the cap, refused, the app never ran" + + def test_health_is_never_counted_or_capped(): - """הפטור של ``blanket-policy-silent-block`` §7 — מבני: ``/healthz`` אינו קריאת כלי ואין לו גוף.""" + """הפטור של ``blanket-policy-silent-block`` §7: ``/healthz`` אינו קריאת כלי, ו-``GET`` אינו נבדק.""" app = build_app(_Backend(), _Store(), max_request_bytes=limits.MIN_MAX_REQUEST_BYTES, rate_limit_per_minute=1) with TestClient(app) as client: statuses = {client.get("/healthz").status_code for _ in range(100)} assert statuses == {200} +@pytest.mark.parametrize("mode", ["pat", "oauth"]) +async def test_a_spoofed_length_on_the_health_check_is_still_a_200_in_both_modes(mode): + """WARN-002 על האפליקציה האמיתית: ``GET /healthz`` עם ``Content-Length`` מעל התקרה ענה 413. + + כל לקוח אנונימי יכול היה לעשות זאת לבקשה שלו, בניגוד למה שהתיעוד הצהיר. + עכשיו ``GET`` אינו נבדק כלל, בשני מצבי האימות. + """ + if mode == "pat": + app = build_app(_Backend(), _Store(), max_request_bytes=limits.MIN_MAX_REQUEST_BYTES) + else: + app = _oauth_app(_Backend(), max_request_bytes=limits.MIN_MAX_REQUEST_BYTES) + + sent = await _drive(app, _scope(path="/healthz", method="GET", headers=[("content-length", "99999999")]), []) + + assert _status(sent) == 200 + + # --------------------------------------------------------------------------- # המגביל, דרך call_tool של ה-FastMCP האמיתי # --------------------------------------------------------------------------- @@ -229,8 +505,12 @@ def _inside_a_request(): async def _call(mcp, name="codekeeper_get_file", arguments=None, *, inside_request=True): with _inside_a_request() if inside_request else contextlib.nullcontext(): - blocks = await mcp.call_tool(name, arguments if arguments is not None else {"file_name": "x"}) - (block,) = blocks + result = await mcp.call_tool(name, arguments if arguments is not None else {"file_name": "x"}) + # A tool's own dict answer comes back as content blocks; a refusal comes back + # as a whole ``CallToolResult`` (see ``AdminAwareFastMCP.call_tool``). Both + # carry one text block with the JSON the client reads. + content = result.content if isinstance(result, mcp_types.CallToolResult) else result + (block,) = content return json.loads(block.text) @@ -325,6 +605,126 @@ async def test_a_refusal_is_logged_once_per_identity_per_window(caplog): assert "11" in warnings[0].getMessage() +async def test_the_once_per_window_ledger_forgets_identities_whose_window_passed(monkeypatch): + """SUGG-011: ``_warned_at`` גדל עם כל זהות שסורבה אי פעם; הוא מנוקה במעבר שכבר כותב אליו.""" + limiter = ToolRateLimiter(per_minute=1) + clock = {"now": 1000.0} + monkeypatch.setattr(limits.time, "monotonic", lambda: clock["now"]) + + for identity in (1, 2, 3): + assert await limiter.admit(identity) is None + assert (await limiter.admit(identity))["error"] == "rate_limited" + assert set(limiter._warned_at) == {1, 2, 3} + + clock["now"] += 61.0 + assert await limiter.admit(4) is None + assert (await limiter.admit(4))["error"] == "rate_limited" + assert set(limiter._warned_at) == {4}, "stamps older than a window go on the write that adds a new one" + + +async def test_the_budget_holds_under_concurrent_calls_from_one_identity(monkeypatch): + """SUGG-024, ‏``TESTING-PATTERNS`` T1(d): טסט סדרתי אינו מוכיח תכונת מקביליות. + + 25 קריאות בו-זמנית לזהות אחת עם תקציב של 2 — בדיוק 2 עוברות ו-23 נדחות, + כי ``RateLimiter`` מכריע ומוסיף תחת ``asyncio.Lock`` אחד בלי ``await`` + באמצע. הטסטים שמעל רצים ברצף ולא היו מוכיחים את זה. + """ + monkeypatch.setattr(srv, "current_user_id", lambda ctx=None: 4242) + mcp = build_mcp(_Backend(), rate_limit_per_minute=2) + + results = await asyncio.gather(*(_call(mcp) for _ in range(25))) + + assert sum(r == {"found": False} for r in results) == 2 + assert sum(r.get("error") == "rate_limited" for r in results) == 23 + + +async def test_a_limiter_passed_in_is_kept_even_when_the_object_is_falsy(monkeypatch): + """SUGG-023, ‏K12 §3: ``or`` היה מחליף מגביל כבוי בברירת המחדל ברגע שלמחלקה יש ``__len__``. + + ``ToolRateLimiter(0)`` הוא ה-kill switch המתועד. בלי ``__bool__`` ו-``__len__`` + כל מופע אמיתי היום, ולכן ``or`` עבד — עד שמישהו מוסיף ``__len__`` (למשל כמה + זהויות נספרות), והמגביל הכבוי הופך שקט ל-60 בדקה: ברירת מחדל שמרחיבה + בלי שאיש החליט. ``is None`` שואל את השאלה שבאמת נשאלת. + """ + + class _Measurable(ToolRateLimiter): + def __len__(self): + return 0 + + off = _Measurable(0) + mcp = srv.AdminAwareFastMCP("probe", tool_rate_limiter=off) + assert mcp._tool_rate_limiter is off + + monkeypatch.setattr(srv, "current_user_id", lambda ctx=None: 5) + + @mcp.tool(name="probe", annotations=_READ_ONLY_TOOL) + def probe(ctx: Context) -> dict: + return {"ok": True} + + for _ in range(80): + assert await _call(mcp, "probe", {}) == {"ok": True}, "switched off means switched off" + + +async def test_a_refusal_does_not_depend_on_the_tools_return_type(monkeypatch): + """SUGG-009: הסירוב הוא ``CallToolResult`` שלם, לא ערך שעובר ב-``convert_result`` של הכלי. + + ``convert_result`` מאמת את הערך מול ``outputSchema`` של הכלי — וכלי עם + טיפוס החזרה מוצהר (``-> list[str]``) הפך את הסירוב ל-``ValidationError`` + בדיוק כשהמגביל נדלק. מטפל ה-``tools/call`` של השרת הנמוך מחזיר + ``CallToolResult`` כמות שהוא, בלי ולידציה של הפלט (``mcp/server/lowlevel/ + server.py``, mcp 1.28.1), ולכן זו הצורה שמשרתת כל כלי — ומה שהלקוח רואה + הוא אותו בלוק טקסט של ``{"ok": false, "error": "rate_limited", ...}``. + """ + monkeypatch.setattr(srv, "current_user_id", lambda ctx=None: 8) + mcp = build_mcp(_Backend(), rate_limit_per_minute=1) + + @mcp.tool(name="probe_typed", annotations=_READ_ONLY_TOOL) + def probe_typed(ctx: Context) -> list[str]: + return ["first"] + + with _inside_a_request(): + first = await mcp.call_tool("probe_typed", {}) + assert first[1] == {"result": ["first"]}, "the typed tool's own answer: content and structured content" + + refused = await _call(mcp, "probe_typed", {}) + assert refused["error"] == "rate_limited" and refused["ok"] is False + + +async def test_a_refusal_survives_the_low_level_handlers_output_validation(monkeypatch): + """אותו סירוב דרך המטפל שה-SDK באמת מריץ על ``tools/call``, לכלי עם ``outputSchema``. + + רשימת בלוקים חשופה הייתה הופכת שם ל-``isError`` ("outputSchema defined but + no structured output returned"); ``CallToolResult`` עובר כמות שהוא. + """ + monkeypatch.setattr(srv, "current_user_id", lambda ctx=None: 12) + mcp = build_mcp(_Backend(), rate_limit_per_minute=1) + + @mcp.tool(name="probe_typed", annotations=_READ_ONLY_TOOL) + def probe_typed(ctx: Context) -> list[str]: + return ["first"] + + handler = mcp._mcp_server.request_handlers[mcp_types.CallToolRequest] + request = mcp_types.CallToolRequest( + method="tools/call", params=mcp_types.CallToolRequestParams(name="probe_typed", arguments={})) + with _inside_a_request(): + first = (await handler(request)).root + second = (await handler(request)).root + + assert isinstance(first, mcp_types.CallToolResult) and first.isError is False + assert first.structuredContent == {"result": ["first"]} + assert isinstance(second, mcp_types.CallToolResult) and second.isError is False + assert json.loads(second.content[0].text)["error"] == "rate_limited" + + +async def test_an_unknown_tool_over_budget_is_refused_before_the_name_is_looked_up(monkeypatch): + """SUGG-009: שם כלי לא מוכר מעל התקציב קיבל את שגיאת ה-SDK במקום ``rate_limited``.""" + monkeypatch.setattr(srv, "current_user_id", lambda ctx=None: 9) + mcp = build_mcp(_Backend(), rate_limit_per_minute=1) + + assert await _call(mcp) == {"found": False} + assert (await _call(mcp, "no_such_tool", {}))["error"] == "rate_limited" + + # --------------------------------------------------------------------------- # הגדרה ממשתני סביבה: טעות תצורה לעולם אינה מרחיבה # --------------------------------------------------------------------------- @@ -363,3 +763,112 @@ async def test_seconds_until_allowed_is_zero_with_room_and_the_rest_of_the_windo wait = await limiter.seconds_until_allowed(1) assert 0 < wait <= 60 assert await limiter.check_rate_limit(1) is False + + +async def test_asking_about_an_identity_does_not_create_an_entry_for_it(): + """SUGG-011: ``_live_entries`` קרא ``self._requests[user_id]`` על ``defaultdict`` — ויצר רשומה קבועה למי ששאלו עליו בלבד.""" + limiter = RateLimiter(max_per_minute=1) + + assert await limiter.get_current_usage_ratio(1) == 0.0 + assert await limiter.seconds_until_allowed(1) == 0.0 + assert 1 not in limiter._requests + + assert await limiter.check_rate_limit(1) is True + assert 1 in limiter._requests, "an admitted call is what creates the entry" + + +async def test_an_identity_whose_window_emptied_is_forgotten(): + limiter = RateLimiter(max_per_minute=2) + assert await limiter.check_rate_limit(2) is True + limiter._requests[2] = [datetime.now(timezone.utc) - timedelta(seconds=120)] + + assert await limiter.get_current_usage_ratio(2) == 0.0 + assert 2 not in limiter._requests, "nothing left in the window: the key goes with it" + assert await limiter.check_rate_limit(2) is True + assert len(limiter._requests[2]) == 1 + + +# --------------------------------------------------------------------------- +# התקרה נגזרת מגודל הקובץ המותר, ולא מוקלדת לצידו +# --------------------------------------------------------------------------- + + +def _real_config_module(cwd): + """המודול ``config`` האמיתי, לפי נתיב — התקדים ב-``tests/test_redis_safety.py``. + + תחת pytest התיקייה ``tests`` בראש ``sys.path`` ו-``tests/config.py`` מאפיל + על המודול האמיתי. pydantic דורש שהמודול יהיה ב-``sys.modules`` בזמן + הגדרת המחלקה (אחרת ``BotConfig`` "is not fully defined"), ו-``BotConfig()`` + שנבנה בייבוא קורא ``.env`` יחסית לתיקיית העבודה — לכן הטעינה רצה + בתוך ``tmp_path``, ומה שנשמר ומשוחזר הוא ה-cwd ורשומת המודול. + """ + import os + import sys + + spec = importlib.util.spec_from_file_location("_real_config_for_the_cap", _REPO / "config.py") + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + saved_cwd = os.getcwd() + sys.modules[spec.name] = module + try: + os.chdir(cwd) + spec.loader.exec_module(module) + finally: + os.chdir(saved_cwd) + sys.modules.pop(spec.name, None) + return module + + +def test_the_body_cap_is_derived_from_the_code_size_ceiling(tmp_path): + """SUGG-022, ‏R6: עותק שני של כלל בלי מנגנון שתופס סחיפה הוא הצורה הפסולה. + + ‏``DEFAULT_MAX_REQUEST_BYTES`` היה מוקלד (1MiB) לצד הערה שגוזרת אותו ביד + מ-``MAX_CODE_SIZE``; ``MAX_CODE_SIZE`` שיעלה מעל כ-170,000 תווים היה מביא + שמירה לגיטימית של קובץ עברי ל-413. עכשיו התקרה נגזרת מהמספר, והגזירה + מוצמדת כאן לפי התקדים של ``test_the_two_ceilings_are_the_same_number``: + העותק ב-``handlers`` שווה לברירת המחדל של התצורה, המספר המתועד (1MiB) + הוא הגזירה על ברירת המחדל, ותקרת קוד אחרת נותנת תקרת גוף אחרת. + """ + assert handlers.DEFAULT_MAX_CODE_SIZE == _real_config_module(tmp_path).BotConfig.model_fields["MAX_CODE_SIZE"].default + assert limits.DEFAULT_MAX_REQUEST_BYTES == limits.request_bytes_for(handlers.DEFAULT_MAX_CODE_SIZE) == 1_048_576 + assert limits.request_bytes_for(3 * handlers.DEFAULT_MAX_CODE_SIZE) == 2 * 1_048_576 + assert limits.request_bytes_for(handlers.DEFAULT_MAX_CODE_SIZE) % (1024 * 1024) == 0, "whole MiBs, rounded up" + + +def test_a_hebrew_file_at_the_size_ceiling_fits_under_the_cap_with_its_envelope(): + """הבקשה הלגיטימית הגדולה ביותר נכנסת: ``codekeeper_save_file`` על קובץ עברי בגודל המקסימלי, בקידוד היקר. + + ``json.dumps`` בברירת המחדל (``ensure_ascii=True``) כותב כל תו עברי כ-``\\uXXXX`` + — שישה בתים — וזה מה שלקוח שמקודד כך שולח. המעטפת: JSON-RPC, שם הכלי, שם + הקובץ, שפה, ותיאור בתקרתו (``FILE_DESCRIPTION_MAX_CHARS``); לשם הקובץ אין + תקרה בשכבה הזאת, ולכן נלקח שם ארוך מהמקובל. + """ + from database.repository import FILE_DESCRIPTION_MAX_CHARS + + request = { + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { + "name": "codekeeper_save_file", + "arguments": { + "file_name": "שם-קובץ-ארוך-" * 20 + ".py", + "code": "ש" * handlers.DEFAULT_MAX_CODE_SIZE, + "language": "python", + "description": "ת" * FILE_DESCRIPTION_MAX_CHARS, + }, + }, + } + body = json.dumps(request, ensure_ascii=True).encode("ascii") + + assert len(body) > handlers.DEFAULT_MAX_CODE_SIZE * limits.JSON_BYTES_PER_CHAR, "the escape really costs six bytes a character" + assert len(body) <= limits.DEFAULT_MAX_REQUEST_BYTES + + +def test_the_config_inspector_shows_the_limits_the_service_reads(): + """שני משתני הסביבה בתצוגת התצורה: אותם שמות ואותן ברירות מחדל שהקוד קורא — לא עותק שנסחף.""" + from services.config_inspector_service import ConfigService + + cap = ConfigService.CONFIG_DEFINITIONS[limits.MAX_REQUEST_BYTES_ENV] + rate = ConfigService.CONFIG_DEFINITIONS[limits.RATE_LIMIT_ENV] + assert cap.key == limits.MAX_REQUEST_BYTES_ENV and cap.default == str(limits.DEFAULT_MAX_REQUEST_BYTES) + assert rate.key == limits.RATE_LIMIT_ENV and rate.default == str(limits.DEFAULT_RATE_LIMIT_PER_MINUTE) + assert str(limits.MIN_MAX_REQUEST_BYTES) in cap.description diff --git a/tests/test_mcp_to_thread.py b/tests/test_mcp_to_thread.py index 1bb90fbae..987809a5c 100644 --- a/tests/test_mcp_to_thread.py +++ b/tests/test_mcp_to_thread.py @@ -976,12 +976,16 @@ def test_a_pool_without_a_readable_width_does_not_fail_startup_and_says_so(caplo from mcp_server.server import _log_dispatch_capacity, _read_pool_size + sizing = _read_pool_size(512 * _MiB) with caplog.at_level(_logging.INFO, logger="mcp_server.server"): - _log_dispatch_capacity(_types.SimpleNamespace(), "cgroup v2: 512.0MiB", _read_pool_size(512 * _MiB)) + _log_dispatch_capacity(_types.SimpleNamespace(), "cgroup v2: 512.0MiB", sizing) info = [r.getMessage() for r in caplog.records if r.levelno == _logging.INFO] warnings = [r.getMessage() for r in caplog.records if r.levelno == _logging.WARNING] - assert any("read pool 10 (requested; installed width unreadable) threads" in m for m in info), info + # The width belongs to the memory arithmetic, which has its own test; this + # one is about the label, so it reads the number back instead of typing it. + expected = f"read pool {sizing.workers} (requested; installed width unreadable) threads" + assert any(expected in m for m in info), info assert any("_max_workers" in m for m in warnings), warnings diff --git a/tests/test_md_parser.py b/tests/test_md_parser.py index 7b0d93701..f89e16d1a 100644 --- a/tests/test_md_parser.py +++ b/tests/test_md_parser.py @@ -662,6 +662,41 @@ def test_input_that_is_not_a_string_is_refused_in_the_entry(bad): md_parser.parse_document(bad) +@pytest.mark.parametrize("bad", [None, 17, b"# bytes\n", ["# list"]]) +@pytest.mark.parametrize("entry", [md_parser.token_count, md_parser.front_matter_end]) +def test_the_two_small_entries_refuse_a_non_string_with_the_modules_own_message(entry, bad): + """SUGG-019: ``token_count`` ו-``front_matter_end`` עוברים באותו שער כמו ``parse_document``. + + עד עכשיו הן קראו ל-``_MD.parse`` ישירות: ערך שאינו מחרוזת נפל ב-``TypeError`` + של markdown-it, בלי לומר מה התקבל. ה-``match`` הוא מה שמבדיל: ההודעה של + המודול, לא של הספרייה. + """ + with pytest.raises(TypeError, match="expects str"): + entry(bad) + + +def test_the_two_small_entries_see_past_a_bom_like_the_parser(): + """BOM בתחילת הקובץ הסתיר את בלוק ה-front matter (0 במקום 3) והפך תבליט לפסקה.""" + page = "---\nsummary: x\n---\n\n# T\n" + assert md_parser.front_matter_end("" + page) == md_parser.front_matter_end(page) == 3 + assert md_parser.token_count("- a\n") == md_parser.token_count("- a\n") + + +def test_the_two_small_entries_refuse_a_lone_cr_instead_of_disagreeing_with_split(): + """‏``\\r`` בודד: markdown-it סופר אותו כמעבר שורה ו-``split("\\n")`` לא. + + ``front_matter_end`` החזיר 4 על בלוק שה-``---`` הסוגר שלו יושב באינדקס 2 + של ``split("\\n")`` — ו-``scripts/generate_ai_map.py`` היה מדלג על השורה + הראשונה של הגוף. עכשיו שתי הפונקציות מרימות את החריגה של הכלי, עם מספר + השורה. + """ + page = "---\rsummary: x\r---\n\n# T\n" + for entry in (md_parser.front_matter_end, md_parser.token_count): + with pytest.raises(md_parser.InconsistentLineEndings) as excinfo: + entry(page) + assert excinfo.value.args == (1,) + + def test_the_two_exceptions_come_from_the_same_module(): """המטפל שימיר אותן לתשובת MCP מייבא את שתיהן ממקום אחד.""" assert md_parser.TooManySections is doc_sections.TooManySections diff --git a/tests/test_measure_md_parse_cost_script.py b/tests/test_measure_md_parse_cost_script.py index 9137fc386..804ee7b1d 100644 --- a/tests/test_measure_md_parse_cost_script.py +++ b/tests/test_measure_md_parse_cost_script.py @@ -66,9 +66,12 @@ def test_main_reports_each_parsers_candidate_from_its_densest_shape(monkeypatch, monkeypatch.setattr(script, "real_files", lambda suffix: [docs[suffix]]) monkeypatch.setattr(script, "density", lambda text, parser="md": 1.0) costs = {"corpus": 25.0, "tiled_": 70.7, "hostile": 290.0, "outline_": 7.7} + seen_kwargs = {} def fake_peak_cost(path, workdir, *, parser="md", kwargs=None): - cost = next(v for prefix, v in costs.items() if path.name.startswith(prefix)) + prefix = next(prefix for prefix in costs if path.name.startswith(prefix)) + seen_kwargs[(parser, prefix)] = kwargs + cost = costs[prefix] return { "module": script.PARSERS[parser]["module"], "input_bytes": 1, @@ -90,6 +93,17 @@ def fake_peak_cost(path, workdir, *, parser="md", kwargs=None): assert last["rst"]["constant_candidate_bytes_per_input_byte"] == 70.7 assert last["rst"]["hostile_bound_bytes_per_input_byte"] == 290.0 + # WARN-004 (סקירת שבעת ה-PRים): הצורה העוינת מודדת את הפרסר בלי התקרה — + # עם ברירת המחדל היא נעצרת על MAX_SECTIONS והמספר מתאר עצירה. הקורפוס + # והמסמך הצפוף רצים כמו הכלי, בלי kwargs; ה-outline עם התקרה שלו. + from mcp_server.outline_scanners._ceiling import MAX_SYMBOLS + + for parser in ("md", "rst"): + assert seen_kwargs[(parser, "hostile")] == {"max_sections": None}, parser + assert seen_kwargs[(parser, "corpus")] is None and seen_kwargs[(parser, "tiled_")] is None + assert seen_kwargs[("rst", "outline_")] == {"max_sections": MAX_SYMBOLS} + assert "max_sections=None" in last["note"] + def test_a_checkout_with_no_document_of_the_minimum_size_fails_with_a_named_message(): """בלי קובץ מתאים הסקריפט אומר זאת — לא ``StatisticsError`` גולמי מהחציון.""" diff --git a/utils.py b/utils.py index e26f1eaf8..c1141f857 100644 --- a/utils.py +++ b/utils.py @@ -1521,10 +1521,11 @@ def normalize_code(text: str, # Fast path: delegate to domain normalizer when all defaults are used # This preserves behavior and enables gradual migration to domain layer + # (the normalizer is imported unconditionally since #3427 — there is no + # path on which it is missing). try: if ( - _DOMAIN_NORMALIZER is not None - and strip_bom is True + strip_bom is True and normalize_newlines is True and replace_nbsp is True and replace_all_space_separators is True diff --git a/webapp/push_api.py b/webapp/push_api.py index 1508769cc..609165bd2 100644 --- a/webapp/push_api.py +++ b/webapp/push_api.py @@ -1106,7 +1106,7 @@ def _send_for_user(user_id: int | str, reminders: list[dict]) -> None: #: שני נימוקים, ושניהם חיצוניים לקוד הזה: #: #: 1. אין היום שום תקרת אורך על ``file_name`` בכלי הכתיבה של ה-MCP — יש על -#: גוף הקובץ (``_max_code_size``) ועל תוכן פתק, לא על השם. כלומר האורך +#: גוף הקובץ (``max_code_size``) ועל תוכן פתק, לא על השם. כלומר האורך #: מגיע מחוץ לתהליך ואין עליו הגבלה. #: 2. גוף ההתראה נשלח בבקשת Web Push, ושם התקרה היא של הפרוטוקול: #: *"Push services MUST NOT return a 413 status code in responses to an From f2c52fa811115e5328af307b1ccc912465ff3996 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 04:23:35 +0000 Subject: [PATCH 9/9] =?UTF-8?q?fix(normalize):=20=D7=95=D7=A8=D7=99=D7=90?= =?UTF-8?q?=D7=A6=D7=99=D7=94-=D7=A1=D7=9C=D7=A7=D7=98=D7=95=D7=A8=20?= =?UTF-8?q?=D7=9E=D7=95=D7=A1=D7=A8=20=D7=9C=D7=A4=D7=99=20=D7=A7=D7=95?= =?UTF-8?q?=D7=93=20=D7=94=D7=AA=D7=95=20=D7=95=D7=9C=D7=90=20=D7=9C=D7=A4?= =?UTF-8?q?=D7=99=20=D7=A6=D7=95=D7=A8=D7=AA=20=D7=94=D7=9B=D7=AA=D7=99?= =?UTF-8?q?=D7=91=20=E2=80=94=20\U0000FE0F=20=D7=9B=D7=9E=D7=95=20\uFE0F?= =?UTF-8?q?=20(CodeRabbit=20=D7=A2=D7=9C=20#3443)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit הענף של \UXXXXXXXX ב-strip_hidden_escapes בדק רק את הטווח האידאוגרפי (U+E0100–U+E01EF), ולכן \U0000FE0F נשאר כמות שהוא בזמן ש-\uFE0F הוסר עם remove_variation_selectors=True. עכשיו כלל אחד (_is_hidden) לשתי הצורות: Cf תמיד, ושני טווחי ה-VS רק לפי בקשה. טסט רגרסיה שנפל על הקוד הישן. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SfJTSpDAhDr2yhtmpFkwTx --- src/domain/services/code_normalizer.py | 33 ++++++++++---------- tests/test_utils_normalize_hidden_escapes.py | 15 +++++++++ 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/domain/services/code_normalizer.py b/src/domain/services/code_normalizer.py index 7a16a4ea0..b9b18dba3 100644 --- a/src/domain/services/code_normalizer.py +++ b/src/domain/services/code_normalizer.py @@ -137,33 +137,32 @@ def strip_hidden_escapes(s: str, *, remove_variation_selectors: bool = False) -> שמתעדכנת עם התקן** — ולכן נשארה הקטגוריה לבדה. - ``Cf`` (format) מוסר תמיד, בשתי הצורות. - - Variation Selectors (``U+FE00``–``U+FE0F``, ובצורה הארוכה - ``U+E0100``–``U+E01EF``) הם ``Mn`` ולא ``Cf``, ולכן הם ענף נפרד שנדלק - רק עם ``remove_variation_selectors=True`` — ברירת המחדל שומרת אותם, - כמו קודם בשני הצרכנים. + - Variation Selectors (``U+FE00``–``U+FE0F`` ו-``U+E0100``–``U+E01EF``) + הם ``Mn`` ולא ``Cf``, ולכן הם ענף נפרד שנדלק רק עם + ``remove_variation_selectors=True`` — ברירת המחדל שומרת אותם, כמו קודם + בשני הצרכנים. **ההחלטה היא על קוד התו, לא על צורת הכתיב:** ``\\U0000FE0F`` + הוא אותו תו כמו ``\\uFE0F`` ומקבל אותה תשובה. עד סקירת #3443 הענף הארוך + בדק רק את הטווח האידאוגרפי, כאילו הכתיב קובע את הטווח. - רצף שאינו מתפענח לקוד תו (``\\uZZZZ`` אינו תואם את הביטוי; ``chr`` מעבר לטווח) נשאר כמות שהוא. """ - def _strip_if_hidden_u4(m: "re.Match[str]") -> str: - code = int(m.group(1), 16) + def _is_hidden(code: int) -> bool: + # הכלל האחד לשתי הצורות. ``chr`` מרים ``ValueError``/``OverflowError`` + # מעל U+10FFFF — הצורה הארוכה היא היחידה שיכולה להגיע לשם, והיא תופסת. if unicodedata.category(chr(code)) == "Cf": - return "" - if remove_variation_selectors and code in _VS_HEX4: - return "" - return m.group(0) + return True + return remove_variation_selectors and (code in _VS_HEX4 or code in _IDEOGRAPHIC_VS) + + def _strip_if_hidden_u4(m: "re.Match[str]") -> str: + return "" if _is_hidden(int(m.group(1), 16)) else m.group(0) def _strip_if_hidden_u8(m: "re.Match[str]") -> str: - code = int(m.group(1), 16) try: - hidden = unicodedata.category(chr(code)) == "Cf" + hidden = _is_hidden(int(m.group(1), 16)) except (ValueError, OverflowError): return m.group(0) # מעבר לטווח Unicode — לא תו, נשאר כמות שהוא - if hidden: - return "" - if remove_variation_selectors and code in _IDEOGRAPHIC_VS: - return "" - return m.group(0) + return "" if hidden else m.group(0) s = _ESCAPE_U4.sub(_strip_if_hidden_u4, s) s = _ESCAPE_U8.sub(_strip_if_hidden_u8, s) diff --git a/tests/test_utils_normalize_hidden_escapes.py b/tests/test_utils_normalize_hidden_escapes.py index 1870da5be..a40441097 100644 --- a/tests/test_utils_normalize_hidden_escapes.py +++ b/tests/test_utils_normalize_hidden_escapes.py @@ -94,6 +94,21 @@ def test_variation_selectors_stay_a_separate_branch_because_they_are_mn_not_cf() assert normalize_code("a\\uFE0Fb", strip_bom=False, remove_variation_selectors=True) == "ab" +def test_a_variation_selector_is_stripped_whatever_escape_form_spells_it(): + """‏``\\U0000FE0F`` ו-``\\uFE0F`` הם אותו תו, ולכן אותה החלטה בשתי הצורות (סקירת CodeRabbit על #3443). + + הענף של ``\\UXXXXXXXX`` בדק רק את הטווח האידאוגרפי (``U+E0100``–``U+E01EF``), + כאילו הצורה הארוכה מאייתת רק אותו — אבל ``\\U0000FE0F`` היא כתיב חוקי של + ``U+FE0F``, ונשארה כמות שהיא בזמן ש-``\\uFE0F`` הוסר. כלל אחד לשתי הצורות. + """ + from src.domain.services.code_normalizer import strip_hidden_escapes + + assert strip_hidden_escapes("a\\U0000FE0Fb") == "a\\U0000FE0Fb", "default: kept, like the short form" + assert strip_hidden_escapes("a\\U0000FE0Fb", remove_variation_selectors=True) == "ab" + assert strip_hidden_escapes("a\\U0000FE00b", remove_variation_selectors=True) == "ab" + assert normalize_code("a\\U0000FE0Fb", strip_bom=False, remove_variation_selectors=True) == "ab" + + def test_an_escape_beyond_unicode_is_left_exactly_as_written(): """‏``\\U00110000`` אינו תו (מעל U+10FFFF): לא ``Cf``, לא שגיאה — נשאר כמות שהוא, כמו קודם.""" from src.domain.services.code_normalizer import strip_hidden_escapes