שרת ה-MCP — חיבור Claude ל-CodeKeeper
- summary:
שרת ה-MCP שחושף את CodeKeeper ל-Claude: הכלים, האימות וההרשאות, פריימר הסוכן, עריכה מהדפדפן, ההתראה כשסוכן שומר קובץ, מדידת השימוש ושער הפרטיות שלה, וההפעלה צעד אחר צעד מול Claude.ai ומול Claude Code.
שרת MCP (Model Context Protocol) שחושף את CodeKeeper ל-Claude: הקבצים והאוספים האישיים של כל משתמש, ולאדמין — גם דפדפן הריפו מעל ה-Repo Sync Engine. עובד גם מול Claude.ai (Custom Connector דרך OAuth 2.1) וגם מול Claude Code / Claude Desktop (טוקן אישי).
קוד: mcp_server/ · תכנון מלא:
FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md
מה זה נותן
Claude קורא ומחפש בקבצים השמורים שלך ישירות — בלי העתק-הדבק ובלי צילומי מסך.
שמירה (יצירה/עדכון) מאחורי הרשאת
writeמפורשת — עדכון תוכן תמיד יוצר גרסה חדשה (append-only), אף פעם לא דורס. הסייג:codekeeper_update_file_descriptionנוגע במטא-דאטה ולא בתוכן, ולכן הוא כן דורס — את התיאור של הגרסה האחרונה, בלי ליצור גרסה ובלי לשמור את הערך הקודם. ראו עדכון תיאור בלי גרסה חדשה.לאדמין: קריאה וחיפוש בכל הריפואים המשוקפים (תיעוד, קוד, מסמכי תכנון).
ארכיטקטורה בקצרה
Claude.ai / Claude Code
│ Streamable HTTP (+OAuth 2.1 או Bearer PAT)
▼
שירות MCP נפרד (ASGI, uvicorn) ── import ישיר ──▶ database/ → MongoDB
│ (code_snippets, collections)
└── דפדפן ריפו (אדמין) ──▶ bare mirrors בדיסק המקומי (REPO_MIRROR_PATH)
ה-
user_idנגזר תמיד מהטוקן — לעולם לא מקלט הלקוח.מכבד את חוק ה-Smart Projection: רשימות קבצים וחיפוש מחזירים מטא-דאטה בלבד; תוכן מלא רק בבקשה מפורשת לקובץ בודד. החריג הוא כלי רשימת הפתקים (
codekeeper_list_notes,codekeeper_list_board_notes,codekeeper_list_repo_notes): פתק הוא ישות קטנה, חסומה ב-MAX_NOTE_CHARS, והרשימה מחזירה את גופו כברירת מחדל (include_content=true); בשני הראשוניםinclude_content=falseמשאיר את הגוף במסד ומחזיר במקומו את גודלו — ראו קריאת פתק בודד — codekeeper_get_note.
מודל הריצה של הכלים
גופי הכלים נכתבים סינכרוניים, ו-AdminAwareFastMCP.add_tool מעביר כל אחד
לחוט עובד בזמן הרישום. זו נקודה אחת שמכסה את כל הכלים, ולא החלטה שחוזרת בכל
כלי בנפרד — הכלי הבא נולד נכון בלי שאיש יזכור.
עד #3379 השרת לא עשה את זה, וה-docstring טען שכן. ה-SDK קורא גוף
סינכרוני ישירות (return fn(**arguments)), כלומר בקשה איטית אחת עצרה את כל
הסשנים בתהליך ולא רק את הקורא שלה. האמונה השגויה היא הסיבה שאיש לא בדק, ולכן
הסעיף הזה קיים: תיאור שגוי של מודל ריצה הוא מה שמסתיר את הבאג הבא.
יש שני יעדים, ולא אחד:
קריאות הולכות ל-
asyncio.to_thread, כלומר למאגר ברירת המחדל של לולאת האירועים — ומאז #3391 המאגר הזה הוא שלנו:attach_read_poolב-mcp_server/server.pyמתקין אותו בעליית ה-lifespan של אפליקציית ה-ASGI, וגודלו נגזר ממכסת הזיכרון של הקונטיינר. כמה קריאות רצות במקביל; כמה בדיוק, ולמה דווקא מהזיכרון — בהמשך הסעיף.כתיבות הולכות למאגר ייעודי בעל worker יחיד. לכן גוף כתיבה אחד רץ בכל רגע, והתור מוסר אותם לעובד לפי סדר ההגעה —
queue.SimpleQueueהוא FIFO.
גודל מאגר הקריאות נגזר ממכסת הזיכרון, לא ממספר המעבדים. עד #3391
המאגר היה ברירת המחדל של CPython, min(32, os.cpu_count() + 4), ו-CPython
עצמו מתעד ש-os.cpu_count() ”אינו שקול למספר המעבדים שהתהליך הנוכחי
יכול להשתמש בהם“: בקונטיינר הוא מדווח את ליבות המארח. שורת הקיבולת
בייצור הראתה בדיוק את זה — read pool 12 threads (os.cpu_count=8,
usable=8) מול cpu quota cgroup v2: 0.50 cpu, כלומר 12 חוטים על חצי
מעבד. לקריאת מונגו או דיסק זה לא מזיק, החוטים ישנים; לפרסור בפייתון טהור
אין מקביליות ב-CPU בגלל ה-GIL, ולכן מאגר רחב קונה רק מקביליות בזיכרון
— 12 עצי פרסור בו-זמנית, בלי תפוקה. החסם האמיתי הוא הזיכרון, וממנו נגזר
הגודל:
(מגבלת הזיכרון − קו הבסיס − שוליים) ÷ עלות פרסור אחד
מגבלת הזיכרון נקראת מ-cgroup בזמן ריצה, כמו שמכסת ה-CPU כבר נקראת:
memory.maxב-v2 (maxפירושו ללא מגבלה) ו-memory/memory.limit_in_bytesב-v1 (שם ”ללא מגבלה“ נקרא כמספר סמוך ל-LONG_MAX). היא אינה מקובעת בקוד — השירות כבר עבר תוכנית ואזור פעם אחת. הקורא:_memory_limitב-mcp_server/server.py, לצד_cpu_budget.קו הבסיס (
_PROCESS_BASELINE_BYTES, 92MiB) הוא ה-RSS של השירות בשקט בייצור, כפי שנמדד במדדי Render ב-2026-09-19; השוליים (_NON_PARSE_MARGIN_BYTES, 64MiB) הם הפער בין קו הבסיס לשיאי העבודה שנצפו באותם מדדים ביום שאחריו — כל מה שהתהליך מחזיק ואינו הפרסור המתוקצב.עלות פרסור אחד היא
_PARSE_RSS_PER_INPUT_BYTEכפול הקלט הגדול ביותר שפרסור יכול לקבל,MAX_FILE_SIZE_FOR_DISPLAY: ``codekeeper_docs_get_section`` קורא דרךRepoBackend.get_fileבליlines, ולכן בליmax_size, ודוחהtoo_largeלפני שהוא מפרסר. הקבוע מיובא ולא מועתק, כדי שהתקציב יזוז עם התקרה. הקבוע נמדד עלservices.md_parser(``markdown-it-py`` הנעוץ) — היקר מבין שני הפרסרים שהכלי מריץ:codekeeper_docs_get_sectionמריץservices.rst_parserעלdocs/*.rstו-services.md_parserעל*.md, ומסלול ה-RST זול בהרבה. כל המספרים כאן נמדדו מחדש ב-2026-09-27 בשיטה המתוקנת — איפוסVmHWMרגע לפני הפרסור, 40 סידורי זיכרון לכל מדידה, וחסם עליון שהוא המקסימום ועוד פיגור המונים של הקרנל (השיטה: סקריפטים שימושיים). קורפוס ה-RST של הריפו משוכפל עד תקרת הקריאה מגיע ל-2.0 בתים לכל בית קלט (השיטה שלפני האיפוס לא הראתה ממנו כלום — השיא שלפני הפרסור הסתיר אותו), העמוד הצפוף ביותר (docs/modules/index.rst) משוכפל עד התקרה ל-7.0, וצורה עוינת של כותרת בת תו אחד בכל שורה ל-90.8 בלי תקרה — 44.3MiB לפרסור אחד, מעל 35.2MiB שהנוסחה מקצה לחוט. לכן יש ל-RST תקרת סקשנים מאז הסקירה של #3429 — היוםMAX_SECTIONS(50,000), ברירת המחדל של שני הפרסרים, שהכלי אינו דורס — ו-too_many_sectionsחוזר במקום פרסור של הקובץ כולו: אותה צורה עוינת נעצרת ב-20.2MiB (41.4 בתים לכל בית קלט), בתוך ההקצאה, ואף עמוד אמיתי אינו מתקרב לתקרה (ראו איך docs_get_section מוצא כותרת). העלות לכל בית קלט היא שיא ה-RSS בזמן הפרסור — מה שכמה פרסורים במקביל מחזיקים יחד — והיא נגזרת מ**צפיפות** המסמך ולא מגודלו: משוכפלים עד תקרת הקריאה, קורפוס ה-Markdown של הריפו מגיע ל-24.6 בתים לכל בית קלט, והמסמך הצפוף ביותר בו (CLOUD.md, כ-170 טוקנים ל-KB) ל-72.4 — 73.2 כחסם עליון (על 3.0.0, באותו סקריפט: 24.7 ו-72.8, חסם 73.6 — כלומר השדרוג ל-4.2.0 אינו מזיז את העלות). הצורה הזו עולה יותר מהקבוע, ותמיד עלתה: 72 נקבע ב-#3429 ממדידה אחת שלה (71.7) מעוגלת כלפי מעלה; על 40 סידורי זיכרון אותה צורה על הפרסר של אז (3.0.0) נעה בין 71.2 ל-72.8, כלומר 71.7 היה הגרלה, והשיטה שבאה אחריה — שבה השיא שלפני הפרסור הסתיר כחצי MB — קראה אותה נמוך עוד יותר (70.1 ו-71.2). הקבוע מחזיק רק בזכות התקרות של #3391 (#3467): הכלי דוחה את שתי הצורות המשוכפלות בתקרת השורות לפני הפרסור, ולכן הסקריפט חותך כל קלט למה שהכלי עוד מפרסר, ומה שמכריע את הקבוע הוא הקלט העוין. ומאז #3391 הקבוע חוסם גם את הקלט העוין — בתוך התקרות של הפרסר. בלי תקרות, קובץ עוין של שורות-תבליט בודדות עולה כ-292 בתים לבית, כלומר כ-142.5MiB לפרסור אחד, מה ששום רוחב מאגר אינו סופג; ותקרת הכותרות אינה נוגעת בו, כי אין בו אף כותרת.md_parserמסרב לקובץ ארוך מ-MAX_LINESלפני הפרסור ועוצר פרסור ב-MAX_TOKENS, והקלט הגרוע שנמצא תחת שתיהן נכנס ב-_PARSE_COST_BYTES: החסם העליון שלו הוא 69.02 בתים לכל בית של תקרת הקריאה — 95.9% מהקבוע, מרווח של 4.1% בלבד. זה לא הממוצע ולא ריצה טיפוסית: השיא הגבוה ביותר שנמדד על פני הסידורים (34,959,360 בתים; הפיזור 92.2%–94.8% מ-_PARSE_COST_BYTES, כך שמי שמריץ פעם אחת ומקבל פחות פגש סידור ולא שינוי), ועוד פיגור המונים של הקרנל. מדידה עתידית שעוברת את 72 אינה רעש — החסם כבר כולל את שניהם, ולכן מעבר שלו פירושו שהפרסור התייקר. איך החסם נבנה והפיזור המלא — ליד_PARSE_RSS_PER_INPUT_BYTE; הקלט — לידMAX_TOKENSב-services/md_parser.py; ואיך נבחרו שני המספרים — ב- איזה קובץ docs_get_section קורא, ובאיזה ריפו. ``scripts/measure_md_parse_cost.py`` מודד את המספרים מחדש לשני הפרסרים, כולל הקלט העוין כמו שהכלי מקבל אותו, ויוצא בקוד 1 כשמשהו כבר אינו נכנס — מריצים אותו כשאחד מהם משתנה.ההחזקה הגדולה ביותר של חוט קריאה אינה הפרסור, וזו החלטה מתועדת. כלי האדמין
codekeeper_get_repo_fileעםoutline=trueאוlines=קורא עדRANGE_READ_MAX_BYTES(10MiB), והמראה טוענת blob שמתחת לתקרה כולו לזיכרון לפני הפענוח והחיתוך (blob שמעליה נדחה מ-git cat-file -sבלי להיקרא — ראו #3433 למטה). נמדד מחדש ב-2026-09-27, עם האיפוס ועל 40 סידורי זיכרון (בסוגריים — החסם העליון), ברתמה שאינה בריפו, כי הסקריפט מודד רק את הפרסור: outline של קובץ RST בגודל 10MB מחזיק לאורך המסלול כולו 80.8MiB (81.2) בשכפול ריאליסטי ו-91.6MiB (92.0) עם כותרות עוינות. הפרסור לבדו הוא 61.2MiB (שורתoutline_densest_realשל הסקריפט), והשאר הוא הטקסט של 10MB והקריאה שהביאה אותו; השיטה שלפני האיפוס שמה את הפרסור לבדו על 51MiB, כי חוצץ הקריאה של 10MB הסתיר את ההפרש. קריאתlines=שלwebapp/static/js/md_preview.bundle.jsהאמיתי (7MB) מחזיקה 29.6MiB (29.9). כלומר ההחזקה הגדולה ביותר מגיעה עד פי 2.6 מ-35.2MiB שהמחלק מקצה לחוט. המחלק נשאר מתומחר לפי הפרסור בכוונה: הפרסור הוא מה שהכלי הציבורי יכול לעלות בכל רגע; קריאות הטווח הן אדמין בלבד; ההחזקה הגדולה ביותר על קובץ שקיים היום במראות — קריאת הבאנדל — קטנה מההקצאה של חוט אחד, ולכן נכנסת גם ברוחב מלא; וה-81–92MiB דורשים קובץ RST של 10MB, שאין באף ריפו משוקף (הגדול ביותר כאן,docs/mcp-server.rst, קטן מ-250KB ב-2026-09-27). תמחור לפי הצורה הזו היה מצמצם את המסלול הציבורי ל-3 חוטים (לפי 92MiB) בגלל צורה שאין לה מקור. מה שכן השתנה בגלל זה הוא התקרה (למטה); והשורש — לחסום את הקריאה במקור — נסגר ב-#3433:get_file_at_commitשואל את מאגר האובייקטים לגודל (git cat-file -s <sha>:<path>) לפניgit show, ולכןmax_sizeהוא חסם על מה שנקרא ולא בדיקה בדיעבד. נמדד מחדש (אותו יום, אותה שיטה): קובץ של 12MB שנדחה עלה 22MiB שיא לפני, ואפס אחרי; קובץ של 7MB שמתחת לתקרה עולה 14MiB לפני ואחרי, כי אותו כן קוראים.רצפה (
_READ_POOL_FLOOR, 2): כדי שקריאת מונגו תקועה אחת לא תשבית את השאר. שניים הוא הגודל הקטן ביותר עם התכונה הזאת, וכל יחידה מעליו היא זיכרון שהחשבון אמר שאין. תקרה (_READ_POOL_CAP, 12): הרוחב שהייצור כבר הריץ לפני #3391. מעליה עוד חוטים הם רק עוד פרסורים מקבילים תחת ה-GIL, ומעבר לתוכנית גדולה לא ירחיב את המאגר — על בסיס מחלק שמתאר את הפרסור ולא את קריאות הטווח — בלי שאיש החליט. הגרסה הראשונה של ה-PR נקבה ב-32, התקרה ש-CPython בחר ל-executor ברירת המחדל שלו; הסקירה של #3429 הורידה אותה.כשאין מגבלה שאפשר לקרוא — הקובץ חסר, אינו ניתן לפענוח, או אומר ”ללא מגבלה“ — הגודל הוא הרצפה, ולא
cpu_count + 4, המספר שהגזירה הזאת באה להפסיק להישען עליו. הקצה השמרני נבחר בכוונה: מאגר צר מדי הוא איטיות גלויה, ושורת הקיבולת אומרת למה; מאגר רחב מדי הוא OOM שאיש לא מייחס. ה-fallback נרשם גם כ-WARNING נפרד, כי מסלול גרוע שאיש לא מדווח עליו הוא זה שנשאר.
על תוכנית הייצור (0.5c-512mb): (512MiB − 92MiB − 64MiB) ÷ 35.2MiB =
10 חוטים, במקום 12. ``_read_pool_size`` היא הפונקציה הטהורה שעושה את
החשבון, וכל אחד מהתנאים — הגזירה, הרצפה, התקרה וה-fallback — מעוגן בטסט
למספר שחושב ביד, כך שמימוש שמחזיר קבוע נופל.
איפה ההתקנה קורית, ולמה שם. ``loop.set_default_executor`` דורש לולאה
רצה ומקבל רק ThreadPoolExecutor, ו-build_app רץ בזמן ייבוא, לפני
שיש ל-uvicorn לולאה. ``FastMCP.streamable_http_app()`` בונה את אפליקציית
Starlette עם lifespan= משלה, ולכן on_startup אינו רץ כלל — עטיפת
ה-lifespan היא ה-seam היחיד, אותו seam שניקוז ה-PostHog כבר משתמש בו.
המאגר נבנה שלם ומתפרסם בהצבה אחת. הוא אינו נסגר ביציאה מה-lifespan:
ה-executor ברירת המחדל שייך ללולאה, ו-asyncio.run של uvicorn סוגר אותו
ב-shutdown_default_executor עם המתנה — קריאה שבאמצע מסתיימת בדיפלוי,
כמו כתיבה. ומכיוון שהשירות רץ כתהליך אחד (uvicorn בלי --workers;
בלוגים Started server process פעם אחת לכל מופע), המאגר הזה הוא כל
מקביליות הקריאה של השירות.
הכתיבות אינן נועלות מנעול על המאגר המשותף, וזה נמדד ולא הוכרע בטעם: כותב שחסום על מנעול עדיין תופס חוט שהקוראים צריכים, ומנעול גם אינו מבטיח סדר. עם מאגר נפרד, קורא שנשלח בזמן ש-40 כתיבות ממתינות חוזר מיד; קודם הוא חיכה שניות.
contextvars מועתקים במפורש בדרך לכתיבה. asyncio.to_thread מעתיק את
ההקשר בעצמו, ו-loop.run_in_executor אינו מעתיק — ובלי ההעתקה
get_access_token() מחזיר None בתוך העובד, כלומר require_write ו-
require_admin מפסיקים לראות מי שואל.
המחיר, במפורש: תור אחד פירושו שכתיבות של משתמשים שונים ממתינות זו לזו. קריאות לעולם אינן ממתינות לכתיבה. כל כתיבה רושמת שורה שמפרידה בין זמן ההמתנה בתור לזמן הריצה, כי סכום אחד אינו מבדיל בין שמירה איטית לשמירה שחיכתה; מעל 10 שניות המתנה — יותר מגוף כתיבה שלם לפי ה-p95 — היא נרשמת כאזהרה.
הרמה נבחרה במודע: info ולא debug. הרמה שבתוקף בשירות היא INFO,
כך ש-debug אינו שקט אלא נעדר — וזו בדיוק התקלה שהשורות האלה נולדו
ממנה. הנפח אינו יכול להתפרץ: worker יחיד מריץ כתיבה אחת בכל רגע וגוף כתיבה
נמדד ב-4.3–4.9 שניות ב-p50, כלומר השורה חסומה בסביבות שלוש-עשרה לדקה תהיה
התנועה אשר תהיה. ורישום של המקרה האיטי בלבד היה אומר מתי ההמתנה חריגה בלי
לומר לעולם מהי ההמתנה הרגילה — המספר שכל החלטה על רוחב התור נשענת עליו.
ומה שהופך את השורות לנראות בכלל: תהליך ה-MCP לא הגדיר לוגים בעצמו, ומה
שכן הגדיר אותם הוא FastMCP.__init__ שמסתיים ב-configure_logging. לכן
שורה שנרשמה לפני בניית שרת ה-MCP נזרקה במקום. שורת הקיבולת נרשמת מתוך
ה-lifespan, מיד אחרי שמאגר הקריאות הותקן — מאוחר עוד יותר מ-build_mcp
— ומהמאגר שהותקן בפועל: היא קוראת את _max_workers שלו ואינה מחשבת
מספר בנפרד, כי רשומה שמתארת מצב ומתעדכנת בנפרד ממנו היא בדיוק מה שמשקר
בשקט. ``mcp_server/app.py`` מגדיר לוגים כבר בייבוא (``LOG_LEVEL``, ברירת
מחדל INFO) כדי שגם רשומות מוקדמות יותר ייראו.
ביטול, כפי שנמדד: בקשת כתיבה שבוטלה בזמן שהיא עדיין בתור אינה רצה כלל. בקשה שבוטלה אחרי שהגוף התחיל רצה עד הסוף, כי גוף סינכרוני אינו ניתן להפסקה — הקורא מפסיק להמתין, הכתיבה קורית. לקוח שעבר timeout וניסה שוב מייצר אפוא כתיבה שנייה, לא החלפה של הראשונה.
כלי כתיבה אינו יכול להיות async def. מאגר בעל worker יחיד מסדר
בטור גופים סינכרוניים; גוף אסינכרוני היה מחזיר לעובד קורוטינה ורץ על הלולאה
בלי תור ובלי סדר, בזמן שהכלי עדיין מוצהר ככתיבה. הרישום נכשל במפורש במקרה
הזה, כי זו הנקודה היחידה שבה אפשר לראות את זה.
הכלים
כלי משתמש (לכל משתמש מחובר):
כלי |
תיאור |
|---|---|
|
רשימת קבצים (מטא-דאטה, עם עימוד) |
|
חיפוש טקסט מלא ( |
|
תוכן מלא של קובץ (לפי שם/מזהה, אופציונלית גרסה). |
|
כתיבה: יצירת קובץ חדש. שם שכבר תפוס נדחה ב- |
|
כתיבה: מצא-והחלף מדויק בקובץ קיים ( |
|
כתיבה: הוספת טקסט לסוף קובץ קיים (גרסה חדשה). דורש |
|
כתיבה: החלפת ה- |
|
היסטוריית גרסאות של קובץ. |
|
פתקים דביקים של קובץ (לפי |
|
פתק בודד לפי |
|
כתיבה: יצירת פתק דביק על קובץ קיים; |
|
כתיבה: עדכון חלקי של פתק לפי |
|
כתיבה: מצא-והחלף מדויק בתוך פתק ( |
|
הגרסאות הקודמות של פתק (מטא-דאטה בלבד: מספר, זמן, ואורך בתווים —
לא בבתים כמו |
|
תוכן של גרסה קודמת אחת. הגוף הנוכחי הוא |
|
הלוחות של המשתמש — משטחים שנושאים פתקים שאינם שייכים לשום קובץ. מייצר את לוח ברירת המחדל בקריאה הראשונה |
|
הפתקים שעל לוח יחיד (לפי |
|
כתיבה: פתק חדש על לוח — בלי קובץ. |
|
חיפוש פתק בשלושת המקומות שפתק יכול לשבת בהם — קובץ, לוח, או קובץ
בריפו משוקף. לפי שם כברירת מחדל; |
|
האוספים והקבצים שבתוכם |
|
כתיבה: שיוך קובץ שמור קיים לאוסף קיים. |
|
סקשן בודד מקובץ תיעוד — RST או Markdown, לפי הריפו — במקום קובץ שלם.
בלי |
הערה
שני החיפושים אינם עובדים אותו דבר, ולא במקרה.
codekeeper_search_code הוא $text של מונגו מול אינדקס טקסט
(search_text_idx ב-code_snippets) שמכסה file_name,
description, tags ו-code. מונגו מפרק שם למילים ומבצע
stemming, ולכן ההתאמה היא למילים שלמות ולא לתת-מחרוזות: חיפוש
auth לא יתפוס authenticate.
לאיתור מחרוזת חלקית בקובץ שמור — codekeeper_get_file עם query.
זו סריקת מחרוזת על הקובץ עצמו — בלי stemming, בלי הרשאת אדמין, ובלי
למשוך את התוכן: מה שחוזר הוא המופעים. ראו חיפוש בתוך קובץ שמור (query).
ההפרדה בין השניים היא בשאלה שהם עונים עליה: codekeeper_search_code
עונה על איזה קובץ, ו-query עונה על איפה בתוך הקובץ, ולכן
הצירוף הרגיל הוא קודם האחד ואז השני.
בריפו משוקף המקבילה היא codekeeper_search_repo, והוא כלי אדמין.
codekeeper_search_notes הוא רג’קס לא-מעוגן, ולכן כן תופס חלק
ממילה. כברירת מחדל הוא נשען על user_title_idx ושואל על title
בלבד.
search_content=true מוסיף פרדיקט על גוף הפתק. הדגל קיים בגלל פתק
בלי שם: רוב הפתקים בפועל נכתבים בלי כותרת, ולכן היו בלתי-נראים
לחיפוש לחלוטין — לא ”קשים למציאה“, אלא בלתי-ניתנים למציאה. הוא כבוי
כברירת מחדל כי אין ולא יהיה עליו אינדקס: חיפוש בגוף היה מחייב אינדקס
טקסט שני על sticky_notes, ומונגו מתיר אינדקס טקסט אחד לכל אוסף
— החלטה חד-כיוונית שלא נשרפה כאן. מה שכן מגן: user_id נשאר פרדיקט
ראשון, ולכן הסריקה חסומה למכסת המשתמש (1000 פתקים) ואינה COLLSCAN.
הפגיעה עדיין לא נושאת תוכן. הפרויקציה מנתקת את עלות ההעברה מגודל
הפתק (עד MAX_NOTE_CHARS תווים), ולכן גם פגיעת-תוכן מחזירה ”איפה“
ולא ”מה“; את הפתק קוראים ב-codekeeper_get_note לפי המזהה שבפגיעה —
לא בכלי הרשימה, שמחזיר את המשטח כולו כדי להגיע לפתק אחד (ראו
קריאת פתק בודד — codekeeper_get_note). הסינון עצמו כן קורא את הגוף בשרת — זו העלות
שהדגל קונה.
מלכודת בנתונים היסטוריים: פתקים ישנים נשמרו עם ישויות HTML
(" במקום "); הניקוי רץ רק על כתיבה חדשה. חיפוש תוכן על תו
כזה יפספס אותם. זו מגבלת נתונים, לא של השאילתה. ובקריאה: כלי הרשימה
מפענחים את הישויות, ואילו codekeeper_get_note ו-
codekeeper_get_note_version מחזירים את הגוף בצורה המאוחסנת — למה,
ב-קריאת פתק בודד — codekeeper_get_note.
כלי אדמין (דפדפן הריפו — מוסתרים וחסומים לכל משתמש אחר):
כלי |
תיאור |
|---|---|
|
הריפואים המשוקפים (מטא-דאטה) |
|
נתיבי קבצים בריפו (עימוד, סינון תיקייה/ref; בלי תוכן).
|
|
תוכן קובץ בודד (עד 500KB לקובץ מלא, עד 10MB עם |
|
חיפוש טקסט בריפו (קטעים קצרים עם path+line, עם תקרות).
|
|
כמה סעיפי תיעוד וקבצים מהמראות בקריאת כלי אחת, במקום קריאה לכל אחד — למשל כל הדפוסים שסבב ריוויו חייב לקרוא. כל פריט עובר דרך ה-handler של הכלי שהוא משקף ( |
|
פתקים דביקים על קובץ בריפו משוקף ( |
|
מפת גילוי: אילו קבצים בריפו נושאים פתקים, וכמה על כל אחד.
|
|
כתיבה: פתק חדש על קובץ בריפו משוקף. |
הערה
דפדפן הריפו הוא קריאה בלבד לצמיתות (החלטת עיצוב) — אין, ולא תתווסף,
עריכה/commit/push לריפו GitHub. ה-mirrors בדיסק הם רפליקה
חד-כיוונית מ-GitHub (מקור האמת), וה-MCP לעולם לא כותב אליהם.
codekeeper_create_repo_note אינו יוצא מן הכלל הזה. הפתק נכתב
לאוסף sticky_notes שב-CodeKeeper, והוא הערה על הקובץ ולא שינוי
בו — לא ב-mirror ולא בריפו ב-GitHub. כתוב כאן כדי שאיש לא יסיק אחרת
משם הכלי.
הקבועים, התקרות ואוצר המילים של התשובה
המספרים כאן חיים בקוד ונאכפים בו. ההפניה היא לשם המודול ולשם הקבוע ולא למספר שורה, כדי שהטבלה לא תצביע למקום אחר אחרי הריפקטור הבא.
הקבוע |
הערך |
מה הוא מגביל |
|---|---|---|
|
512,000 |
קריאה מלאה של קובץ. יושב ב- |
|
10,485,760 |
קריאת טווח ואאוטליין, ב- |
|
256,000 |
תקציב הבתים של תשובה אחת |
|
1,048,576 |
גוף בקשה אחת במתודות שנושאות גוף, ב- |
|
45 |
קריאות כלים לזהות אחת בדקה, ב- |
|
8,000 |
שורות בקובץ Markdown, ב- |
|
45,000 |
טוקני בלוק בפרסור Markdown אחד, ב- |
|
0.66 |
לא תקרה אלא מדידה: זמן המעבד של הפרסור הגרוע שנמצא תחת |
|
200 · 1000 |
עמוד בעץ הקבצים |
|
100 · 500 |
סימבולים בעמוד באאוטליין. קבועים משלו ולא מיחזור של |
|
50 · 200 |
ריפואים ב- |
|
50 · 100 |
פגיעות ב- |
|
10 |
שורות ההקשר סביב פגיעה בחיפוש |
|
100,000 |
התקרה שעד אליה |
|
200,000 |
קבצים תואמים שמעבר הספירה קורא, ב- |
|
50 · 100 · 10 · 256,000 |
חיפוש בתוך קובץ שמור ( |
|
500 |
טקסט של רשומה אחת בתשובת |
|
50,000 |
סימבולים לקובץ באאוטליין. יושב ב- |
|
20 |
פריטים בקריאה אחת של |
|
10 |
שניות קיר לבאץ«, מהכניסה ל- |
|
256,000 |
|
כל קבוע שהטבלה אינה נוקבת במודול שלו יושב ב-mcp_server/repo_handlers.py,
ליד ה-handler שאוכף אותו.
אזהרה
ערך מעל התקרה נצמד בשקט, ואינו נדחה. per_page=1000 באאוטליין
מקבל 500, ואין בתשובה שדה שמצהיר שזה קרה — מה שכן יש הוא per_page
שחוזר בתשובה, והוא הערך שנאכף, ולכן זה מה שכדאי להשוות אליו ולא
למה שנשלח. per_page=0 נצמד ל-1 ולא לברירת המחדל.
ומה שאינו מספר שלם אינו מגיע להצמדה בכלל — הוא נדחה בסכימה. נמדד
מול הסכימה של הכלי: ערך עם שבר (2.5), null, ומחרוזת שאינה
מספר נדחים לפני שהכלי רץ; מחרוזת מספרית ("250") ומספר עשרוני
שלם (2.0) עוברים ומומרים. ובוליאני עובר ונעשה 1, בלי שגיאה:
per_page=true מחזיר עמוד של רשומה אחת. זו אותה המרה ש-lines
חוסם ב-strict — ראו את ההערה שם — וכאן היא אינה חסומה.
ההצמדה היא מדיניות מוצהרת (_clamp ב-mcp_server/handlers.py)
ולא מקריות: דחייה על כל ערך מחוץ לטווח הייתה מכשילה קריאה שלמה בגלל
פרמטר עימוד. התקרה על הגודל דווקא כן דוחה, וההבדל מכוון — עמוד
שאינו נכנס בתקציב הבתים חוזר כ-page_too_large ולא נחתך בשקט. וכך גם מספר הפריטים בבאץ«: codekeeper_read_batch עם יותר מ-MAX_BATCH_ITEMS פריטים נדחה ב-too_many_items ולא נחתך, כי באץ« שנחתך בשקט היה מחזיר פחות ממה שביקשו, בלי לומר אילו פריטים חסרים.
אוצר המילים של התשובה, ושתי המשפחות אינן מתערבבות. status מתאר
הצלחה, error מתאר סירוב, וב**תשובת כלי** error מגיע תמיד עם
"ok": false. תשובת HTTP של השרת עצמו אינה תשובת כלי ואינה נושאת את
השדה — 401 על טוקן פסול, למשל, הוא {"error": "invalid_token"}
לבדו, עם הכותרת WWW-Authenticate. ל-status יש בתשובת
כלי רשימה סגורה: ok, outline, query, binary, too_large
ו-no_outline. ל-error אין רשימה כזאת כאן בכוונה — הערכים
נגזרים מהכלי שסירב (path_denied, outline_and_lines,
query_and_lines, page_too_large, range_out_of_bounds ועוד
רבים), ורשימה חלקית שנראית סגורה גרועה מאין רשימה. ערך status חדש
שיתווסף לקוד מתווסף גם כאן.
``truncation_reason`` אינו אף אחד מהשניים. הוא אינו מתאר הצלחה ואינו
מתאר סירוב, אלא למה תשובה מוצלחת אינה שלמה — ולכן הוא מופיע רק כאשר
truncated הוא true, ולעולם לא כ-null בתשובה תקינה. הערכים:
max_results (התוצאות נחתכו בתקרה שביקשו), byte_budget (העמוד לא
נכנס בתקציב הבתים), count_ceiling ו-count_output_limit (הספירה
נעצרה בתקרה), count_failed (הספירה לא הסתיימה — קוד יציאה, או
error: ב-stderr גם כשהקוד תקין), matches_per_file (התקרה של
ההתאמות לכל קובץ), policy_filtered (שורה הוסרה במדיניות הסודות),
search_failed (המנוע נפל אחרי שכבר אסף תוצאות — הן מוגשות, ואינן
שלמות), timeout, output_limit ו-process_killed. כשהספירה היא זו שנקטעה, הסיבה שלה גוברת — כי
היעדר total הוא מה שדורש הסבר, ואילו חיתוך התוצאות כבר משתמע
מ-count שקטן מ-total_at_least.
``unread_reason`` אינו ``truncation_reason``, ובכוונה. תשובה של codekeeper_read_batch אינה חתוכה: כל פריט בה שלם, ומה שחסר בה הוא פריטים שלמים שלא נכנסו או שלא הגיעו אליהם בזמן. לכן אין בה truncated, והסיבה נקראת בשם אחר — unread_reason, שמופיע רק לצד unread (האינדקסים שלא נקראו) ולעולם לא בלעדיו. שני הערכים שלו לקוחים מאותו אוצר מילים: byte_budget ו-timeout. ראו קריאה קבוצתית — codekeeper_read_batch.
הערה
codekeeper_get_file הוא היחיד שנושא status רק לפעמים, וזה
מכוון: הוא מעולם לא נשא את השדה, ולכן קריאה רגילה וקריאת טווח מחזירות
היום בדיוק את מה שהחזירו קודם. status: "query" מופיע אך ורק כשהועבר
query, ומסמן שהתשובה נושאת מופעים ולא תוכן.
אימות והרשאות
שני מסלולים, מאוחדים באותו שרת:
OAuth 2.1 — עבור Claude.ai (Custom Connector). זרימה מלאה: רישום לקוח דינמי (DCR) + PKCE + מסך אישור. הזהות נקבעת דרך התחברות הטלגרם בוובאפ.
טוקן אישי (PAT) — עבור Claude Code / Desktop. מונפק מהבוט בפקודת
/connect_claude(או/connect_claude writeלטוקן עם הרשאת כתיבה), נשמר כ-hash בלבד וניתן לביטול.
שכבות ההרשאה:
read— ברירת המחדל לכל חיבור.write— נדרש לכל כלי שמשנה נתונים; ניתן רק באישור מפורש (מסך ההרשאה ב-Claude.ai או טוקןwriteמהבוט).הערה
מה נחשב ”כלי כתיבה“ נקבע במטא-דאטה של הכלי, לא ברשימת שמות. ``_declares_write`` ב-
mcp_server/server.pyקורא אתreadOnlyHintשהכלי כבר מצהיר עליו, ו-require_writeנקרא בשורה הראשונה של גוף הכלי. רשימת שמות הייתה מקום שני לסנכרן, והכלי שנוסף בשבוע הבא הוא בדיוק זה שהיה נשמט ממנה — בלי שגיאה ובלי בדיקה שנופלת. הטבלה שלמעלה מסמנת את כלי הכתיבה ב-כתיבה:, והיא תיעוד של המצב ולא המקור שלו.אדמין — כלי הריפו זמינים רק למשתמשים שב-
ADMIN_USER_IDS; לכל אחד אחר הם גם לא מופיעים ברשימת הכלים וגם נחסמים בקריאה ישירה (fail-closed). זה חל גם על שלושת כלי פתקי הריפו, אף שהם אינם נוגעים ב-mirror.codekeeper_create_repo_noteהוא הכלי היחיד שעובר שני שערים, ובסדר הזה: אדמין ואז כתיבה. בסדר ההפוך משתמש רגיל היה מקבל ”צריך הרשאת כתיבה“ — רמז שטוקן אחר יפתח לו את הכלי, וזה אינו נכון.codekeeper_search_notesאינו כלי אדמין, אף שהוא מוצא גם פתקי ריפו: הוא מסונן ל-user_idשל הקורא ולכן יכול להחזיר רק פתקים שלו, ומשתמש רגיל אינו יכול ליצור פתק ריפו מלכתחילה.codekeeper_get_noteגם הוא אינו כלי אדמין — אבל פתק ריפו חסום בו לאדמין בדיוק כמו ב-codekeeper_list_repo_notes, רק שהשער נסגר בשקט: מי שאינו אדמין מקבלnot_found, אותה תשובה כמו לפתק של מישהו אחר ולמזהה שאינו קיים, כך שהסירוב אינו מגלה שהמזהה קיים (require_adminהיה זורקadmin_onlyומגלה). הבדיקה נעשית לפי סוג הפתק, לפני שתוכן כלשהו חוזר. ראו קריאת פתק בודד — codekeeper_get_note.
גבולות הבקשה — גודל הגוף והקצב
עד #3431 המידלוור היחיד בשרת היה האימות: כל משתמש מאומת יכול היה לשלוח בקשות
בכל גודל ובכל תדירות. תקרת האורך ל-path של codekeeper_docs_get_section
(#3428) חסמה הגברה בבקשה בודדת, ולא נגעה לא במספר הבקשות ולא בשאר הכלים.
היום יש שני גבולות, כל אחד בשכבה שמתאימה לו, ושניהם חיים ב-mcp_server/limits.py.
גודל גוף הבקשה — במידלוור ASGI, לפני שהטרנספורט מפענח JSON. הטרנספורט של
ה-SDK קורא את הגוף כולו ומפענח אותו לפני שאף כלי רואה ארגומנט, ולכן התקרה
הזאת היא הגבול היחיד על כל ארגומנט של כל כלי, קיים ועתידי. התקרה
נגזרת ולא מוקלדת: הארגומנט הלגיטימי הגדול ביותר הוא 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 — הוא הגיע לפני שהגוף נגמר, ואין
טעם להמשיך לקרוא אותו:
{"error": "body_too_large", "max_bytes": 1048576, "content_length": 20971520}
{"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, והמקום היחיד
שרואה את הזהות בשני המצבים הוא קריאת הכלי עצמה. לכן המגביל יושב ב-call_tool
של AdminAwareFastMCP — המתודה שה-SDK רושם כמטפל של tools/call, כלומר
נקודה אחת שכל קריאת כלי עוברת בה — ומכריע לפני שגוף סינכרוני נמסר לחוט
מהמאגר ולפני שגוף אסינכרוני רץ על הלולאה: קריאה שנדחתה אינה עולה עובד, ומה
שאינו קריאת כלי (initialize, ``tools/list``) אינו נספר. הוא בנוי מעל
rate_limiter.RateLimiter — אותו חלון מתגלגל של דקה שהבוט משתמש בו, בזיכרון
ובלי Redis, וזה מספיק כי השירות רץ במופע אחד — הגדרה של השירות ב-Render
(numInstances: 1), מחוץ לריפו; מי שמוסיף מופע מאבד את הזיכרון המשותף, וזה
המקום להתחיל ממנו. ברירת המחדל היא DEFAULT_RATE_LIMIT_PER_MINUTE קריאות
בדקה לזהות, ומאיפה המספר: הטענה שהוא שומר עליה היא שזהות אחת אינה עוברת דקת
מעבד אחת בדקה, גם כשכל הקריאות שלה הן הקלט היקר ביותר שנמצא. המכסה היא 0.5
מעבד, כלומר 30 שניות-מעבד בדקה, והקלט היקר ביותר הוא פרסור Markdown תחת
התקרות של md_parser — WORST_CASE_CPU_SECONDS ב-services/md_parser.py,
שם גם הקלט, המדידה והתאריך. החשבון: 45 × 0.66 = 29.7 שניות-מעבד בדקה, מתוך 30.
``tests/test_md_parse_worst_case_claims.py`` גוזר אותו מהקבועים ונופל כשהוא נשבר,
והשורה הזאת נבדקת שם מול אותם קבועים. עד #3391 המגבלה הייתה 60, והיא נגזרה
מהמסמך הצפוף האמיתי (0.47 שניות מעבד; שישים קריאות שלו הן 28 שניות) — כי
הקלט העוין של אז לא היה חסום בכלל, ושישים קריאות שלו היו פי כמה מהמכסה. עם
התקרות הוא חסום, ו-60 היה משאיר אותו מעל המכסה: טענה שצריך להסביר במקום
לבדוק. עמוד RST של 500KB עולה 0.24 שניות עם תקרת הסקשנים (נמדד ב-#3431),
וקריאה רגילה (10–50ms) הופכת את המגבלה לאחוזים בודדים מהמכסה. הסירוב הוא
תשובת כלי רגילה, עם הזמן שנותר עד שהחלון משתחרר:
{"ok": false, "error": "rate_limited", "limit_per_minute": 45, "retry_after_seconds": 17}
באץ« נשקל כמספר הפריטים שלו. codekeeper_read_batch עם N פריטים עולה N קריאות מהמכסה, כי כל פריט הוא קריאה ופרסור בדיוק כמו קריאה בודדת — אחרת באץ« היה דרך לעקוף את המכסה פי MAX_BATCH_ITEMS. שלושה דברים קורים ב-call_tool לפני שדבר נקרא. רגע הכניסה נרשם, והדדליין של הבאץ« נמדד ממנו. רשימה ריקה ורשימה מעל התקרה נדחות לפני השקילה (missing_items, too_many_items), כך שבקשה שלעולם לא תעבור שומעת את הסיבה שלה, ולא rate_limited עם זמן המתנה שלא יעזור. זה נעשה רק לאדמין: מי שאינו אדמין נשקל כקריאה אחת ומסורב בגוף הכלי ב-admin_only, בלי ללמוד את התקרה של כלי שהוא אינו רואה. והשקילה היא הכל או כלום: באץ« שאינו נכנס כולו במכסה נדחה כולו, לפני שפריט אחד נקרא, ו-retry_after_seconds הוא הזמן עד שכל המשקל נכנס. check_rate_limit ו-seconds_until_allowed של RateLimiter מקבלים weight, וברירת המחדל 1 משאירה את הבוט בדיוק כמו שהיה. התקרה של הכלי, MAX_BATCH_ITEMS, קטנה מ-DEFAULT_RATE_LIMIT_PER_MINUTE כדי שבאץ« מלא יוכל לעבור בכלל, וכשהמכסה בפריסה נמוכה ממנה — התקרה יורדת איתה (item_cap). פריטים שלא נקראו אינם מוחזרים למכסה; הנימוק ב-קריאה קבוצתית — codekeeper_read_batch.
הפטור לנתיב הדופק — שני חצאים, ורק אחד מהם מבני. מהמגביל /healthz פטור
במבנה: הוא אינו קריאת כלי ולכן לעולם אינו מגיע למגביל. זו המלכודת שמתוארת
ב-blanket-policy-silent-block §7 — מגבלה גורפת שמחזירה 429 למוניטור
החיצוני ומכריזה על שירות בריא כמנותק — והיא נסגרת כאן על ידי המבנה. מתקרת
הגוף הוא פטור בכלל, לא במבנה: 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 שניות) אינו ניתן לכיוון מהסביבה: אין לו
קורא שמצדיק זאת.
עדכון תיאור בלי גרסה חדשה
codekeeper_update_file_description מחליף את ה-description של קובץ
קיים, ולא נוגע בשום דבר אחר. הוא נועד למקרה שבו התיאור השמור התיישן ביחס
לתוכן — הקובץ עצמו עודכן, והשורה שמתארת אותו בחיפוש וברשימות נשארה מלפני
כמה עריכות.
עד שהוא נוסף לא הייתה לכך דרך דרך ה-MCP: codekeeper_save_file מקבל
description אבל מסרב לקובץ שכבר קיים, ו-codekeeper_edit_file
ו-codekeeper_append_file מעתיקים את התיאור הקיים לגרסה החדשה ואין
להם פרמטר לשנות אותו. הדרך היחידה לרענן תיאור הייתה מהדפדפן.
זהו אותו מסלול בדיוק של ”עדכון תיאור מהיר“ שכבר קיים בעמוד הקובץ
בוובאפ: שני הערוצים קוראים ל-update_file_metadata_in
ב-database/repository.py, ואין שני מימושים של אותה כתיבה.
חשוב
לא נוצרת גרסה, ולכן התיאור הקודם אינו נשמר בשום מקום.
השינוי הוא כתיבה אחת על מסמך הגרסה האחרונה. שלוש נגזרות שחשוב להכיר:
codekeeper_list_versionsלא יציג את השינוי, ואין ממה לשחזר את הערך הקודם. התשובה של הכלי מחזירה אותו (previous_description) — וזה המקום היחיד שבו הוא עוד קיים.רק הגרסה האחרונה מתעדכנת. גרסאות קודמות נשארות עם התיאור הישן, וקריאה מפורשת של גרסה ישנה (
codekeeper_get_fileעםversion) תחזיר אותו. זה עקבי עם מה שמוצג: גם החיפוש וגם רשימות הקבצים מקבצות לגרסה האחרונה לכל שם קובץ וקוראות אתdescriptionממנה.התוכן ומספר הגרסה אינם זזים. מספר הגרסה מוחזר בתשובה במפורש כדי לומר זאת.
בניגוד לשלושת כלי הכתיבה שיוצרים גרסה, הכלי הזה אינו שולח התראת Web Push: ההתראה אומרת שסוכן שמר קובץ, וכאן לא נשמר קובץ. ראו עובדי Push.
תיאור ארוך מהמותר נדחה ואינו נחתך, עם קוד description_too_long
שנושא את המגבלה. הראוט בוובאפ דווקא חותך, וההבדל מכוון: שם אדם רואה את
הטקסט בתיבה לפני השליחה ואחריה, וכאן סוכן אינו רואה את התוצאה — חיתוך
שקט הוא טקסט שאבד בלי שיידע. מחרוזת ריקה היא בקשה תקפה, ומשמעותה ניקוי
התיאור.
הקריאה מסמנת את התיאור כנבדק. היא מאפסת את
description_age_versions (ראו גיל התיאור — description_age_versions) גם כששלחתם
בדיוק את הטקסט שכבר כתוב שם — ואז התשובה נושאת unchanged: true, כדי
שיהיה גלוי ששום דבר לא השתנה מלבד החותמת. זה הפוך מהכלל של עריכה, וזו
הכרעה: עריכה שמעתיקה תיאור אינה אומרת דבר על התוכן, וקריאה לכלי הזה
אומרת שמישהו השווה ביניהם. ניקוי התיאור מסיר את החותמת במקום לאפס
אותה — לקובץ בלי תיאור אין גיל.
גיל התיאור — description_age_versions
תיאור מתיישן בשקט. codekeeper_edit_file ו-codekeeper_append_file
מעבירים את התיאור הקיים לגרסה החדשה, וזו ברירת מחדל נכונה — עריכת שורת
קוד אינה מבטלת את מה שכתוב על הקובץ. מה שהיה חסר הוא הסימן: תיאור
שנכתב בגרסה 1 נראה בגרסה 11 בדיוק כמו תיאור שנכתב אתמול, ואי אפשר היה
לדעת שכדאי לבדוק אותו בלי לקרוא את כל הקובץ.
השדה עונה על זה: כמה גרסאות הקובץ זז מאז שהתיאור נקבע. הוא מוחזר
על ידי codekeeper_list_files, codekeeper_search_code
ו-codekeeper_get_file, ולצידו כבר יושב version — 3 בלי לדעת
שהקובץ בגרסה 4 אינו אומר דבר.
חשוב
זה רמז לבדוק, לא קביעה שהתיאור שגוי. עשר עריכות קטנות אינן הופכות תיאור לשגוי, ועריכה אחת גדולה כן יכולה. מה שהמספר אומר הוא ”התיאור הזה לא נגע בתוכן כבר כמה זמן“, ולא ”התיאור הזה לא נכון“.
ו-null אינו אפס. אפס הוא טענה — התיאור נקבע לגרסה הזו —
ו-null אומר שאיננו יודעים. שדה שנעדר לגמרי אומר משהו שלישי: אין
כאן מה לבדוק.
מה חוזר |
מתי |
|---|---|
מספר |
הפרש בין מספר הגרסה לגרסה שבה התיאור נקבע. |
|
לקובץ יש תיאור, ואין חותמת שאפשר למדוד ממנה — נשמר לפני שהשדה קיים, או שהמיגרציה לא הצליחה לשחזר את שרשרת הגרסאות שלו. |
השדה נעדר |
לקובץ אין תיאור, או שהוא כלל אינו ממוספר בגרסאות — קובץ גדול
( |
מתי החותמת זזה
החותמת עצמה (description_set_at_version) מאוחסנת על מסמך הגרסה,
ו**הגיל מחושב בזמן קריאה** ואינו נשמר: ערך שמור היה חייב להתעדכן בכל
שמירה, והשמירה שתשכח אותו הייתה מייצרת בדיוק את השקר שהשדה בא לחשוף.
גרסה חדשה עם תיאור ששונה מהקודם ← החותמת היא מספר הגרסה החדשה.
גרסה חדשה עם אותו תיאור ← החותמת עוברת כמות שהיא מהגרסה הקודמת. זה מה שגורם לגיל לספור עריכות במקום להתאפס בכל אחת מהן.
עדכון תיאור בלי גרסה (
codekeeper_update_file_descriptionוהעדכון המהיר בעמוד הקובץ) ← החותמת היא הגרסה הנוכחית, גם כשהטקסט זהה.גרסה קודמת בלי חותמת ← אין חותמת גם בחדשה. אי-ידיעה נשארת אי-ידיעה; ”השלמה“ בגרסה החדשה הייתה הופכת אותה לטענה הגרועה ביותר האפשרית, שהתיאור נכתב עכשיו.
הכלל יושב במקום אחד, file_description.py שבשורש, וכל מסלול
שכותב גרסה מריץ אותו — מסלול השמירה של הבוט ושל ה-MCP
(save_code_snippet) והראוטים בוובאפ שכותבים ישירות לאוסף. החותמת
נגזרת מהשוואה בין התיאור שנכתב לזה של הגרסה הקודמת ואינה מועתקת
ביד, כי העתקה ידנית הייתה נשמטת במסלול הראשון שמישהו יוסיף — והשדה היה
מדווח ”עדכני“ בדיוק על המקרה שהוא נועד לחשוף.
הערה
הגיל הוא הפרש של מספרי גרסה, לא מניין עריכות שקרו. מספור הגרסאות רץ על כל המסמכים של אותו שם קובץ כולל אלה שבסל המיחזור, ולכן גרסאות שנמחקו משאירות חורים ברצף והמספר יוצא גדול במקצת. לשימוש שלו — סדר גודל — זה לא משנה.
קבצים שנשמרו לפני שהשדה קיים
הם מחזירים null עד שמריצים מיגרציה חד-פעמית שמשחזרת את החותמת
מההיסטוריה:
python scripts/migrate_description_set_at_version.py --dry-run
python scripts/migrate_description_set_at_version.py
היא הולכת אחורה בשרשרת הגרסאות כל עוד התיאור זהה, וקובעת את החותמת לגרסה המוקדמת ביותר ברצף. כששרשרת הגרסאות קטועה — חסר מספר גרסה באמצע הדרך אחורה, מה שקורה כשגרסאות עברו לסל — היא משאירה את הקובץ בלי חותמת, כי התיאור בגרסה החסרה הוא בדיוק מה שהיה מכריע. הדוח מפריד בין ”קיבלו חותמת“, ”הושלמו“, ”כבר היו שלמים“, ”חותמת שונה מהשרשרת“, ”נשארו null“ ו“בלי תיאור“ — כל אחד מהם מצב אחר, ואיחוד שלהם היה הופך את הדוח לבלתי קריא.
היא אינה דורסת חותמת קיימת לעולם. אבל ”לא לדרוס“ אינו ”לא לגעת“, והשניים נבדלים בדיוק במקרה שבו הרצה חוזרת נחוצה: הכתיבה אינה אטומית בין מסמכים, ולכן הרצה שנקטעה משאירה חלק מהשרשרת כתוב וחלק לא.
החותמת הקיימת שווה למה שהשרשרת אומרת ← המסמכים החסרים מושלמים. בטוח בהגדרה, כי הערך שנכתב זהה לזה שכבר שם.
החותמת הקיימת שונה ← לא נוגעים.
codekeeper_update_file_descriptionמזיז את החותמת בלי ליצור גרסה, ומאותו רגע שרשרת הגרסאות כבר אינה מתארת אותה; חישוב מחדש היה מחזיר את הגיל אחורה ומוחק עדכון אמיתי.
קריאת פתק בודד — codekeeper_get_note
עד שהכלי הזה נוסף לא הייתה דרך לקרוא פתק אחד לפי מזהה.
codekeeper_search_notes מחזיר מזהה בלי תוכן, codekeeper_get_note_version
מחזיר תוכן רק לגרסאות קודמות, וכלי הרשימה מחזירים את המשטח כולו — לוח של
כמה עשרות פתקים חוזר בעשרות אלפי תווים כדי להגיע לפתק אחד, וזה יותר ממה
שלקוח מציג. סוכן בלי shell לא יכול היה לקרוא את הפתק בכלל, ו-
codekeeper_note_str_replace, שדורש את הגוף הנוכחי המדויק, חייב משיכה של
הלוח כולו לפני כל עריכה. התקציב של השרת לא הגן על המקרה הזה, ואינו נאכף
על רשימות פתקים כלל: OUTPUT_BYTE_BUDGET נמדד ונאכף בכלי הריפו (עץ,
אאוטליין וחיפוש), ו-query נאכף בקבוע משלו, QUERY_OUTPUT_BYTE_BUDGET;
על רשימות פתקים אף אחד מהם אינו נאכף — לוח שלם עבר מתחתיו, ומה שחסם היה
הלקוח. אכיפה שם היא שינוי נפרד, רשום ב-#3457 יחד עם שאר הפריטים שנדחו
מה-PR. גוף פתק אחד חסום ב-MAX_NOTE_CHARS תווים, ולכן
תשובה של codekeeper_get_note חסומה מראש.
מה חוזר. note — הפתק באותה צורה שכלי הרשימה מחזירים (תוכן, כותרת,
color ו-color_id, שורה, מועדים); version — מספר הגוף הנוכחי
(null לגוף ריק: ההיסטוריה אינה מצלמת גוף ריק, ולכן אין מספר שיחזיר
אותו, ולא ממציאים אחד); ואיפה הפתק יושב, במוסכמה של פגיעת חיפוש: target ואיתו בדיוק
הארגומנטים שכלי הרשימה המתאים דורש — file_name (ו-file_id),
board_id, או repo_name + repo_path. פתק קובץ ישן שנושא file_id
בלבד מקבל file_name באותה השלמה שהחיפוש עושה. פתק ריפו נושא גם
orphaned: true כשהנתיב כבר אינו בעץ המשוקף, באותו כלל של
codekeeper_list_repo_notes ומאותו קוד (repo_path_orphaned
ב-mcp_server/backend.py): הדגל נדלק רק על תשובה מפורשת מהמניפסט,
ושאילתה שנכשלה אינה מסמנת קובץ חי כמיותם.
זו אותה קריאה-לפי-מזהה של codekeeper_note_str_replace — get_note
ב-mcp_server/backend.py — ולא מסלול שני. הבעלות יושבת במסנן של
השאילתה, ולכן פתק של משתמש אחר הוא not_found, אותה תשובה כמו למזהה
שאינו קיים.
הגוף חוזר בדיוק כפי שהוא מאוחסן. פתקים ישנים נשמרו עם ישויות HTML
(" במקום "); כלי הרשימה מפענחים אותן בקריאה, ואילו
codekeeper_get_note — כמו codekeeper_get_note_version — אינו מפענח.
כך ”הגוף של גרסה N“ ו“הגוף הנוכחי“ ניתנים להשוואה בית-בית, וגודל הגוף
ברשימה (content_bytes, למטה) הוא בדיוק גודל מה שהכלי יחזיר. ובעריכה
שתי הצורות תופסות: codekeeper_note_str_replace מפענח גם את
old_string (_sanitize_note_text ב-mcp_server/handlers.py)
ומשווה במרחב אחד, ולכן old_string שהועתק מ-codekeeper_get_note וגם
כזה שהועתק מכלי רשימה מגיעים לאותה התאמה — יש על זה טסט שמריץ את שתי
הצורות על פתק עם ישות, ובודק את המצב במסד אחרי העריכה.
הגוף והגרסה מתארים את אותו רגע, וזה נאכף ולא מונח. הגרסה יושבת באוסף
אחר (sticky_note_versions) ולכן היא קריאה שנייה, ו-codekeeper_update_note
שרץ בין השתיים היה מצמיד לגוף הישן את המספר של הגוף החדש — ואז
codekeeper_get_note_version עם המספר ההוא היה מחזיר גוף אחר מזה שהתשובה
נשאה (נתפס בסקירת ה-PR). לכן הקריאה תחומה משני הצדדים: הפתק נקרא,
ההיסטוריה נקראת, והפתק נקרא שוב, והזוג מוחזר רק אם שום כתיבה לא נגעה בו
בין השתיים — אותה הוכחה משולשת (תוכן, updated_at, write_id) שמסלול
הכשל של update_note משתמש בה. פתק שזז נקרא שוב, עד
_CONSISTENT_READ_RETRIES פעמים; פתק שזז בכולן — מישהו כותב בו ברצף —
מחזיר conflict עם רמז, ולא זוג שאולי אינו תואם. ומה שהחלון הזה אינו
סוגר, סוגר המספור עצמו: הצילום נכתב לפני הדריסה, ולכן בין השניים
ההיסטוריה כבר מחזיקה את הגוף הנוכחי במספר N בעוד הפתק טרם זז — הגוף הזה
כבר ניתן לקריאה כ-N, וזה מה שחוזר, לא N+1 (_version_of_body
ב-mcp_server/backend.py). codekeeper_note_str_replace קורא בלי
הגרסה, ולכן נשאר קריאה אחת.
הלולאה שהכלי סוגר. השער האופטימי של codekeeper_note_str_replace
משווה את התוכן שנקרא בתוך הקריאה עצמה מול מה שבמסד (ראו ”היסטוריית
פתקים“); updated_at ו-write_id משמשים רק להוכחה אחרי כשל כתיבה,
ולא להשוואה. לכן מה שצריך כדי לנסות שוב אחרי conflict הוא הגוף הנוכחי —
לבנות ממנו old_string חדש — וזה מה ש-codekeeper_get_note מחזיר:
conflict ← codekeeper_get_note ← ניסיון חוזר.
ההרשאה נבדקת לפי סוג הפתק, לפני שתוכן כלשהו חוזר. פתק על קובץ ועל לוח
— של הקורא בלבד, כמו בשאר הכלים. פתק על קובץ בריפו משוקף חסום לאדמין, כמו
codekeeper_list_repo_notes. פתק של משתמש אחר ופתק ריפו למי שאינו אדמין
מחזירים not_found — לא סירוב שמגלה שהמזהה קיים. הכלי אינו ב-
_ADMIN_TOOLS: הוא גלוי לכולם, והשער בגוף. ורק True ממש פותח אותו —
ערך ”אמיתי“ שאינו בוליאני נשאר סגור, כמו ב-_declares_write.
``include_content`` ברשימות. codekeeper_list_notes ו-
codekeeper_list_board_notes מקבלים include_content: ברירת המחדל
true מחזירה בדיוק מה שהכלים החזירו קודם, ו-false מחזיר לכל פתק רק
את השדות של LEAN_NOTE_FIELDS (mcp_server/backend.py — הרשימה חיה שם
פעם אחת, ותיאורי הכלים נגזרים ממנה): id, title, color,
color_id, content_bytes ו-updated_at. content_bytes הוא גודל
הגוף המאוחסן ב**בתים** של UTF-8. היחידה נקובה בשם השדה כי בפתק עברי
ההפרש מול ספירת תווים הוא פי שניים, וזו היחידה שבה OUTPUT_BYTE_BUDGET
נמדד. והגוף אינו יוצא מהמסד בכלל: המסלול הרזה הוא צינור אגרגציה
(_lean_notes_pipeline ב-mcp_server/backend.py) שבו $strLenBytes
מודד את הגוף במסד ו-$project מוריד אותו לפני המיון — ולא find שמושך
עד NOTES_LIST_LIMIT גופים כדי לזרוק אותם, שזה מה שהגרסה הראשונה של
ה-PR עשתה וסקירה תפסה. גוף שאינו מחרוזת (אף כותב אינו שומר כזה) מדווח
null — ”לא ידוע“, לא ”ריק“ — ובזכות שמירת $type, ולא מפיל את
השאילתה כולה כפי ש-$strLenBytes על מספר היה עושה. כך הזרימה ללוח גדול
היא רשימה קטנה ואחריה codekeeper_get_note. המסלול המלא
(include_content=true) נשאר find בלי היטלה, כי הוא מחזיר את הגוף —
זו התנהגות כלי הרשימה מאז ומתמיד, והחריג היחיד לחוק ה-Smart Projection
כאן: הגוף חוזר כשביקשו אותו, ולא נמשך כשלא. codekeeper_list_repo_notes
נשאר בלי הפרמטר: קובץ בריפו חסום ל-MAX_NOTES_PER_REPO_FILE פתקים,
והפער הוא בלוחות.
קריאה טהורה. readOnlyHint: true ורץ במאגר הקריאות, בלי יצירה של
דבר — בשונה מ-codekeeper_list_boards, שמוצהר קריאה ובכל זאת יוצר לוח
ברירת מחדל; שם זו הכרעה מנומקת ולא תקדים. ובדשבורד המדידה הכלי אינו נושא
ck_read_mode כלל: התווית מתויגת רק לכלי קריאת קובץ
שב-_TOOL_READ_MODE_PARAMS, וכלי שאינו במפה אינו נספר כ-full — ראו
מצב הקריאה (ck_read_mode).
היסטוריית פתקים
codekeeper_update_note דורס את כל תוכן הפתק במה שנשלח. עד היום לא
היה ממה לשחזר: שורה אחת שגויה מוחקת פתק בן 100 שורות, וכלי שרץ בלי אישור
היה מוחק אותו בשקט.
לכן כל עדכון שמשנה את ה-content שומר את הגוף הקודם באוסף
sticky_note_versions — לפני הדריסה. עדכון עם תוכן זהה אינו מייצר
גרסה: כפילות הייתה דוחפת החוצה גרסה שעוד אפשר לשחזר, בתמורה לעותק של מה
שכבר יש. הקריאה היא read-modify-write, ולכן הסדר אינו הפיך:
codekeeper_note_str_replace נוסף אחרי ההיסטוריה ולא לפניה, שאם לא
כן היה עוד מסלול שדורס בלי רשת.
codekeeper_note_str_replace נושא גם שער אופטימי: הדריסה מותנית
בכך שהגוף במסד עדיין שווה למה שנקרא. שתי עריכות חופפות בלי השער היו
מדווחות שתיהן הצלחה בזמן שהאחרונה מוחקת את עריכת הראשונה; עם השער
המפסידה מקבלת conflict — קוראים את הפתק מחדש ב-codekeeper_get_note
ומנסים שוב (ראו קריאת פתק בודד — codekeeper_get_note).
עובדה |
פירוט |
|---|---|
אוסף נפרד |
ולא מערך מוטבע במסמך הפתק: שלוש פונקציות הרשימה קוראות בלי פרויקציה, ומערך היסטוריה היה נגרר לכל רשימת פתקים — הפרה של Smart Projection בשלושה מסלולים חמים |
20 גרסאות לפתק |
תקרת שימור. אחריה המקור נדחף החוצה, ולכן |
כשל צילום עוצר את העדכון |
|
דריסה שלא קרתה מוחקת את צילומה — רק בהוכחה |
צילום שנכתב ואז הדריסה שאחריו לא התרחשה (מחיקה מקבילה, קונפליקט)
נמחק בחזרה — גרסה שמתארת החלפה שלא הייתה היא עדות כוזבת. אבל חריגה
אינה הוכחה: כשל רשת יכול ליפול אחרי שהשרת כבר כתב, ואז מחיקת הצילום
היא בדיוק האובדן שהמנגנון מונע. ההוכחה היא קריאה חוזרת שמראה
ש**שום** שדה לא זז — תוכן, |
צילום דורש אינדקס מאומת |
האילוץ הייחודי הוא מה שהופך מספר גרסה כפול לשגיאה שנתפסת; צילום
בלעדיו היה מייצר היסטוריה שקרית בשקט. עד שהאינדקס מאומת, עדכוני
תוכן נדחים ב- |
שורדת מחיקת פתק |
הבעלות נשמרת במסמך הגרסה עצמו, ולכן ההיסטוריה נקראת גם אחרי שהפתק עצמו נעלם |
השחזור הוא codekeeper_get_note_version ואז codekeeper_update_note
עם התוכן שנקרא. אין כלי restore נפרד — הוא היה מסלול כתיבה רביעי לאותו שדה.
מי מחזיר את ההווה ומי את העבר. codekeeper_get_note מחזיר את הגוף
הנוכחי ואומר ב-version באיזה מספר codekeeper_get_note_version מחזיר
את הגוף הזה: מספר הגרסה החדשה ביותר אם היא כבר מחזיקה אותו (החלון שבין
הצילום לדריסה), ואחרת המספר שהדריסה הבאה דרך השרת הזה תיתן לו. החישוב
הוא _version_of_body ב-mcp_server/backend.py, והוא חולק עם הצילום
(_snapshot_note) את שני חלקי הבניין של המספר — _newest_snapshot
ו-_number_after — ולא עותק של החשבון. גוף ריק, שהצילום מדלג עליו, נושא
null ולא מספר. ההבטחה מותנית בכך שהדריסה הבאה עוברת דרך השרת הזה:
ההיסטוריה נכתבת רק במסלול ה-MCP (update_note ב-mcp_server/backend.py),
ועריכה מהוובאפ אינה מוסיפה לה ואינה מזיזה את המספר — פתק שנערך בוובאפ בין
הקריאה לדריסה הבאה יופיע במספר ההוא עם הגוף שהוובאפ השאיר, לא עם מה
שנקרא. 1 הוא פתק שמעולם לא נדרס דרך השרת הזה. הגיזום אינו מזיז את
המספר אף פעם, כי הגרסה החדשה ביותר אינה נגזמת לעולם.
התראה כשסוכן שומר קובץ
כל שמירת קובץ דרך ה-MCP — codekeeper_save_file, codekeeper_edit_file
ו-codekeeper_append_file — שולחת למשתמש התראת Web Push. זה נועד למקרה שבו
סוכן כותב בזמן שאיש אינו מסתכל: בלי ההתראה, הכתיבה מתגלה רק כשנכנסים.
המקור נקבע במיקום, לא בשדה. ה-hook יושב ב-ProductionBackend.save_file,
ומחלקה זו מיובאת רק בשירות ה-MCP. כתיבה מהוובאפ או מהבוט אינה עוברת שם ולכן
אינה יכולה לייצר התראה כזו, בלי שדה origin ובלי דגל מקור. שלושת הכלים
מתנקזים לאותה מתודה, ולכן ה-hook אחד.
codekeeper_update_file_description אינו שולח התראה, וזו החלטה ולא
השמטה: הוא אינו עובר ב-save_file כי הוא אינו שומר קובץ — לא נוצרת גרסה
והתוכן אינו זז. התראה שאומרת ”סוכן שמר קובץ“ על שינוי שהמשתמש לא יזהה
כשמירה היא רעש, ולא מידע.
חשוב
שירות ה-MCP אינו שולח את ההתראה. הוא רושם שורה באוסף push_events,
ושולח הפוש שחי בתהליך הוובאפ אוסף אותה בסבב הבא. המשמעות המעשית: יש עיכוב
של עד דקה, ובפריסה שבה רק שירות ה-MCP רץ ההתראות לא יישלחו כלל — הן
יירשמו, ואינדקס TTL ינקה אותן אחרי שבוע.
כיבוי:
MCP_PUSH_NOTIFICATIONS_ENABLED=falseעל שירות ה-MCP. הדגל נקרא בנקודת הרישום, ולכן כיבוי עוצר את הרישום עצמו ואינו מותיר תור שאיש אינו קורא.תנאי מוקדם: למשתמש צריך להיות מנוי Push פעיל בדפדפן (נרשמים ב-
/settings). בלעדיו האירועים נפסלים מיד ואינם נצברים.כמה התראות: אחת לכל שמירה. שתי שמירות רצופות לאותו קובץ מגיעות כהתראה אחת שמתעדכנת, כי ה-
tagמזהה את הקובץ ולא את הגרסה.
התיעוד המלא של המנגנון — האוסף, האינדקסים, תקרת הניסיונות ומה קורה כשמסירה נכשלת — יושב ב-עובדי Push.
פריימר לסוכן — GET /api/agent/primer
אנדפוינט HTTP רגיל (לא כלי MCP) שמחזיר טקסט חופשי שהסוכן קורא בפתיחת סשן. הוא חי על שירות ה-MCP, לא על הוובאפ:
https://<MCP-HOST>/api/agent/primer
לדוגמה: https://code-keeper-mcp.onrender.com/api/agent/primer.
אימות. אותו Authorization: Bearer <token> של שאר השירות — PAT מהבוט
(/connect_claude) או טוקן OAuth. בלי טוקן תקין: 401.
תשובה. text/plain; charset=utf-8, לא JSON — הגוף נקרא על ידי המודל
כפי שהוא. הוא מורכב משני חלקים:
שדה ”הוראות לסוכן“ מעמוד ההגדרות בוובאפ (
/settings) — עיקר התוכן.שורת מצב קצרה שנבנית בזמן אמת: שלושת הקבצים האחרונים שנשמרו ומתי.
התנהגות |
פירוט |
|---|---|
|
יש הוראות. הגוף = הוראות + שורת מצב (אם יש קבצים). |
|
שדה ההוראות ריק. גוף ריק לחלוטין — הפריימר קיים כדי לשאת את ההוראות, ושמות קבצים בלי מסגור הם רעש ולא פריימר. |
|
אין טוקן, או שהטוקן בוטל/פג. |
תקרת 24KB |
חריגה גורמת לחיתוך + שורה שמודיעה על כך; לעולם לא שגיאה, כי פריימר חתוך עדיף על פריימר חסר. שורת המצב מקבלת מקום שמור ולא נדחפת החוצה. |
Cache |
60 שניות לכל משתמש ( |
סינון סודות |
כל הגוף עובר צנזור של דפוסים שנראים כמו מפתח ( |
התקרה וה-TTL הם קבועים בקוד ולא משתני סביבה, במכוון: התקרה אינה כוונון תפעולי אלא תקציב מול חלון ההקשר של המודל. אילו היה משתנה סביבה, מצב הכשל של העלאתו היה בלתי-נראה — הקשר נאכל בשקט בלי שאף אחד מתריע. שינוי דורש PR.
הערה
הראוט מאמת בגוף שלו, ולא מסתמך על כך שהאפליקציה מוגנת. במצב OAuth
ה-PATAuthMiddleware אינו מותקן כלל, וה-SDK עוטף רק את ה-mount של
/mcp — ולכן ראוט שנרשם ידנית (כמו /healthz) מוגש ללא אימות.
כל ראוט חדש שאסור שיהיה ציבורי חייב לקרוא ל-authenticate_bearer
בעצמו. ראו mcp_server/auth.py.
עריכה מהדפדפן
/settings → הכרטיס ”הוראות לסוכן“. השדה מציג מונה בייטים חי ומתריע
כשמתקרבים לתקרה (מ-21KB) וכשחוצים אותה — כדי שחריגה תיתפס בכתיבה, ולא רק דרך
שורת חיתוך שמופיעה בגוף שרק הסוכן קורא.
האחסון (48KB תווים) רחב מההגשה (24KB) בכוונה: החיתוך הוא החלטה של ההגשה ולא
של האחסון, כדי שלא יימחק טקסט שהמשתמש כתב. הסודות נשמרים כפי שהם ומסוננים רק
בהגשה — אחרת המשתמש היה פותח את ההגדרות ומוצא ***REDACTED*** בלי לדעת מה
נמחק לו.
אזהרה
הוק שמושך את הפריימר בפתיחת סשן צריך להצביע על ה-MCP host. הצבעה על
הוובאפ מחזירה 404, ואם ההוק שותק בכל כשל — הוא ייכשל לנצח בלי לומר
מילה. הבחינו בין 204 (אין פריימר — שתיקה נכונה) לבין 404/401
(תקלה — ראוי להשמיע קול פעם אחת).
חשוב
פלאגין לא נטען בסשן שרץ בדפדפן, ולכן ההוק מחויב לריפו. קונטיינר של
סשן Claude Code בדפדפן עולה עם SKIP_PLUGIN_MARKETPLACE=true, ולכן שום
פלאגין מהמרקטפלייס לא נפרס לתוכו: אין תיקיית plugins, אין
CLAUDE_PLUGIN_ROOT, וההוק שהפלאגין נושא לא רץ. ומכיוון שלא רץ, הוא גם
אינו יכול לדווח על כך — השתיקה נראית בדיוק כמו 204.
שלוש בדיקות מפרידות בין המצבים: הכלי ListPlugins מחזיר רשימה ריקה
למרות שהפלאגין דולק בהגדרות, הסקריפט של הפלאגין לא נמצא בדיסק,
ו-CLAUDE_PLUGIN_ROOT ריק.
הדגל מוזרק על ידי הרנר של הסביבה ואינו ניתן לכיבוי מצד החשבון — הוא מופיע
ברשימה שה-environment-manager רושם ביומן תחת startup_context_env_var_keys,
לצד CLAUDE_CODE_REMOTE ו-CLAUDE_CODE_SYNC_SKILLS, במקום שבו משתנים
שהמשתמש הגדיר אינם מופיעים. השכן ברשימה הוא כל התמונה:
CLAUDE_CODE_SYNC_SKILLS=1 — סקילים כן מסונכרנים לסביבות האלה,
פלאגינים לא.
לכן .claude/settings.json ו-.claude/hooks/codekeeper-primer.sh
יושבים בריפו הזה ולא נשענים על הפלאגין. הראיות המלאות:
codekeeper-plugin#3.
נבדק ב-13 באוגוסט 2026 בסביבת cloud_default. זו התנהגות של הפלטפורמה
ולא חוזה יציב — שווה לאמת מחדש אם הפריימר מתחיל להיטען לבד דרך הפלאגין.
הפעלה — צעד אחר צעד
שלב 1 — שירות חדש
שירות web נפרד (ASGI) ב-Render, שמתחבר לאותו MongoDB של הבוט והוובאפ:
Start command: uvicorn mcp_server.app:app --host 0.0.0.0 --port $PORT
Health check: /healthz
שלב 2 — מצב בסיס (PAT בלבד)
מספיק כדי לעבוד מול Claude Code / Desktop:
משתנה |
הערה |
|---|---|
|
זהים לבוט/וובאפ |
|
נדרש רק לטעינת מודול ה-config המשותף |
שלב 3 — מצב OAuth (מוסיף את Claude.ai)
נדלק אוטומטית כשמוגדרים בשירות ה-MCP:
משתנה |
הערה |
|---|---|
|
ה-URL הציבורי (https) של שירות ה-MCP |
|
ה-URL הציבורי של הוובאפ (למסך התחברות הטלגרם) |
|
זהה לוובאפ, ערך אקראי חזק (≥16 תווים) — חותם את זהות המשתמש בין השירותים |
בנוסף: על הוובאפ להגדיר MCP_SERVER_URL (+אותו SECRET_KEY), ועל
הבוט MCP_SERVER_URL (לפקודת /connect_claude).
שלב 4 — דפדפן הריפו (אדמין, אופציונלי)
משתנה |
הערה |
|---|---|
|
מזהה הטלגרם של האדמין (CSV). ריק = הכלים כבויים לכולם |
|
ב-Render דיסק הוא פר-שירות; בלי דיסק ה-mirrors משוכפלים מחדש אחרי כל deploy |
|
רק לריפואים פרטיים (אימות ל-clone/fetch) |
אין צורך ב-GITHUB_WEBHOOK_SECRET בשירות ה-MCP — ה-webhook ממשיך להגיע
לוובאפ
בלבד, וה-MCP מתעדכן לבד (ראו ”רענון אוטומטי“ למטה).
הרשימה המלאה של המשתנים: משתני סביבה - רפרנס.
חיבור לקוחות
Claude.ai (Custom Connector)
Settings → Connectors → Add custom connector → הזינו את הכתובת:
https://<mcp-host>/mcp
זהו — Claude מבצע DCR + OAuth לבד, מפנה להתחברות טלגרם ולמסך אישור. אין צורך ב-Client ID/Secret. כדי לקבל write, ה-connector צריך להירשם עם ההרשאה — אם כבר חיברתם לקריאה בלבד, הסירו והוסיפו מחדש ואשרו ”קריאה וכתיבה“.
Claude Code (טוקן)
שלחו לבוט בצ’אט פרטי /connect_claude (או /connect_claude write), ואז:
claude mcp add --transport http codekeeper https://<mcp-host>/mcp \
--header "Authorization: Bearer <token>"
Claude Desktop
ב-claude_desktop_config.json:
{
"mcpServers": {
"codekeeper": {
"type": "http",
"url": "https://<mcp-host>/mcp",
"headers": { "Authorization": "Bearer <token>" }
}
}
}
דפדפן הריפו — איך זה עובד
רענון אוטומטי (autosync)
שירות ה-MCP מריץ thread רקע (אותו דפוס כמו ה-worker בוובאפ) ששומר על ה-mirrors המקומיים טריים — בלי cron ובלי שירות נוסף:
merge ל-main → GitHub webhook → הוובאפ מסנכרן את הדיסק שלו וכותב SHA ל-Mongo
→ ה-autosync ב-MCP מזהה שה-SHA המקומי שונה → git fetch מקומי
ריפו שקיים ב-
repo_metadataאך חסר בדיסק המקומי — משוכפל אוטומטית (אין צורך ב-import ידני בצד ה-MCP).שליטה:
MCP_REPO_AUTOSYNC(ברירת מחדל פעיל),MCP_REPO_AUTOSYNC_INTERVAL(ברירת מחדל 300 שניות).
מדיניות סינון סודות
נתיבים רגישים (.env*, *.pem, *.key, id_rsa*, secrets.*,
credentials* ועוד) נחסמים בקריאת קובץ, מושמטים מרשימות ו**מדולגים**
בחיפוש — בכל הריפואים, תמיד. ההתאמה case-insensitive על הנתיב המלא ועל כל
רכיב בו, ולכן שם רגיש נתפס גם כשהוא תיקייה ולא קובץ: config/.env
נחסם, וגם config/.env/README.md שבתוכה. הרחבת הרשימה:
MCP_REPO_DENYLIST_EXTRA (CSV של תבניות glob). על שגיאה פנימית המדיניות
נכשלת סגור (חוסמת).
בחיפוש ההחרגה נכנסת ל-git grep עצמו, ולכן קובץ חסום אינו נסרק ואינו
נספר — ולא רק מסונן מהתוצאות. התבניות הן אותן תבניות; הסינון על התוצאות
נשאר כשכבה אחרונה.
קוד חיצוני וקבצים מקומפלים הם רשימה אחרת לגמרי, ולא להתבלבל. סוד חסום
גם בקריאה; node_modules ובאנדלים רק אינם נכללים בחיפוש כברירת מחדל,
וממשיכים להיקרא דרך codekeeper_get_repo_file. ראו כמה מופעים יש באמת (total מול total_at_least).
והכלי הציבורי אינו יוצא מן הכלל. codekeeper_docs_get_section הוא הכלי
היחיד כאן שאינו דורש אדמין, והוא קורא דרך אותה פונקציה — ולכן המדיניות
חלה עליו במלואה. path="secrets" מחזיר path_denied ולא not_found,
ובריפו שהתיעוד שלו יושב בשורש גם secrets.md ו-credentials.md חסומים.
ההבדל בין שני הקודים אינו סגנוני: 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
אין נעילת קריאה מול ה-sync, ולכן קריאה שנכשלת בזמן ש-sync רץ מחזירה:
{"ok": false, "error": "sync_in_progress", "retry_after": 30}
זה סימן לנסות שוב אחרי המתנה קצרה — לא להסיק שהקובץ או הריפו לא קיימים.
איזה קובץ docs_get_section קורא, ובאיזה ריפו
לכל ריפו יש מדיניות נתיבים משלו: איפה התיעוד שלו יושב, ובאיזה פורמט.
ריפו |
שורש |
סיומת |
דוגמה ל-slug קצר |
|---|---|---|---|
|
|
|
|
|
שורש הריפו |
|
|
שני שערים, ולא אחד. MCP_DOCS_REPO הוא מה שהפריסה מתירה בזמן ריצה;
הטבלה שלמעלה, שחיה ב-mcp_server/docs_handlers.py, היא מה שהקוד יודע
לקרוא. ריפו חייב לעבור את שניהם. ריפו שנמצא במשתנה הסביבה ואין לו מדיניות
נדחה ב-repo_not_configured ו**אינו** נופל לברירת מחדל מתירנית — אחרת ריפו
שמישהו הוסיף להגדרות היה מוגש תחת כללים שאיש לא החליט עליהם.
חשוב
הכניסה הראשונה ב-``MCP_DOCS_REPO`` היא ברירת המחדל, ולכן היא קובעת גם
את הפורמט. קריאה שאינה נוקבת ב-repo מקבלת את השורש והסיומת של אותו
ריפו. שינוי הסדר במשתנה הסביבה הוא שינוי התנהגות, לא סידור: אותו
path="mcp-server" יחפש docs/mcp-server.rst בסדר אחד ו-
mcp-server.md בשני.
כלל ה-slug. קלט בלי / מקבל את השורש ואת הסיומת של הריפו; קלט
שכבר יש בו / נלקח כמות שהוא, ורק הסיומת מתווספת אם היא חסרה. מכאן ששני
דברים נכונים בו-זמנית: בריפו ששורשו docs/ קובץ בתת-תיקייה דורש את הנתיב
המלא (docs/observability/error_codes, לא observability/error_codes),
ובריפו ששורשו שורש הריפו הנתיב המקונן עובד מאליו
(bugbot-rules/race-toctou).
בקשה לפורמט שהריפו אינו מגיש נדחית בשמה. .md תחת CodeBot, או
.rst תחת amir-bug-patterns, מחזירים:
{"ok": false, "error": "suffix_not_allowed", "repo": "CodeBot",
"requested_path": "CRITICAL-PATTERNS.md", "allowed_suffixes": [".rst"]}
הסיומת אינה מושלמת בשקט מעל הקיימת. אחרת הבקשה הייתה הופכת ל-
docs/CRITICAL-PATTERNS.md.rst, חוזרת כ-not_found, והקורא היה מחפש את
הבעיה בקובץ במקום במדיניות.
הערה
הסדר: עגינה, אחריה נרמול, ורק אז בדיקת הגבול — והגבול נבדק כיחידת
נתיב ולא כרצף תווים. docsecret/x אינו תחת docs/ אף שהוא מתחיל
באותם תווים, ו-docs/../secrets נדחה כי הוא נעגן לפני שנפתר,
ולכן הנרמול מוציא אותו אל מחוץ לשורש במקום לתוכו. מנגד
bugbot-rules/../README.md מתקבל — הוא נפתר אל תוך הריפו,
ו-.. אינו מילה אסורה אלא נרמול שנבדק לפי לאן הוא מגיע.
ושורש ריק אינו ”הכול“. הוא ”כל דבר בתוך הריפו“, והסיומת עדיין מסננת:
ב-amir-bug-patterns כל קובץ .md נגיש בכל עומק, כולל תחת
.claude/ — וזו החלטה מודעת, על ריפו ציבורי שכל 93 קובצי ה-.md בו
הם מסמכים שנועדו לקריאה. .claude/settings.json אינו נגיש, כי הוא
אינו .md.
נתיב ארוך מדי נדחה לפני שמישהו קורא אותו. path מעל 4,096 תווים מקבל
path_too_long עם max_chars ו-actual_chars, והסירוב קורה בשלב
הראשון של פתרון הנתיב — לפני מדיניות הסודות, לפני המראה, ולפני הפרסור. הנתיב
הארוך ביותר בכל הריפואים שמוגשים כאן קצר מ-120 תווים, ולכן התקרה אינה חוסמת
שום קובץ אמיתי; מה שהיא חוסמת הוא העבודה שגדלה עם אורך הקלט, ובמסלול הזה
העבודה הזאת היא סריקת הרכיבים של מדיניות הסודות. זו הקטנת משטח ולא הגבלת
קצב — היא מגבילה את המחיר של בקשה אחת, לא את מספר הבקשות; ראו אישו #3430.
הסירובים שמגיעים מהפרסור עצמו, וכולם error ולא כשל כללי:
inconsistent_line_endingsבקובץ יש
\rשאינו חלק מ-\r\n— הפורמט של Mac שלפני 2001. שם שכבר קיים באוצר המילים:outlineמחזיר אותו כ-reasonעל אותה מחלקת קלט בדיוק. בקובץ כזה ספירת השורות אינה חד-משמעית, ומפה שמצביעה לשורה שאי אפשר לבקש גרועה ממפה שאין. CRLF עובד במלואו.too_many_linesקובץ Markdown ארוך מ-
MAX_LINESשורות (services/md_parser.py; הערך ב-הקבועים, התקרות ואוצר המילים של התשובה). נבדק לפני הפרסור, ולכן הסירוב אינו עולה דבר. התשובה נושאתmax— התקרה — ו-total_lines, מספר השורות באותה יחידה כמו כל שדהlinesבתשובות ה-MCP;lineאין בה, כי הפרסור לא התחיל. קובץ כזה אפשר לקרוא בטווחים, ב-codekeeper_get_repo_fileעםlines. למה תקרת שורות ולא רק תקרת טוקנים: חלק מהזיכרון שלmarkdown-it-pyנגזר ממספר השורות ולא ממספר הטוקנים — מערכים עם ערך לכל שורה שנבנים לפני שכלל כלשהו רץ, הגדרות קישור שנשמרות בלי טוקן, וציטוט מקונן ששומר רשימות לכל שורה בכל רמה. קובץ כזה יכול לעלות את כל ההקצאה לחוט עם כמה עשרות טוקנים בלבד.too_many_tokensהפרסור הגיע ל-
MAX_TOKENSטוקני בלוק (services/md_parser.py; הערך ב-הקבועים, התקרות ואוצר המילים של התשובה). התקרה נבדקת ברגע שנוצר כל טוקן — גם באמצע טבלה ובאמצע רשימה, שנבנות בקריאה אחת לכלל שלmarkdown-it-py— ולכן הפרסור נעצר באמצע, ולא אחרי שכל העבודה כבר נעשתה. התשובה נושאתmaxואתline, השורה שהפרסור הגיע אליה (בליlineכשעוד לא נוצר טוקן שיש לו מיקום). למה היא נחוצה גם אחרי השדרוג ל-4.2.0: התקרה של upstream לטבלה שמשלימה תאים ריקים (MAX_AUTOCOMPLETED_CELLS, #364) סופרת לכל טבלה, והמונה מתאפס בכל טבלה חדשה — קובץ של 128KB עם הרבה טבלאות כאלה הגיע ל-3GB בלי התקרה ונפל ב-MemoryError.איך נבחרו שתי התקרות (#3391) — יחד, משני תנאים שחייבים להתקיים בו-זמנית: הקלט העוין הגרוע שנמצא בתקרת הקריאה נכנס בעלות שמאגר הקריאות מקצה לפרסור אחד (
_PARSE_COST_BYTESב-mcp_server/server.py; ראו ”מודל הריצה של הכלים“ למעלה), וגם אף קובץ אמיתי אינו נחסם — לא ב-docs/, לא בכל הריפו, לא ב-amir-bug-patterns ולא בקבצים השמורים. המרווח יושב בשורות ולא בטוקנים, בכוונה: הקובץ האמיתי הארוך ביותר קרוב לתקרה שלו יותר מהצפוף ביותר לשלו, וקובץ שגדל בשורות הוא התרחיש השכיח. הקלט, המדידות, התאריך והסקריפט שמשחזר אותן — לידMAX_LINESו-MAX_TOKENSב-services/md_parser.py;scripts/measure_md_parse_cost.pyבודק את שני התנאים מחדש בכל שינוי של הפרסר או של התקרות.too_many_sectionsהקובץ חוצה את תקרת הכותרות — 50,000 בשני המסלולים, ובאותה דרך: ברירת המחדל של
max_sectionsבשני הפארסרים היאMAX_SECTIONSשלservices.doc_sections, והכלי אינו מעביר תקרה משלו. (עד #3420 ברירת המחדל שלrst_parserהייתה ללא תקרה, והכלי העביר לו את_ceiling.MAX_SYMBOLSבמפורש מאז #3429; היישור נעשה אחרי מדידה על כל 208 קובצי ה-RST — הקובץ העשיר ביותר נושא 50 סקשנים, אחד לאלף מהתקרה — ופלט הכלי על כולם זהה לפני ואחרי.) התקרה של סורק האאוטליין,_ceiling.MAX_SYMBOLS, היא אותו מספר, וטסט מחזיק את השניים שווים. ב-Markdown היא אינה יכולה להיפגע עם ברירות המחדל (מאז #3391): כל כותרת פותחת בשורה משלה, ו-MAX_LINESקטן מ-MAX_SECTIONS— כלומר קובץ שהיה חוצה אותה נעצר קודם, ב-too_many_lines. עד #3391 זה היה הפוך: Markdown עוין של שורות-תבליט בלי אף כותרת עבר אותה בלי שום סירוב, כי היא סופרת כותרות. היא נשארת בשביל RST, ובחוזה המשותף של שני הפרסרים.
כשהפארסר מספק מספר שורה, התשובה נושאת גם line — השורה שגרמה לסירוב,
1-מבוססת, כפי שהפארסר עצמו חישב אותה. זה מה שהופך את הסירוב לפעולה:
inconsistent_line_endings בלי מספר אומר ”יש איפשהו \r בקובץ של 4,000
שורות“. md_parser מספק אותו ל-inconsistent_line_endings, ל-too_many_tokens
ול-too_many_sections; too_many_lines נושא במקומו את total_lines, כי
הפרסור לא התחיל; rst_parser מרים TooManySections בלי מספר, ולכן סירוב RST
בא בלי השדה. השדה מופיע רק כשיש מספר, ולא כ-null — כמו
suggestions_truncated ו-remaining_chars באותו כלי.
כל התשובות האלה נושאות repo, path, ref ו-resolved_commit, כדי
שאפשר יהיה לפתוח בדיוק את הקובץ שסירב.
``includes`` הוא שדה של RST. הוא מונה יעדי .. include::, ול-Markdown
אין צורה כזאת — ולכן בעמוד .md הוא תמיד []. אפס שם אינו ”לא
בדקנו“ אלא ”אין מה לבדוק“.
ותקרת הגודל היא זו של שירות המראה, 500KB. הכלי קורא את הקובץ כולו, בלי
lines ובלי outline, ולכן קובץ גדול יותר מחזיר
unreadable_too_large ולא תשובה חתוכה. למדידה: הקובץ הגדול ביותר ב-
amir-bug-patterns הוא 107KB, כלומר מרווח של פי 4.7.
ראו גם מדיניות סינון סודות — מדיניות הסודות חלה על הכלי הזה במלואה.
איפה מפת ה-Markdown חולקת על GitHub
המפה ש-services.md_parser בונה נמדדת מול cmark-gfm — הפארסר ש-GitHub מריץ — על צורות מחוללות, ב-tests/test_md_parser_oracle.py. הפערים הידועים והמדודים יושבים בטבלה אחת בקוד, _KNOWN_DIVERGENCES, וכל שורה בה היא סיבה ולא רשימת מופעים. הטבלה כאן מעתיקה ממנה, והטסט test_the_documentation_table_is_this_table משווה ביניהן בכל שורה בנפרד: הסיבה, המספרים, הגרסאות והתאריך. מה שכל צד מחזיר על הצורה לדוגמה נבדק מול שני הפארסרים עצמם, בטסט אחר.
הסיבה |
צורה לדוגמה |
מה GitHub מחזיר |
מה אנחנו מחזירים |
נמדד |
upstream |
|---|---|---|---|---|---|
תגית HTML מסוג 7 צמודה לשורת מכל (פריט רשימה או ציטוט) |
|
סעיף אחד: |
שני סעיפים: |
12 מתוך 26 צורות שנמדדו; markdown-it-py 4.2.0, cmarkgfm 2025.10.22; 2026-09-27 |
https://github.com/executablebooks/markdown-it-py/issues/434 |
רשימת תגיות הבלוק של CommonMark 0.31.2: source/search |
|
עם |
עם |
28 מתוך 60 צורות שנמדדו; markdown-it-py 4.2.0, cmarkgfm 2025.10.22; 2026-09-27 |
אין אישו — זה שינוי במפרט: |
טבלה שעוברת את תקרת התאים המשלימים של markdown-it-py 4.x |
|
סעיף אחד: כל השורות בטבלה, ו- |
שני סעיפים: הטבלה נחתכת בתקרה, השורות שנשארו בחוץ הן פסקה, ו- |
2 מתוך 8 צורות שנמדדו; markdown-it-py 4.2.0, cmarkgfm 2025.10.22; 2026-09-27 |
אין אישו — זו תקרה מכוונת, שנוספה ב-markdown-it-py 4.0.0 (#364, https://github.com/executablebooks/markdown-it-py/pull/364). ב-3.0.0 אין תקרה, ושם המפה הסכימה עם GitHub. |
כותרת setext שצמודה להגדרת קישור |
|
הכותרת מתחילה בשורה 1, שורת ההגדרה |
הכותרת מתחילה בשורה 2, שורת הכותרת עצמה |
92 מתוך 720 צורות שנמדדו; markdown-it-py 4.2.0, cmarkgfm 2025.10.22; 2026-09-27 |
אין אישו. הפער קדם לשדרוג: 3.0.0 מחזיר בדיוק את אותן מפות. |
תו רווח ש-markdown-it-py מזהה ו-cmark-gfm לא, אחרי שם תגית HTML |
|
שני סעיפים: אצל cmark-gfm השורה היא פסקה, ו- |
סעיף אחד: אצלנו השורה פותחת בלוק HTML, ו- |
2,316 מתוך 5,075 צורות שנמדדו; markdown-it-py 4.2.0, cmarkgfm 2025.10.22; 2026-09-27 |
אין אישו. הפער קדם לשדרוג: 3.0.0 מחזיר בדיוק את אותן מפות. |
”N מתוך M צורות שנמדדו“ הוא מדידה, לא גבול. M הוא מספר הצורות שהמחולל של אותה שורה בטבלה מייצר, ו-N הוא כמה מהן חולקות על GitHub. על צורה שלא נמדדה המספר אינו אומר דבר.
איך הפערים נמצאו. שתי השורות הראשונות נמדדו לפני השדרוג ובמהלכו. שלוש האחרונות נמצאו בסקירה של השדרוג: אף מחולל לא בנה טבלה רחבה כל כך, הגדרת קישור או תגית עם רווח כזה. מאז כל אחת מהן היא משפחה קבועה באורקל, ולידן משפחה של כל תגית בלוק ותגית מסוג 1, בכל צורות הכתיבה של _HTML_TAG_FORMS ובכל ההקשרים של השורה השנייה. כך שינוי ברשימת התגיות של markdown-it-py יתגלה בשדרוג הבא בלי שמישהו יחשוד בו מראש: test_the_two_block_tag_lists_differ_only_by_the_known_row נופל, וגם הצורות של השם שנוסף או הוסר.
הפער של source/search בפירוט. נמדדו 60 צורות: 2 תגיות, 6 צורות כתיבה (פתיחה, סגירה, עם מאפיין, באותיות גדולות, עם טקסט אחרי התגית, ולא סגורה) ו-5 הקשרים. מה שמכריע הוא אם השם נמצא ברשימה (בלוק HTML ”מסוג 6“, שקוטע פסקה) או לא (לכל היותר ”סוג 7“, שדורש תגית שלמה ולבדה בשורה ואינו קוטע פסקה):
תגית שלמה ולבדה בשורה, מיד אחרי שורת פסקה — פער בשתי התגיות, בכיוונים הפוכים.
אותה תגית מיד אחרי שורת פריט ברשימה — פער ב-
sourceבלבד. אצלנו היא סוג 7, וזה בדיוק המנגנון של השורה הראשונה בטבלה (#434). כלל השיוך: צורה ששורתה מתחילה ב-<sourceאו ב-<searchשייכת לשורה השנייה, גם כשהמנגנון בפועל הוא של הראשונה — כך כל צורה יושבת בשורה אחת בדיוק, וטסט אוכף זאת.תגית עם טקסט אחריה (
<source> טקסט) או לא סגורה (<source) — פער בכל הקשר מחוץ לציטוט, גם אחרי שורה ריקה.בתוך ציטוט — אין פער.
ופער אחד נסגר בשדרוג: <search> שלמה ולבדה, מיד אחרי שורת פריט ברשימה. ב-markdown-it-py 3.0.0, שבו search עוד לא הייתה ברשימה, זה היה מופע של #434 — ## אחרי נשאר כותרת אצלנו ונבלע אצל GitHub. מאז 4.0.0 search פותחת בלוק HTML שקוטע את הפריט, כמו אצל GitHub, ושני הצדדים מחזירים סעיף אחד. גם זה מקובע בטסט (closed בשורה של הטבלה).
הטבלה שעוברת את התקרה בפירוט. מ-4.0.0, markdown-it-py מגביל את מספר התאים שהוא משלים בטבלה אחת: MAX_AUTOCOMPLETED_CELLS (65,536) ב-markdown_it/rules_block/table.py. שורת גוף שיש בה פחות תאים משורת הכותרות מקבלת את התאים החסרים, והספירה מצטברת משורה לשורה. השורה שמעבירה אותה מעל התקרה כבר אינה בטבלה: הטבלה נגמרת שם, וכל השורות שנשארו בחוץ הן פסקה. --- או === שבא מיד אחריהן הופך את הפסקה לכותרת setext, ברמה 2 או 1. ב-cmark-gfm אין תקרה כזאת: כל השורות בטבלה, והקו שאחריה אינו יוצר כותרת. המשפחה היא טבלה שבדיוק בתקרה וטבלה שעוברת אותה בשורה אחת, ואחרי כל אחת כל אחד מההמשכים של _TABLE_CAP_FOLLOWERS: ---, ===, שורת טקסט, או סוף הקובץ. פער יש רק כשהטבלה נחתכה וגם קו setext בא אחריה. הקובץ לדוגמה בטבלה הוא 1,682 בתים בלבד: כדי לעבור את התקרה לא צריך קובץ גדול, מספיקה טבלה רחבה. ב-3.0.0 אין תקרה, וכל הצורות מסכימות. והפער הזה אינו נגיש דרך הכלי: הפער מתחיל רק אחרי 196,608 טוקנים — שלושה לכל תא משלים עד התקרה — והכלי מסרב לכל מסמך שעובר את MAX_TOKENS ב-too_many_tokens, לפני שהוא מגיע לשם. האורקל משווה בלי התקרות של md_parser, בכוונה: הוא שואל איך הפרסר מבין את הטקסט, והתקרות הן מדיניות משאבים שקובעת אם הכלי יפרסר קובץ בכלל, ולא מה הוא רואה בו (#3391).
הגדרות הקישור בפירוט. המשפחה היא כל צירוף של הקשר שלפני ההגדרה, צורה של הגדרה, ומה שבא מיד אחריה (_reference_cells). יש שלושה סוגי פער:
82 — אותה כותרת, בשורה אחרת. כותרת setext שמתחילה מיד אחרי הגדרה. cmark-gfm מדווח את השורה שבה ההגדרה מתחילה: אצלו הכותרת נבנית מהפסקה שהתחילה בשורת ההגדרה. כשמגיע קו ה-setext, הוא מוציא מהפסקה את ההגדרה והופך את מה שנשאר לכותרת, והכותרת שומרת את שורת ההתחלה של הפסקה (
open_new_blocksב-src/blocks.c). אנחנו מדווחים את השורה של הכותרת עצמה.6 — כותרת שיש רק ב-GitHub.
[a]:בלי יעד, ומיד אחריה===. ``markdown-it-py`` קורא את===כיעד של ההגדרה, ואין כותרת. cmark-gfm מנסה לקרוא את[a]:לבדה כהגדרה, לא מצליח, ומקבל כותרת setext שהטקסט שלה[a]:.4 — כותרת שיש רק אצלנו. הגדרה מוזחת לתוך פריט רשימה, ואחריה שורת טקסט וקו setext בלי הזחה. אצלנו הפריט נגמר אחרי ההגדרה, והקו הופך את שורת הטקסט לכותרת. cmark-gfm ממשיך את הפסקה בתוך הפריט, והקו הוא קו מפריד.
הפער קדם לשדרוג: 3.0.0 מחזיר בדיוק את אותן מפות, והשכתוב של כלל ההגדרות ב-4.0.0 (#367, תיקון של סיבוכיות ריבועית) לא הזיז אף צורה.
הרווח שאינו רווח בפירוט. markdown-it-py כותב עם \s של פייתון גם את תנאי הפתיחה של בלוק HTML (HTML_SEQUENCES ב-markdown_it/rules_block/html_block.py) וגם את התגית עצמה (markdown_it/common/html_re.py). cmark-gfm 0.29.0.gfm.13 מקבל שם רק את spacechar שלו (src/scanners.re): רווח, טאב, טאב אנכי, מעבר דף ושני התווים של מעבר שורה. תו ש-\s מזהה ו-spacechar לא — למשל רווח שאינו נשבר (NBSP), שיכול להגיע בהעתקה מדף אינטרנט — פותח אצלנו בלוק HTML, ואצל cmark-gfm הוא אינו חלק מתגית בכלל, והשורה היא פסקה. לכן בכל פער הכיוון אחד: כותרת שיש ב-GitHub ואין אצלנו. זה קורה ב-23 תווים (_PYTHON_ONLY_SPACES), ב-61 תגיות הבלוק (כל שם ברשימה של אחד הפארסרים, חוץ מ-source ו-search), ב-4 התגיות מסוג 1, ובתגיות מסוג 7:
תגית בלוק או תגית מסוג 1, והתו צמוד לשם — פער בכל הקשר מחוץ לציטוט. אצלנו השורה פותחת בלוק שקוטע פסקה.
תגית בלוק או תגית מסוג 1, ורווח רגיל כבר הפריד את השם — אין פער. שני הצדדים פתחו בלוק על הרווח הרגיל.
כל תגית אחרת, כולל תגית סגירה של סוג 1 — פער רק אחרי שורה ריקה או מיד אחרי כותרת. אצלנו זו תגית שלמה מסוג 7, והיא אינה קוטעת פסקה.
בתוך ציטוט — אין פער.
גם הפער הזה קדם לשדרוג, ו-3.0.0 מחזיר בדיוק את אותן מפות. הטריגר של השורה הזאת מסמן רק תו כזה מיד אחרי שם התגית. המשפחה מראה פער גם כשהתו יושב בין מאפיינים או לפני > של תגית מסוג 7, אבל סריקה של שורה אחת אינה יכולה להבדיל שם בין רווח לבין תו בתוך ערך של מאפיין, בלי לכתוב מחדש את הדקדוק של תגית.
קבצים אמיתיים — האם מישהו מושפע:
עמודי ה-``.md`` תחת ``docs/``, ב-CI — הטסט
test_no_markdown_page_under_docs_holds_a_known_divergence_triggerסורק כל עמוד, ונופל על כל שורה שמפעילה שורה בטבלה שיש לה טריגר (triggerב-_KNOWN_DIVERGENCES). שני סוגי שורות אינם נסרקים, והפארסר עצמו הוא שקובע אותם ולא כלל שנכתב ביד: שורה בתוך בלוק קוד (גדר, קוד מוזח, front matter), ושורה בתוך בלוק HTML שנפתח בשורה קודמת — כמו<source>בתוך<video>, שהיא חלק מהבלוק של<video>ואינה פותחת בלוק משלה. הפטור לא חל כשהבלוק אולי קיים רק אצלנו: כששורת הפתיחה שלו היא בעצמה טריגר, או כשיש בה, בכל מקום, תו רווח ש-cmark-gfm אינו מכיר (השורה החמישית). קבצים שתולים בטסטים מוכיחים את שני הצדדים: תגית מחוץ לבלוק נתפסת ובתוכו לא,<source>בתוך<video>עוברת ו-<source>לבדה נופלת, שורת טריגר בתוך בלוק שנפתח רק אצלנו נתפסת, ו-NBSP מיד אחרי שם של תגית נתפס. עמוד שאינו UTF-8 מפיל את הסריקה עם שם הקובץ, ותיקייה בלי אף עמוד היא כישלון ולא הצלחה.כל קורפוס, על פי דרישה —
scripts/compare_md_parser_to_cmark.pyמשווה את המפה של כל קובץ ל-cmark-gfm, ובאותה ריצה סורק טריגרים באותה פונקציה של ה-CI. כל אי-הסכמה וכל שורת טריגר מפילות את הריצה. לשורות בטבלה שאין להן טריגר, השוואת המפות הזאת היא הבדיקה היחידה על קבצים אמיתיים, כי ה-CI סורק רק טריגרים. נמדד ב-2026-09-27, על markdown-it-py 4.2.0:docs/— 34 קבצים, 358 כותרות, אפס אי-הסכמות ואפס שורות טריגר.amir-bug-patterns, בקומיט456e90b— 97 קבצים, 1,240 כותרות, אפס אי-הסכמות ואפס שורות טריגר. ה-CI אינו רואה את הריפו הזה.הריפו הזה כולו — כל קובץ
.mdשנמצא ב-git, כוללnode_modules: 427 קבצים, 9,547 כותרות, אפס אי-הסכמות ואפס שורות טריגר.
הקבצים השמורים — נמדדו פעם אחת במסד, בקריאה בלבד, ב-2026-09-27, ורק באוסף
code_snippets(האוסףlarge_filesלא נסרק): 981 מסמכי Markdown פעילים (כל גרסה היא מסמך; Markdown לפי שפה או סיומת) ו-143 בסל. השיטה הייתה שונה מזו של ה-CI: ביטוי רגולרי במסד על תחילת שורה,^ {0,3}</?(source|search)(\s|/?>|$), כלומר רק השורה השנייה בטבלה, ובלי הפארסר. כל פגיעה נבדקה ביד. נמצאה אחת: במסמך פעיל אחד יש שורה שמתחילה ב-<source src=...>, והיא בתוך בלוק קוד מגודר, ולכן אינה משפיעה על המפה. בסל לא נמצא אף אחד. השורה החמישית לא נמדדה במסד. ה-CI אינו רואה את המסד, ולכן זו מדידה מתוארכת ולא טסט. ולקבצים שמורים ההשוואה הרלוונטית היא ממילא הוובאפ ולא GitHub: הוובאפ מרנדר עםhtml: false, ושם כל שורת HTML מתנהגת אחרת מאצלנו — הבדל דיאלקט רחב יותר, שאינו חלק מהטבלה הזאת.
איך docs_get_section מוצא כותרת
הכלי מקבל section ומחזיר את הסעיף הזה בלבד. ההתאמה נעשית בשני שלבים,
ובסדר הזה בדיוק.
קודם שוויון מלא על טקסט הכותרת, אחרי נרמול סלחני: רווחים בקצוות
ורווחים כפולים מתכווצים, צורות המקף מאוחדות, והאותיות הלטיניות מושוות בלי
תלות ברישיות. חלק מכותרת אינו מתאים לה — section="טבלה" לא ימצא
את טבלה מרכזית.
ואם השוויון לא מצא כלום, ורק אז, נבדקת התאמת מזהה. היא נדלקת רק
כשהשאילתה עצמה בנויה כמזהה — עד שלוש אותיות ואחריהן עד שלוש ספרות,
עם נקודה אופציונלית בסוף (K11, K11., U3, P3) — ואז מוחזרת
הכותרת שנפתחת באותו מזהה, כשאחריו נקודה, רווח, או סוף הכותרת. שאילתה
שאינה בנויה כך אינה עוברת במסלול הזה כלל, ולכן section="איך" ממשיך
להחזיר אפס התאמות גם כשיש בעמוד חמש-עשרה כותרות שמתחילות במילה הזאת.
הערה
מזהה נקרא כיחידה, לא כקידומת של מחרוזת. section="K1" מחזיר את
K1 בלבד, ולעולם לא את K10 עד K15 — הכותרת K10. טקסט
נקראת כמזהה K10, והוא אינו שווה ל-K1. זה חשוב כי הצורה
השבורה של הבדיקה הזאת (השוואת תווים במקום השוואת יחידות) היא אותה
מחלקת באג שמתוארת ב-amir-bug-patterns תחת K16.
ומאותה סיבה, נקודה שפותחת תת-מספור אינה גבול. בכותרת K11. טקסט
הנקודה מסיימת את המזהה; בכותרת K11.1 טקסט היא מפרידה בתוך מזהה
ארוך יותר. לכן section="K11" מחזיר את K11. בלבד ולא את
תת-הסעיפים שלו, וכותרת בתת-מספור נמצאת בשמה המלא בלבד — היא אינה
בצורת המזהה שהחוזה מגדיר.
מזהה שחוזר פעמיים באותו עמוד הוא ``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}
— בלי עץ כותרות חלקי, והפרסור נעצר במקום שבו התקרה נחצתה ולא אחריו. אף
עמוד אמיתי אינו מתקרב לזה: הכלי קורא עד 500KB, והעמוד הצפוף ביותר בריפו
מחזיק 6.5 סקשנים ל-KB — כ-3,300 בתקרת הקריאה. מה שהתקרה עוצרת הוא קלט
שנבנה בכוונה, כותרת בת תו אחד בכל שורה: בלעדיה הוא עולה 44.3MiB לפרסור
אחד, יותר ממה שמאגר הקריאות מקצה לחוט, ואיתה הוא נעצר ב-20.2MiB (נמדד מחדש
ב-2026-09-27, עם איפוס VmHWM; החשבון ב“מודל הריצה של הכלים“).
ובעמוד Markdown עוד שתי תקרות, מאז #3391: too_many_lines לפני הפרסור,
ו-too_many_tokens ברגע שנוצר כל טוקן — כי קובץ עוין בלי אף כותרת אינו נראה
לתקרת הסקשנים בכלל. עם שתיהן על ברירת המחדל, תקרת הסקשנים אינה יכולה להיפגע
ב-Markdown. מה כל אחת עוצרת ואיך נבחרו — ב-איזה קובץ docs_get_section קורא, ובאיזה ריפו.
אזהרה
כותרת שנכתבה עם סימון literal דורשת את הבקטיקים גם בשאילתה.
הכותרות מוחזרות כטקסט המקור ולא כטקסט מרונדר, ולכן שם שמופיע בכותרת
בתוך בקטיקים כפולים חייב להגיע כך גם ב-section:
section = ``codekeeper_get_file`` ← מוצא
section = codekeeper_get_file ← לא מוצא, ונראה כמו באג
זה נוגע להרבה כותרות בעמודי התיעוד כאן. הדרך הבטוחה: קריאה בלי
section מחזירה את עץ הכותרות, ומשם מעתיקים את השם המדויק.
וכששאילתה אינה נמצאת, suggestions מחזיר קודם כול כותרת שקרובה למה
שהוקלד, כשיש כזו בעמוד. ורק כשאין אף כותרת קרובה הוא מחזיר במקומה את
המזהים שכן קיימים באותו עמוד. כלומר שאילתה בצורת מזהה יכולה לחזור דווקא עם
כותרת, ואין לקרוא את השדה כאילו הוא תמיד רשימת מזהים.
הסדר הזה מכוון, ולא מקרי: מחרוזת יכולה להיות בצורת מזהה בלי להיות מזהה
בעמוד הזה — H2 הוא אות וספרה, ובעמוד שיש בו כותרת H2O זו הכותרת
שתחזור. כותרת אמיתית שהקורא יכול להעתיק היא תשובה טובה יותר מרשימת מזהים
שאינה קשורה לשאלה.
והכמות: עד 50 הצעות. זה נכון לשני המקרים — כותרת קרובה או רשימת
מזהים — ואם היו עוד, התשובה נושאת suggestions_truncated: true. הדגל
אומר ”היו עוד וחתכנו“, ולא משנה איזה משני המסלולים החזיר את התשובה.
ולמה דווקא 50 ולא מספר קטן ונוח יותר: רשימת המזהים אינה מדורגת. היא עונה על ”ביקשת מזהה שאינו קיים, אלה שכן“, ולשאלה הזאת אין תשובה ”חמש הטובות ביותר“ — חיתוך שלה מסתיר פריטים בלי שום קריטריון, ולקורא אין דרך לבקש את השאר. לכן התקרה גבוהה מספיק כדי שעמוד רגיל ייענה במלואו.
מפת סימבולים (outline)
codekeeper_get_repo_file עם outline=true מחזיר את המפה של הקובץ
במקום את התוכן: כל פונקציה וכל מחלקה, עם שורת התחלה וסיום. משם ממשיכים
ל-lines=[start, end] וקוראים בדיוק את מה שצריך.
{"ok": true,
"status": "outline",
"file": {"path": "webapp/app.py", "ref": "refs/heads/main",
"resolved_commit": "aca7599", "size": 805594, "lines": 20035,
"encoding": "utf-8"},
"symbols": [{"name": "api_db_collections", "start": 5421, "end": 5481}],
"total": 530, "page": 1, "per_page": 100}
זו התשובה במלואה, לא קטע ממנה: ok ו-file תמיד שם, ואין שדה
truncated.
הערה
אין כאן ``truncated``, ובכוונה. ב-codekeeper_list_repo_tree המילה
הזו פירושה ”תקציב הפלט חתך רשומות בתוך העמוד“ — לא ”יש עמוד נוסף“.
באאוטליין אין חיתוך כזה: עמוד שאינו נכנס בתקציב נדחה במלואו ולא
מוחזר חלקית, כי חיתוך בתוך עמוד יחד עם עימוד אריתמטי מאבד סימבולים —
העמוד נעצר באמצע והבא מתחיל אחרי per_page המלא.
הגודל נמדד על הסריאליזציה האמיתית ב-UTF-8, ולא בספירת תווים. גרסה
קודמת חסמה את אורך השם ב-200 תווים והסיקה מכך שהעמוד נכנס — וזה
שגוי פעמיים: תו CJK הוא שלושה בתים, כך שעמוד מקסימלי הגיע ל-323,000
בתים מול תקציב של 256,000; וקיצוץ השם הרס את הזהות, כך ש-symbol=
עם השם המלא כבר לא מצא את הסימבול.
שדה שתמיד false ומסמן משמעות אחרת מזו של הכלי השכן גרוע מהיעדרו.
מה שאומר אם יש עוד עמודים הוא ``total`` מול ``page`` ו-``per_page``.
מגבלת הגודל. אאוטליין נשפט מול אותה תקרה של קריאת טווח — 10MB, ולא 500KB — כי הוא קורא את הקובץ ומחזיר פלט זעיר. ראו קריאת טווח שורות, והמספרים עצמם — כולל תקרות העימוד — ב-הקבועים, התקרות ואוצר המילים של התשובה.
``lines`` ו-``outline`` אינם מצטברים. קריאה שמעבירה את שניהם מוחזרת
כ-{"ok": false, "error": "outline_and_lines"}, ולא מתעלמת בשקט מאחד
מהם. בקשו מפה, ואז טווח.
השם מלא, עם נקודות. ClassName.method, outer.inner,
_register_repo_tools.get_repo_file. הכלל אחד: מרחב שמות בפייתון הוא
פונקציה או מחלקה, ולכן התחילית גדלה רק בהם. if, try, with
ו-for אינם מרחב שמות — פונקציה שהוגדרה בתוך except ImportError
מופיעה ברמה שלה, בלי תחילית.
הערה
הכלל הזה הוא כל הפיצ’ר. בלעדיו mcp_server/server.py החזיר תשעה
סימבולים במקום 38, ו-build_mcp נראתה כבלוק אטום של 513 שורות שכל
כלי ה-MCP חבויים בתוכו. גרסת ביניים שירדה רק לתוך גופי פונקציות עדיין
החמיצה 62 סימבולים ב-webapp/app.py, כי מחלקות ה-fallback שם יושבות
בתוך except ImportError.
שורת ההתחלה היא של המעטר. ב-webapp/app.py 203 מתוך 408 הפונקציות
ברמה העליונה מעוטרות; טווח שהיה מתחיל ב-def היה מחמיץ את
@app.route(...), כלומר בדיוק את השורה שמזהה את הנתיב.
``symbol=`` מסנן על השם המלא, ללא תלות ברישיות. לכן
symbol="build_mcp" מחזיר גם את הפונקציה וגם את כל מה שמוגדר בתוכה —
”תן לי הכול תחת המרחב הזה“. total סופר את ההתאמות אחרי הסינון, כי
עליו נשען העימוד. על webapp/app.py, symbol="api_db" מחזיר חמישה
סימבולים במקום 530.
והוא חל על כל שם שהמפה מחזירה, לא רק על המנוקדים — וזה מה שהופך
אותו לכלי המרכזי בקבצים לא-פייתון. symbol="@media" על קובץ CSS גדול
מחזיר את שאילתות המדיה בלבד, כלומר בדיוק את המובייל וה-RTL עם הטווחים
שאפשר להמשיך מהם ל-lines=, במקום כל בלוקי הסלקטור. בתבנית,
symbol="block " מחזיר את תגיות {% block %} בלבד, ו-symbol="#"
את כל מה שנושא id — גם עוגני HTML בצורת tag#id וגם סלקטורי id
מתוך <style>, כי הסינון הוא בהכלה ולא בתחילית.
שמות אינם ייחודיים. if/else אינם מרחב שמות, ולכן שתי הגדרות
בשני הענפים חולקות שם מלא ונבדלות רק בשורה. וזה אינו מוגבל לענפים ואף
לא לשפה אחת: בתבנית, אלמנט עם id="main" וכלל CSS בשם div#main
בתוך בלוק <style> באותו קובץ מחזירים שתי שורות עם אותו שם בדיוק, ו-
symbol="div#main" מתאים לשתיהן. בכל המקרים שתיהן מוחזרות. הרשימה
ממוינת לפי שורת התחלה, ושובר-השוויון הוא השם, כדי שגבול העמוד יהיה יציב
בין קריאות ושתי השורות יחזרו.
השם מוחזר במלואו ולעולם אינו מקוצץ — מזהה שנחתך אינו מזהה, ו-symbol=
עם השם המלא חייב למצוא אותו.
הסיומות הנתמכות הן .py ו-.pyi לפייתון, .html, .htm,
.jinja, .jinja2 ו-.j2 לתבניות, .css ל-CSS, ו-.rst
לתיעוד — ללא תלות ברישיות. symbol= משווה ב-casefold, כך ש-STRASSE מוצא את
straße.
הערה
איפה הרשימה הזאת גרה בקוד, למי שמתחזק. מקור האמת הוא
outline._SCANNERS; מה שהסוכן קורא הוא תיאור הפרמטר outline
(``_OUTLINE_PARAM_DOC`` ב-mcp_server/server.py), ותיאור הפרמטר
symbol נושא את פסקת הסינון. שניהם ישבו עד כה בתיאור הכלי, ועברו
משם כשהתיאור הגיע ל-2,482 תווים ונחתך אצל הלקוח בדיוק בקטעים האלה.
``tests/test_mcp_server_build.py`` אוכף את שניהם במיקומם החדש, ובנוסף
שתיאור הכלי עדיין מפנה אליהם — סוכן שלא יֵדע שהפרמטרים נושאים
פירוט הוא אותו כשל במיקום חדש.
כל שורה שהמפה מחזירה היא שורה שאפשר להגיע אליה ב-lines=. זה נשמע
מובן מאליו אבל אינו: ast סופר שורות עם universal newlines, ואילו קריאת
הטווח מפצלת ב-split("\n") — כדי להתאים ל-lines_count שמשותף עם
הוובאפ, ראו קריאת טווח שורות. בקובץ שמכיל \r בודד השתיים נפרדות,
ולכן קובץ כזה נדחה במפורש במקום להחזיר מפה שמצביעה לשומקום. הערך היחיד של
המפה הוא שאפשר להמשיך ממנה לטווח.
מצב |
התשובה |
|---|---|
סיומת שאין לה סורק |
|
תחביר שבור, פייתון 2, קידוד פגום |
|
סופי שורות שאינם עקביים |
|
עמוד גדול מתקציב הפלט |
|
יותר סימבולים ממה שהמפה ממפה |
|
חשוב
no_outline הוא status ולא error, בדיוק כמו binary:
הקריאה הצליחה, פשוט אין תוכן מהסוג שביקשו. מי שבודק רק אם נזרקה
חריגה יראה קובץ שבור כקובץ בלי סימבולים — לכן חובה לבדוק את status.
HTML ו-Jinja
השמות שטוחים, לא מנוקדים. ב-HTML אין מרחבי שמות, ו-div בעומק
שתים-עשרה אינו שם משמעותי. התחילית נוספת רק כשיש עוגן אמיתי:
מה שבתבנית |
השם במפה |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
חשוב
הוספת id לבלוק <script> קיים משנה את שמן של כל
הפונקציות שבתוכו. זו התנהגות ולא באג — היא זהה לעטיפת פונקציות
במחלקה בפייתון, ששם גם היא משנה את שמות המתודות. מי שנשען על
symbol= עם שם פונקציה יצטרך את התחילית.
הפונקציות בתוך בלוק סקריפט הן העיקר. בלוק בן מאות שורות שמוחזר
כסימבול אחד נותן גבול ולא ניווט. ב-webapp/templates/base.html הבלוק
הגדול ביותר הוא מעל שבע-מאות שורות, והוא מפורק לעשרות הגדרות.
בלוק <script> נסרק פנימה רק כשהוא באמת JavaScript — כלומר בלי
type, או עם module, או עם MIME type של JavaScript לפי
mimesniff.spec.whatwg.org. <script type="application/json"> הוא
data block: גבולותיו מוחזרים, תוכנו לא נסרק.
הערה
הסורק אינו פרסר עץ, ובכוונה. תבנית Jinja אינה HTML תקין: תגית
שנפתחת בענף אחד של {% if %} ונסגרת בענף אחר היא הכתיב הרגיל כאן.
פרסר DOM מאזן את זה בשקט ומחזיר שורות שאינן במקום שבו הטקסט יושב.
תגית שלא נסגרה מדווחת עד סוף הקובץ — זו התשובה הכנה, כי היא באמת לא
נסגרה.
וגם בלוק <style> הוא מפה ולא רק גבול. הבלוקים שבתוך התבניות
נושאים נפח CSS שדומה לזה של קובצי ה-.css עצמם, ובהם מקור האמת של
טוקני הערכה: ה-:root הגלובלי ובלוקי :root[data-theme="..."]
יושבים ב-webapp/templates/base.html ולא בקובץ CSS, ראו
מערכת ערכות הנושא והטוקנים החדשה. הבלוק נסרק בסורק ה-CSS, והשמות נבנים לפי
אותו מודל תחילית: בלוק בלי id ← סלקטורים שטוחים; בלוק עם id ←
style#user-custom-theme.:root[data-theme="custom"].
בלוק <style> נסרק פנימה רק כשהוא באמת CSS — כלומר בלי type, או עם
text/css בדיוק. type="text/plain" אינו יוצר גיליון סגנון בדפדפן,
וגם type="text/css; charset=utf-8" אינו — פרמטרים נדחים, כמו בכלל
ה-essence של <script>.
CSS
הסימבול הוא בלוק הסלקטור, והעיקר הוא ה-at-rules. שם קבורים המובייל
וה-RTL, ושם באמת מחפשים: @media (max-width: 768px) מקבל טווח שורות
משלו, ואחריו אפשר להמשיך ישר ל-lines=.
מה שבקובץ |
השם במפה |
|---|---|
|
|
סלקטורים מרובים על כמה שורות |
שם אחד, מנורמל לרווח יחיד: |
|
|
|
|
|
|
|
אינו סימבול — at-rule בלי בלוק אין לה טווח |
הבלוקים שבתוך @media הם סימבולים שטוחים בפני עצמם, בלי
תחילית. ההשתייכות נקראת מטווח השורות, בדיוק כמו ב-HTML.
ההערה שמעל הסלקטור אינה חלק מהשם, ושורת ההתחלה היא של הסלקטור ולא של
ההערה. הערה בתוך סלקטור אינה שוברת אותו: .a /* x */ .b הוא סלקטור
אחד.
חשוב
בקובץ ממוזער כל הסימבולים מצביעים לאותה שורה, וזו התנהגות ולא באג.
קובץ שכל תוכנו בשורה אחת מחזיר מפה שכל רשומה בה אומרת אותה שורה —
כי שם הבלוקים באמת יושבים. ה-start וה-end נכונים, והחוזה נשמר:
אפשר להמשיך מהם ל-lines=. מפה כזאת פשוט אינה מוסיפה ניווט, ומי
שמחפש בלוק בקובץ ממוזער ימשיך ב-symbol= ולא בשורות.
RST
הסימבול הוא כותרת, והמפה היא תוכן עניינים עם טווחי שורות. זה בדיוק המקום
שבו חיפוש טקסט מאכזב: שאילתה כמו רמת לוג תמצא אזכורים בפרוזה ולא את
הסעיף שמגדיר אותה, ומפה שאומרת באילו שורות הסעיף יושב מאפשרת להמשיך ישר
ל-lines=.
ומה שמעגן את הכללים שלמטה: זיהוי הכותרות מיושר לפארסר של docutils 0.23, ונבדק מול כל קובצי ה-RST של התיעוד הזה — אותן רמות ואותה ספירה בכל קובץ — ומול טבלאות של צורות כותרת סינתטיות. זה נאמר כאן כי הכללים נראים כמו החלטות שלנו, והם אינם: הם מה ש-Sphinx יעשה עם אותו קובץ.
חשוב
אין משמעות מובנית לתו הפיסוק. === אינו ”רמה 1“. ההיררכיה נקבעת
לפי סדר הופעת התווים בכל קובץ בנפרד: התו הראשון שנתקלים בו הוא רמה
1, התו החדש הבא הוא רמה 2, וכן הלאה. לכן אותו קובץ יכול להשתמש ב-^
לרמה 3 ואחר ב-~, ושניהם תקינים.
השמות מנוקדים לפי ההיררכיה, כמו בפייתון: התקנה.דרישות.פייתון.
כותרת המסמך הופכת לתחילית של כל שם, וזה מה שמפריד שתי כותרות זהות תחת
אבות שונים.
המבנה |
השם |
|---|---|
כותרת עם קו תחתון |
הכותרת, מנוקדת לפי האבות שלה |
כותרת עם קו מעליה ומתחתיה |
אותו דבר, והטווח מתחיל בשורת הקו העליון |
|
|
יעדי .. _label: הם סימבולים נפרדים, ואלה היעדים שאליהם
:ref: מצביע. כשקישור פנימי בתיעוד נשבר, זה מה שמחפשים —
symbol="_" מגיע אל כולם. יעד חיצוני (.. _site: http://x)
אינו יעד של :ref: ואינו במפה, וכך גם יעד אנונימי (.. __:) שאין לו
שם להפנות אליו.
אזהרה
ו-``symbol=“_“`` אינו מחזיר את התוויות ואותן בלבד. הסינון הוא
בהכלה בכל השפות, כפי שכתוב בפסקת symbol= למעלה, ולכן כל כותרת
שיש בה קו תחתון חוזרת גם היא. בעמודי ה-API של התיעוד הזה זה המצב
הרגיל ולא החריג, כי הכותרת שם נושאת את ה-escape של RST. תווית מזוהה
בכך ש-start שווה ל-end; לכותרת יש טווח.
הערה
הנקודה בשם אינה מפריד בלעדי, וזו התנהגות מוצהרת. כותרת RST יכולה
להכיל נקודה בעצמה — עמודי ה-API בתיעוד הזה עושים זאת, עם כותרות כמו
chatops.permissions module — ולכן a.b.c יכול להיות ”c תחת
a.b“ או ”b.c תחת a“. השם הוא תווית ל-``symbol=`` ולא
מבנה שנפרס חזרה, וההשתייכות נקראת מטווח השורות. זו אותה מוסכמה שכבר
חלה על style#theme..foo בתבניות.
הערה
טקסט הכותרת הוא המקור ולא הרינדור, ויש לזה מחיר ב-``symbol=``.
עמוד autodoc נושא כותרת כמו services.backup\_service module —
עם הלוכסן, כי כך היא כתובה בקובץ — ולכן symbol="backup_service"
אינו מוצא אותו, וצריך את ה-escape בשאילתה. זה נאמר כאן במפורש
כי אחרת התוצאה הריקה נראית כמו עמוד בלי אאוטליין.
כותרת שנכתבה
`` code_snippets `` מוחזרת כך, ולא כ-code_snippets. הסיבה:
start מצביע לשורה בקובץ המקור, ושם מנורמל לא היה תואם לשורה שאליה
הוא מצביע.
אזהרה
מחלקה אחת מוצהרת שאינה ממודלת: בלוק literal מצוטט. תקן RST מתיר בלוק שאינו מוזח ששורותיו נפתחות בתו פיסוק, ו-Sphinx קורא את כולו כטקסט מילולי. הסורק כאן אינו מזהה את הצורה הזאת, ולכן שורת פיסוק בתוכה עלולה להיראות לו כקו של כותרת — כלומר שורה במפה שאינה כותרת בעמוד המרונדר. בקובצי התיעוד האלה אין לצורה אף מופע, והיא כתובה כאן כדי שלא תיראה כבאג במי שייפגש בה בריפו אחר.
וקובץ RST בלי אף כותרת אינו כשל — הוא מחזיר מפה ריקה עם status:
outline, כמו קובץ CSS בלי כללים. no_outline שמור לסיומת שאין לה
סורק ולתקרת הסימבולים.
קריאת טווח שורות
codekeeper_get_file ו-codekeeper_get_repo_file מקבלים lines=[start, end]
— אותו שם, אותה צורה ואותה סמנטיקה בשני הכלים, כי אסימטריה בין הקבצים
השמורים לבין הריפואים היא בדיוק סוג הפער שמייצר באגים. 1-indexed, כולל את שני
הקצוות. בלי הפרמטר מוחזר הקובץ המלא, כמו תמיד.
בתשובה של קריאת טווח נוסף בלוק range, זהה בשני הכלים:
{"range": {"start": 3, "end": 40, "total_lines": 812, "truncated": false}}
total_lines קיים כדי שהקורא ידע מה לא קיבל.
הערה
הספירה היא len(content.split("\n")), כלומר קובץ שנגמר בשורה ריקה
נספר כשורה נוספת: ל-"a\nb\nc\n" יש ארבע שורות, והרביעית ריקה.
זו אותה קונבנציה שלפיה נספרים השדות שיושבים לצידו באותה תשובה —
file.lines ב-codekeeper_get_repo_file ו-file.lines_count
בקובץ שמור — כך שאותה תשובה לא נושאת שני מספרים סותרים. כל שורה
ש-total_lines מצהיר עליה גם ניתנת לבקשה בפועל.
entries.lines ב-codekeeper_list_repo_tree מגיע מהאינדקסר, שסופר
ב-splitlines() ולכן אינו סופר את השורה הריקה האחרונה. אלה שתי
תשובות נפרדות ולא סתירה בתוך אחת, אבל כדאי לדעת לפני שמשווים ביניהן.
הערה
הטקסט מוחזר בית-בית כפי שהוא בקובץ. בקובץ עם סופי שורה של חלונות
(CRLF) כל שורה מגיעה עם \r בסופה, וזו התנהגות מכוונת ולא באג: היא
מה שמאפשר להרכיב טווחים. הטווח המלא מחזיר את הקובץ במדויק, וחיבור שני
טווחים סמוכים ב-\n משחזר אותו — מי שקורא קובץ בחלקים מקבל בסוף את
המקור, ולא גרסה שהשתנתה בשקט. קיצוץ ה-\r היה שובר את שתי התכונות
האלה, וגם מחזיר ספירה שסותרת את file.lines שיושב באותה תשובה.
הערה
``file.encoding`` אומר פחות ממה שהוא נראה, וכדאי לדעת מה. הפענוח
מנסה קודם חתימת BOM ואחר כך רשימה, ב-services/git_mirror_service.py:
utf-8— הקובץ פוענח כ-UTF-8. זה הרוב המוחלט.utf-8-sig— היה BOM בתחילת הקובץ. הוא מוסר מ-``content``, וזה החריג היחיד ל“בית-בית כפי שהוא בקובץ“ שלמעלה.sizeמגיע מ-git וסופר את הבלוב, כלומר כולל את שלושת הבתים שלא תקבלו, ו-linesנספר על הטקסט אחרי ההסרה.latin-1— נפילה-לאחור ולא זיהוי.latin-1מפענח כל רצף בתים ואינו נכשל לעולם, ולכן הוא תופס כל מה ש-UTF-8 דחה. נמדד: קובץ עברי שנכתב ב-cp1255חוזר עם התוויתlatin-1ועם תוכן משובש —שלוםהופך ל-ùìåí. התווית נראית בטוחה, והתוכן אינו.
המשמעות המעשית: utf-8 ו-utf-8-sig הם זיהוי, ו-latin-1
הוא ”שום דבר אחר לא עבד“. קובץ שחוזר עם התווית הזאת ראוי לחשד לפני
שנשענים על תוכנו — ובעברית זה המקרה השכיח, כי cp1255 אינו נגיש
ברשימה אחרי latin-1.
קלט |
התנהגות |
|---|---|
|
מקוצץ לסוף הקובץ, ו- |
|
|
|
|
ערך שאינו מספר שלם (מחרוזת, שבר, בוליאני) |
נדחה כבר בסכימה, לפני שהכלי רץ |
הערה
הדחייה של בוליאני אינה קוסמטית. list[int] רגיל מקבל [true, 5]
וממיר אותו ל-[1, 5], כלומר קריאה שגויה הייתה מחזירה בשקט את שורות 1‑5.
ההמרה קורית בגבול הסכימה, לפני שקוד הכלי רץ, ולכן ההגנה יושבת בהצהרת הטיפוס
(strict) ולא בוולידציה שבתוך הכלי.
אזהרה
ל-codekeeper_get_repo_file יש שתי תקרות, לפי מצב הקריאה: 500KB
לקובץ מלא, ו-10MB כשמועבר lines. הבלוב עדיין נקרא ומפוענח במלואו
לפני החיתוך, ולכן התקרה הוחלפה ולא בוטלה — קובץ מעל 10MB מוחזר
כ-status: "too_large" גם עם טווח, והשדה max בתשובה אומר לפי איזו
תקרה נשפט. ה-500KB ממשיכה לחול על הוובאפ ועל כל קריאה בלי lines.
הטווח אינו דרך לעקוף את התקרה.
כמה מופעים יש באמת (total מול total_at_least)
codekeeper_search_repo מחזיר שני מספרים שקל להתבלבל ביניהם, ולכן הם
נאמרים כאן במפורש:
count— כמה פגיעות חזרו בתשובה הזו. חסום ב-max_results.total— כמה פגיעות יש בריפו לשאילתה הזו.
עד ספטמבר 2026 ``total`` היה שווה ל-``count`` תמיד, כי אחרי שנאספו
max_results תוצאות התהליך נהרג ואיש לא ספר את השאר. זו הייתה מלכודת
אמיתית: באותו שרת, query= של codekeeper_get_file מחזיר בשדה בעל
אותו שם את הסך האמיתי — וסוכן שלמד את המשמעות בכלי אחד ייחס אותה לשני.
החוזה היום
חשוב
אם ``total`` קיים — הוא מדויק. אין דגל שצריך לקרוא כדי לדעת אם לסמוך עליו, כי שדה שמשמעותו משתנה לפי דגל הוא בדיוק המלכודת שתוארה למעלה.
כשהספירה הסתיימה, התשובה נושאת total עם המספר המדויק — גם כשהוא גדול
בהרבה מ-count. כשהספירה נקטעה (תקרה או timeout), אין total
כלל; במקומו מגיע total_at_least — חסם תחתון — ועוד truncation_reason
שאומר מה עצר אותה. שני השדות לעולם אינם מופיעים יחד.
יש מקרה שלישי, נדיר: שורה שנחסמה בשכבה האחרונה של מדיניות הסודות,
זו שרצה על התוצאות. אם היא הסירה משהו, פירוש הדבר שהמנוע סרק וספר קובץ
שאנחנו מסרבים להגיש — הסך שלו כולל מופעים שאיש לא יקבל, והצגתו כמדויק
הייתה גם טענה שגויה וגם אמירה בקול כמה מופעים יש בתוך קובץ חסום. גם שם
חוזר total_at_least, עם truncation_reason: "policy_filtered".
ורביעי: המנוע נפל באמצע, אחרי שכבר אסף תוצאות — למשל אובייקט פגום
במראה. מה שנאסף מוגש, עם truncation_reason: "search_failed" ובלי
total; ואם sync רץ באותו רגע, התשובה היא sync_in_progress כמו
בשאר המסלולים, כי עמוד חלקי מעץ שזז מתחת לרגליים גרוע מ“נסה שוב“. ואובייקט
חסר — ש-git grep מדלג עליו ומתלונן ב-stderr בזמן שהוא יוצא ב-0 —
נתפס דרך ה-stderr ומוריד את התשובה ל-total_at_least עם
count_failed: קוד יציאה תקין אינו הוכחה לסריקה שלמה.
זה אותו קו שכבר קיים באאוטליין: כש-too_many_symbols עוצר את הסריקה,
אין total, כי לא ידוע כמה יש. השמטה בשקט אינה תשובה, ומספר שנראה
מדויק ואינו — גרוע ממנה.
{"ok": true, "repo": "CodeBot", "query": "get_db(",
"count": 2, "total": 65, "truncated": true,
"truncation_reason": "max_results",
"results": [{"path": "webapp/app.py", "line": 118, "snippet": "..."}]}
איך זה נספר
הספירה היא מעבר שני של git grep, נפרד ממעבר התוצאות, והיא אינה
שומרת ולו שורה אחת בזיכרון: git grep -c סופר בעצמו ומדפיס רשומה אחת
לכל קובץ תואם.
למה שני מעברים ולא אחד. מעבר התוצאות מגביל את ההתאמות לכל קובץ
(git grep -m), וזה מה שמפזר את התוצאות על פני קבצים במקום למלא את
כולן מקובץ אחד. אותה מגבלה חלה גם על הספירה — קובץ עם חמש התאמות מדווח
שתיים תחת -m 2 — ולכן מעבר אחד אינו יכול לתת גם תוצאות מפוזרות וגם
סך מדויק. המעבר השני רץ בלי המגבלה, ו**רק כשהראשון אינו יכול לענות**:
חיפוש צר שהסתיים מעצמו ושבו שום קובץ לא נגע במכסה כבר יודע את הסך.
שני המעברים רצים על אותו קומיט ולא על אותו שם ענף, כי ביניהם ה-
autosync יכול למשוך עדכון — ואז total היה מתאר עץ אחר מזה שהתוצאות
הגיעו ממנו.
מה לא נכנס לספירה
קבצים חסומים במדיניות הסודות. ההחרגה נכנסת ל-
git grepעצמו, ונגזרת מאותה רשימת תבניות שחוסמת קריאה — לא מרשימה שנייה. קובץ חסום אינו נסרק, אינו מוחזר ואינו נספר. ראו מדיניות סינון סודות.קוד חיצוני וקבצים מקומפלים —
node_modulesבכל עומק, ו-*.bundle.js,*.min.js,*.min.css,*.map— כברירת מחדל. הרשימה ב-VENDORED_PATH_GLOBSב-services/git_mirror_service.py, ו-include_vendored=trueמחזיר את כולם יחד לחיפוש ולספירה. קבצים כאלה ממשיכים להיקרא כרגיל דרךcodekeeper_get_repo_file— ההחרגה היא של החיפוש בלבד. מה שקובע כאן הוא שורות תואמות ולא קבצים: באנדל אחד יכול להחזיק את רוב המופעים של מילה שגרתית כמוfunction.שורות הקשר.
context_linesאינו משנה את הספירה: שורת הקשר אינה התאמה.
אזהרה
אפס תוצאות אינו ”המחרוזת לא קיימת בריפו“. ברירת המחדל מחריגה קוד
חיצוני, ולכן מחרוזת שיושבת רק תחת node_modules תחזיר אפס. מי
שמחפש אותה במפורש — include_vendored=true.
רישיות
ברירת המחדל אינה רגישה לרישיות, כלומר Config מתאים גם ל-config
ול-CONFIG. זו ההתנהגות שהייתה כאן מאז ומתמיד (git grep -i), והיא
לא זזה.
case_sensitive=true מבקש התאמה מדויקת. הוא חל גם עם regex=true
(שני מתגים נפרדים ב-git grep, ולכן נבדק ולא הונח), וגם על הספירה:
מופע שהדגל מוציא מהתוצאות אינו נספר ב-total.
הערה
עד ספטמבר 2026 לא הייתה דרך לבקש זאת. הפרמטר היה קיים לכל אורך המנוע,
אבל שכבת ה-MCP פשוט לא העבירה אותו — ולכן git grep רץ תמיד עם
-i. ושתי השכבות שמתחת מצהירות ברירות מחדל הפוכות (False
ב-RepoSearchService.search, True ב-search_with_git_grep),
ולכן כל קורא בשרשרת מעביר את הערך במפורש ואינו נשען על אף אחת מהן.
חיפוש בתוך קובץ שמור (query)
codekeeper_get_file עם query="..." מחזיר את המופעים של המחרוזת
בקובץ במקום את התוכן — אותה תבנית שבה outline=true מחזיר מפה במקום תוכן
ב-codekeeper_get_repo_file. הבעיה שזה פותר: סוכן שרוצה שדה אחד מתוך קובץ
JSON גדול, או כלל אחד מתוך מסמך, מושך היום את הקובץ כולו — או מנחש טווח.
{"found": true,
"status": "query",
"file": {"id": "6a98...", "file_name": "CobaltNext.json", "version": 2,
"lines_count": 812, "file_size": 31174, "language": "json"},
"query": "timeout",
"count": 2, "total": 2, "truncated": false,
"results": [{"line": 41, "snippet": "\"timeout\": 30,"},
{"line": 207, "snippet": "\"timeout_ms\": 4500,"}]}
אותם שמות שדות כמו ב-codekeeper_search_repo: count, total,
truncated ו-results, ולכל פגיעה line ו-snippet. context_lines=N
מוסיף context_before ו-context_after בדיוק כמו שם, ושני המפתחות
מתווספים רק כשביקשו אותם. אין כאן אוצר מילים שני לאותו דבר.
ומה שאינו זהה, כדי שלא יוסק מהשורה שמעל. אין path, כי מדובר בקובץ
אחד. ו-total כאן קיים תמיד ותמיד מדויק — סריקה של קובץ בודד
מסתיימת תמיד. בריפו שלם יש לספירה תקרה ו-timeout, ולכן שם היא יכולה
להיקטע, ואז חוזרים total_at_least ו-truncation_reason במקום
total. שני השדות האלה אינם קיימים במסלול הזה. ראו
כמה מופעים יש באמת (total מול total_at_least).
file נושא מטא-דאטה בלבד. התוכן יורד — זה כל הרעיון — דרך אותה רשימת
שדות כבדים שמסירה אותו בכל מסלול רשימה או חיפוש אחר בשרת הזה.
ההמשך הוא lines. כל line שחוזר הוא שורה שאפשר לבקש בפועל:
lines=[line - 20, line + 20] לקטע סביבו, או lines=[line, line]
לשורה עצמה. זה מובטח מפני שהסריקה מפצלת שורות בדיוק כמו קריאת הטווח
(split("\n")), ראו קריאת טווח שורות.
מה ההתאמה עושה, ומה לא
סריקת מחרוזת, ותו לא. אין stemming, אין רג’קס ואין גבולות מילה — תו
מיוחד בשאילתה הוא תו. אותו חוזה בדיוק חל על codekeeper_search_repo,
שמתאים מילולית כברירת מחדל ועובר ל-git grep -E רק כש-regex=true
נמסר במפורש. מה שנשאר שונה הוא codekeeper_search_code: הוא $text
של מונגו, ולכן מתאים למילים שלמות ולא למחרוזת.
ההתאמה אינה רגישה לרישיות, כמו ברירת המחדל של
codekeeper_search_repo. ההשוואה היא ב-casefold, אותה בחירה כמו
symbol= באאוטליין. מה ש**כן** שונה: לחיפוש בריפו יש
case_sensitive=true שמבקש התאמה מדויקת, ולמסלול הזה אין — כאן אין
פרמטר כזה, וההתאמה תמיד חסרת רישיות.
השאילתה אינה מקוצצת, כאן ובריפו כאחד: חיפוש של הזחה (" return")
הוא שימוש אמיתי, וקיצוץ היה משנה בשקט את מה שביקשו. הקיצוץ משמש רק כדי
להכריע אם השאילתה ריקה.
שורה אחת לכל פגיעה. שני מופעים באותה שורה הם רשומה אחת, כמו ב-git grep.
מצבי הקצה
קלט |
התנהגות |
|---|---|
|
|
|
|
אפס מופעים |
הצלחה, עם |
|
|
|
מתקבל, ומחזיר מופעים. |
יותר מופעים מהתקרה |
|
|
|
|
נצמדים, ואינם נדחים — אותה מדיניות מוצהרת של שאר פרמטרי העימוד
בשרת הזה, ראו הקבועים, התקרות ואוצר המילים של התשובה. ההצמדה חלה רק כשהועבר |
חשוב
התקרה על טקסט הרשומה נמדדת בבתים, לא בתווים. המספר נלקח מ-
codekeeper_search_repo, שחותך ב-500; מה שהוחלף היא היחידה. תו עברי
הוא שני בתים ותו CJK שלושה, ולכן חסם בתווים אינו אומר דבר על גודל
התשובה בפועל — וזה כבר נפל כאן פעם אחת, כשחסם של 200 תווים לרשומה הניב
עמוד של 323,000 בתים מול תקציב של 256,000.
החיתוך נוחת תמיד על גבול תו. encode()[:n] לבדו מחזיר רצף בתים
פגום שאינו זורק במקום שגרם לו אלא אצל מי שמנסה לפענח אותו, ולכן הפענוח
נעשה מיד ומה שיוצא הוא תמיד מחרוזת שלמה.
תקציב הבתים של התשובה כולה נמדד על הסריאליזציה האמיתית ב-UTF-8, ולא בספירת תווים ולא בהערכה פר-רשומה.
גודל וספירת שורות ברשימת הריפו
codekeeper_list_repo_tree עם include_stats=true מוסיף לתשובה מפתח
entries — רשומה לכל נתיב באותו עמוד ובאותו סדר של paths:
{"path": "webapp/app.py", "size": 48219, "lines": 1204, "lines_commit_sha": "a1b2c3d"}
paths עצמו אינו משתנה, וללא הדגל התשובה זהה לחלוטין לזו של היום.
מאיפה כל שדה מגיע, ולמה זה משנה:
size— מ-git ls-tree -r -l, כלומר גודל הבלוב בבייטים. אפס עלות נוספת: אותה קריאת git יחידה שממילא מרכיבה את הרשימה, רק עם עמודה נוספת. הערך הואnullעבור תת-מודול (submodule): אין לו גודל בבייטים כי התוכן שלו חי בריפו אחר. הנתיב עצמו אינו מושמט — תת-מודול מופיע ברשימה בדיוק כמו כל נתיב אחר, רק בלי גודל.lines— מאוסףrepo_files, שהאינדקסר (services/code_indexer.py) ממלא. שאילתה אחת לעמוד, חסומה בגודל העמוד ולא בגודל הריפו, ונשענת על האינדקס(repo_name, path)שהשירות מוודא בעלייה. את הווידוא הזה עושהDatabaseManager.safe_create_index— הוא ולא קוד יצירה מקומי, כי רק הוא קורא את האינדקס הקיים אחרי התנגשות ומאשר רק אם המפתחות וגםuniqueתואמים. הדגל הואunique=True, בדיוק כפי ש-scripts/create_repo_indexes.pyמצהיר: הצמד הזה הוא הזהות שה-upsert של האינדקסר מניח, ואינדקס לא-ייחודי על אותם מפתחות היה חוסם את יצירת הייחודי. git אינו יכול לספק ספירת שורות בלי לקרוא כל בלוב בנפרד, וזו עלות שלא משלמים כאן.lines_commit_sha— הקומיט שבו הספירה נלקחה.
חשוב
lines הוא null כשהקובץ מעולם לא אונדקס (למשל קובץ גדול שהאינדקסר
מדלג עליו), והוא עלול להיות מיושן: את repo_files ממלא הסנכרון של
הוובאפ, בעוד ה-autosync של שירות ה-MCP מריץ git fetch בלבד. ספירה
מיושנת גרועה מספירה חסרה, כי היא נראית תקינה — ולכן lines_commit_sha
נשלח לצידה. שוו אותו ל-ref שביקשתם כדי לדעת אם הספירה מתארת את הגרסה
שאתם קוראים.
truncated: true בתשובה אומר שהעמוד נחתך בתקציב הפלט. עם
include_stats הרשומות שמנות יותר, ולכן החיתוך עשוי לקרות מוקדם יותר
מאשר בקריאה רגילה — paths ו-entries תמיד נחתכים יחד ונשארים
באותו אורך ובאותו סדר.
קריאה קבוצתית — codekeeper_read_batch
כלי אדמין שקורא כמה סעיפי תיעוד וקבצים מהמראות בקריאת כלי אחת. הצורך נולד מסוכן ריוויו: לפני כל סבב הוא חייב לקרוא כל מסמך ב-amir-bug-patterns שהטריגר שלו נדלק על הדיף, ובסבב שבו הוא דיווח על כך דרך get_more_tools אלה היו 16 קריאות נפרדות. הקוד: mcp_server/read_batch.py; השקילה והרישום: mcp_server/server.py.
למה באץ«, ולא קריאות במקביל
הקריאות של סבב מגיעות לשרת אחת אחרי השנייה ולא יחד, וכמעט כל הזמן שלהן עובר מחוץ לשרת. זה נמדד ב-PostHog, ולא הונח. בסבב של 2026-09-23 (סשן ses_01a0cd48, בין 11:41:33 ל-11:43:49 UTC) הקריאות הגיעו בארבעה פרצים של ארבע, ובתוך כל פרץ — במרווחים של 0.64 עד 1.35 שניות זו מזו. ארבעת הפרצים נמשכו יחד כ-11.5 שניות, והשרת עבד מתוכן כ-463 אלפיות שנייה: כל קריאה נענתה בין 11 ל-181 אלפיות שנייה. גם בכל התנועה מאז המעבר לפרנקפורט (17–23.9; אירועי $mcp_tool_call של codekeeper_docs_get_section ו-codekeeper_get_repo_file, המרווח בין קריאות עוקבות באותו $session_id) רק 3 מתוך 491 מרווחים היו קצרים מ-50 אלפיות שנייה, והעשירון התחתון היה 837 אלפיות שנייה — בזמן שהחציון של זמן השרת היה 19.4 אלפיות שנייה לסעיף ו-9.9 לקובץ. כלומר רוב הזמן של סבב אינו השרת.
מה ממלא את המרווח. Claude Code מתעד שהוא מריץ כלים לקריאה בלבד במקביל (CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY, עשרה כברירת מחדל; code.claude.com/docs/en/env-vars.md, נקרא ב-2026-09-23), ובכל זאת אף פרץ לא הגיע יחד. יש שני הסברים אפשריים: המודל כותב את הקריאות אחת-אחת, וכל קריאה יוצאת כשהוא גומר לכתוב אותה; או שלכל קריאה נוסף זמן קבוע בדרך מהלקוח. תצפית אחת מהפרודקשן נוטה להסבר הראשון. שלושה באצ’ים נשלחו באותה הודעה (2026-09-23, Claude Code 2.1.281), והשני והשלישי, 20 פריטים כל אחד, הגיעו לשרת 6.7 ו-7.0 שניות אחרי הקודם — בזמן שהשרת עבד על כל באץ« 14 עד 18 אלפיות שנייה. בסבב שלמעלה, קריאות בודדות הגיעו במרווחים של 0.64 עד 1.35 שניות. מרווח שגדל עם אורך הקריאה מתאים לזמן שלוקח לכתוב אותה, ולא לזמן קבוע לכל קריאה. זו תצפית אחת, ולא מדידה מבוקרת.
ומה באץ« חוסך. באץ« הוא קריאת כלי אחת, ולכן הוא חוסך את מה שחוזר בכל קריאה — בשני ההסברים. אם לכל קריאה נוסף זמן קבוע, הוא משולם פעם אחת במקום N. אם הזמן הוא זמן הכתיבה, נחסך מה שהמודל כותב בכל קריאה מעבר לפריט עצמו: שם הכלי והמבנה של הקריאה, ומשפט ה-context שהאנליטיקס מבקש מכל קריאה (ראו ”לכידת הכוונה“ ב-מדידת שימוש (PostHog MCP Analytics)). את כתיבת הפריטים עצמם באץ« אינו חוסך: באץ« של N פריטים עדיין דורש לכתוב N פריטים. לכן באץ« הוא ”מרווח אחד במקום N“ רק בהסבר השני. כמה באץ« חוסך בסבב אמיתי לא נמדד. המדידה שתכריע: אותם פריטים באותה הודעה, פעם כקריאות בודדות ופעם כבאץ« אחד, והשוואה של זמני ההגעה ב-PostHog.
אדמין בהחלטה, ולא בירושה. פריטי הסעיף משקפים כלי ציבורי, אבל הצורך נולד מסוכן הריוויו של האדמין, פריטי הקובץ ממילא דורשים אדמין, ומשטח ציבורי שמכפיל עלות פרסור בקריאה אחת אינו נחוץ היום. הכלי רשום ב-_REPO_BROWSER_TOOLS, מוסתר מ-tools/list לכל מי שאינו אדמין, ו-require_admin היא השורה הראשונה בגוף שלו.
פריט, ומה הוא מחזיר
שני סוגי פריטים, וכל אחד עובר בדיוק דרך ה-handler של הכלי שהוא משקף — אין כאן מסלול קריאה שני. פריט סעיף {"kind": "section", "path", "section"?, "repo"?} עובר דרך החלקים ש-codekeeper_docs_get_section עצמו עשוי מהם: resolve_docs_target, load_document ו-answer_section ב-mcp_server/docs_handlers.py, עם ברירות המחדל של העימוד שלו. פריט קובץ {"kind": "file", "repo", "path", "lines"?} עובר דרך repo_handlers.get_repo_file. לכן ה-result של כל פריט זהה בית-בית למה שהכלי הבודד מחזיר על אותם ארגומנטים, כולל תשובות הסירוב, וכישלון של פריט אינו כישלון של הקריאה:
{"ok": true, "count": 2, "items": [
{"index": 0, "request": {"kind": "section", "path": "CRITICAL-PATTERNS", "section": "K11"},
"resolved_commit": "456e90b66c49c731006656a27b77a28b21163252",
"result": {"ok": true, "mode": "section", "section": "K11. …", "content": "…"}},
{"index": 1, "request": {"kind": "file", "repo": "amir-bug-patterns", "path": "nope.md"},
"result": {"ok": false, "error": "not_found"}}
]}
הסדר הוא סדר הבקשה, ו-
indexהוא המקום של הפריט בה.countהוא מספר הפריטים שבתשובה.פריט אינו מקבל ``ref``. כל ריפו נקרא בענף הראשי שלו, מקובע ל-commit אחד לכל הקריאה (למטה). לקריאה ב-ref אחר יש את הכלים הבודדים.
פריט סעיף נקרא בברירות המחדל של העימוד —
max_charsהואMAX_CHARS_DEFAULT, ו-offsetהוא 0. סעיף ארוך יותר חוזר עםtruncatedו-next_offset, כמו תמיד, וממשיכים אותו ב-codekeeper_docs_get_sectionעצמו.פריט קובץ אינו מקבל ``outline``, ``symbol`` או ``page``. הבאץ« קורא תוכן; מפה של קובץ נשארת בכלי הבודד.
כל מפתח אחר, או ערך מטיפוס לא נכון, הוא ``invalid_item`` של אותו פריט בלבד, עם
problemsשאומר איפה ומה — בלי להדהד את הערך שנשלח, כי הוא יכול להיות תוכן. הבדיקהstrict:lines=[true, 5]נדחה ולא הופך ל-[1, 5], בדיוק כמו בכלי הבודד. הסכימה שהלקוח רואה לפריט נגזרת משני המודלים שמאמתים אותו (SectionItemו-FileItem), כך שהחוזה המוצהר והאכיפה אינם יכולים להיפרד.פריטים כפולים מותרים, והם חולקים את אותה קריאה.
commit אחד לכל ריפו
בתחילת הקריאה RepoBackend.snapshot בונה תמונת מצב (ReadSnapshot ב-mcp_server/repo_backend.py). היא שואלת שלוש שאלות, כל אחת פעם אחת לכל ריפו ולא לכל פריט: מה הענף הראשי, לאיזה commit הוא מצביע עכשיו (GitMirrorService.resolve_commit), והאם סנכרון רץ — את האחרונה רק במסלול הכשל. כל הפריטים של אותו ריפו נקראים מה-SHA הזה, ולכן autosync שמושך באמצע הבאץ« אינו מפצל את התשובה בין שני commits.
התשובה נושאת את ה-``ref`` שהכלי הבודד היה כותב — שם הענף ולא ה-SHA — כדי שה-result יישאר זהה בית-בית. ה-SHA עצמו מופיע ב-resolved_commit: בתוך ה-result כמו תמיד, וגם ברמת הפריט, כך שאפשר לראות מאיזה commit בא כל פריט בלי להכיר את צורת התשובה של כל כלי. פריט שלא נקרא בו תוכן (סירוב, קובץ שלא נמצא) אינו נושא resolved_commit.
אזהרה
כשהקיבוע נכשל באופן זמני, פריטים של אותו ריפו יכולים להגיע מ-commits שונים. אם resolve_commit עונה timeout או internal_error, השרת רושם WARNING אחד לריפו, והפריטים שלו נקראים לפי שם הענף — כל אחד פותר אותו בנפרד, כמו הכלי הבודד. autosync שמושך באותו רגע יכול אז לפצל אותם בין שני commits. זה לא נמנע, אבל זה גלוי: resolved_commit של כל פריט אומר מאיזה commit הוא בא, ומי שצריך commit אחד בודק שכולם שווים. קוד שאומר ”אין כזה“ (invalid_ref, repo_not_found) אינו נרשם: הוא חוזר מהקריאה עצמה בשמו, בדיוק כמו בכלי הבודד.
קריאה אחת לכל קובץ, ושני סדרים
הפריטים מקובצים לפי הקובץ שהם קוראים — ``(repo, path, lines)`` — וכל קבוצה נקראת ומפורסרת פעם אחת, כשהמעבר מגיע לפריט הראשון שלה. כך K11 ו-K13 מאותו CRITICAL-PATTERNS.md עולים קריאה אחת ופרסור אחד. המסמך משתחרר כשהקבוצה נענתה, לפני שהקבוצה הבאה נקראת. אין מטמון ואין פינוי: בסבב האמיתי רק שני זוגות של פריטים חלקו קובץ, ומטמון עם תקרה היה מנגנון בלי עבודה.
``lines`` הוא חלק מהמפתח, וזה לא עניין של יעילות. קריאה מלאה נשפטת מול 500KB (MAX_FILE_SIZE_FOR_DISPLAY), וקריאת טווח מול RANGE_READ_MAX_BYTES — ותקרת ה-500KB היא ההגנה היחידה של הפרסור. פריט סעיף הוא תמיד קריאה מלאה, ולכן הוא חולק קבוצה רק עם פריט קובץ בלי lines. קובץ של 600KB שמבקשים בו גם טווח וגם סעיף, באותו באץ«, מחזיר לפריט הסעיף unreadable_too_large — בדיוק כמו הכלי הבודד — ואינו מפרסר טקסט שנקרא תחת התקרה של טווח.
שני סדרים, ואיך הם חיים יחד. הקריאה מהמראה נעשית לפי הקבוצות, אבל התשובה נבנית ונמדדת לפי סדר הבקשה. המעבר הולך על האינדקסים 0, 1, 2…, וכשהוא מגיע לפריט שהקבוצה שלו עוד לא נקראה, הוא קורא אותה ועונה באותה הזדמנות על כל הפריטים שלה — גם על אלה שבהמשך. תשובה כזו מחכה עד שהמעבר מגיע לאינדקס שלה, ורק אז נכנסת לתקציב. לכן התשובה היא תמיד רצף מתחילת הבקשה, ו-unread הוא תמיד הסיפא שלה: כשפריט אינו נכנס בתקציב, או שהדדליין הגיע לפני שהקבוצה שלו נקראה, המעבר נעצר, והפריט הזה וכל מה שאחריו מדווחים ב-unread. זה כולל פריט שכבר נענה כחלק מקבוצה, כי תשובה שמופיעה אחרי החור לא תיכנס. זה מה שמאפשר unread_reason אחד, ואת הכלל הפשוט ללקוח: שולחים שוב את כל מה שב-unread.
והזיכרון. מה שכבר נכנס לתשובה, ועוד כל תשובה שמחכה לתורה, אינם עוברים יחד את OUTPUT_BYTE_BUDGET — זה האינווריאנט של _keep. תשובה נשמרת רק אם היא עוד תיכנס אחרי כל מה שלפניה. וכשתשובה חדשה נשמרת לפני תשובות שכבר מחכות, הראשונה מהן שכבר לא תיכנס מסומנת כנקודת העצירה ומפונה מיד, יחד עם כל מה שאחריה — המעבר ייעצר בה ממילא. בלי הפינוי הזה החסם לא היה קיים: קבוצה שנקראת עונה גם על פריטים רחוקים בבקשה, וכל תשובה רחוקה שנבדקה רק מול מה שלפניה נכנסה לבדה. בבאץ« של עשרה סעיפים ואחריהם עשרת הקבצים שלהם בסדר הפוך, התשובות שחיכו יחד היו פי כמה מהתקציב, ואף אחת מהן לא נשלחה (test_answers_waiting_for_their_turn_never_hold_more_than_the_budget). הפינוי אינו משנה את התשובה ללקוח, כי פריט שפונה לא היה נכנס ממילא, ותשובה לפריט שאחרי נקודת העצירה אינה נבנית כלל. מעל התקציב יושבים רק הקריאה של הקבוצה הנוכחית — קובץ אחד והמסמך המפורסר שלו — ותשובה אחת שנבנית ברגע זה. נמדד ב-scripts/measure_read_batch.py (2026-09-23, מראה של amir-bug-patterns ב-commit 456e90b6, שיא ההקצאות של פייתון ב-tracemalloc): קריאה בודדת של סעיף מהקובץ הגדול ביותר שהכלי מגיש (107,494 בתים) — 2.50MiB; באץ« מלא של סעיפים שמדלג בין חמשת הקבצים הגדולים — 2.51MiB; הסבב האמיתי — 0.74MiB. כלומר באץ« עולה את הקריאה היקרה שבו, ולא את סכום הקריאות. מאגר הקריאות מתומחר לפי פרסור אחד לחוט (ראו ”מודל הריצה של הכלים“), ולכן הוא אינו צריך תמחור אחר בשביל הבאץ«. והבאץ« רץ כולו בחוט אחד של המאגר, פריט אחרי פריט, בלי חוטים משלו.
תקציב הבתים, ולמה בתים הם גם תווים
התקציב הוא ``OUTPUT_BYTE_BUDGET`` לכל התשובה, והוא נמדד בצורה שה-SDK שולח. תשובת dict של כלי יוצאת כ-pydantic_core.to_json(..., indent=2) (_convert_to_content ב-mcp 1.28.1), והכלי מודד בדיוק את זה — כולל ההזחה של כל פריט בתוך items — ולא את json.dumps הדחוס שכלים אחרים בשרת נמדדים בו. הצורה שנשלחת לעולם אינה קטנה מהדחוסה, כי היא מוסיפה רק רווחים ושורות, ולכן תשובה שנכנסת במדידה הזו נכנסת בשתיהן. המעטפת שמורה מראש: לפני הפריט הראשון נשמר המקום של המעטפת במקרה הגרוע — כל האינדקסים ב-unread והסיבה הארוכה מבין השתיים — כך שהמעטפת האמיתית לעולם אינה דוחקת פריט שכבר נכנס.
פריט נכנס שלם, או לא בכלל. אין חיתוך בתוך פריט. פריט שגדול לבדו מכל מה שתשובה יכולה להכיל מקבל במקום התשובה שלו את הגודל שהיה לו, את התקרה, ואת הכלי הבודד שיקרא אותו. זו תשובה אמיתית, על קובץ עברי של 444,012 בתים בבאץ« של פריט אחד:
{"ok": false, "error": "item_too_large", "bytes": 456557, "max": 255893, "read_with": "codekeeper_get_repo_file"}
max הוא מה שנשאר לפריט אחרי המעטפת השמורה, ולכן הוא תלוי במספר הפריטים בבאץ«. פריט קובץ יכול גם לצמצם את עצמו ב-lines.
למה בתים הם גם תקרת תווים. Claude Code שומר לקובץ תשובת כלי שעוברת את הסף שלו — 25,000 טוקנים כברירת מחדל (MAX_MCP_OUTPUT_TOKENS) — ומחליף אותה בהקשר בהפניה לקובץ. כלי יכול להעלות את הסף של עצמו עד 500,000 תווים, ב-_meta["anthropic/maxResultSizeChars"] שהוא מצהיר ב-tools/list (code.claude.com/docs/en/mcp.md, ”Raise the limit for a specific tool“, נקרא ב-2026-09-23). בלי ההצהרה, באץ« של סבב ריוויו היה מגיע כקובץ ולא להקשר — וזה כבר קורה לכלים הבודדים: קובץ עברי של 79,626 בתים שנקרא ב-codekeeper_get_repo_file נשמר לקובץ (אישו #3460, מחוץ לשינוי הזה). הכלי מצהיר MAX_RESULT_CHARS, שהוא OUTPUT_BYTE_BUDGET עצמו, כמספר תווים, ובכוונה: בכל טקסט UTF-8 מספר התווים אינו עולה על מספר הבתים — וגם לא מספר יחידות ה-UTF-16 שבהן JavaScript סופר אורך — ולכן תשובה שחסומה ב-OUTPUT_BYTE_BUDGET בתים לעולם אינה עוברת את מה שהוצהר. ההזחה כבר בתוך המדידה, ואין מה להוסיף עליה.
ההצהרה נבדקה בפרודקשן (2026-09-23, Claude Code 2.1.281 בסשן ענן). אותו קובץ של 79,626 בתים נשמר לקובץ כשנקרא ב-codekeeper_get_repo_file (52,158 תווים), ונכנס להקשר במלואו כשנקרא כפריט יחיד בבאץ« (52,542 תווים). התשובה של הבאץ« גדולה יותר בגלל המעטפת, ובכל זאת נכנסה — כלומר מה שהכניס אותה הוא ההצהרה, ולא הגודל. גם הסבב האמיתי נכנס במלואו (71,425 תווים). ומכאן שאותה הצהרה על הכלים הבודדים אמורה להכניס להקשר גם את התשובות שלהם (אישו #3460). בצ’אט של claude.ai (הלקוח Anthropic/ClaudeAI ב-PostHog) באץ« של פריט אחד על אותו קובץ הגיע לשיחה שלם, אבל שם לא הורצה אותה קריאה בכלי הבודד, ולכן זה לא מראה אם ההצהרה היא שעשתה את ההבדל. אפליקציית הדסקטופ לא נבדקה.
הדדליין
``DEADLINE_SECONDS`` — עשר שניות קיר, מהרגע שהקריאה נכנסה ל-``call_tool``. לא מתחילת הגוף: בין השניים יש המתנה לחוט פנוי במאגר הקריאות, וגם היא על השעון של הלקוח. call_tool רושם את הרגע הזה ראשון, ומעביר אותו לגוף ב-ContextVar (asyncio.to_thread מעתיק את ההקשר לחוט). כשהדדליין עבר, המעבר עוצר לפני הקריאה הבאה מהמראה: מה שכבר נקרא חוזר, ומה שלא נקרא מדווח ב-unread עם unread_reason: "timeout". פריט שכבר נענה — סירוב של השער, או חבר בקבוצה שכבר נקראה — נכנס גם אחרי הדדליין, כי העבודה שלו כבר נעשתה.
מול איזו מגבלה של הלקוח. ל-Claude Code שתי מגבלות על קריאת כלי לשרת HTTP, וזה מה שהתיעוד שלו אומר (code.claude.com/docs/en/mcp.md, נקרא ב-2026-09-23). האחת: טיימר לכל בקשה, 60 שניות כברירת מחדל, ש“מכסה כל בקשה עד הבית הראשון של התשובה מהשרת“. השנייה: חלון idle של חמש דקות — קריאה שלא הגיעה ממנה תשובה ולא הודעת התקדמות במשך החלון מבוטלת (מ-Claude Code 2.1.187; הסבב שהוליד את הכלי רץ על 2.1.280). השרת הזה עונה על tools/call בזרם SSE: json_response כבוי — ברירת המחדל של FastMCP, ש-build_mcp אינו משנה — ו-mcp/server/streamable_http.py (mcp 1.28.1) מתחיל את תשובת ה-SSE, כלומר שולח את הכותרות, לפני שהוא מעביר את הבקשה לכלי. כך גם נמדד בהרצה מקומית: הכותרות הגיעו תוך אלפיות שנייה, גם מכלי שרץ 12 שניות. כלומר היום טיימר ה-60 נסגר מיד, ומה שמגביל קריאה ארוכה מצד הלקוח הוא חלון ה-idle. אבל בתשובות JSON (json_response=True) הבית הראשון הוא התשובה עצמה, ואז 60 השניות מכסות את כל הריצה — ולכן הדדליין נבחר מול המחמירה מבין השתיים. ההתנהגות מאחורי הפרוקסי של Render לא נמדדה.
חשוב
ההנחה שהמספר נשען עליה: הדדליין נבדק בין פריטים בלבד. פריט שהתחיל רץ עד סופו, ולכן באץ« יכול לעבור את DEADLINE_SECONDS בכדי הפריט היקר ביותר שבו. היקר ביותר הוא פרסור Markdown תחת התקרות של md_parser — WORST_CASE_CPU_SECONDS ב-services/md_parser.py, שם גם הקלט, המדידה והתאריך: 0.66 שניות מעבד, שהן 13.2 שניות קיר תחת עומס מלא — עשרה חוטים של מאגר הקריאות על חצי מעבד. 10 + 13.2 = 23.2, מתחת ל-60, וממילא מתחת לחמש הדקות. ``tests/test_md_parse_worst_case_claims.py`` גוזר את החשבון הזה מהקבועים, ובודק שהשורה כאן היא החשבון שלהם. עד #3391 הפריט היקר לא היה חסום בכלל, והחשבון נשען על מדידה אחת שלו — שכבר לא הייתה הגרועה: רשימה מקוננת עלתה בערך פי שניים ממנה, והחשבון איתה היה עובר את 60. מי שמשנה את DEADLINE_SECONDS, את רוחב המאגר, את מכסת המעבד או את התקרות של md_parser — משנה את החשבון הזה. ומה שהחשבון אינו מכסה: קריאת git שנתקעת עד ה-timeout של עצמה (get_file_at_commit ב-services/git_mirror_service.py) היא כשל ולא קריאה איטית, והיא קיימת בכלי הבודד באותה מידה.
מכסת הקצב, ולמה אין החזר
כל פריט נספר כקריאה אחת, והכל או כלום, לפני שדבר נקרא — הפרטים ב-גבולות הבקשה — גודל הגוף והקצב. פריטים שלא נקראו (``unread``) אינם מחזירים את היחידות שלהם, וזו החלטה. המקרה נדיר: בפרודקשן הסבב האמיתי היה 98,981 בתים בצורה שנשלחת — 39% מהתקציב — ו-270 אלפיות שנייה, 2.7% מהדדליין (2026-09-23, Claude Code 2.1.281; הבתים נספרו על התשובה שהגיעה ללקוח, והזמן הוא $mcp_duration_ms של האירוע ב-PostHog). החזר היה מנגנון שני במגביל, עם מקרי קצה משלו (חלון שכבר התגלגל, קריאות מקבילות של אותה זהות), בלי שום דבר שמצדיק אותו היום. אם unread יתחיל להופיע בשימוש אמיתי, זה המקום לחזור אליו.
אותו סבב במדידה המקומית. REVIEW_ROUND ב-scripts/measure_read_batch.py מריץ את הסבב מעל מראה מקומית, באותו commit שהפרודקשן קרא (456e90b6), ומודד 98,816 בתים ו-135 אלפיות שנייה. הבתים שונים בדבר אחד: הסקריפט רץ בלי מונגו, ולכן RepoBackend._default_ref מחזיר בו HEAD, ובפרודקשן refs/heads/main (מ-repo_metadata) — 11 בתים פחות בכל פריט שנקרא. כשאותה ריצה מקבלת refs/heads/main כענף הראשי, היא מחזירה 98,981 בתים, בדיוק כמו בפרודקשן. הזמן שונה בשני דברים: הסקריפט מודד רק את read_batch.read_batch, ו-$mcp_duration_ms עוטף את ToolManager.call_tool — כולל אימות הארגומנטים, ההעברה לחוט של מאגר הקריאות וההמרה ל-JSON (_instrument_fastmcp.py ב-posthog 7.45.3, ו-FastMCP.call_tool ב-mcp 1.28.1); והפרודקשן רץ על מכונה אחרת. כמה מהפער בא מכל אחד מהשניים לא נמדד. מול הדדליין קובע המספר של הפרודקשן, ובפועל הזמן שנספר מול הדדליין קצת יותר ארוך ממנו: הדדליין נמדד מהכניסה ל-call_tool, עוד לפני הקטע ש-PostHog מודד.
ב-PostHog באץ« הוא אירוע אחד. $mcp_tool_call נרשם לכל קריאת כלי, ולכן באץ« הוא אירוע אחד עם משך אחד — ספירה לפי שם כלי אינה סופרת פריטים. הוא גם אינו נושא ck_read_mode (ראו מצב הקריאה (ck_read_mode)). וסירובי התקרה, missing_items ו-too_many_items, מוכרעים ב-call_tool לפני התפר שהמדידה עוטפת — בדיוק כמו rate_limited — ולכן אינם נראים שם כלל.
מדידת שימוש (PostHog MCP Analytics)
השרת מדווח ל-PostHog אירוע על כל
קריאת כלי, על כל tools/list ועל כל לחיצת יד — כדי לדעת באילו כלים באמת
משתמשים, כמה זמן הם לוקחים וכמה מהם נכשלים. הקוד: mcp_server/analytics.py.
הסעיף הזה מתאר את צד ה**כתיבה**: מה השרת שולח, ומה נחסם בשער הפרטיות שלפניו.
לקריאת הנתונים חזרה יש מסך אדמין ייעודי בוובאפ — /admin/mcp — עם משתני
הסביבה שהוא דורש ומצבי הכשל שלו: MCP Analytics (מדידת השימוש בכלי ה-MCP). שימו לב
ש-POSTHOG_HOST נושא שם משותף וערך שונה בין שני השירותים, והפרטים שם.
מה לא יוצא מהשרת. בשרת הזה הארגומנטים של הכלי והתוצאה שלו הם התוכן של
המשתמש: codekeeper_get_file מחזיר גוף של קובץ, codekeeper_save_file
מקבל אחד, ואותם מסלולים משרתים גם פתקים ומסמכים משוקפים. לכן הוק
before_send על לקוח ה-PostHog מחזיק רשימת היתר של מאפייני $mcp_*,
וכל מאפיין שאינו בה נזרק:
מאפיין |
מה קורה איתו |
|---|---|
|
נזרק, בכל אירוע ובלי חריג. הארגומנטים של הקריאה — בכלי הכתיבה זה הקובץ עצמו |
|
נזרק, בכל אירוע ובלי חריג. תוצאת הכלי — גוף קובץ, פתק, מסמך |
|
נשלח, כשהערך מחרוזת, ואחרי סינון סודות. הודעת החריגה של קריאה שנכשלה, שבלעדיה הדשבורד יודע שמשהו נדחה ולא איזה שדה. ראו את האזהרה מתחת לטבלה — גם אחרי הסינון ההודעה עלולה לשאת קטע מהקלט שנדחה |
|
נשלח, כשהערך מחרוזת, ואחרי סינון סודות. המשפט שהסוכן כותב על
מה שהוא מנסה להשיג. ראו ”לכידת הכוונה“ למטה — הוא נוצר רק כי
|
|
נשלח, כשהערך הוא בדיוק |
|
מוחלף בטקסט קבוע, בכל אירוע. לא אותו מידע כמו
|
|
נשלח, כשהערך אחד מארבעה: |
|
נשלחים. מטא-דאטה של השרת ושל הפרוטוקול בלבד |
הלקוח הזה משרת רק את שרת ה-MCP, וכל מה שהשרת נוגע בו הוא תוכן של המשתמש.
לכן הכלל חל על כל אירוע שהלקוח שולח, ולא רק על אירועי $mcp_.
שני שדות של טקסט חופשי, ובדיקה אחת שחלה על שניהם. $mcp_error_message
ו-$mcp_intent הם היחידים שיוצאים ואינם נכתבו על ידי השרת. שניהם מוצדקים
כ“משפט שאדם קורא“, ולכן שניהם עוברים רק כשהערך הוא מחרוזת: מילון או רשימה
תחת אותו שם אינם המשפט הזה אלא מבנה שלם — ומבנה אי אפשר לחטא חלקית, ולכן
הוא נזרק. זה נמדד ולא נוסח: מילון תחת $mcp_error_message עבר גרסה
קודמת של השער, שבדקה מי מבקש ולא מה עובר, עם גוף קובץ בתוכו.
ואז שניהם עוברים סינון סודות. אותה רשימת דפוסים שמסננת את
הפריימר, ב-mcp_server/redaction.py — מודול טהור
ששני הצרכנים מייבאים, ולא עותק שני. זה לא סגנון: כשלכל צרכן הייתה רשימה
משלו, דפוס נוסף לאחד ולא לשני, וזה כבר קרה בפרויקט הזה.
נתפס |
לא נתפס |
|---|---|
כל צורת סוד מוכרת, בכל מקום במחרוזת: מפתח AWS בזנב של
|
הכלל לפי שם ( |
— |
נתיבי קבצים ושמות. הם אינם סודות, והמסנן אינו מתיימר להיות מנקה PII |
המסנן מצמצם חשיפה; הוא אינו הופך את השדות לבטוחים. האזהרה שמתחת נשארת תקפה במלואה.
אזהרה
הודעת השגיאה עלולה לשאת קטע מתוכן המשתמש, וזה התקבל במודע.
ההודעה של Pydantic מכילה את input_value — הערך שנדחה. הוא מקוצץ ל-50
תווים, אבל הקיצוץ שומר ראש וזנב, ולכן ערך רגיש בתחילת הקלט או בסופו
עובר שלם. נמדד על pydantic 2.12.3: קלט בן 500+ תווים שהסתיים
ב-AWS_KEY=... החזיר את הזנב במלואו. והמקרה הרחב יותר הוא שדה חובה חסר
(type=missing), שבו Pydantic מדווח כ-input_value את אובייקט
הארגומנטים כולו — כלומר codekeeper_save_file בלי file_name שם
קטע מגוף הקובץ בהודעה.
וזה כבר לא מוגבל ל-Pydantic. בגרסה קודמת ההודעה נשלחה רק כאשר
$mcp_error_type היה בדיוק ValidationError; הגידור הזה הוסר, ולכן
הודעה של חריגה מכל סוג יוצאת — כולל חריגה שספרייה זרקה. חריגת pymongo
יכולה לנקוב בכותרת שחיפשנו, ושגיאת מערכת יכולה לנקוב בנתיב. מה שמצמצם את
זה בפועל אינו השער אלא הקוד: repo_backend.py תופס רחב ומחזיר
{"ok": false, "error": ...} במקום לזרוק, ולכן רוב הכשלים לעולם אינם
מגיעים לכאן כחריגה. זו תכונה של הקוד היום, לא הבטחה של השער — מסלול
חדש שנותן לחריגה לעלות מרחיב את מה שיוצא, בלי לגעת ב-analytics.py.
הגידור הוא בהיקף ולא בניקוי: אין כאן ניסיון לחטא את המחרוזת, כי
סניטציה מומצאת על טקסט חופשי נכשלת בשקט. האורך מגודר על ידי ה-SDK, שמקצץ
את $mcp_error_message ל-2,048 תווים.
למה רשימת היתר ולא מחיקה של שני השמות. מחיקה לפי שם היא רשימה שחורה: ה-SDK של MCP הוא טרום-1.0, ומאפיין נושא-תוכן שייכנס במהדורה עתידית היה יוצא לרשת לפני שמישהו הספיק להוסיף אותו לרשימה. רשימת היתר זורקת דווקא את מה שלא מכירים. ההוק גם נכשל סגור: תקלה בתוכו מפילה את האירוע במקום להעביר אותו כמות שהוא.
לכידת הכוונה (context)
$mcp_intent אינו נוצר מעצמו. posthog.mcp מייצר אותו רק כשהאופציה
context דלוקה — או כשמוגדר intent_fallback, שאינו מוגדר כאן — ולכן
פתיחת המאפיין בשער לבדה לא הייתה משנה דבר. האופציה דלוקה.
המחיר, שנמדד ולא הוערך. במסלול ה-FastMCP של השרת הזה context=True
מוסיף לסכימה המוצהרת של כל כלי פרמטר context מסוג מחרוזת, ומוסיף
אותו גם לרשימת ה-required. זו הפעם הראשונה שהאינסטרומנטציה נוגעת בסכימה
של כלי קיים.
מה שהמדידה הראתה, ומרגיע: קריאה שאינה מעבירה context אינה נדחית.
הכלי רץ בדיוק כמו קודם, והפרמטר המוזרק אינו מגיע לגוף הכלי כלל — ה-SDK מסיר
אותו לפני ההפעלה. כלומר לקוח קיים אינו נשבר; מה שהשתנה הוא מה שרשימת הכלים
מצהירה, ושסוכנים מתבקשים למשפט שלא התבקשו לו קודם.
התיאור של הפרמטר הוא שלנו ולא ברירת המחדל של ה-SDK, משתי סיבות: ברירת
המחדל היא בלוק ארוך שצועק YOU MUST provide 15-25 words (count carefully),
והוא מוגש עם כל כלי; והטקסט הזה הוא המקום היחיד שבו סוכן נאמר לו מה לא
לשים בשדה שמגיע עכשיו ל-PostHog. זו הנחיה למודל ולא גבול שהשרת אוכף, והשער
כתוב מתוך הנחה שאפשר להתעלם ממנה.
מצב הקריאה (ck_read_mode)
outline=true ו-lines=[5, 80] הן אותו כלי, ולכן דשבורד שסופר לפי שם
הכלי סופר אותן באותה עמודה — בזמן שהן הפוכות במשמעות: אאוטליין הוא מפה זולה,
וקריאת תוכן היא הניווט היקר שהמפה באה להחליף.
ההבחנה חיה ב-$mcp_parameters, והוא חסום ונשאר חסום. לכן במקום לפתוח את
הארגומנטים, השרת מחשב בעצמו תווית אחת מתוך קבוצה סגורה ושולח אותה:
ערך |
מתי |
|---|---|
|
הועבר |
|
הועבר |
|
הועבר |
|
לא הועבר אף אחד מהם — קריאת הקובץ המלא, היקרה מכולן |
הנגזרת נשענת על נוכחות הפרמטר, ולעולם לא על ערך שהגיע מהקורא: מה שיוצא
הוא אחת מארבע המילים האלה. query הוא בדיוק המקום שבו זה נדרש, כי הערך
שלו הוא טקסט חופשי שהקורא כתב. השער אוכף את הקבוצה הסגורה — מאפיין משלנו אינו
נושא את התחילית $mcp_, ולכן רשימת ההיתר אינה חלה עליו, ובלי האכיפה הזו
היה כאן שם שדרכו אפשר לשלוח כל מחרוזת.
הערה
כל פרמטר נקרא רק עבור הכלי שמצהיר עליו. outline קיים ב-
codekeeper_get_repo_file בלבד, ו-query ב-codekeeper_get_file
בלבד. הקולבק מקבל את מילון הארגומנטים הגולמי — לפני ש-pydantic
מסלק ממנו מפתחות שאינם בסכימה של הכלי — ולכן מפתח תועה של הכלי האחר
מגיע אליו ונקרא. בלי השיוך, קריאה כזו נספרת בעמודה הלא נכונה ושתי
העמודות האחרות יוצאות חסרות באותה מידה, בלי שגיאה ובלי סימן. השיוך
יושב ב-_TOOL_READ_MODE_PARAMS ב-mcp_server/analytics.py,
ו-FILE_READ_TOOLS נגזר ממנו ואינו נכתב פעמיים.
מאותה סיבה codekeeper_search_code, שנושא פרמטר בשם query ואינו
קריאת קובץ, אינו נכנס לעמודה הזו כלל: הוא אינו במפה.
גם ``codekeeper_read_batch`` אינו במפה, בהחלטה. lines ושאר הפרמטרים שהקולבק מחפש יושבים אצלו בתוך הפריטים ולא ברמה העליונה, ובאץ« אחד מערב קריאה מלאה, טווח וסעיף — אין לו מצב קריאה אחד לתייג. תווית לכל פריט הייתה מחייבת לפתוח את items לקולבק, כלומר לקרוא את הארגומנטים ש-$mcp_parameters חוסם בכוונה. מה שנשלח עליו הוא שם הכלי והמשך בלבד, ולכן באץ« הוא אירוע אחד בדשבורד, ולא אירוע לכל פריט (ראו קריאה קבוצתית — codekeeper_read_batch).
התווית נוספת רק לקריאת כלי מבין הכלים שמקבלים lines. ה-SDK מריץ את
הקולבק על כל אירוע, וגרסה שנפלה ל-full בברירת מחדל תייגה גם את
לחיצת היד ואת tools/list — כלומר ניפחה בדיוק את העמודה שהמאפיין נבנה כדי
למדוד.
מה כבוי בכוונה. enable_conversation_id מזריקה ארגומנט נוסף וגם
מוסיפה הנחיה לתוצאת הכלי, כלומר עורכת את מה שהכלי מחזיר לקורא שלו — וזה
החוזה של הכלי, לא של האנליטיקס. גם capture_exception_code_variables כבוי
במפורש: הוא לוכד משתנים מקומיים, ובשרת הזה המשתנים המקומיים מחזיקים תוכן
קבצים.
מה כן דלוק: ``report_missing``. היא מוסיפה כלי וירטואלי אחד,
get_more_tools, שהסוכן קורא לו כדי לומר איזו יכולת חסרה לו. היא אינה
נוגעת באף כלי קיים, וגם הסינון של כלי האדמין ממשיך לעבוד
(get_more_tools מתווסף אחרי הסינון ולכן גלוי לכולם, כפי שהוא אמור להיות).
המשפט שהסוכן כותב שם מגיע כ-$mcp_intent על אירוע
$mcp_missing_capability — אותו מאפיין שעובר היום גם על קריאת כלי רגילה.
קונפיגורציה. שני משתני סביבה בשירות ה-MCP: POSTHOG_PROJECT_TOKEN
ו-POSTHOG_HOST. חסר אחד מהם — בפרודקשן השרת עולה רגיל והמדידה פשוט לא
פועלת (עם אזהרה בלוג), ובסביבת פיתוח העלייה נכשלת ברעש, כדי ש“אין אירועים“
לא ייראה כמו ”הכל תקין“. ”פרודקשן“ הוא ENVIRONMENT/ENV ששווה
production או prod, וכן המצב שבו אף אחד מהם אינו מוגדר; כל ערך אחר
נחשב פיתוח. הטוקן לעולם אינו בקוד ואינו בריפו.
הערה
כשהמדידה כבויה (אין טוקן, אין host) האינסטרומנטציה אינה רצה כלל — ולכן גם
context אינו מוזרק והסכימות של הכלים זהות לאלה שלפניה. זה מה שמסביר
למה סוויטת הבדיקות רואה סכימות נקיות: היא רצה בלי קונפיגורציה של PostHog.
ניקוז. posthog.shutdown רשום ב-atexit, ובנוסף ה-lifespan של
אפליקציית ה-ASGI מנקז את האירועים שעדיין באוויר לפני שהלולאה של uvicorn
נסגרת. atexit לבדו אינו מספיק: הלכידה האוטומטית היא asyncio.Task על
אותה לולאה, ומשימה שנשארה תלויה כשהלולאה נסגרת לא מגיעה בכלל לתור השליחה.
אבטחה — עקרונות
זהות תמיד מהטוקן; טוקנים נשמרים כ-hash בלבד ומוצגים פעם אחת.
ראוט שנרשם ידנית (לא
/mcp) אינו מוגן במצב OAuth — הוא חייב לקרוא ל-authenticate_bearerבגוף שלו. ראו פריימר לסוכן — GET /api/agent/primer.קוד הרשאה ו-refresh token חד-פעמיים (זיהוי replay); refresh לא מרחיב הרשאות.
SECRET_KEYחלש/ריק/ברירת-מחדל — מצב OAuth מסרב לעלות (fail-closed).מסך האישור מציג במדויק קריאה בלבד לעומת קריאה וכתיבה.
כלי האדמין: הסתרה מ-tools/list היא נוחות בלבד — האכיפה האמיתית היא בגוף כל כלי.
פתרון תקלות
סימפטום |
כיוון |
|---|---|
|
הטוקן בוטל/פג או ששירות ה-MCP מחובר ל-MongoDB אחר מזה שהנפיק אותו |
|
ה-URL מצביע על הוובאפ במקום על ה-MCP host — האנדפוינט חי רק על ה-MCP |
|
תקין, לא תקלה: שדה ”הוראות לסוכן“ ריק. מלאו אותו ב- |
הפריימר לא נטען, בלי שום הודעה |
אם ההוק מגיע מפלאגין: בסשן בדפדפן פלאגינים אינם נטענים כלל ולכן הוא לא
רץ. |
הפריימר לא מתעדכן |
ה-cache הוא 60 שניות — המתינו דקה אחרי השמירה |
|
|
|
|
|
חיבור בקריאה בלבד — חברו מחדש עם write (או |
|
כבר קיים קובץ בשם הזה, ו- |
|
התיאור חורג מהמגבלה, וההודעה נושאת אותה ב- |
|
אין קובץ פעיל בשם הזה אצל המשתמש המאומת. השם נלקח כפי שהוא מופיע
ב- |
|
בדיקת הקיום נכשלה — זה אינו ”הקובץ אינו קיים“. שום דבר לא נשמר;
נסו שוב בעוד רגע. אותה הבחנה כמו ב- |
|
ה-user_id אינו ב- |
|
שם הריפו אינו ברשימת המשוקפים. |
|
הנתיב אינו בעץ המשוקף. הנתיב נלקח כפי שהוא מופיע ב- |
|
השאילתה על המניפסט נכשלה — זה אינו ”לא קיים“. אל תתקנו את הנתיב; נסו שוב |
|
ה- |
|
השם אינו של קובץ שמור. השיוך אינו יוצר קובץ — שמרו קודם ב- |
|
צילום הגרסה הקודמת נכשל, ולכן העדכון לא בוצע. הפתק שלם; נסו שוב |
|
מספר הגרסה אינו בטווח שנשמר (20 האחרונות).
|
|
|
|
הפתק השתנה בין הקריאה לכתיבה (עריכה מקבילה). קראו אותו מחדש ונסו שוב — שום דבר לא נדרס |
|
השם כבר תפוס — על אותו לוח, או על אותו קובץ בריפו |
|
|
|
|
|
שאילתה ארוכה משם פתק אפשרי לא תתפוס דבר. קצרו אותה |
|
רענון רץ ברגע זה — נסו שוב לפי |
אין שום לוג מהשרת, גם כשהכול תקין |
מאז #3393 ``mcp_server/app.py`` מגדיר לוגים בייבוא (``LOG_LEVEL``,
ברירת מחדל |
|
ה-mirror עוד לא שוכפל מקומית (המתינו למעבר autosync) או שאין דיסק/ |
|
אותו מצב, מהצד של |
|
הנתיב פותר אל מחוץ לשורש התיעוד של הריפו ( |
הסוכן שמר קובץ ולא הגיעה התראה |
לפי הסדר: (1) יש מנוי Push פעיל בדפדפן? נרשמים ב- |
|
|
|
יותר פריטים ממה שבאץ« אחד נושא. |
|
הפריט לבדו גדול מכל מה שתשובה יכולה להכיל. |
|
הפריט אינו תואם לסכימה, ו- |
פריטים ב- |
הם לא נקראו, וכל מה שלפניהם שלם. |
|
באץ« נשקל כמספר הפריטים שלו, והמכסה נגבית כולה או לא בכלל: באץ« של 20 פריטים כשנשארו 15 יחידות נדחה כולו, ו- |
ראו גם
MCP Analytics (מדידת השימוש בכלי ה-MCP) — מסך האדמין שקורא את הנתונים האלה חזרה
משתני סביבה - רפרנס — כל משתני הסביבה (כולל קטגוריית MCP)
mcp_server/README.md— תיעוד תפעולי קצר בתוך הריפוFEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md— מסמך התכנון המלאSecurity Guide — מדיניות האבטחה הכללית