#JSONRPC
📦 near/near-jsonrpc-client-php v0.1.8

Typed PHP client and OpenAPI generator for NEAR JSON-RPC

🔗 https://github.com/hosseinkarami-dev/near-jsonrpc-client-php
November 7, 2026 at 6:20 AM
Comment un chat Claude réclame-t-il des tâches osFoundry via MCP sans événements push ?
>> {"jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": {"name": "claim_linked_chat_task", "arguments": {"chat_link_id": "3f6c0b1e-link", "task_id": "d97fd414-3916-4b13-937d-d78de6b3b053"}}} << {"jsonrpc": "2.0", "id": 7, "result": {"content": [{"type": "text", "text": "{\"claim_id\": \"8ffc774f-0a76-4846-9bbd-d66493ca08f4\", \"lease_until\": 1793942663.8222258}"}]}} >> {"jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": {"name": "submit_linked_chat_result", "arguments": {"chat_link_id": "3f6c0b1e-link", "task_id": "d97fd414-…", "claim_id": "8ffc774f-…", "status": "completed", "result": "1. Who signs off the pricing copy?"}}} << {"jsonrpc": "2.0", "id": 7, "result": {"content": [{"type": "text", "text": "{\"ok\": true}"}]}} >> (same submit again, after a "lost" response) << {"jsonrpc": "2.0", "id": 7, "result": {"content": [{"type": "text", "text": "{\"ok\": true, \"replayed\": true}"}]}} Voici un claim, un submit et un submit rejoué, capturés sur une reconstruction en Python (un serveur sqlite de 180 lignes) du transfert qu'utilise osFoundry pour envoyer du travail à une conversation Claude ou ChatGPT liée. Les noms d'outils sont ceux du vrai connecteur. Le serveur, le champ de lease et le flag de replay viennent de la reconstruction. Aucun binaire ni compte Claude n'a été utilisé : le « chat » de l'autre côté est un script Python qui fait les mêmes appels JSON-RPC qu'un hôte, sur un fichier sqlite local. Le problème a une forme inhabituelle. Le worker est une conversation de chat qui ne tourne que lorsqu'un humain l'ouvre et tape quelque chose. Le serveur ne peut pas la réveiller, ne peut pas la voir et ne peut pas savoir si l'humain a fermé l'onglet au milieu d'une tâche. ## Qu'est-ce qu'osFoundry expose réellement ? Quatre outils du connecteur MCP d'osFoundry portent le transfert, et leurs schémas disent l'essentiel de ce qui compte : Outil | Arguments obligatoires | Ce que promet sa description ---|---|--- `link_current_chat` | `project_id`, `label`, `idempotency_key` | Un lien distinct par conversation ; le libellé et la référence sont déclarés par le chat lui-même `read_linked_chat_tasks` | `chat_link_id` | Tâches en file et récentes, limitées à la connexion OAuth et au projet `claim_linked_chat_task` | `chat_link_id`, `task_id` | "Repeating the claim returns the same claim while the task remains active." (répéter le claim renvoie le même claim tant que la tâche reste active) `submit_linked_chat_result` | `chat_link_id`, `task_id`, `claim_id`, `status`, `result` | `completed` ou `failed`, résultat jusqu'à 20,000 caractères ; les claims annulés, expirés, révoqués ou qui ne correspondent pas ne peuvent pas être complétés Le côté propriétaire vit dans l'application web, dans `apps/web/src/screens/settings/ConnectedChatTasks.tsx`. Son union de statuts est `queued | claimed | completed | failed | cancelled | expired`, avec les libellés "Waiting for host chat" (ou "Waiting for Claude"), "Claimed by host chat", "Completed", "Failed", "Cancelled" et "Expired". L'assignation poste l'objectif avec un `idempotency_key` issu de `crypto.randomUUID()`, et le composant conserve cette clé tant que le texte de l'objectif ne change pas : un double-clic ou un retry après un timeout envoie donc la même clé. Le panneau rafraîchit sa liste avec `setInterval(..., 15000)`. Les tâches expirent au bout de 24 heures. Un cinquième outil, `upload_linked_chat_artifact`, prend un `artifact_id` stable lors d'un retry. Même idée, appliquée aux fichiers. ## Pourquoi le serveur ne peut-il pas pousser la tâche vers Claude ? Parce que rien n'écoute côté hôte. osFoundry traite un connecteur personnalisé Claude comme piloté par les requêtes : la conversation active peut appeler des outils, et il n'existe pas de déclencheur d'événements général qui lancerait un tour. ChatGPT dispose de MCP Events pour les chats Work qui s'abonnent ; le panneau propriétaire affiche "An event subscription is active for this chat" uniquement dans ce cas, et sinon "Open the host conversation to start each task." (ouvrez la conversation hôte pour lancer chaque tâche). Le scheduler est donc une personne. Cela change les pannes qui sont normales. Une tâche en file depuis six heures n'est pas une panne. Une tâche réclamée dont le chat a été fermé est le cas courant, pas le cas limite. Et un retry n'est pas qu'une affaire de réseau : le modèle peut tout simplement appeler l'outil deux fois dans le même tour. ## Comment deux claims sur la même tâche peuvent-ils gagner tous les deux ? Le handler évident lit la ligne, vérifie `status == 'queued'` et écrit `claimed`. La reconstruction propose cette version derrière `--naive 1`, et la version conditionnelle par défaut : cur = c.execute("UPDATE tasks SET status='claimed', claim_id=?, lease_until=?, updated_at=? " "WHERE id=? AND chat_link_id=? AND status='queued'", (cid, now + self.lease, now, task_id, link)) if cur.rowcount == 1: return {"claim_id": cid, "lease_until": now + self.lease} Test : 200 tâches, et pour chacune, huit threads libérés par un `threading.Barrier` appellent `claim_linked_chat_task` au même instant via HTTP. Handler | Essais avec 2+ gagnants | Gagnants moyens par tâche ---|---|--- Lire, puis écrire (2 ms entre les deux) | 200 / 200 | 7.40 Lire, puis écrire (sans délai ajouté) | 97 / 200 et 172 / 200 sur deux exécutions | 1.82 et 3.36 `UPDATE … WHERE status='queued'` conditionnel | 0 / 200 | 1.00 Le délai de 2 ms simule un saut réseau ou un ORM entre les deux requêtes. Sans lui, la race condition se produit quand même sur environ la moitié des essais ou plus, et le chiffre variait avec la charge de la machine. Le verrou d'écriture de SQLite sérialise les deux écritures mais n'aide pas : les deux lecteurs ont déjà décidé. Huit claims simultanés depuis un même chat lié, ça paraît artificiel. Deux, non : l'utilisateur demande au chat de vérifier les tâches, s'impatiente, redemande, et l'hôte exécute les deux tours. C'est aussi pour cela que le claim d'osFoundry renvoie le claim existant au même lien au lieu d'une erreur. La reconstruction fait de même pour une répétition venant du même chat : "claim_replay": {"first": "28a03379", "repeat_same_chat": "28a03379", "same_claim": true, "other_link": {"error": "not claimable: missing"}} Un autre lien qui demande le même ID de tâche n'obtient rien, pas même la confirmation que la tâche existe. ## Que doit-il se passer quand le chat qui a réclamé la tâche disparaît ? C'est ici que la reconstruction va plus loin que tout ce que la documentation d'osFoundry indique pour les chats liés. Elle mentionne une expiration des tâches à 24 heures et, pour le dispatcher desktop qui permet à un agent osFoundry d'attendre un chat lié, une limite d'exécution de 30 minutes. La reconstruction ajoute un lease par claim : un claim qui n'est pas complété avant `lease_until` repasse en `queued` et peut être réclamé à nouveau. L'expiration est paresseuse. Il n'y a pas de thread de minuterie ; chaque lecture, claim et submit lance d'abord un balayage : c.execute("UPDATE tasks SET status='queued', claim_id=NULL, lease_until=NULL, updated_at=? " "WHERE status='claimed' AND lease_until<=?", (now, now)) Test avec un lease d'une seconde : le chat A réclame puis se tait. Le chat B tente de réclamer toutes les 100 ms. "lease": {"lease_s": 1.0, "takeover_after_s": 1.064, "failed_claims_before_takeover": 10, "late_submit_A": {"error": "rejected: stale claim, task was re-claimed"}, "submit_B": {"ok": true}} B a pris la main 64 ms après l'expiration du lease, soit un intervalle de polling plus le temps de requête. La ligne la plus importante est la troisième : quand A est revenu et a soumis avec son ancien `claim_id`, le serveur a refusé. L'ID de claim est un fencing token. Sans lui dans la clause `WHERE`, la réponse tardive de A écraserait celle de B, et le propriétaire verrait celle qui a fini en dernier. Choisir la durée du lease est la partie difficile avec un worker piloté par un humain. Trop court, et un tour Claude lent mais bien vivant perd sa tâche en pleine réponse. Trop long, et un onglet fermé bloque du travail pendant des heures. Un lease n'a d'intérêt que si quelqu'un d'autre peut reprendre la tâche ; avec exactement un chat lié par tâche, l'expiration à 24 heures plus un bouton "Cancel" côté propriétaire est peut-être le design honnête. ## Que se passe-t-il quand la réponse d'un submit se perd ? L'hôte envoie le résultat, le serveur fait le commit, la réponse se perd au retour, l'hôte réessaie. Le test envoie le même submit quatre fois, puis un cinquième avec un résultat différent : Handler | Réponses aux 4 envois identiques | Réponse à un résultat modifié | Résultat stocké ---|---|---|--- Ajoute chaque appel (comme poster un message) | ok, ok, ok, ok | ok | `4\n4\n4\n4\n5` Protégé par le claim, hash du résultat comparé | ok, puis 3 × ok + `replayed` | `rejected: task is completed` | `4` La version idempotente garde `sha256(status + result)` sur la ligne. Un retry avec le même claim et le même hash reçoit un succès ; tout le reste sur une tâche terminée est refusé. osFoundry applique la même règle aux complétions sur son relais Graph : une complétion identique après une réponse perdue est de nouveau acceptée, une complétion modifiée est refusée. Le retry doit renvoyer un succès, pas une erreur. Une erreur pousserait le modèle à « corriger » un résultat déjà enregistré. ## Une tâche annulée peut-elle encore être complétée ? Non, si l'annulation est un changement d'état et que le submit exige `status='claimed'` : "cancel": {"cancel": {"cancelled": true}, "submit_after_cancel": {"error": "rejected: task is cancelled"}} Le plus intéressant, c'est ce que voit le chat. Le tour Claude a fait le travail ; la réponse a rebondi. La description d'outil d'osFoundry prévient le modèle à l'avance que les claims annulés ne peuvent pas être complétés, ce qui est le moyen le moins coûteux de l'empêcher de réessayer en boucle. ## Combien coûte le polling ? Il existe deux pollers distincts. Le panneau propriétaire dans le navigateur interroge toutes les 15 secondes, soit 5,760 requêtes par jour et par panneau ouvert. Le chat hôte ne fait aucun polling ; il lit une fois, chaque fois que vous le lui demandez. Les deux passent par la même requête de lecture. Mesuré sur la reconstruction avec 10,000 tâches historiques réparties sur 500 liens, 2,000 lectures d'un lien, HTTP en localhost : | Requête | Réponse | Aller-retour p50 | Serveur p50 ---|---|---|---|--- Boîte vide | 137 B | 87 B | 0.72 ms | 0.26 ms Une tâche en file | 137 B | 294 B | 0.89 ms | 0.30 ms Le coût n'est pas la bande passante. La première version était environ neuf fois plus lente côté serveur, et la cause était le balayage paresseux : deux `UPDATE` à chaque lecture, qui prennent le verrou d'écriture de SQLite même quand ils ne touchent aucune ligne. Trois exécutions de 2,000 polls vides chacune : Balayage avant chaque lecture | Serveur p50 (3 exécutions) ---|--- Deux `UPDATE`, scan de table | 2.17 / 2.02 / 1.96 ms Deux `UPDATE`, index partiel sur les lignes actives | 1.33 / 1.33 / 1.20 ms D'abord `SELECT 1 … LIMIT 1` sur l'index partiel, `UPDATE` seulement si quelque chose est échu | 0.24 / 0.21 / 0.22 ms Pas de balayage | 0.19 / 0.19 / 0.22 ms Une garde en lecture seule ramène un poll dans le bruit par rapport à l'absence totale de balayage. L'index seul n'y parvient pas, parce que le coût vient de la transaction d'écriture, pas du scan. Pour un client scripté qui fait réellement du polling, le compromis est le compromis habituel. 40 tâches arrivant à 0.2 à 1.2 s d'intervalle : Intervalle de polling | Délai moyen de prise en charge | Max | Polls | Polls vides ---|---|---|---|--- 0.1 s | 0.058 s | 0.107 s | 244 | 204 0.5 s | 0.226 s | 0.484 s | 54 | 17 2.0 s | 1.103 s | 2.006 s | 15 | 2 Le délai moyen de prise en charge tombe autour de la moitié de l'intervalle. Pour un chat hôte, l'intervalle réel est « quand l'utilisateur l'ouvre », et le coût par lecture qui compte, c'est le contexte, pas les millisecondes : chaque résultat de lecture entre dans la conversation. Avec 87 octets pour une boîte vide et environ 210 de plus par tâche en file dans ce format, une liste de tâches concise garde la vérification assez bon marché pour la demander sans y penser. Tous les chiffres : Apple M1, Python 3.9.6, SQLite 3.43.2 en mode WAL, `ThreadingHTTPServer` sur 127.0.0.1, avec d'autres jobs de rendu en cours sur la machine (load average entre 8 et 22 pendant les exécutions). Fiez-vous donc aux p50 et considérez les queues de distribution comme bruitées. ## Si vous câblez cela vous-même * Faites du claim une seule écriture conditionnelle et renvoyez le claim existant au même appelant ; le double-clic et le double appel d'outil sont le cas normal. * Mettez l'ID de claim dans la clause `WHERE` du submit pour qu'un worker en retard ne puisse pas écraser un plus récent. * Stockez un hash du résultat final et répondez à un retry identique par un succès, à un retry modifié par un refus. * Demandez-vous si un lease apporte quelque chose. Si un seul chat peut jamais prendre la tâche, une expiration et une annulation visible suffisent peut-être. * Gardez les vérifications d'expiration en lecture seule tant que rien n'est réellement échu ; un poll qui prend un verrou d'écriture coûte plus cher que la requête qu'il protège. * Indiquez dans la description de l'outil ce qui sera refusé, pour que le modèle ne réessaie pas sur une tâche annulée.
dev.to
November 7, 2026 at 8:37 PM
📦 near/near-jsonrpc-client-php v0.1.7

Typed PHP client and OpenAPI generator for NEAR JSON-RPC

🔗 https://github.com/hosseinkarami-dev/near-jsonrpc-client-php
November 6, 2026 at 6:59 AM
osFoundry 的 MCP 工具清單會吃掉 Codex 多少 token,又該如何依專案精簡?
下面是 MCP 用戶端在 handshake 之後馬上會送出的呼叫。2026-11-06 從東京的一台 Mac 用 curl 打到 osFoundry 的伺服器,這個呼叫不需要 token: $ curl -s -D - -X POST https://api.osfoundry.io/api/v1/external-mcp \ -H 'content-type: application/json' \ -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' HTTP/2 200 content-type: application/json content-length: 45368 {"jsonrpc":"2.0","id":2,"result":{"tools":[ …59 tools… ]}} 59 個工具中的其中一筆: {"name": "read_note_tab", "title": "Read one Note tab", "description": "Read a selected Note, Markdown, Slides, Sheet, Mermaid, Board, Whiteboard, Design, or Artifacts tab by instance ID. Use list_note_attachments for Files and Sources.", "inputSchema": {"type": "object", "properties": {"note_id": {"type": "string"}, "tab_id": {"type": "string"}}, "required": ["note_id", "tab_id"], "additionalProperties": false}, "annotations": {"title": "Read one Note tab", "readOnlyHint": true, "openWorldHint": false, "destructiveHint": false}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:read"]}]} Codex CLI 透過一個 `[mcp_servers.osfoundry]` table 加上 `url` 連到這台伺服器。除非用戶端把其中一部分延後載入,否則 Codex 之後送給模型的每個請求,都會以某種形式夾帶這 59 個宣告。以下這些問題,就是你在決定要帶多少個工具時一定會遇到的。 ## Codex 在第一輪對話之前會抓哪些東西? 對同一個 URL 送三次 POST:`initialize`、`notifications/initialized` 通知,以及 `tools/list`。線上的伺服器回應如下: 請求 | 狀態碼 | 內容 ---|---|--- `initialize` | 200 | 308 bytes:協定 `2025-06-18`、`osfoundry 0.1.0`、`tools.listChanged: false`、instructions `notifications/initialized` | 202 | 空 `tools/list` | 200 | 45,368 bytes,59 個工具 不帶 token 的 `tools/call` | 401 | 附 `resource_metadata` 的 `WWW-Authenticate` `listChanged: false` 表示伺服器在 session 進行中不會通知清單有變動。Codex 啟動時拿到的清單,就是整個 session 用的清單。 `instructions` 字串長 125 個字元:"Use list_granted_resources first. Every Note edit must use the exact tab ID. Local-only data is unavailable to cloud clients." OpenAI 的 Codex 文件說,Codex 會把這個欄位當成整台伺服器的使用指引,而且前 512 個字元要能獨立看懂。這段遠遠低於上限。 401 是 OAuth 開始的地方。那一段,以及 osFoundry 為什麼每次呼叫都檢查存取權限,另外有一篇從 Claude 端看同一個 endpoint 的文章在談。這裡只關心工具目錄本身。 ## 59 個工具是多少 token? 送到模型的並不是原始 JSON。Codex 會把每個 MCP 工具轉成一個 function 宣告。模型需要的是名稱、描述和輸入 schema;`title`、`annotations` 和 `securitySchemes` 是用戶端自己用的協定 metadata。OpenAI 沒有公開他們那邊怎麼計算工具宣告的 token,所以下面的數字算的是 payload,不是帳單。 用 tiktoken 0.14.0 和 `o200k_base` 編碼,在壓縮過的 JSON 上量測: 項目 | Token ---|--- 原始 `tools/list` 結果,所有欄位 | 10,494 59 個宣告(含 `mcp__osfoundry__` 前綴的名稱、描述、schema) | 7,609 其中描述 | 2,194 其中輸入 schema | 4,118 其中 `mcp__osfoundry__` 前綴 | 413 留在用戶端的 metadata(title、annotations、securitySchemes) | 3,590 單一宣告的中位數是 116 token。最大的是 `edit_note_tab`,336;接著是 `send_channel_message`(237)和 `upload_linked_chat_artifact`(233)。最小的是 `list_granted_resources`,57。 依 scope 來看:30 個 `osfoundry:read` 工具合計 2,906 token,26 個 `osfoundry:write` 工具 4,378,3 個 `osfoundry:run` 工具 329。寫入類工具單支比較貴,因為要帶 `expected_version`、`idempotency_key` 和要寫入的內容。 不管模型有沒有呼叫 osFoundry,每個請求前面都固定掛著大約 7,600 token。有快取的 prompt prefix 會讓實際成本比聽起來低,但它還是佔用上下文,而且模型在判斷該用哪個工具時,59 個名稱會互相搶注意力。 ## Codex 在哪裡套用 `enabled_tools`? 這些 key 寫在 OpenAI 的 Codex MCP 文件頁面上:`enabled_tools` 是允許清單,`disabled_tools` 是封鎖清單,"applied after `enabled_tools`"。實作在 openai/codex 儲存庫的 `codex-rs/codex-mcp/src/tools.rs`: pub(crate) fn allows(&self, tool_name: &str) -> bool { if let Some(enabled) = &self.enabled && !enabled.contains(tool_name) { return false; } !self.disabled.contains(tool_name) } pub(crate) fn filter_tools(tools: Vec<ToolInfo>, filter: &ToolFilter) -> Vec<ToolInfo> { tools .into_iter() .filter(|tool| filter.allows(&tool.tool.name)) .collect() } 這代表兩件事。第一,過濾比對的是 `tool.tool.name`,也就是原始的 MCP 名稱,所以在 `config.toml` 裡要寫 `read_note_tab`,不是 `mcp__osfoundry__read_note_tab`。前綴是之後在同一個檔案裡、產生模型看到的名稱時才加上去的。第二,set 查詢永遠不會失敗。`tools/list` 裡不存在的名稱什麼都比對不到,也不會有任何提示。 過濾是在 `tools/list` 回來之後、在用戶端執行的。它縮短的是 prompt,不是下載量,伺服器也完全不知道有這個過濾。 ## 重建用戶端那一側 做量測的那台 Mac(Apple M1)沒有安裝 `codex` 執行檔,所以用 `catalog.py` 來模擬 Codex 的啟動流程。它有 92 行,用 `uv run --python 3.12 --with tiktoken` 執行。它會從 TOML 檔讀取 `[mcp_servers.*]` table,透過 `http.client` 對線上 endpoint 送出那三個請求,對原始名稱套用同樣的先允許、後封鎖規則,產生 `mcp__osfoundry__<tool>` 宣告再計算 token: kept = [t for t in tools if (allow is None or t["name"] in allow) and t["name"] not in deny] decls = [model_decl("osfoundry", t) for t in kept] unknown = sorted(((allow or set()) | deny) - names) profile 檔每種設定各有一個 table。真正的 `config.toml` 只會有一個 `[mcp_servers.osfoundry]` table,放在全域或各個儲存庫裡: [mcp_servers.notes_kb_records] url = "https://api.osfoundry.io/api/v1/external-mcp" enabled_tools = [ "list_granted_resources", "list_project_resources", "list_note_tabs", "read_note_tab", "read_sheet_range", "search_knowledge_base", "read_knowledge_article", "list_record_items", "read_record_item", ] [mcp_servers.deny_list] url = "https://api.osfoundry.io/api/v1/external-mcp" disabled_tools = ["request_session_run", "run_graph_session", "cancel_graph_run", "propose_gitnest_work", "…the other propose_ tools…", "join_agent_network", "send_agent_message", "wait_for_messages", "list_agents", "set_status", "claim_connected_profile"] [mcp_servers.typo] url = "https://api.osfoundry.io/api/v1/external-mcp" enabled_tools = ["list_granted_resources", "list_project_resources", "read_note", "search_knowledge_base"] 輸出: $ uv run --python 3.12 --with tiktoken python catalog.py profiles.toml --runs 10 status (200, 202, 200) initialize 308 B tools/list 45368 B 59 tools instructions 125 chars raw tools/list result: 10494 tokens (o200k_base) profile tools bytes tokens unknown names all_59 59 33559 7609 - read_only 30 13181 2906 - notes_kb_records 9 3937 881 - coding_project 16 7245 1604 - deny_list 40 20943 4693 - typo 3 1449 309 read_note `read_only` 是所有標了 `readOnlyHint: true` 的工具。`coding_project` 是九個 Note/KB/Record 讀取工具,加上五個 GitNest 讀取工具、`edit_record_item` 和 `post_session_note`。`deny_list` 拿掉了 19 個對有本機 checkout 的程式碼助理來說用處不大的工具:三個 run 工具、十個 `propose_…` 工具,以及六個 agent-network 和 profile 相關工具。 從 59 個工具縮到九個讀取工具,宣告從 7,609 降到 881 token,少了 88%。封鎖清單只降到 4,693,因為最貴的是寫入類工具,而它大部分都留著。允許清單比較好預測,而且伺服器新增工具時它也不會變長,這正是該選它的理由。 `typo` 那一列才是你實際上會踩到的雷。`read_note` 不是工具(`read_note_tab` 才是),所以這個 profile 最後只剩三個工具,而且沒有任何警告。Codex 會很順地列出 Note,然後卻沒辦法打開它。這個模擬器會印出不認得的名稱;就文件和過濾程式碼來看,Codex 不會。 ## handshake 要花多少時間? `catalog.py --runs 10` 從頭到尾量了三個請求的時間,量兩次,一次共用同一條 TLS 連線,一次每個請求都開新連線: 批次(各 n=10) | `initialize` 中位數 | `tools/list` 中位數 | 整體啟動中位數 | 最小–最大 ---|---|---|---|--- 同一條連線,批次 1 | 482 ms | 826 ms | 1,515 ms | 1,034–2,899 ms 新連線,批次 1 | 521 ms | 704 ms | 1,715 ms | 1,122–3,107 ms 同一條連線,批次 2 | 454 ms | 828 ms | 1,441 ms | 741–1,915 ms 新連線,批次 2 | 432 ms | 478 ms | 1,257 ms | 1,088–1,557 ms 重用連線沒有帶來一致的差異,每次執行之間的波動比效果本身還大。過濾完全不影響這個數字,因為 Codex 不管怎樣都會下載完整清單。 有三個寫在文件裡的 Codex 設定跟這個數字有關。`startup_timeout_sec` 預設 10 秒,這裡最慢的一次是 3.1 s。`mcp_optional_startup_grace_ms` 預設 1,000 ms,依文件說明,它 "controls how long Codex waits for optional MCP servers when building the initial tool catalog"。這裡量到的每一次啟動都比這個久。如果 osFoundry 的工具必須從第一輪就可用,文件裡的做法有兩個:`required = true` 會讓啟動流程等伺服器,伺服器沒準備好就失敗;`startup_readiness = "catalog"` 則允許用一份有效的快取目錄先完成啟動,同時再建立線上連線。工具呼叫還是需要線上連線。 ## annotations 能幫你什麼? `default_tools_approval_mode` 可以設為 `auto`、`prompt`、`writes` 或 `approve`,而 "the `writes` mode prompts for tools that aren't marked read-only"。osFoundry 在 59 個工具中有 30 個標了 `readOnlyHint: true`。設成 `writes` 時,Codex 會直接執行這些工具,其餘 29 個則會先詢問,正好就是你自己手動會畫出的分界。針對單一工具,`tools.<tool>.approval_mode` 可以覆蓋預設值,`tools.<tool>.output_token_limit` 則限制單一工具的結果最多能往上下文塞多少。 scope 在 OAuth 層也是同樣的道理。如果設定檔有設 `scopes`,Codex 就用它;沒有的話,就從伺服器的 challenge 或 protected-resource metadata 取得 scope,而 osFoundry 列出的是 `osfoundry:read`、`osfoundry:write`、`osfoundry:run` 和 `osfoundry:models`。設 `scopes = ["osfoundry:read"]` 時,權杖只會發成唯讀,而每個寫入工具都在自己的 `securitySchemes` 宣告了 `osfoundry:write`,不管 `enabled_tools` 怎麼寫都一樣。 ## 哪一層過濾才是真正有效的? 總共有三層,其中只有一層是安全邊界。 * `config.toml` 裡的 `enabled_tools` / `disabled_tools` 決定要提供哪些工具給模型。能改這個檔案的人都能改它。 * osFoundry Connected 分頁裡的 Tools 勾選清單,會以 `allowed_tools` 存在連線上。`null` 代表「目前和未來所有支援的工具」。這由擁有者設定,本機檔案無法把它放寬。 * 專案成員資格和每次呼叫的存取檢查,決定任何工具能碰到什麼。一條沒有任何專案的連線,就算開放全部工具也讀不到任何東西。 用戶端過濾是 prompt 的整潔問題。拿它來省 token、讓模型更好選工具,權限則交給另外兩層。 ## 如果你要自己接起來 * 伺服器有幾十個工具時,用 `enabled_tools` 而不是 `disabled_tools`。token 比較少,伺服器新增工具時也比較穩定。 * 寫原始的 MCP 名稱,伺服器更新時拿你的清單跟最新的 `tools/list` 做 diff。不認得的名稱會默默失效。 * 明確設定 `scopes`,讓權杖不會帶超過設定檔預期的權限。 * `default_tools_approval_mode = "writes"` 要搭配會誠實設定 `readOnlyHint` 的伺服器。詢問的分界只會跟 annotations 一樣準。 * 如果伺服器延遲在一秒以上,就在 `required = true` 和 `startup_readiness = "catalog"` 之間做出選擇,不要交給 1,000 ms 的 grace 預設值。 * 伺服器的 `instructions` 保持在 512 個字元以內並能獨立看懂。它跟工具清單一樣,每個 session 都會送。 工具清單和上下文預算之間的整體關係,可以看 osFoundry 的 context window 詞彙頁。
dev.to
November 8, 2026 at 12:36 AM
ローカルMCPサーバーを自作したい方へ。
デバッグ用の出力を分離すると、起動直後の切断を防げます。
ローカルMCPサーバーの作り方と疎通テスト手順が分かります。 https://m32006400n.com/claude/local-mcp-server-stdio-claude-code-antigravity-jsonrpc-guide/?utm_source=bluesky&utm_medium=social&utm_campaign=sns_20261106&utm_content=sns-20261106-tonari-bsky
November 6, 2026 at 12:50 PM
Why does osFoundry give Codex one tool to propose a handoff and another to start a run?
This is what Codex CLI gets back the first time it calls a session tool on osFoundry before signing in: POST /api/v1/external-mcp {"method":"tools/call","params":{"name":"propose_session_handoff","arguments":{}}} HTTP/2 401 www-authenticate: Bearer resource_metadata="https://api.osfoundry.io/.well-known/oauth-protected-resource/api/v1/external-mcp", error="invalid_token", error_description="Connect osFoundry to continue" {"jsonrpc":"2.0","id":3,"result":{"isError":true, "content":[{"type":"text","text":"Authentication required"}], …}} `initialize` and `tools/list` answer without a token, so a client can see all 59 tools before anyone signs in. Two of them look like they do the same job: {"name": "propose_session_handoff", "description": "Send a scoped task and context summary to the owner of an invited session. The owner must accept before a run is queued.", "required": ["session_type","session_id","goal","context_summary","requested_output","idempotency_key"], "annotations": {"readOnlyHint": false, "openWorldHint": true, "destructiveHint": true}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]} {"name": "request_session_run", "description": "Queue a bounded goal for an agent, team, or loop session where this assistant is an invited runner. The osFoundry runtime executes the task when available.", "required": ["session_type","session_id","goal","idempotency_key"], "annotations": {"readOnlyHint": false, "openWorldHint": false, "destructiveHint": true}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:run"]}]} Both end with a session doing work. The rest of this post is about why they're kept apart, what bounds sit on each, and a Python rebuild of the client side that checks those bounds against the live schemas. ## What separates the two calls? Who says yes, and when. A handoff asks a person now. The owner gets a notification ("Codex CLI requests a handoff"). The session's Handoffs tab lists it under "Outside assistant requests" with the goal, a fold-out "Context and expected output" and the line "Up to N rounds on your agent runtime". Nothing is queued until someone clicks Accept and run. The web client posts `{decision: "accept"}` to `/agent-sessions/{id}/external-handoffs/{handoff_id}/decision`, and the toast reads "Handoff accepted and run queued". The handoff row moves through `pending`, then `accepted`, `declined` or `cancelled`. A run was approved earlier. The owner gave the connection a Runner seat in the Connected tab, and that is the yes. The help text there: "Invite it to a session as Contributor to post notes and propose handoffs, or Runner to also request runs. A handoff still needs the session owner's approval." On the sign-in page, the run scope reads "Run sessions where you grant a runner role". If you folded these into one tool with a `needs_approval` flag, the caller would decide whether a human gets asked. Two tools put that decision on the owner's side, in two places the caller can't touch: the OAuth scope (`osfoundry:write` versus `osfoundry:run`) and the seat (Contributor versus Runner). A Codex config that leaves `osfoundry:run` out of `scopes` can't start a run even on a Runner seat. The annotations follow the same split. `propose_session_handoff` is `openWorldHint: true` because its output reaches a person outside the call. `request_session_run` is `openWorldHint: false`, because it only touches the queue of a session that agreed in advance. Both are `destructiveHint: true`, so a client running Codex's `default_tools_approval_mode = "writes"` asks before either. ## Why does the handoff demand a context summary? Because a person reads it before anything runs. `context_summary` and `requested_output` are required on the handoff and absent from the run. The owner is deciding whether to spend their session's runtime on this, and "fix the export" isn't enough to decide on. A Runner already has that trust, so the run takes only a `goal`. The caps differ too. A handoff `goal` stops at 5,000 characters, plus 5,000 of context and 1,000 of requested output. A run `goal` goes to 10,000. In practice the run's `goal` carries the context, since there's no other field for it. ## What does Codex get to read back? Less than the session holds: * `read_session_task_result`: "the status and attributed runtime result for one task started by this connection". Another connection's task id is no use. * `read_session_mail`: "notes addressed to this connection and notes it posted", 1 to 20 per page with `before_id` for older ones. "Internal agent analysis is excluded." * `read_session_state`: current status and recent tasks of a session the connection joined. * `post_session_note`: an attributed note that agents can reply to through "the returned external sender slug". It "does not trigger an automatic run", so a note can't be used to get round the owner's click. In a team session, those notes show up in Team mail with the connection's display name and an "outside assistant" label. ## The rebuild No `codex` binary is installed on this Mac, so `handoff_sim.py` (about 220 lines) plays the client. It does three things: 1. Calls the live server without a token: `initialize`, `tools/list` and one `tools/call`, 15 times each, and saves the tool list. 2. Validates twelve argument cases against the live `inputSchema`s with `jsonschema` (Draft 2020-12), so nothing about the bounds is guessed. 3. Runs a small in-memory model of seats, scopes, owner decisions and idempotency. osFoundry's server code isn't public, so this part is a reconstruction from the tool descriptions and the web client that renders handoffs. Its responses are the model's, not the server's. $ uv run --with tiktoken --with jsonschema python3 handoff_sim.py ### The wire initialize HTTP 200 median 566.3 p90 799.0 tools/list HTTP 200 median 559.7 p90 937.7 tools/call (no token) HTTP 401 median 482.6 p90 677.0 Milliseconds, measured from this Mac in Japan through CloudFront's KIX edge, so the numbers say more about the route than the server. The point is the shape. Discovery is free and fast enough to run on every Codex start. The first real call costs one 401 round trip, and that 401 carries `resource_metadata`, which is how Codex finds the OAuth server without being told. The seven session tools are 1,152 tokens of the full list's 10,197 (11.3%, `cl100k_base`, so treat it as approximate for Codex's own tokenizer). Listing only those in `enabled_tools` keeps the prompt small. ### The bounds Tool | Scope | Required | Caps ---|---|---|--- `propose_session_handoff` | write | 6 | goal 5,000, context 5,000, output 1,000, max_rounds 1–10 `cancel_session_handoff` | write | 1 | handoff_id only `request_session_run` | run | 4 | goal 10,000, max_rounds 1–10 `read_session_state` | read | 2 | none `read_session_task_result` | read | 3 | none `read_session_mail` | read | 2 | limit 1–20 `post_session_note` | write | 5 | subject 200, body 10,000 Twelve cases against the live schemas: propose_session_handoff 6,000-char goal REJECT maxLength: goal request_session_run 6,000-char goal ok propose_session_handoff no context_summary REJECT required propose_session_handoff max_rounds omitted ok request_session_run max_rounds 0 REJECT minimum: max_rounds request_session_run max_rounds 11 REJECT maximum: max_rounds request_session_run no idempotency_key REJECT required request_session_run session_type 'room' REJECT enum: session_type request_session_run extra field 'model' REJECT additionalProperties read_session_mail limit 21 REJECT maximum: limit post_session_note 201-char subject REJECT maxLength: subject cancel_session_handoff only handoff_id ok Two of these are worth a second look. The same 6,000-character goal is fine as a run and too long as a handoff. A client that builds one long prompt and sends it to whichever tool is available will hit this. And `additionalProperties: false` turns away a `model` field: the caller can't pick the session's model. The session runs on its own settings. `max_rounds` is optional on both. The schemas don't publish a default, so if the number matters to you, send it. ### Seats, cancel and accept (model) tool observer contributor runner propose_session_handoff - yes yes cancel_session_handoff - yes yes request_session_run - - yes read_session_state yes yes yes read_session_task_result yes yes yes read_session_mail yes yes yes post_session_note - yes yes runner seat, token without osfoundry:run -> {'error': 'token lacks osfoundry:run'} cancel while pending -> {'status': 'cancelled'} owner clicks Accept after cancel -> cancelled owner accepts -> accepted | cancel after -> {'error': 'already accepted'} another connection reads that task -> {'error': 'not a task started by this connection'} The matrix is the Connected tab's help text turned into a table. The interesting rule is the cancel window. `cancel_session_handoff` is described as working "before its owner accepts it", so whichever happens first wins. Once a run is queued, withdrawing it is no longer the caller's call. That's also why cancel takes nothing but a `handoff_id`: there's no state to pass, only a pending row to close. ### Lost responses Every write takes a required `idempotency_key`. To see what that buys, the model sends 1,000 `request_session_run` calls, drops 20% of responses (seed 7) and lets the client retry up to three times with the same key: server honours idempotency_key=True calls sent 1276 tasks queued 1000 duplicates 0 server honours idempotency_key=False calls sent 1276 tasks queued 1276 duplicates 276 276 extra runs on someone else's runtime is the case the required key is there for. Any client that times out and retries will hit it, and a run that already started is not something you want twice. Making the key required rather than optional means no client can forget it. The whole script took 26.4 s wall-clock on an Apple M1. 45 network calls account for nearly all of it, and user CPU time was 0.37 s. ## If you wire this up yourself * Split "ask a person" and "act on standing permission" into two tools with two scopes, so the owner's choice lives in the token and the seat, not in an argument the caller sets. * Require the fields a person needs to decide (`context_summary`, `requested_output`) on the proposal only, and cap them tighter than the run's goal. * Let the caller cancel only while a request is pending. After acceptance, stopping work is the owner's job. * Scope every read-back to what this connection started or was sent, and leave the session's internal analysis out of it. * Make `idempotency_key` required on every write, and return the first result on a repeat. * Close the schema with `additionalProperties: false`, so a caller can't sneak in a model or a tool list the session didn't choose. How osFoundry's own agents pass work between steps is on the handoffs page. For the protocol underneath, see the MCP glossary entry.
dev.to
November 8, 2026 at 8:37 AM
osFoundry는 왜 Claude에게 GitNest push 대신 변경 제안만 시킬까
Claude 채팅이 osFoundry를 거쳐 GitNest 저장소에 파일을 넣고 싶을 때 쓰는 tool이 이거다. osFoundry MCP 엔드포인트의 `tools/list`는 인증 없이도 찔러 볼 수 있다. 2026-11-06에 curl로 받아 온 응답에서 해당 항목만 뽑았다. $ curl -s -X POST https://api.osfoundry.io/api/v1/external-mcp \ -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' {"name": "propose_gitnest_file_import", "description": "Prepare up to 20 UTF-8 files (200 KB total) for a native GitNest repository branch. The owner reviews every file and approves the commit in osFoundry. Repository GitNest must be enabled.", "inputSchema": {"required": ["repository_id", "branch", "message", "files", "idempotency_key"], "properties": { "files": {"minItems": 1, "maxItems": 20, "items": {"properties": {"path": {"maxLength": 2048}, "content": {"maxLength": 100000}}}}, "idempotency_key": {"type": "string", "minLength": 8, "maxLength": 128}}}, "annotations": {"readOnlyHint": false, "destructiveHint": true, "openWorldHint": false}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]} 동사를 보면 답이 나온다. tool은 "prepare"만 하고, 커밋을 "approve"하는 건 오너다. 호출은 Git을 건드리기 전에 끝나고, 커넥터 어디에도 Git을 직접 건드리는 tool은 없다. `idempotency_key`는 선택이 아니라 필수고 최소 길이까지 있다. 빈 문자열이나 한 글자짜리 키로 멱등이라고 우길 수가 없다. 이 글에서는 에이전트의 쓰기를 왜 이런 모양으로 만드는지, 그게 말뿐이 되지 않으려면 서버가 뭘 해야 하는지 정리했다. 그다음 진짜 Git 저장소 위에 최소 버전을 다시 만들어 보고, 부품을 하나씩 빼면 뭐가 깨지는지 측정했다. ## propose tool 7개는 각각 뭘 고정할까 커넥터에 있는 GitNest tool은 13개다. 그중 6개는 읽기 전용이다. `read_gitnest_repository`, `search_gitnest_code`, `list_gitnest_work`, `read_gitnest_work`, `list_gitnest_saved_results`, `list_gitnest_checks`. 나머지 7개가 `propose_`로 시작한다. 필수 필드를 나란히 놓으면 모델이 호출하는 순간 뭘 박아 두는지 보인다. Tool | `idempotency_key` | `expected_change_set_version` | `expected_composition_oid` | `openWorldHint` ---|---|---|---|--- `propose_gitnest_work` | 필수 | 없음 | 없음 | false `propose_gitnest_change_set` | 필수 | 없음 | 없음 | false `propose_gitnest_server_composition` | 필수 | 필수 | 없음 | false `propose_gitnest_file_import` | 필수 | 없음 | 없음 | false `propose_gitnest_check_rerun` | 필수 | 필수 | 필수 | false `propose_gitnest_native_publication` | 필수 | 필수 | 필수 | true `propose_gitnest_provider_publication` | 필수 | 필수 | 필수 | true 결과가 클수록 고정하는 것도 많아진다. 태스크는 키만 고정한다. 합성(composition)은 모델이 읽은 change set 버전을 고정한다. 체크 재실행과 PR 공개는 버전에 더해 합성된 커밋 자체까지 고정한다. publication 두 개만 `openWorldHint: true`인 건 제안 저장소 바깥에 결과를 남기기 때문이다. PR이 생기고, provider 쪽이면 GitHub나 GitLab에 브랜치가 push된다. 오너가 보는 승인 카드에는 이 값이 그대로 "Change set: … · expected version 3", "Reviewed commit: 9c1e47a…"로 찍힌다. 오너가 승인하는 건 그 버전, 그 커밋이다. 클릭이 도착한 시점의 최신 상태가 아니다. ## 그냥 에이전트한테 push 토큰을 주면 안 될까 push는 모델이 출력하는 순간 결정된다. 제안은 지금 권한을 가진 사람이 들여다본 순간 결정된다. 차이는 이게 전부다. osFoundry 코드는 이 선을 분명하게 그어 놨다. GitNest의 쓰기 라우트는 `denyAgentScope`로 막혀 있고, 앱 안의 Maestro에서 GitNest로 가는 브리지도 읽기만 허용한다. Connected tool이 브라우저 토큰을 빌려서 이걸 우회하지도 않는다. 제안은 저장되고, 커넥션 오너에게 보이고, 승인되면 osFoundry 자체의 쓰기 로직이 실행하면서 커넥션 이름으로 기록을 남긴다. Claude 대화는 저장소에 쓸 수 있는 인증 정보를 한 번도 쥐지 않고, 오너의 GitHub/GitLab 토큰도 못 본다. provider publication은 그 change set 전용 브랜치만, ref가 아직 없다는 조건(absent-ref lease)으로 push하고, 상관없는 ref를 미러링하지 않는다. 대신 시간이 든다. "제안됨"과 "승인됨" 사이는 사람의 시간이다. 몇 분일 때도 있고 하루일 때도 있다. 그 사이에 브랜치는 앞으로 가고, 누가 change set을 다시 합성하고, 체크가 끝나고, 오너가 저장소 권한을 잃거나 커넥션이 취소된다. osFoundry의 승인 처리는 커넥션, 프로젝트 소속, 오너의 현재 저장소 권한, 만료를 다시 확인한다. change set 쪽이면 버전과 커밋까지, publication이면 리스크 정책과 필수 체크까지 본다. 계획 문서에는 거절됐거나 만료됐거나 낡은 제안은 실행하면 안 된다는 문장이 있다. 그래도 실패 두 가지가 남는다. 이번에 다시 만들어 본 게 이 부분이다. 1. 같은 베이스에 대한 승인 두 개. 각각만 보면 둘 다 멀쩡하다. 2. 반쯤 실패한 뒤의 재시도. Git에는 커밋이 들어갔는데 DB는 아직 모른다. 그 상태에서 오너가 한 번 더 누른다. 두 번째에 대해서는 osFoundry 계획 문서에 Git 서비스 쪽 대응이 적혀 있다. 파일 import는 바뀌지 않는 제안 ID를 멱등 키로 Git 서비스에 넘기고, 서비스는 브랜치와 replay 마커를 compare-and-swap으로 원자적으로 갱신한다. 그래서 Git 커밋 뒤에 DB가 실패해도 커밋을 하나 더 만들지 않고 재시도할 수 있다. ## 직접 다시 만들어 봤다 파일은 두 개고, 표준 라이브러리와 git CLI(Apple Git 2.39.5)만 썼다. 돌린 건 Apple M1 Mac인데, 다른 작업의 영상 렌더링도 같이 돌고 있었다. * `server.py`(225줄): bare 저장소와 sqlite 파일 위에 띄운 JSON-RPC 엔드포인트 * `client.py`(203줄): 스크립트로 흉내 낸 "Claude"와 오너 Claude 바이너리도 계정도 안 썼다. 클라이언트는 연결된 채팅이 보낼 법한 `tools/call`을 그대로 보낼 뿐이다. tool은 `read_repo`, `propose_file_import`, `read_proposal` 세 개다. 이름은 이 재구현에서 붙인 거고, 셋 다 Git에 쓰지 않는다. 오너는 별도 라우트 `/owner/decide`에서 결정하고, 이 라우트는 에이전트 토큰이 오면 `403 agent_scope_denied`를 돌려준다. `denyAgentScope` 아이디어를 `if` 하나로 옮긴 셈이다. 일부러 다르게 한 게 하나 있다. 재구현은 파일 import에도 베이스 커밋을 고정한다(`expected_base_oid`, 생략하면 제안 시점의 브랜치 끝). osFoundry의 `propose_gitnest_file_import` 스키마에는 이 필드가 없다. 이 부분은 재구현의 설계 선택이지 osFoundry 설명이 아니다. 제안 접수는 `(커넥션, idempotency_key)` 기준으로 멱등하게 했다. 본문 해시도 같이 들고 있어서, 같은 키에 내용만 다른 제안은 조용히 replay하지 않고 에러로 돌려보낸다. c.execute("INSERT OR IGNORE INTO proposals (id,conn,idem_key,body_hash,branch,base_oid,files," "message,status,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,'pending',?,?)", (...)) row = c.execute("SELECT * FROM proposals WHERE conn=? AND idem_key=?", (conn, key)).fetchone() if row["body_hash"] != h: raise Conflict("idempotency_key_reused_with_different_body") 승인 때 커밋은 plumbing 명령만으로 조립한다(임시 인덱스에 `read-tree`, `hash-object -w`, `update-index --cacheinfo`, `write-tree`, `commit-tree -p <base>`). 여기까지는 ref를 하나도 안 움직인다. 마지막에 트랜잭션 한 번으로, 브랜치가 아직 리뷰된 베이스를 가리킬 때만 옮기고 동시에 제안 ID 이름으로 마커 ref를 만든다. tx = (f"start\nupdate refs/heads/{branch} {new} {base_oid}\n" f"create refs/proposals/{proposal_id} {new}\nprepare\ncommit\n") git("update-ref", "--stdin", inp=tx) old 값을 준 `update`가 compare-and-swap이고, `create`는 ref가 이미 있으면 실패한다. git은 둘 다 적용하거나 둘 다 안 한다. 승인 로직은 뭔가 조립하기 전에 `refs/proposals/<id>`부터 찾는다. 있으면 앞선 시도가 Git까지 갔다는 뜻이니 그 커밋을 기록하고 replay로 돌려준다. ## 스크립트 Claude가 본 것 `python3 client.py scenario` 출력이다. 제안 UUID는 줄였다. 만료 줄만 TTL 0.3초로 띄운 별도 서버에서 나온 결과다. read_repo -> head 29a940e8 (1 commit) propose A README.md key=readme-fix-0001 -> {"proposal_id": "96dcf05f-…", "status": "pending", "base_oid": "29a940e8"} propose A (retry, same key+body) -> {"proposal_id": "96dcf05f-…", "status": "pending", "base_oid": "29a940e8", "replayed": true} propose A' (same key, other body) -> idempotency_key_reused_with_different_body propose B app.py key=app-bump-0001 -> {"proposal_id": "c91824e3-…", "status": "pending", "base_oid": "29a940e8"} decide A with the agent token -> HTTP 403 {'error': 'agent_scope_denied'} owner approves A -> HTTP 200 {"status": "executed", "commit": "f07b9fda"} (2 commits) owner approves B -> HTTP 200 {"status": "stale", "refused": true, "base_oid": "29a940e8", "branch_head": "f07b9fda"} (2 commits) propose B2 on new head f07b9fda -> {"proposal_id": "bc3cedfc-…", "status": "pending", "base_oid": "f07b9fda"} owner approves B2, crash before DB -> HTTP 500 {'error': 'injected: crash after git, before DB'} (3 commits) owner retries approval of B2 -> HTTP 200 {"status": "executed", "commit": "fb736eda", "replayed": true} (3 commits) owner clicks approve on A again -> HTTP 200 {"status": "executed", "commit": "f07b9fda", "replayed": true} (3 commits) approve after decline -> HTTP 200 {'status': 'declined', 'refused': True} (3 commits) approve after expiry (ttl 0.3 s) -> HTTP 200 {'status': 'expired', 'refused': True} (1 commit) fb736ed Claude (conn-claude-research) | repo-owner | Bump app f07b9fd Claude (conn-claude-research) | repo-owner | Pin install version 29a940e owner | owner | seed A는 README.md, B는 app.py를 건드려서 충돌도 없는데 B는 거절됐다. "오너는 29a940e8 기준으로 리뷰했다"를 엄격하게 읽으면 이렇게 된다. 해결은 싸다. 클라이언트가 새 head를 다시 읽고 다시 제안하면 되고, 그게 B2다. `git log` 줄에는 이 저장소가 남기는 출처가 보인다. author는 커넥션, committer는 승인한 사람이고, 메시지에 `Proposal: <id>`와 `Approved-by:` 트레일러가 붙는다. ## compare-and-swap을 빼면 뭐가 깨질까 `--mode naive`는 트랜잭션만 빼고 나머지는 다 남긴 모드다. head 확인은 Python에서 하고, 커밋을 조립한 다음 old 값도 마커도 없이 `git update-ref refs/heads/main <new>`를 날린다. 서버, 클라이언트, 저장소 구성은 똑같다. 경합 테스트: 같은 베이스에 대한 서로 다른 제안 두 개를 스레드 두 개에서 동시에 승인한다(`threading.Barrier`). 실행 한 번에 100회, 두 번 돌렸다. 매번 executed로 돌아온 커밋이 진짜 `main` 히스토리에 있는지 `git merge-base --is-ancestor`로 확인했다. 크래시 테스트: Git 처리 뒤, DB 갱신 전에 실패를 주입해서 승인하고, 한 번 더 승인한다. 실행 한 번에 50회, 두 번 돌렸다. 전후로 `main` 커밋 수를 셌다. 모드 | 경합: 둘 다 executed | 경합: 승인됐는데 main에 없는 커밋 | 크래시 후 재시도 | 재시도로 늘어난 커밋 ---|---|---|---|--- CAS + 마커, 1회차 | 0 / 100 | 0 | 50번 모두 executed (replay) | 0 CAS + 마커, 2회차 | 0 / 100 | 0 | 50번 모두 executed (replay) | 0 naive, 1회차 | 100 / 100 | 100 | 50번 모두 stale | 0 naive, 2회차 | 100 / 100 | 100 | 50번 모두 stale | 0 naive 경합은 "가끔"이 아니라 매번 깨졌다. "head가 베이스와 같다"부터 "ref를 쓴다"까지 git 프로세스가 네 개 끼어 있어서 수십 ms짜리 틈이 생긴다. 배리어로 동시에 풀린 스레드 두 개는 매번 그 틈 안에 들어간다. 두 승인 모두 커밋 ID와 함께 `executed`를 돌려주니까 오너 눈에는 성공이 두 개다. 그런데 그중 하나는 브랜치 히스토리에 없다. 에러는 어디에도 안 뜬다. CAS 쪽은 모든 시도에서 executed 하나, 새 head를 담은 `stale` 거절 하나로 끝났다. naive의 크래시 후 재시도는 이중 커밋까지는 안 갔다. 브랜치가 이미 앞으로 가 있어서 베이스 확인에서 걸렸기 때문이다. 문제는 걸린 모양이 `stale`이라는 거다. 그 제안의 커밋은 `main`에 들어가 있는데, 저장소는 그 제안을 거절됨으로 보고한다. 이게 보기 싫어서 베이스 확인을 빼면, 이번엔 재시도가 같은 내용을 한 번 더 커밋한다. "그건 이미 실행됐고 커밋은 이거야"라고 답할 수 있는 건 replay 마커가 있어서다. 삽질 포인트는 딱 여기다. ## 비용은 얼마나 될까 클라이언트에서 본 요청당 경과 시간이다. 루프백 HTTP(urllib, 매번 새 연결)로 실행 한 번에 연산별 200회, 두 번 돌렸다. 범위는 1회차~2회차 값이다. 연산 | CAS 중앙값 | CAS p95 | naive 중앙값 | naive p95 ---|---|---|---|--- 제안 (새 키) | 4.42–4.44 ms | 7.1–12.3 ms | 4.08–4.66 ms | 6.4–10.0 ms 제안 (같은 키, replay) | 1.42–1.50 ms | 3.3–4.5 ms | 1.27–1.49 ms | 2.4–4.1 ms 승인 후 실행 | 115–119 ms | 199–257 ms | 92–105 ms | 130–193 ms 승인했지만 stale로 거절 | 28–31 ms | 52–70 ms | 14–17 ms | 24–34 ms 이미 실행된 걸 다시 승인 | 1.46–1.60 ms | 3.4–4.7 ms | 1.38–1.54 ms | 2.6–4.2 ms 거의 다 프로세스 띄우는 시간이다. CAS로 실행까지 가는 승인은 git을 8번 띄운다(마커 확인, head 확인, `read-tree`, `hash-object`, `update-index`, `write-tree`, `commit-tree`, `update-ref --stdin`). naive는 6번이다. stale 거절은 CAS가 2번, naive가 1번이다. 부하가 걸린 이 머신에서 git 한 번에 대략 14 ms니까, 안전한 경로의 추가 비용은 프로세스 두 번, 15–25 ms 정도다. 어차피 사람 클릭을 기다리는 요청이다. git을 셸로 부르지 않고 libgit2를 링크하면 모든 칸이 줄겠지만, 둘의 차이는 확인 두 번만큼 그대로 남는다. 두 표 모두에서 제일 싼 건 replay였다. sqlite를 한 번 읽으면 끝난다. 재시도하는 클라이언트가 새 요청보다 서버에 덜 부담을 준다. ## 직접 붙인다면 * 에이전트에게는 저장소 쓰기 tool을 주지 말고 `propose_*`만 준다. 결정 라우트에서는 호출자가 에이전트 토큰이 아닌지 확인한다. 에이전트가 라우트를 못 찾을 거라고 기대하지 않는다. * 리뷰어가 본 것(버전과 커밋)을 제안에 고정하고 승인 때 비교한다. 거절할 때는 현재 head를 같이 돌려줘서 클라이언트가 다시 읽고 다시 낼 수 있게 한다. * Git 쓰기는 ref 트랜잭션 한 번으로 묶는다. old 값을 준 브랜치 업데이트와, 제안 이름의 create 전용 마커를 같이 넣는다. 뭔가 조립하기 전에 마커부터 찾는다. * 멱등 키는 최소 길이가 있는 필수 값으로 하고, 본문 해시와 같이 저장한다. 같은 키에 내용이 다르면 거절한다. * 출처는 커밋 자체에 쓴다. author는 커넥션, committer는 승인자, 트레일러에 제안 ID. DB가 날아가도 `git log`에는 남는다. * PR에서 멈춘다. 머지와 배포는 저장소 오너가 자기 화면에서 정한다. GitNest 쪽 clone, push, 액세스 토큰 이야기는 osFoundry의 GitNest 페이지에 있다. 커넥터는 그냥 MCP라서, 같은 propose tool을 Claude Code에서든 다른 MCP 호스트에서든 쓸 수 있다.
dev.to
November 8, 2026 at 12:37 AM
iStock (⭐️ 12)

StockMatchs 子项目, 模拟的股票交易系统 go语言实现,基于http的jsonrpc接口通信

#go
November 2, 2026 at 1:13 AM
📦 uma/json-rpc v6.0.0

a JSON-RPC 2.0 server

🔗 https://github.com/1ma/JsonRpc
November 1, 2026 at 12:41 AM
📦 winternet-studio/odoo-jsonrpc-client v1.4.0

A simple Odoo JSON-RPC client you can understand

🔗 https://github.com/winternet-studio/php-odoo-jsonrpc-client
October 28, 2026 at 8:00 AM
📦 near/near-jsonrpc-client-php v0.1.2

Typed PHP client and OpenAPI generator for NEAR JSON-RPC

🔗 https://github.com/hosseinkarami-dev/near-jsonrpc-client-php
October 25, 2026 at 3:18 PM
📦 tochka-developers/jsonrpc v5.2.0

JsonRpc extension for Laravel

🔗 https://github.com/tochka-developers/jsonrpc
October 23, 2026 at 12:00 PM
📦 tochka-developers/jsonrpc v6.0.0-beta.2

JsonRpc extension for Laravel

🔗 https://github.com/tochka-developers/jsonrpc
October 23, 2026 at 12:00 PM
json
{"jsonrpc": "2.0", "method": "meow"}
October 16, 2026 at 10:39 PM
MCPサーバーを手で追うのをやめたくなる話
https://papoo.work/doc/f6b26158502585f2
#mcp #テスト #デバッグ #jsonrpc #ai
MCPサーバーを手で追うのをやめたくなる話
papoo.work
October 10, 2026 at 4:58 AM
📦 winternet-studio/odoo-jsonrpc-client v1.3.2

A simple Odoo JSON-RPC client you can understand

🔗 https://github.com/winternet-studio/php-odoo-jsonrpc-client
October 8, 2026 at 7:59 PM
What's live:

• A registry of 200k+ agent skills and MCP servers from 9 public registries, every one runnable
• The Studio: describe a workflow in a sentence, run it, publish it
• One remote MCP endpoint for any client: rokha.ai/mcp/jsonrpc
rokha.ai
October 8, 2026 at 5:07 PM
MCPサーバーを手で追うのをやめたくなる話
https://papoo.work/doc/f6b26158502585f2
#mcp #テスト #デバッグ #jsonrpc #ai
MCPサーバーを手で追うのをやめたくなる話
papoo.work
October 6, 2026 at 1:26 PM
MCPサーバーを手で追うのをやめたくなる話
https://papoo.work/doc/f6b26158502585f2
#mcp #テスト #デバッグ #jsonrpc #ai
MCPサーバーを手で追うのをやめたくなる話
papoo.work
October 5, 2026 at 5:33 PM
MCP Failure Lab found Ruby MCP SDK 1.6.1 accepts tool responses with missing `jsonrpc` or `"jsonrpc":"1.0"` over stdio and Streamable HTTP. Reproduced 3/3 per transport using protocol 2025-11-25.

github.com/modelcontext...
Client accepts tool responses with missing or invalid jsonrpc version · Issue #589 · modelcontextprotocol/ruby-sdk
Observed behavior With mcp 1.6.1, MCP::Client#call_tool returns normally when the server's response omits jsonrpc or sets it to "1.0". Both variants were accepted in three independent runs over std...
github.com
October 5, 2026 at 4:55 PM
MCPサーバーを手で追うのをやめたくなる話
https://papoo.work/doc/f6b26158502585f2
#mcp #テスト #デバッグ #jsonrpc #ai
MCPサーバーを手で追うのをやめたくなる話
papoo.work
October 5, 2026 at 12:28 AM
#Secretspec now has a #JSONRPC #IPC. I think this is where the tool is going to unlock so much. Being able to get secrets across the system using secretspec can let integration on the runtime. Like service accounts authorization without the vendor requirement. 🫠
IPC wire protocol
Shared framing, negotiation, cancellation, errors, and lifecycle for SecretSpec IPC
secretspec.dev
September 29, 2026 at 12:22 AM
Production MCPを読んで考えたこと
papoo.work
September 28, 2026 at 1:42 AM
Production MCPを読んで考えたこと
papoo.work
September 26, 2026 at 1:45 PM
Production MCPを読んで考えたこと
papoo.work
September 24, 2026 at 6:25 AM