@allansantos-dev/smart-tool 0.9.43 → 0.9.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/README.md +25 -5
- package/autostart.py +5 -3
- package/browser_control.py +71 -15
- package/client_hooks.py +2 -2
- package/config.py +12 -0
- package/document_read.py +272 -0
- package/document_text.py +212 -54
- package/folder_picker.py +7 -5
- package/hook_decision.py +9 -1
- package/i18n.py +213 -0
- package/lazy_mcp_proxy.py +185 -0
- package/locales/pt-BR.json +923 -0
- package/package.json +1 -1
- package/projects_ui.py +7 -2
- package/requester.py +76 -0
- package/research_metrics.py +5 -3
- package/setup_audit.py +5 -3
- package/setup_ui.py +15 -2
- package/smart_tool_daemon.py +69 -10
- package/toast_notice.py +3 -2
- package/tool_redirect.py +2 -1
- package/tray.py +106 -49
- package/update_check.py +28 -10
- package/update_splash.py +197 -0
- package/version.py +1 -1
- package/web_search_health.py +5 -2
package/hook_decision.py
CHANGED
|
@@ -25,6 +25,7 @@ import index_scope
|
|
|
25
25
|
import endpoint_sync
|
|
26
26
|
import project_store
|
|
27
27
|
import redirect_rule
|
|
28
|
+
import requester
|
|
28
29
|
import router
|
|
29
30
|
import tool_redirect
|
|
30
31
|
|
|
@@ -298,6 +299,8 @@ def decide(payload, client, web_route, jobs):
|
|
|
298
299
|
router.CLIENT.set(client)
|
|
299
300
|
router.SESSION.set(payload.get("session_id"))
|
|
300
301
|
router._log_decision(SMART_SEARCH_TOOL, {}, "allow", "Smart Tool search, logged to measure followed redirects")
|
|
302
|
+
requester.note_hook(payload["tool_name"].removeprefix(f"mcp__{SMART_TOOL_SERVER}__"), payload.get("tool_input"),
|
|
303
|
+
payload.get("session_id"), payload.get("cwd"))
|
|
301
304
|
return {}
|
|
302
305
|
mode = config.hook_mode(cfg)
|
|
303
306
|
if mode == "off" or not isinstance(payload, dict):
|
|
@@ -321,7 +324,12 @@ def _redirect(payload, client, plan, jobs):
|
|
|
321
324
|
else runs as the agent wrote it and, in Claude Code, `after` swaps its output for the answer."""
|
|
322
325
|
key, tool_name = payload["tool_use_id"], payload["tool_name"]
|
|
323
326
|
tool_input = payload.get("tool_input") if isinstance(payload.get("tool_input"), dict) else {}
|
|
324
|
-
|
|
327
|
+
who = requester.CURRENT.set(requester.from_hook(client, payload)) if tool_name in WEB_TOOLS else None
|
|
328
|
+
try:
|
|
329
|
+
jobs.start(key, plan["kind"], plan["arguments"])
|
|
330
|
+
finally:
|
|
331
|
+
if who is not None:
|
|
332
|
+
requester.CURRENT.reset(who)
|
|
325
333
|
if tool_name not in tool_redirect.NO_OP:
|
|
326
334
|
return {}
|
|
327
335
|
text = _answer(jobs, key, PRE_WAIT_S, tool_name)
|
package/i18n.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Interface language: Brazilian Portuguese or English, following the Windows display language until the user picks
|
|
2
|
+
one with the language button (config `language`).
|
|
3
|
+
|
|
4
|
+
Strings are written in English in the code and the English text is the catalog key (the gettext convention): a
|
|
5
|
+
missing Portuguese entry shows the English text instead of breaking the screen, and tests/test_i18n.py fails when a
|
|
6
|
+
user-visible string has no entry. The same catalog serves Python (tray, notifications, update screen) and the web
|
|
7
|
+
pages, where PAGE_SCRIPT translates the rendered DOM and whatever the page scripts insert afterwards.
|
|
8
|
+
|
|
9
|
+
Agent-facing text (MCP tool answers, hook messages) stays in English: agents read it, users do not."""
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import config
|
|
16
|
+
|
|
17
|
+
ROOT = os.path.dirname(os.path.abspath(__file__))
|
|
18
|
+
LOCALES_DIR = os.path.join(ROOT, "locales")
|
|
19
|
+
PORTUGUESE_PRIMARY_LANGID = 0x16
|
|
20
|
+
_CATALOGS = {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def system_language():
|
|
24
|
+
""" "pt-BR" when the Windows display language is any Portuguese, else "en"."""
|
|
25
|
+
if sys.platform != "win32":
|
|
26
|
+
return "en"
|
|
27
|
+
import ctypes
|
|
28
|
+
return "pt-BR" if ctypes.windll.kernel32.GetUserDefaultUILanguage() & 0x3FF == PORTUGUESE_PRIMARY_LANGID else "en"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def language(cfg=None):
|
|
32
|
+
"""The language the interface shows now: the user's choice, else the Windows display language."""
|
|
33
|
+
return config.language(cfg) or system_language()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def set_language(value):
|
|
37
|
+
"""Saves the user's choice ("pt-BR" or "en") and returns it; raises ValueError for any other value."""
|
|
38
|
+
if value not in config.LANGUAGES:
|
|
39
|
+
raise ValueError(f"Unknown language {value!r}: use one of {', '.join(config.LANGUAGES)}.")
|
|
40
|
+
config.update_config(lambda cfg: cfg.__setitem__("language", value))
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def catalog(lang):
|
|
45
|
+
"""English text -> translated text for lang; English itself has an empty catalog. Raises when the locale file is
|
|
46
|
+
missing or malformed: a broken install must not pass for an untranslated one."""
|
|
47
|
+
if lang == "en":
|
|
48
|
+
return {}
|
|
49
|
+
if lang not in _CATALOGS:
|
|
50
|
+
with open(os.path.join(LOCALES_DIR, f"{lang}.json"), encoding="utf-8") as stream:
|
|
51
|
+
data = json.load(stream)
|
|
52
|
+
messages = data["messages"]
|
|
53
|
+
if not isinstance(messages, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in messages.items()):
|
|
54
|
+
raise ValueError(f"locales/{lang}.json: 'messages' must map text to text.")
|
|
55
|
+
_CATALOGS[lang] = messages
|
|
56
|
+
return _CATALOGS[lang]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_PLACEHOLDER = re.compile(r"\{(\w*)\}")
|
|
60
|
+
_WORD = re.compile(r"[A-Za-z]")
|
|
61
|
+
_SENTENCE_BREAK = re.compile(r"(?<=[.!?…])\s+(?=[A-Z])")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _normal(text):
|
|
65
|
+
return " ".join(text.split())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _fragment_key(key):
|
|
69
|
+
"""Keys that may be replaced inside a longer text: two words or more, or one word ending in punctuation."""
|
|
70
|
+
return not _PLACEHOLDER.search(key) and (" " in key or (len(key) >= 4 and key[-1] in ".:!?…"))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _bounded(fragment):
|
|
74
|
+
"""Regex of a phrase that never matches inside a word or a file name: a boundary before it when it starts with a
|
|
75
|
+
letter, after it when it ends with a letter or sentence punctuation ("Saved." is not "Saved.txt"); phrases cut
|
|
76
|
+
from a sentence may start or end with other punctuation ("Accept selected (" before a count)."""
|
|
77
|
+
return ((r"(?<!\w)" if re.match(r"\w", fragment) else "") + re.escape(fragment)
|
|
78
|
+
+ (r"(?!\w)" if re.search(r"[\w.:!?…]$", fragment) else ""))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Translator:
|
|
82
|
+
"""Translates finished English text with one catalog: the whole text, then a template with placeholders
|
|
83
|
+
("Updated from {old} to {new}."), then each sentence on its own when there are several, then known phrases inside
|
|
84
|
+
it. PAGE_SCRIPT runs the same steps."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, messages):
|
|
87
|
+
self.messages = {_normal(k): v for k, v in messages.items()}
|
|
88
|
+
self.templates = []
|
|
89
|
+
for key, value in self.messages.items():
|
|
90
|
+
literal = _PLACEHOLDER.sub("", key)
|
|
91
|
+
if _PLACEHOLDER.search(key) and len(_WORD.findall(literal)) >= 3:
|
|
92
|
+
parts = _PLACEHOLDER.split(key)
|
|
93
|
+
pattern = "".join(re.escape(part) if i % 2 == 0 else f"(?P<p{i // 2}>.+?)" for i, part in enumerate(parts))
|
|
94
|
+
names = parts[1::2]
|
|
95
|
+
self.templates.append((re.compile(f"^{pattern}$", re.S), names, value))
|
|
96
|
+
self.templates.sort(key=lambda item: -len(item[0].pattern))
|
|
97
|
+
fragments = sorted((k for k in self.messages if _fragment_key(k)), key=len, reverse=True)
|
|
98
|
+
self.fragments = (re.compile("|".join(_bounded(f) for f in fragments)) if fragments else None)
|
|
99
|
+
|
|
100
|
+
def __call__(self, text):
|
|
101
|
+
"""text translated, or text unchanged when nothing in the catalog matches it."""
|
|
102
|
+
key = _normal(text)
|
|
103
|
+
if not key or not self.messages:
|
|
104
|
+
return text
|
|
105
|
+
if key in self.messages:
|
|
106
|
+
return self.messages[key]
|
|
107
|
+
for pattern, names, value in self.templates:
|
|
108
|
+
found = pattern.match(key)
|
|
109
|
+
if found:
|
|
110
|
+
values = {name: found.group(f"p{i}") for i, name in enumerate(names)}
|
|
111
|
+
return _PLACEHOLDER.sub(lambda m: self(values.get(m.group(1), m.group(0))), value)
|
|
112
|
+
sentences = _SENTENCE_BREAK.split(key)
|
|
113
|
+
if len(sentences) > 1:
|
|
114
|
+
return " ".join(self(sentence) for sentence in sentences)
|
|
115
|
+
if self.fragments:
|
|
116
|
+
return self.fragments.sub(lambda m: self.messages[m.group(0)], text)
|
|
117
|
+
return text
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_TRANSLATORS = {}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def translator(lang=None):
|
|
124
|
+
"""The Translator of lang (default: the current interface language)."""
|
|
125
|
+
lang = lang or language()
|
|
126
|
+
if lang not in _TRANSLATORS:
|
|
127
|
+
_TRANSLATORS[lang] = Translator(catalog(lang))
|
|
128
|
+
return _TRANSLATORS[lang]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def t(text, lang=None, **values):
|
|
132
|
+
"""text (an English catalog key) in the interface language, with its {placeholders} filled from values."""
|
|
133
|
+
translated = translator(lang)(text)
|
|
134
|
+
return translated.format(**values) if values else translated
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def N_(text):
|
|
138
|
+
"""Marks text as a catalog key without translating it yet (gettext_noop): for tables built at import time,
|
|
139
|
+
translated with t() when shown."""
|
|
140
|
+
return text
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def page_script(token, lang=None):
|
|
144
|
+
"""The <script> the web pages carry in <head>: catalog, current language, the setup token the language button
|
|
145
|
+
sends, translator and language button."""
|
|
146
|
+
lang = lang or language()
|
|
147
|
+
data = {"lang": lang, "token": token, "messages": catalog(lang)}
|
|
148
|
+
return "<script>window.SMART_TOOL_I18N=" + json.dumps(data, ensure_ascii=False).replace("</", "<\\/") + ";</script>\n" \
|
|
149
|
+
+ "<script>" + PAGE_SCRIPT + "</script>"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
PAGE_SCRIPT = r"""
|
|
153
|
+
(()=>{
|
|
154
|
+
const I=window.SMART_TOOL_I18N,PH=/\{(\w*)\}/g,SKIP=new Set(['SCRIPT','STYLE','CODE','PRE','TEXTAREA','KBD','SAMP','svg','SVG']);
|
|
155
|
+
const INLINE=new Set(['STRONG','EM','B','I','A','CODE','BR','SPAN','SMALL','KBD','ABBR','SUP','SUB','U','MARK']);
|
|
156
|
+
const ATTRS=['title','placeholder','aria-label','alt'];
|
|
157
|
+
const norm=s=>s.replace(/\s+/g,' ').trim();
|
|
158
|
+
const esc=s=>s.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
|
|
159
|
+
const messages={};for(const [k,v] of Object.entries(I.messages))messages[norm(k)]=v;
|
|
160
|
+
const templates=[];
|
|
161
|
+
for(const [k,v] of Object.entries(messages)){if(!/\{\w*\}/.test(k)||(k.replace(PH,'').match(/[A-Za-z]/g)||[]).length<3)continue;
|
|
162
|
+
const parts=k.split(PH),names=[];let src='';parts.forEach((p,i)=>{if(i%2){names.push(p);src+='(.+?)'}else src+=esc(p)});
|
|
163
|
+
templates.push([new RegExp('^'+src+'$','s'),names,v])}
|
|
164
|
+
templates.sort((a,b)=>b[0].source.length-a[0].source.length);
|
|
165
|
+
const frag=Object.keys(messages).filter(k=>!/\{\w*\}/.test(k)&&(k.includes(' ')||(k.length>=4&&/[.:!?…]$/.test(k)))).sort((a,b)=>b.length-a.length);
|
|
166
|
+
const bounded=f=>(/^\w/.test(f)?'(?<!\\w)':'')+esc(f)+(/[\w.:!?…]$/.test(f)?'(?!\\w)':'');
|
|
167
|
+
const fragRe=frag.length?new RegExp(frag.map(bounded).join('|'),'g'):null;
|
|
168
|
+
function tr(text){const key=norm(text);if(!key||I.lang==='en')return text;
|
|
169
|
+
if(key in messages){const lead=text.match(/^\s*/)[0],tail=text.match(/\s*$/)[0];return lead+messages[key]+tail}
|
|
170
|
+
for(const [re,names,v] of templates){const m=key.match(re);if(m){const vals={};names.forEach((n,i)=>vals[n]=m[i+1]);return v.replace(PH,(all,n)=>n in vals?tr(vals[n]):all)}}
|
|
171
|
+
const sentences=key.split(/(?<=[.!?…])\s+(?=[A-Z])/);if(sentences.length>1)return text.match(/^\s*/)[0]+sentences.map(tr).join(' ')+text.match(/\s*$/)[0];
|
|
172
|
+
return fragRe?text.replace(fragRe,m=>messages[m]):text}
|
|
173
|
+
const written=new WeakMap();
|
|
174
|
+
function skipped(el){return !el||!el.closest||el.closest('[data-no-i18n],script,style,noscript,code,pre,textarea,kbd,samp,svg')}
|
|
175
|
+
function translateElement(el){
|
|
176
|
+
if(SKIP.has(el.tagName)||skipped(el))return false;
|
|
177
|
+
for(const a of ATTRS){const v=el.getAttribute(a);if(v&&written.get(el)?.[a]!==v){const t=tr(v);if(t!==v){el.setAttribute(a,t);written.set(el,{...(written.get(el)||{}),[a]:t})}}}
|
|
178
|
+
if(el.childElementCount&&[...el.children].every(c=>INLINE.has(c.tagName)&&!c.id&&!Object.keys(c.dataset).length)&&[...el.childNodes].some(n=>n.nodeType===3&&n.data.trim())){
|
|
179
|
+
const key=norm(el.innerHTML);if(key in messages){el.innerHTML=messages[key];return true}}
|
|
180
|
+
return false}
|
|
181
|
+
function translateText(node){if(skipped(node.parentElement)||written.get(node)===node.data)return;const t=tr(node.data);if(t!==node.data){node.data=t;written.set(node,t)}}
|
|
182
|
+
function walk(root){if(I.lang==='en')return;
|
|
183
|
+
if(root.nodeType===3){translateText(root);return}
|
|
184
|
+
if(root.nodeType!==1)return;
|
|
185
|
+
if(translateElement(root))return;
|
|
186
|
+
const it=document.createTreeWalker(root,NodeFilter.SHOW_ELEMENT|NodeFilter.SHOW_TEXT,{acceptNode:n=>n.nodeType===1&&(SKIP.has(n.tagName)||n.hasAttribute('data-no-i18n'))?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT});
|
|
187
|
+
const nodes=[];for(let n=it.nextNode();n;n=it.nextNode())nodes.push(n);
|
|
188
|
+
const done=new Set();
|
|
189
|
+
for(const n of nodes){if([...done].some(d=>d.contains(n)))continue;if(n.nodeType===1){if(translateElement(n))done.add(n)}else translateText(n)}}
|
|
190
|
+
function applyTitle(){if(I.lang!=='en')document.title=tr(document.title)}
|
|
191
|
+
new MutationObserver(list=>{if(I.lang==='en')return;for(const m of list){
|
|
192
|
+
if(m.type==='characterData')translateText(m.target);
|
|
193
|
+
else if(m.type==='attributes')translateElement(m.target);
|
|
194
|
+
else for(const n of m.addedNodes)walk(n)}}).observe(document.documentElement,{subtree:true,childList:true,characterData:true,attributes:true,attributeFilter:ATTRS});
|
|
195
|
+
const nativeConfirm=window.confirm.bind(window),nativeAlert=window.alert.bind(window),nativePrompt=window.prompt.bind(window);
|
|
196
|
+
window.confirm=m=>nativeConfirm(tr(String(m)));window.alert=m=>nativeAlert(tr(String(m)));window.prompt=(m,d)=>nativePrompt(tr(String(m)),d);
|
|
197
|
+
window.smartToolTr=tr;
|
|
198
|
+
const GLOBE='<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.6 3.8 5.6 3.8 9s-1.3 6.4-3.8 9c-2.5-2.6-3.8-5.6-3.8-9S9.5 5.6 12 3z"/></svg>';
|
|
199
|
+
function paintButton(b){b.querySelector('span').textContent=I.lang==='en'?'EN':'PT';
|
|
200
|
+
const label=I.lang==='en'?'Mudar para português':'Switch to English';b.title=label;b.setAttribute('aria-label',label)}
|
|
201
|
+
function button(){const b=document.createElement('button');b.type='button';b.id='lang-toggle';b.setAttribute('data-no-i18n','');
|
|
202
|
+
b.innerHTML=GLOBE+'<span></span>';
|
|
203
|
+
b.style.cssText='display:inline-flex;align-items:center;gap:6px;margin-left:12px;padding:5px 10px;border-radius:999px;border:1px solid var(--line,var(--border,#c9ced6));background:var(--surface,#fff);color:var(--ink,#1b2430);font:600 13px/1 "Segoe UI",system-ui,sans-serif;cursor:pointer;flex:none';
|
|
204
|
+
paintButton(b);
|
|
205
|
+
b.addEventListener('click',async()=>{const next=I.lang==='en'?'pt-BR':'en';b.disabled=true;
|
|
206
|
+
try{const r=await fetch('/setup/language',{method:'POST',headers:{'Content-Type':'application/json','X-Setup-Token':I.token},body:JSON.stringify({language:next})});
|
|
207
|
+
const data=await r.json().catch(()=>({}));if(!r.ok)throw Error(data.error||('HTTP '+r.status));location.reload()}
|
|
208
|
+
catch(e){b.disabled=false;nativeAlert((I.lang==='en'?'Could not change the language: ':'Não foi possível trocar o idioma: ')+e.message)}});
|
|
209
|
+
return b}
|
|
210
|
+
document.addEventListener('DOMContentLoaded',()=>{document.documentElement.lang=I.lang;walk(document.body);applyTitle();
|
|
211
|
+
const header=document.querySelector('header');if(header)header.appendChild(button())});
|
|
212
|
+
})();
|
|
213
|
+
"""
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stdio MCP proxy that starts the real server only when a request needs it.
|
|
3
|
+
|
|
4
|
+
Claude Code and Codex start every registered stdio server with the session, so a Playwright MCP server (Node, about
|
|
5
|
+
97 MB of commit) sat idle in each session that never opened a browser. This proxy is what gets registered: it answers
|
|
6
|
+
`initialize`, `ping` and `tools/list` from the handshake cache browser_control wrote after probing the real server, and
|
|
7
|
+
starts the real command on the first other request (a tool call), replaying the client's `initialize` to it. From then
|
|
8
|
+
on every message passes through unchanged. When the client closes stdin the real server's process tree is killed.
|
|
9
|
+
|
|
10
|
+
Usage: lazy_mcp_proxy.py --handshake FILE -- COMMAND [ARG...]
|
|
11
|
+
The cache (`{"initialize": <result>, "tools": [...]}`) missing or unreadable is an error on stderr and exit code 2, never
|
|
12
|
+
an empty tool list.
|
|
13
|
+
"""
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import queue
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import threading
|
|
20
|
+
|
|
21
|
+
LAZY_ID = "smart-tool-lazy-init"
|
|
22
|
+
INIT_TIMEOUT_S = 60
|
|
23
|
+
KNOWN_PROTOCOLS = ("2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25")
|
|
24
|
+
_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_handshake(path):
|
|
28
|
+
"""The cached initialize result and tool list; RuntimeError when the file cannot be read or has the wrong shape."""
|
|
29
|
+
try:
|
|
30
|
+
with open(path, encoding="utf-8") as stream:
|
|
31
|
+
data = json.load(stream)
|
|
32
|
+
if not isinstance(data["initialize"], dict) or not isinstance(data["tools"], list):
|
|
33
|
+
raise TypeError("initialize must be an object and tools a list")
|
|
34
|
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
35
|
+
raise RuntimeError(f"handshake cache {path} is unusable ({type(exc).__name__}: {exc}); run browser_control.ensure "
|
|
36
|
+
f"again (reinstall Smart Tool).") from exc
|
|
37
|
+
return data
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Proxy:
|
|
41
|
+
"""One client conversation: local answers until a request needs the real server, then a pass-through."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, handshake, command, stdin, stdout):
|
|
44
|
+
self.handshake, self.command = handshake, command
|
|
45
|
+
self.stdin, self.stdout = stdin, stdout
|
|
46
|
+
self.child = None
|
|
47
|
+
self.closing = False
|
|
48
|
+
self.client_init = None
|
|
49
|
+
self.write_lock = threading.Lock()
|
|
50
|
+
|
|
51
|
+
def send(self, message):
|
|
52
|
+
"""Writes one JSON-RPC message (dict) or one raw line (bytes) to the client."""
|
|
53
|
+
data = message if isinstance(message, bytes) else json.dumps(message, ensure_ascii=False).encode("utf-8") + b"\n"
|
|
54
|
+
with self.write_lock:
|
|
55
|
+
self.stdout.write(data)
|
|
56
|
+
self.stdout.flush()
|
|
57
|
+
|
|
58
|
+
def answer(self, request_id, result):
|
|
59
|
+
self.send({"jsonrpc": "2.0", "id": request_id, "result": result})
|
|
60
|
+
|
|
61
|
+
def local_initialize(self, params):
|
|
62
|
+
"""The cached initialize result, speaking the protocol version the client asked for when it is a known one."""
|
|
63
|
+
result = dict(self.handshake["initialize"])
|
|
64
|
+
asked = (params or {}).get("protocolVersion")
|
|
65
|
+
if asked in KNOWN_PROTOCOLS:
|
|
66
|
+
result["protocolVersion"] = asked
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
def start_child(self):
|
|
70
|
+
"""Starts the real server and replays the client's initialize to it; its answer is not forwarded (the client
|
|
71
|
+
already got the cached one). RuntimeError when it exits or answers with an error."""
|
|
72
|
+
child = subprocess.Popen(self.command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=None,
|
|
73
|
+
creationflags=_NO_WINDOW)
|
|
74
|
+
params = self.client_init or {"protocolVersion": KNOWN_PROTOCOLS[2], "capabilities": {},
|
|
75
|
+
"clientInfo": {"name": "smart-tool-lazy-proxy", "version": "1"}}
|
|
76
|
+
child.stdin.write(json.dumps({"jsonrpc": "2.0", "id": LAZY_ID, "method": "initialize", "params": params}).encode()
|
|
77
|
+
+ b"\n")
|
|
78
|
+
child.stdin.flush()
|
|
79
|
+
answer = self.read_initialize(child)
|
|
80
|
+
if "error" in answer:
|
|
81
|
+
self.kill(child)
|
|
82
|
+
raise RuntimeError(f"the real server refused initialize: {answer['error']}")
|
|
83
|
+
child.stdin.write(b'{"jsonrpc":"2.0","method":"notifications/initialized"}\n')
|
|
84
|
+
child.stdin.flush()
|
|
85
|
+
self.child = child
|
|
86
|
+
threading.Thread(target=self.pump_child, args=(child,), daemon=True).start()
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def read_initialize(cls, child):
|
|
90
|
+
"""The real server's answer to the replayed initialize; RuntimeError (child killed) when it exits or stays silent
|
|
91
|
+
for INIT_TIMEOUT_S."""
|
|
92
|
+
answers = queue.Queue()
|
|
93
|
+
|
|
94
|
+
def read():
|
|
95
|
+
for line in child.stdout:
|
|
96
|
+
try:
|
|
97
|
+
message = json.loads(line)
|
|
98
|
+
except ValueError:
|
|
99
|
+
continue
|
|
100
|
+
if message.get("id") == LAZY_ID:
|
|
101
|
+
answers.put(message)
|
|
102
|
+
return
|
|
103
|
+
answers.put(None)
|
|
104
|
+
threading.Thread(target=read, daemon=True).start()
|
|
105
|
+
try:
|
|
106
|
+
answer = answers.get(timeout=INIT_TIMEOUT_S)
|
|
107
|
+
except queue.Empty:
|
|
108
|
+
cls.kill(child)
|
|
109
|
+
raise RuntimeError(f"the real server did not answer initialize in {INIT_TIMEOUT_S}s") from None
|
|
110
|
+
if answer is None:
|
|
111
|
+
raise RuntimeError(f"the real server exited before answering initialize (code {child.wait(10)})")
|
|
112
|
+
return answer
|
|
113
|
+
|
|
114
|
+
def pump_child(self, child):
|
|
115
|
+
"""Child to client until the child closes its stdout; then the proxy exits with the child's code, loudly."""
|
|
116
|
+
for line in child.stdout:
|
|
117
|
+
self.send(line)
|
|
118
|
+
code = child.wait()
|
|
119
|
+
if self.closing:
|
|
120
|
+
return
|
|
121
|
+
print(f"lazy_mcp_proxy: the real server exited (code {code})", file=sys.stderr, flush=True)
|
|
122
|
+
os._exit(code or 1)
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def kill(child):
|
|
126
|
+
if child.poll() is not None:
|
|
127
|
+
return
|
|
128
|
+
if os.name == "nt":
|
|
129
|
+
subprocess.run(["taskkill", "/PID", str(child.pid), "/T", "/F"], capture_output=True, creationflags=_NO_WINDOW)
|
|
130
|
+
else:
|
|
131
|
+
child.kill()
|
|
132
|
+
|
|
133
|
+
def handle(self, line):
|
|
134
|
+
"""One client line: answered here while the real server is not needed, else passed to it."""
|
|
135
|
+
try:
|
|
136
|
+
message = json.loads(line)
|
|
137
|
+
except ValueError:
|
|
138
|
+
print(f"lazy_mcp_proxy: invalid line ignored: {line[:200]!r}", file=sys.stderr, flush=True)
|
|
139
|
+
return
|
|
140
|
+
method, request_id = message.get("method"), message.get("id")
|
|
141
|
+
if method == "initialize":
|
|
142
|
+
self.client_init = message.get("params")
|
|
143
|
+
if self.child is None and request_id is not None and method == "initialize":
|
|
144
|
+
return self.answer(request_id, self.local_initialize(message.get("params")))
|
|
145
|
+
if self.child is None and request_id is not None and method == "ping":
|
|
146
|
+
return self.answer(request_id, {})
|
|
147
|
+
if self.child is None and request_id is not None and method == "tools/list":
|
|
148
|
+
return self.answer(request_id, {"tools": self.handshake["tools"]})
|
|
149
|
+
if self.child is None and (method is None or request_id is None):
|
|
150
|
+
return # notifications and responses mean nothing to a server that is not running
|
|
151
|
+
if self.child is None:
|
|
152
|
+
try:
|
|
153
|
+
self.start_child()
|
|
154
|
+
except (OSError, RuntimeError) as exc:
|
|
155
|
+
self.send({"jsonrpc": "2.0", "id": request_id,
|
|
156
|
+
"error": {"code": -32000, "message": f"could not start the server: {exc}"[:500]}})
|
|
157
|
+
return
|
|
158
|
+
self.child.stdin.write(line if line.endswith(b"\n") else line + b"\n")
|
|
159
|
+
self.child.stdin.flush()
|
|
160
|
+
|
|
161
|
+
def run(self):
|
|
162
|
+
for line in self.stdin:
|
|
163
|
+
if line.strip():
|
|
164
|
+
self.handle(line)
|
|
165
|
+
self.closing = True
|
|
166
|
+
if self.child is not None:
|
|
167
|
+
self.kill(self.child)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def main(argv):
|
|
171
|
+
if "--" not in argv or argv[:1] != ["--handshake"] or len(argv) < 4:
|
|
172
|
+
print("usage: lazy_mcp_proxy.py --handshake FILE -- COMMAND [ARG...]", file=sys.stderr)
|
|
173
|
+
return 2
|
|
174
|
+
split = argv.index("--")
|
|
175
|
+
try:
|
|
176
|
+
handshake = load_handshake(argv[1])
|
|
177
|
+
except RuntimeError as exc:
|
|
178
|
+
print(f"lazy_mcp_proxy: {exc}", file=sys.stderr)
|
|
179
|
+
return 2
|
|
180
|
+
Proxy(handshake, argv[split + 1:], sys.stdin.buffer, sys.stdout.buffer).run()
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__":
|
|
185
|
+
sys.exit(main(sys.argv[1:]))
|