Repository navigation
Expand file tree
/
Copy pathpython.py
More file actions
1817 lines (1566 loc) · 95.2 KB
/
Copy pathpython.py
File metadata and controls
1817 lines (1566 loc) · 95.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
File: ragtag/tools/python.py
Project: Aura Friday MCP-Link Server
Component: Python Execution Tool
Author: Christopher Nathan Drake (cnd)
Tool implementation for executing Python code locally with full MCP tool integration.
Allows AI agents to run Python scripts, save/load code files, and use Python as "glue"
between other MCP tools for data processing and automation tasks.
Copyright: © 2025 Christopher Nathan Drake. All rights reserved.
SPDX-License-Identifier: Proprietary
"signature": "T𝙰tƏ0SƐƧɯՕɊ𝙰ƙ𝙰1p𝟩ꓪКnɯkGΑƼ𝐴GⲢ𝙰Аȣ×ꓝƲk1ƍƊᴛⅮ×ԝҳiJJᏟnυɋƛµᴡµҮƼʌАƿFуՕɯϨ6𝟤ꜱӠzԝ2уeցսɌꓑZȷȢĐƿᗷȣᴅMꓣƨҳᎻҳ0ƳMwßոƽdwᎻՕЗiԝG𝟟ҳƧ"
"signdate": "2026-07-23T02:39:40.754Z",
"""
import ast # trailing-expression result capture (item 23)
import builtins # explicit, deterministic __builtins__ for exec (item 16)
import json
import os
import sys
import time # per-session created/last-used timestamps (items 17, 25)
import traceback
import threading
from pathlib import Path
from easy_mcp.server import MCPLogger, get_tool_token
from ragtag.shared_config import get_user_data_directory
# Dropped unused typing imports (List, Union, BinaryIO) - minor cleanup from review
from typing import Dict, Optional, Tuple
# Import mcp_bridge to provide it with HANDLERS registry access
from . import mcp_bridge
# Constants
TOOL_LOG_NAME = "PYTHON"
# Default cap on returned stdout/stderr for execute results, overridable per call via
# the max_output input parameter (a runaway print loop must not blow the caller's context)
DEFAULT_MAX_OUTPUT_BYTES_FOR_RETURNED_STDOUT_AND_STDERR = 65536
# Sentinel distinguishing "schema has no default" from "schema default is literal None",
# so validate_parameters can honor a legitimate None default instead of silently dropping
# it (item 11). Only this object means "absent".
_SCHEMA_DEFAULT_ABSENT_SENTINEL = object()
# Upper bound on retained persistent sessions. Past this, the least-recently-used session
# is evicted so an agent inventing session_ids cannot grow the cache without bound (item 17).
MAX_RETAINED_PERSISTENT_SESSIONS = 128
# exec_globals keys this tool injects itself; excluded when counting user-created variables
# so clear_session / list_sessions report user variables, not our scaffolding (items 17, 25).
_BASELINE_EXEC_GLOBALS_KEYS_NOT_COUNTED_AS_USER_VARIABLES = frozenset(
{"__builtins__", "mcp", "__name__", "__file__"})
# Serializes only executions that mutate process-global state (sys.argv, cwd, os.environ)
# for run_script argv and per-call cwd/env overrides (items 22, 27). Executions that do not
# request those overrides never acquire this lock, so ordinary runs stay concurrent.
# RLock (not Lock): user code inside an override run can itself call the python tool with
# overrides on the same thread; a plain Lock would self-deadlock there.
_process_global_state_mutation_lock_for_argv_cwd_env_overrides = threading.RLock()
# Module-level token generated once at import time
TOOL_UNLOCK_TOKEN = get_tool_token(__file__)
# Tool name with optional suffix from environment variable
TOOL_NAME_SUFFIX = os.environ.get("TOOL_SUFFIX", "")
TOOL_NAME = f"python{TOOL_NAME_SUFFIX}"
# Persistent session management
_session_globals_cache_for_persistent_execution_contexts = {} # session_id -> exec_globals dict
_session_cache_thread_safety_lock = threading.Lock()
# Per-session execution locks: calls sharing a session_id serialize while different
# sessions run concurrently. RLock so same-thread re-entrancy (user code invoking the
# python tool again) cannot self-deadlock. Guarded by _session_cache_thread_safety_lock.
_session_id_to_execution_serialization_rlock_map = {} # session_id -> threading.RLock
# Per-session created / last-used epoch timestamps, for LRU eviction (item 17) and the
# list_sessions operation (item 25). Guarded by _session_cache_thread_safety_lock.
_session_id_to_created_and_last_used_epoch_times_map = {} # session_id -> {"created": float, "last_used": float}
class Thread_Aware_Standard_Stream_Proxy_Routing_Writes_To_Per_Thread_Capture_Buffers:
"""sys.stdout/sys.stderr replacement that makes output capture thread-safe.
contextlib.redirect_stdout swaps the process-global sys.stdout, so concurrent
executions cross-captured each other's output. This proxy routes each write to
the calling thread's registered capture buffer (threading.local), falling back
to the real underlying stream for threads with no registered buffer.
"""
def __init__(self, real_underlying_stream_for_fallback):
self._real_underlying_stream_for_fallback = real_underlying_stream_for_fallback
self._per_thread_active_capture_buffer_storage = threading.local()
def _current_write_target_stream(self):
active_capture_buffer = getattr(self._per_thread_active_capture_buffer_storage, 'active_capture_buffer', None)
return active_capture_buffer if active_capture_buffer is not None else self._real_underlying_stream_for_fallback
def activate_capture_buffer_for_current_thread(self, capture_buffer):
"""Route this thread's writes to capture_buffer; returns the previously active buffer (for nested executions)."""
previously_active_capture_buffer = getattr(self._per_thread_active_capture_buffer_storage, 'active_capture_buffer', None)
self._per_thread_active_capture_buffer_storage.active_capture_buffer = capture_buffer
return previously_active_capture_buffer
def restore_previous_capture_buffer_for_current_thread(self, previously_active_capture_buffer):
self._per_thread_active_capture_buffer_storage.active_capture_buffer = previously_active_capture_buffer
def write(self, text_to_write):
return self._current_write_target_stream().write(text_to_write)
def writelines(self, lines_to_write):
return self._current_write_target_stream().writelines(lines_to_write)
def flush(self):
return self._current_write_target_stream().flush()
def __getattr__(self, attribute_name):
# Delegate everything else (encoding, isatty, fileno, buffer, ...) to the real stream
return getattr(self._real_underlying_stream_for_fallback, attribute_name)
_thread_aware_stream_proxy_installation_lock = threading.Lock()
_installed_thread_aware_stdout_proxy = None
_installed_thread_aware_stderr_proxy = None
def _install_thread_aware_stream_proxies_over_sys_stdout_and_stderr_once():
"""Idempotently replace sys.stdout/sys.stderr with the thread-aware proxies.
Installed lazily on first execute (not at import) so we wrap whatever streams
friday.py finished setting up. Returns (stdout_proxy, stderr_proxy).
"""
global _installed_thread_aware_stdout_proxy, _installed_thread_aware_stderr_proxy
with _thread_aware_stream_proxy_installation_lock:
if _installed_thread_aware_stdout_proxy is None:
_installed_thread_aware_stdout_proxy = Thread_Aware_Standard_Stream_Proxy_Routing_Writes_To_Per_Thread_Capture_Buffers(sys.stdout)
sys.stdout = _installed_thread_aware_stdout_proxy
if _installed_thread_aware_stderr_proxy is None:
_installed_thread_aware_stderr_proxy = Thread_Aware_Standard_Stream_Proxy_Routing_Writes_To_Per_Thread_Capture_Buffers(sys.stderr)
sys.stderr = _installed_thread_aware_stderr_proxy
return _installed_thread_aware_stdout_proxy, _installed_thread_aware_stderr_proxy
# Tool definitions
TOOLS = [
{
"name": TOOL_NAME,
# The "description" key is the only thing that persists in the AI context at all times.
# To prevent context wastage, agents use `readme` to get the full documentation when needed.
# Keep this description as brief as possible, but it must include everything an AI needs to know
# to work out if it should use this tool, and needs to clearly tell the AI to use
# the readme operation to find out how to do that.
"description": """Execute Python code locally with full MCP tool integration.
- Use this tool to run Python scripts, process data between tools, save/load code files
- Python code can directly call other MCP tools (sqlite, chrome_browser, user, etc.) via injected mcp module
""",
# Standard MCP parameters - simplified to single input dict
"parameters": {
"properties": {
"input": {
"type": "object",
"description": "All tool parameters are passed in this single dict. Use {\"input\":{\"operation\":\"readme\"}} to get full documentation, parameters, and an unlock token."
}
},
"required": [],
"type": "object"
},
# Actual tool parameters - revealed only after readme call
"real_parameters": {
"properties": {
"operation": {
"type": "string",
"enum": ["readme", "execute", "run_script", "save_script", "load_script", "list_scripts", "delete_script", "clear_session", "list_sessions", "list_packages", "pip_install"],
"description": "Operation to perform"
},
"code": {
"type": "string",
"description": "Python code to execute (for execute operation)"
},
"filename": {
"type": "string",
"description": "Script filename for save/load/delete/run_script operations. Only the base name is used and '.py' is appended if missing; stored in the user data directory."
},
"session_id": {
"type": "string",
"description": "Optional session identifier for persistent execution context",
"default": "default"
},
"persistent": {
"type": "boolean",
"description": "Whether to maintain session state between executions",
"default": True
},
"run_on_main_thread": {
"type": "boolean",
"description": "Whether to execute on main thread (required for COM objects to persist across calls)",
"default": False
},
"max_output": {
"type": "integer",
"description": "Maximum bytes of stdout/stderr returned by execute (default 65536); longer output is truncated keeping head and tail with a '[N bytes truncated]' marker",
"default": DEFAULT_MAX_OUTPUT_BYTES_FOR_RETURNED_STDOUT_AND_STDERR
},
"timeout": {
"type": "number",
"description": "Optional wall-clock timeout in seconds for execute/run_script. On a worker thread the code runs in a daemon thread that is abandoned if it overruns (the run keeps going in the background). On the main thread it bounds the wait (default 300s). For pip_install it bounds the pip subprocess (default 300s)."
},
"argv": {
"type": "array",
"description": "Optional list of strings assigned to sys.argv for the run. For run_script, sys.argv[0] is the script path and these follow. Using this serializes concurrent runs (sys.argv is process-global)."
},
"cwd": {
"type": "string",
"description": "Optional working directory to switch to for the run (restored afterward). Using this serializes concurrent runs (cwd is process-global)."
},
"env": {
"type": "object",
"description": "Optional dict of environment variables merged into os.environ for the run (restored afterward). Using this serializes concurrent runs (os.environ is process-global)."
},
"packages": {
"type": "array",
"description": "For pip_install: list of package specifiers (e.g. [\"requests\", \"numpy==1.26.4\"]) to install into the server interpreter."
},
"filter": {
"type": "string",
"description": "For list_packages: optional case-insensitive substring to narrow the returned package list."
},
"tool_unlock_token": {
"type": "string",
"description": "Security token, " + TOOL_UNLOCK_TOKEN + ", obtained from readme operation, or re-provided any time the AI lost context or gave a wrong token"
}
},
"required": ["operation", "tool_unlock_token"],
"type": "object"
},
# Detailed documentation - obtained via "input":"readme" initial call (and in the event any call arrives without a valid token)
# It should be verbose and clear with lots of examples so the AI fully understands
# every feature and how to use it.
"readme": """
Execute Python code locally with full MCP tool integration.
This tool allows AI agents to run Python scripts locally on the user's machine with access to
all other MCP tools. Python code can directly call sqlite, the user's browser (named per running
browser: chrome_browser, edge_browser, ...), user interface, and other tools via an injected
'mcp' module. Perfect for data processing, automation, and serving as
"glue" between different tools when data is too large for direct AI handling.
## Usage-Safety Token System
This tool uses an hmac-based token system to ensure callers fully understand all details of
using this tool, on every call. The token is specific to this installation, user, and code version.
Your tool_unlock_token for this installation is: """ + TOOL_UNLOCK_TOKEN + """
You MUST include tool_unlock_token in the input dict for all operations.
## Operations Available
### 1. execute - Run Python code
Execute Python code in a local environment with MCP tool access. If the last top-level
statement is a plain expression, its repr() is returned as "result" (REPL-style), so you
do not have to wrap a final value in print(). Optional per-call controls: timeout, cwd,
env, argv (see Parameters).
### 2. run_script - Run a saved script by name
Execute a previously saved script by filename without loading its text into your context
first. sys.argv[0] is set to the script path, __file__ is set, and any argv list you pass
follows. Accepts the same session_id/persistent/timeout/cwd/env controls as execute.
### 3. save_script - Save code to file
Save Python code to a named file in the user data directory for later use. The name is
reduced to its base name and '.py' is appended if missing (so it always appears in
list_scripts). Written atomically.
### 4. load_script - Load saved code
Retrieve previously saved Python code from a file.
### 5. list_scripts - List saved files
Show all saved Python script files (with ISO-8601 modified time and raw epoch).
### 6. delete_script - Remove saved file
Delete a saved Python script file.
### 7. clear_session - Clear persistent session
Clear a persistent session's cached variables and state. Use this to free memory or start
fresh with the same session_id. When the session is not found, the reply lists the
currently active session_ids so you can spot a typo.
### 8. list_sessions - List persistent sessions
List the active persistent sessions with their user-variable counts and created/last-used
timestamps. Sessions are capped (least-recently-used are evicted) so runaway session_ids
cannot grow memory without bound.
### 9. list_packages - List installed packages
List installed Python distributions (optionally filtered by a substring) so you can check
whether a package is available before an execute that would otherwise fail on ImportError.
### 10. pip_install - Install packages
Install one or more packages into the server interpreter via `python -m pip install`.
Returns pip's return code plus stdout/stderr.
## MCP Tool Integration
Python code automatically has access to an 'mcp' module (pre-imported in execution context) that can
call any MCP tool using the same structure as AI tool calls.
Token auto-injection: because this bridge runs in-process as an already-trusted caller, you
may OMIT `tool_unlock_token` when calling other tools from here - the bridge fetches the
target tool's token and injects an inter-tool credential automatically (works with the
common targets: sqlite, user, python, remote/browser tools, agent). If a particular tool
rejects the injected credential, call that tool's readme operation and pass its literal
token. The token values shown in the examples below are PLACEHOLDERS, not real tokens.
```python
# Note: 'mcp' is already available - no import needed!
import json
# Example 1: Show popup window to user
mcp.call("user", {
"input": {
"operation": "show_popup",
"html": "<!DOCTYPE html><html><body><h1>Hello!</h1><button onclick=\"window.close()\">OK</button></body></html>",
"title": "Demo",
"width": 250,
"height": 120,
"tool_unlock_token": "<target tool readme token, or omit - auto-injected>" # From user tool readme
}
})
# Example 2: List all browser tabs (async tool - automatically waits for response)
# NOTE: browser tools are named after the user's running browser: chrome_browser,
# edge_browser, etc. (a 2nd instance gets a number suffix, e.g. chrome_browser2).
# They only exist while that browser is running - check the live tool list.
tabs_result = mcp.call("chrome_browser", {
"input": {
"operation": "list_tabs",
"tool_unlock_token": "<target tool readme token, or omit - auto-injected>" # From chrome_browser tool readme
}
})
tabs_text = tabs_result['content'][0]['text']
print(f"Browser tabs: {tabs_text[:200]}...")
# Example 3: Query SQLite database
db_result = mcp.call("sqlite", {
"input": {
"sql": "SELECT * FROM users LIMIT 10",
"database": "myapp.db",
"tool_unlock_token": "<target tool readme token, or omit - auto-injected>" # From SQLite tool readme
}
})
print(f"Database query result: {db_result}")
# Example 4: Navigate browser to a URL
nav_result = mcp.call("chrome_browser", {
"input": {
"operation": "navigate",
"url": "https://example.com",
"tool_unlock_token": "<target tool readme token, or omit - auto-injected>"
}
})
# Example 5: Extract page text and store in database
content_result = mcp.call("chrome_browser", {
"input": {
"operation": "extract_text",
"tabId": 123, # a tab id from list_tabs
"tool_unlock_token": "<target tool readme token, or omit - auto-injected>"
}
})
# Store the extracted text (TSV of visible text nodes with node IDs)
page_text = content_result['content'][0]['text']
insert_result = mcp.call("sqlite", {
"input": {
"sql": "INSERT INTO web_content (content) VALUES (:content)",
"bindings": {"content": page_text},
"database": "scraped_data.db",
"tool_unlock_token": "<target tool readme token, or omit - auto-injected>"
}
})
# The bridge is completely generic - works with ANY tool
# Just use the exact same JSON structure you see in tool documentation
# tool_unlock_token is usually optional here - omit it and the bridge auto-injects
# Async tools (chrome_browser, remote) automatically wait for responses
```
## File Management
All script files are stored in the user data directory (e.g., C:\\Users\\user\\AppData\\Roaming\\AuraFriday\\user_data\\python_scripts\\).
## Session Management
- **persistent**: true (default) - Variables and imports persist between executions within the same session_id
- **persistent**: false - Fresh environment for each execution
- **session_id**: Optional identifier for multiple parallel sessions (default: "default")
- **run_on_main_thread**: false (default) - Execute on worker thread (fast, concurrent)
- **run_on_main_thread**: true - Execute on main thread (required for COM objects to persist)
- Use **clear_session** operation to free memory and clear cached session state
### Main Thread Execution
By default, Python code executes on worker threads for maximum concurrency. However, Windows COM
objects have thread affinity and cannot persist across different worker threads. For multi-call
COM automation workflows, set `run_on_main_thread: true` to execute on the main thread where
COM objects can safely persist between calls.
**When to use run_on_main_thread=true:**
- Multi-call COM workflows (Excel, Word, Outlook, etc.)
- When COM objects need to persist across multiple execute calls
- When using persistent sessions with COM objects
**Trade-offs:**
- Worker thread (default): Fast, concurrent, but COM objects don't persist between calls
- Main thread: COM objects persist, but may delay other main thread operations slightly
### Persistent Session Example:
```python
# Call 1: Create variables in persistent session
result = mcp.call("python", {
"input": {
"operation": "execute",
"session_id": "my_session",
"persistent": True,
"code": "counter = 0\\nprint(f'Counter initialized: {counter}')",
"tool_unlock_token": "<this python tool token, same one you used to call python>"
}
})
# Call 2: Variables from Call 1 still exist!
result = mcp.call("python", {
"input": {
"operation": "execute",
"session_id": "my_session", # Same session_id
"persistent": True,
"code": "counter += 1\\nprint(f'Counter incremented: {counter}')",
"tool_unlock_token": "<this python tool token, same one you used to call python>"
}
})
# Call 3: Clear the session when done
result = mcp.call("python", {
"input": {
"operation": "clear_session",
"session_id": "my_session",
"tool_unlock_token": "<this python tool token, same one you used to call python>"
}
})
```
### COM Automation Example (Windows):
```python
# Call 1: Create Excel COM objects on main thread
result = mcp.call("python", {
"input": {
"operation": "execute",
"session_id": "excel_work",
"persistent": True,
"run_on_main_thread": True, # Required for COM persistence!
"code": "import win32com.client\\nimport pythoncom\\nif 'com_init' not in dir():\\n pythoncom.CoInitialize()\\n com_init = True\\nexcel = win32com.client.Dispatch('Excel.Application')\\nwb = excel.Workbooks.Add()\\nsheet = wb.Worksheets(1)\\nprint('Excel objects created')",
"tool_unlock_token": "<this python tool token, same one you used to call python>"
}
})
# Call 2: Use same Excel instance (objects persist because we're on main thread)
result = mcp.call("python", {
"input": {
"operation": "execute",
"session_id": "excel_work",
"persistent": True,
"run_on_main_thread": True, # Same mode
"code": "sheet.Range('A1').Value = 'Hello from Python!'\\nsheet.Range('A2').Value = 42\\nprint('Data written to Excel')",
"tool_unlock_token": "<this python tool token, same one you used to call python>"
}
})
# Call 3: Save and close
result = mcp.call("python", {
"input": {
"operation": "execute",
"session_id": "excel_work",
"persistent": True,
"run_on_main_thread": True,
"code": "wb.SaveAs('C:\\\\\\\\temp\\\\\\\\test.xlsx')\\nwb.Close()\\nexcel.Quit()\\nprint('Workbook saved and closed')",
"tool_unlock_token": "<this python tool token, same one you used to call python>"
}
})
```
## Input Examples
### 1. Get documentation:
```json
{
"input": {"operation": "readme"}
}
```
### 2. Execute: List browser tabs and count by domain
```json
{
"input": {
"operation": "execute",
"code": "import json\\nfrom collections import Counter\\n\\n# Get browser tabs (tool is named after the user's browser: chrome_browser, edge_browser, ...)\\ntabs = mcp.call('chrome_browser', {'input': {'operation': 'list_tabs', 'tool_unlock_token': '<target tool readme token, or omit - auto-injected>'}})\\ntabs_text = tabs['content'][0]['text']\\nprint(f'Raw tabs data (first 200 chars): {tabs_text[:200]}')\\n\\n# Parse and count domains\\ndomains = []\\nfor line in tabs_text.strip().split('\\\\n')[1:]:\\n parts = line.split('\\\\t')\\n if len(parts) >= 7 and 'http' in parts[6]:\\n domain = parts[6].split('/')[2]\\n domains.append(domain)\\n\\ncounts = Counter(domains)\\nprint(f'\\\\nDomain counts: {dict(counts)}')",
"session_id": "browser_analysis",
"persistent": true,
"tool_unlock_token": """ + f'"{TOOL_UNLOCK_TOKEN}"' + """
}
}
```
### 3. Execute: Query SQLite and process results
```json
{
"input": {
"operation": "execute",
"code": "import json\\n\\n# Query database\\nresult = mcp.call('sqlite', {\\n 'input': {\\n 'sql': 'SELECT name, price FROM products LIMIT 5',\\n 'database': 'store.db',\\n 'tool_unlock_token': '<target tool readme token, or omit - auto-injected>'\\n }\\n})\\n\\n# Parse and display results\\ndata = json.loads(result['content'][0]['text'])\\nprint(f'Found {len(data)} products:')\\nfor row in data:\\n print(f' - {row[\\\"name\\\"]}: ${row[\\\"price\\\"]}')",
"session_id": "data_query",
"persistent": false,
"tool_unlock_token": """ + f'"{TOOL_UNLOCK_TOKEN}"' + """
}
}
```
### 4. Save script: Browser tab monitoring
```json
{
"input": {
"operation": "save_script",
"filename": "monitor_tabs.py",
"code": "import json\\nfrom datetime import datetime\\n\\n# Get current browser tabs\\ntabs_result = mcp.call('chrome_browser', {\\n 'input': {\\n 'operation': 'list_tabs',\\n 'tool_unlock_token': '<target tool readme token, or omit - auto-injected>'\\n }\\n})\\n\\n# Count tabs\\ntabs_text = tabs_result['content'][0]['text']\\ntab_count = len(tabs_text.strip().split('\\\\n')) - 1\\n\\n# Store in database\\nmcp.call('sqlite', {\\n 'input': {\\n 'sql': 'INSERT INTO tab_history (timestamp, count) VALUES (:ts, :count)',\\n 'bindings': {'ts': datetime.now().isoformat(), 'count': tab_count},\\n 'database': 'monitoring.db',\\n 'tool_unlock_token': '<target tool readme token, or omit - auto-injected>'\\n }\\n})\\n\\nprint(f'Logged {tab_count} tabs at {datetime.now()}')",
"tool_unlock_token": """ + f'"{TOOL_UNLOCK_TOKEN}"' + """
}
}
```
### 5. Load saved script:
```json
{
"input": {
"operation": "load_script",
"filename": "monitor_tabs.py",
"tool_unlock_token": """ + f'"{TOOL_UNLOCK_TOKEN}"' + """
}
}
```
### 6. List saved scripts:
```json
{
"input": {
"operation": "list_scripts",
"tool_unlock_token": """ + f'"{TOOL_UNLOCK_TOKEN}"' + """
}
}
```
### 7. Delete saved script:
```json
{
"input": {
"operation": "delete_script",
"filename": "old_script.py",
"tool_unlock_token": """ + f'"{TOOL_UNLOCK_TOKEN}"' + """
}
}
```
## Use Cases & Real-World Examples
### 1. Web Scraping to Database
Scrape a page via the user's browser and store its text in SQLite:
```python
# Navigate and get the loaded page's text in one call (withText)
url = "https://store.example.com/products"
result = mcp.call("chrome_browser", {"input": {"operation": "navigate", "url": url, "withText": True, "tool_unlock_token": "<target tool readme token, or omit - auto-injected>"}})
page_text = result['content'][0]['text']
# Store the page text in the database
mcp.call("sqlite", {
"input": {
"sql": "INSERT INTO scraped_pages (url, content) VALUES (:url, :content)",
"bindings": {"url": url, "content": page_text},
"database": "products.db",
"tool_unlock_token": "<target tool readme token, or omit - auto-injected>"
}
})
print(f"Stored {len(page_text)} chars from {url}")
```
### 2. Browser Tab Analysis
Analyze open browser tabs and categorize by domain:
```python
import json
from collections import Counter
# Get all open tabs
tabs_result = mcp.call("chrome_browser", {"input": {"operation": "list_tabs", "tool_unlock_token": "<target tool readme token, or omit - auto-injected>"}})
tabs_text = tabs_result['content'][0]['text']
# Parse tab data (tab-separated format)
tabs = []
for line in tabs_text.strip().split('\\n')[1:]: # Skip header
parts = line.split('\\t')
if len(parts) >= 7:
tabs.append({'url': parts[6], 'title': parts[7]})
# Count domains
domains = Counter(url.split('/')[2] for url in [t['url'] for t in tabs] if 'http' in t['url'])
# Store analysis in database
for domain, count in domains.items():
mcp.call("sqlite", {
"input": {
"sql": "INSERT OR REPLACE INTO tab_stats (domain, count, last_updated) VALUES (:domain, :count, datetime('now'))",
"bindings": {"domain": domain, "count": count},
"database": "browser_stats.db",
"tool_unlock_token": "<target tool readme token, or omit - auto-injected>"
}
})
```
### 3. Data Processing Pipeline
Process large datasets that exceed AI context limits:
```python
import json
# Query large result set from database
result = mcp.call("sqlite", {
"input": {
"sql": "SELECT * FROM large_dataset LIMIT 10000",
"database": "bigdata.db",
"tool_unlock_token": "<target tool readme token, or omit - auto-injected>"
}
})
# Process data (transform, aggregate, filter)
data = json.loads(result['content'][0]['text'])
processed = [{'id': row['id'], 'value': row['raw_value'] * 1.5} for row in data]
# Store processed results
for row in processed:
mcp.call("sqlite", {
"input": {
"sql": "INSERT INTO processed_data (id, value) VALUES (:id, :value)",
"bindings": {"id": row['id'], "value": row['value']},
"database": "bigdata.db",
"tool_unlock_token": "<target tool readme token, or omit - auto-injected>"
}
})
```
### 4. Cross-Tool Automation
Monitor browser activity and trigger actions based on content:
```python
# Check if specific page is open
tabs = mcp.call("chrome_browser", {"input": {"operation": "list_tabs", "tool_unlock_token": "<target tool readme token, or omit - auto-injected>"}})
tabs_text = tabs['content'][0]['text']
if 'gmail.com' in tabs_text:
# Log browser activity
mcp.call("sqlite", {
"input": {
"sql": "INSERT INTO activity_log (timestamp, activity) VALUES (datetime('now'), 'Gmail tab detected')",
"database": "monitoring.db",
"tool_unlock_token": "<target tool readme token, or omit - auto-injected>"
}
})
```
These examples demonstrate how Python serves as "glue" between MCP tools, enabling complex
workflows that would be impossible with AI context limits alone.
## Execution Timeout (execute / run_script)
- No timeout by default on worker-thread runs: an infinite loop would run indefinitely.
- Pass `timeout` (seconds) to bound a run. On a worker thread the code runs in a daemon
thread that is ABANDONED if it overruns - it keeps running in the background (Python
cannot force-preempt a thread), its later output goes to a buffer nobody reads, and for
a persistent session further calls to that same session_id may block until it finishes.
If you need a hard timeout, use a fresh session_id per attempt (or persistent: false).
- On the main thread (run_on_main_thread: true), `timeout` bounds the wait (default 300s);
an abandoned main-thread task can delay other main-thread work until it completes.
## Working Directory / Environment / argv (execute and run_script)
- `cwd`: switch the process working directory for the run, restored afterward.
- `env`: dict merged into os.environ for the run, restored afterward.
- `argv`: list assigned to sys.argv for the run (run_script prepends the script path).
- These mutate process-global state, so a run that uses any of them holds a process-wide
lock for its duration; runs that use none of them stay fully concurrent.
## Output Capture Caveats
- stdout/stderr capture is thread-aware (concurrent runs do not cross-capture each other),
but it only intercepts Python-level writes. Output from `subprocess`, or fd-level writes
from C extensions, bypasses it and goes to the real server console.
- Threads you start in user code outlive the call. Their later prints go to whatever
capture buffer (or real stream) is active at that later moment, not this call's result.
## Security & Isolation
There is NO sandbox. Code runs in-process with the full Python interpreter: it can import
anything, touch the filesystem and network, and can crash or exit the whole server (e.g.
`os._exit()` or a segfaulting C extension will take the server down). The safety model is
account/OS isolation, not language-level restriction - treat this exactly like a shell on
the machine. All MCP tool calls made via `mcp` are logged and subject to the same security
policies as direct tool usage.
## Return Format
Returns a JSON payload with:
- `stdout` / `stderr`: captured output, each capped at max_output bytes (default 65536);
longer output keeps the head and tail with a '[N bytes truncated]' marker in between.
- `result`: repr() of the last top-level expression, or null if the code did not end in a
bare expression (execute / run_script only).
- `mcp_calls`: log of MCP tool calls made during execution.
- `success`: true if the code ran without raising; false if it raised or timed out.
- `session_id`, `persistent`, and (on timeout) `timed_out` / `timeout_seconds`.
IMPORTANT: the tool response `isError` is False for a successful TOOL CALL even when your
code raised - a failing user script is still a successful invocation of this tool. Check
the `success` field in this payload to tell whether your code ran cleanly, not `isError`.
"""
}
]
# Python script storage directory
def get_python_scripts_directory() -> Path:
"""Get the directory where Python scripts are stored."""
scripts_dir = get_user_data_directory() / "python_scripts"
scripts_dir.mkdir(parents=True, exist_ok=True)
return scripts_dir
def resolve_safe_script_path_confined_to_scripts_directory(requested_filename, scripts_directory: Path) -> Tuple[Optional[Path], Optional[str]]:
"""Resolve a caller-supplied script filename to a safe path inside scripts_directory.
Fixes the path-traversal hole (item 3): callers could previously pass "../../x.py" or
an absolute path (pathlib replaces the base with an absolute right operand) to read,
write or delete arbitrary files. We strip to the bare name, reject empty/'.'/'..',
append '.py' when missing so save/load/delete stay consistent with the '*.py' listing
(item 14), then defensively confirm the resolved path is still under the directory.
Returns (script_path, None) on success or (None, error_message) on rejection.
"""
if not requested_filename or not isinstance(requested_filename, str):
return None, "Parameter 'filename' is required and must be a non-empty string."
# Path(...).name discards any directory components (including absolute-path prefixes),
# so traversal segments cannot escape the scripts directory.
bare_name_without_any_directory_components = Path(requested_filename).name
if bare_name_without_any_directory_components in ("", ".", ".."):
return None, f"Invalid filename '{requested_filename}': must be a plain script name, not a path."
if not bare_name_without_any_directory_components.endswith(".py"):
bare_name_without_any_directory_components += ".py"
candidate_script_path = scripts_directory / bare_name_without_any_directory_components
# Defensive belt-and-braces check in case symlinks or odd names still point outside.
try:
resolved_candidate = candidate_script_path.resolve()
resolved_scripts_root = scripts_directory.resolve()
if not resolved_candidate.is_relative_to(resolved_scripts_root):
return None, f"Invalid filename '{requested_filename}': resolves outside the scripts directory."
except (OSError, ValueError) as path_resolution_error:
return None, f"Invalid filename '{requested_filename}': {path_resolution_error}"
return candidate_script_path, None
def validate_parameters(input_param: Dict) -> Tuple[Optional[str], Dict]:
"""Validate input parameters against the real_parameters schema.
Args:
input_param: Input parameters dictionary
Returns:
Tuple of (error_message, validated_params) where error_message is None if valid
"""
real_params_schema = TOOLS[0]["real_parameters"]
properties = real_params_schema["properties"]
required = real_params_schema.get("required", [])
# For readme operation, don't require token
operation = input_param.get("operation")
if operation == "readme":
required = ["operation"] # Only operation is required for readme
# Check for unexpected parameters
expected_params = set(properties.keys())
provided_params = set(input_param.keys())
unexpected_params = provided_params - expected_params
if unexpected_params:
# Error stays terse (item 13); point at the readme operation instead of an attached doc
return f"Unexpected parameters provided: {', '.join(sorted(unexpected_params))}. Expected parameters are: {', '.join(sorted(expected_params))}. Use the readme operation for full documentation.", {}
# Check for missing required parameters
missing_required = set(required) - provided_params
if missing_required:
return f"Missing required parameters: {', '.join(sorted(missing_required))}. Required parameters are: {', '.join(sorted(required))}", {}
# Validate types and extract values
validated = {}
for param_name, param_schema in properties.items():
if param_name in input_param:
value = input_param[param_name]
expected_type = param_schema.get("type")
# Type validation
if expected_type == "string" and not isinstance(value, str):
return f"Parameter '{param_name}' must be a string, got {type(value).__name__}. Please provide a string value.", {}
elif expected_type == "object" and not isinstance(value, dict):
return f"Parameter '{param_name}' must be an object/dictionary, got {type(value).__name__}. Please provide a dictionary value.", {}
elif expected_type == "integer" and (isinstance(value, bool) or not isinstance(value, int)):
# bool is an int subclass in Python - it must not pass as an integer
return f"Parameter '{param_name}' must be an integer, got {type(value).__name__}. Please provide an integer value.", {}
elif expected_type == "number" and (isinstance(value, bool) or not isinstance(value, (int, float))):
# "number" accepts int or float; bool is an int subclass and must be excluded (item 9 spirit)
return f"Parameter '{param_name}' must be a number, got {type(value).__name__}. Please provide a numeric value.", {}
elif expected_type == "boolean" and not isinstance(value, bool):
return f"Parameter '{param_name}' must be a boolean, got {type(value).__name__}. Please provide true or false.", {}
elif expected_type == "array" and not isinstance(value, list):
return f"Parameter '{param_name}' must be an array/list, got {type(value).__name__}. Please provide a list value.", {}
# Enum validation
if "enum" in param_schema:
allowed_values = param_schema["enum"]
if value not in allowed_values:
return f"Parameter '{param_name}' must be one of {allowed_values}, got '{value}'. Please use one of the allowed values.", {}
validated[param_name] = value
elif param_name in required:
# This should have been caught above, but double-check
return f"Required parameter '{param_name}' is missing. Please provide this required parameter.", {}
else:
# Use the schema default if one is present. Sentinel (not "is not None") so a
# schema default of literal None would still be applied rather than dropped (item 11).
default_value = param_schema.get("default", _SCHEMA_DEFAULT_ABSENT_SENTINEL)
if default_value is not _SCHEMA_DEFAULT_ABSENT_SENTINEL:
validated[param_name] = default_value
return None, validated
def readme(with_readme: bool = True) -> str:
"""Return tool documentation.
Args:
with_readme: If False, returns empty string. If True, returns the complete tool documentation.
Returns:
The complete tool documentation with the readme content as description, or empty string if with_readme is False.
"""
try:
if not with_readme:
return ''
MCPLogger.log(TOOL_LOG_NAME, "Processing readme request")
# Return pure content; the blank-line separator is now supplied by callers
# (create_error_response) so readme() has no leading whitespace of its own.
return json.dumps({
"description": TOOLS[0]["readme"],
"parameters": TOOLS[0]["real_parameters"] # the caller knows these as the dict that goes inside "input" though
#"real_parameters": TOOLS[0]["real_parameters"] # the caller knows these as the dict that goes inside "input" though
}, indent=2)
except Exception as e:
MCPLogger.log(TOOL_LOG_NAME, f"Error processing readme request: {str(e)}")
return ''
def create_error_response(error_msg: str, with_readme: bool = True) -> Dict:
"""Log and Create an error response that optionally includes the tool documentation.
example: if some_error: return create_error_response(f"some error with details: {str(e)}", with_readme=False)
Per item 13 the full readme is attached only on token-level failures; ordinary
parameter/operation errors pass with_readme=False to stay terse and save agent context.
"""
MCPLogger.log(TOOL_LOG_NAME, f"Error: {error_msg}")
readme_text = readme(with_readme)
# Separator lives here (not inside readme()) so readme() returns pure content
separator_before_readme = "\n\n" if readme_text else ""
return {"content": [{"type": "text", "text": f"{error_msg}{separator_before_readme}{readme_text}"}], "isError": True}
def handle_execute(params: Dict, handler_info: Optional[Dict] = None) -> Dict:
"""Handle Python code execution.
Args:
params: Dictionary containing the operation parameters
handler_info: Handler info containing server instance with tool_handlers
Returns:
Dict containing execution results or error information
"""
try:
# Presence check only. validate_parameters already guaranteed the string type when
# 'code' is present (it is not in 'required'), so the redundant type check was dropped.
code = params.get("code")
if code is None:
return create_error_response("Parameter 'code' is required for execute operation. Please provide the Python code to execute.", with_readme=False)
session_id = params.get("session_id", "default")
persistent = params.get("persistent", True)
run_on_main_thread = params.get("run_on_main_thread", False)
max_output = params.get("max_output", DEFAULT_MAX_OUTPUT_BYTES_FOR_RETURNED_STDOUT_AND_STDERR)
# New optional execution controls (items 24/8 timeout, 27 cwd/env, 22-style argv)
timeout_seconds = params.get("timeout")
argv_list = params.get("argv")
cwd_override = params.get("cwd")
env_overrides = params.get("env")
# Fail fast with a terse parameter error instead of an in-run chdir traceback
if cwd_override is not None and not os.path.isdir(cwd_override):
return create_error_response(f"Parameter 'cwd' must be an existing directory, got '{cwd_override}'.", with_readme=False)
# Log the execution request
MCPLogger.log(TOOL_LOG_NAME, f"Processing execute request: session_id={session_id}, persistent={persistent}, run_on_main_thread={run_on_main_thread}, code_length={len(code)}, timeout={timeout_seconds}")
# Execute the Python code with MCP integration
result = _execute_python_code(
code, session_id, persistent, run_on_main_thread, handler_info, max_output,
timeout_seconds=timeout_seconds, argv_list=argv_list, cwd_override=cwd_override,
env_overrides=env_overrides, execution_filename_for_tracebacks="<mcp python execute>",
synthetic_file_path=None)
return _wrap_execution_result_dict_as_tool_response(result)
except Exception as e:
return create_error_response(f"Error processing execute request: {str(e)}", with_readme=False)
def _remove_handler_info_keys_recursively_from_nested_dicts_and_lists(container_object):
"""Recursively strip 'handler_info' keys (they hold MCPSession/MCPServer objects that
are not JSON serializable). Module-level so any handler can reuse it (minor-cleanup
item: hoisted out of handle_execute)."""
if isinstance(container_object, dict):
return {key: _remove_handler_info_keys_recursively_from_nested_dicts_and_lists(value)
for key, value in container_object.items() if key != 'handler_info'}
elif isinstance(container_object, list):
return [_remove_handler_info_keys_recursively_from_nested_dicts_and_lists(item) for item in container_object]
else:
return container_object
def _wrap_execution_result_dict_as_tool_response(execution_result_dict: Dict) -> Dict:
"""Serialize an execution result dict into the standard tool response.
Strips non-serializable handler_info defensively and uses default=repr so a stray
non-serializable value in mcp_calls cannot blow up the whole response (item 18).
Note: isError is intentionally False even when the user's code failed - a failed
*user script* is still a successful *tool call*. Agents must check the "success"
field inside the JSON payload, not isError (documented in the readme, item 12).
"""
safe_result = _remove_handler_info_keys_recursively_from_nested_dicts_and_lists(execution_result_dict)
return {
"content": [{"type": "text", "text": json.dumps(safe_result, indent=2, default=repr)}],
"isError": False
}
DEFAULT_MAIN_THREAD_EXECUTION_TIMEOUT_SECONDS = 300
def _execute_python_code(code: str, session_id: str, persistent: bool, run_on_main_thread: bool, handler_info: Optional[Dict] = None, max_output: int = DEFAULT_MAX_OUTPUT_BYTES_FOR_RETURNED_STDOUT_AND_STDERR, timeout_seconds=None, argv_list=None, cwd_override=None, env_overrides=None, execution_filename_for_tracebacks: str = "<mcp python execute>", synthetic_file_path: Optional[str] = None) -> Dict:
"""Execute Python code using exec() in the same process with MCP bridge access.
Args:
code: Python code to execute
session_id: Session identifier
persistent: Whether to maintain session state between executions
run_on_main_thread: Whether to execute on main thread (required for COM persistence)
handler_info: Handler info containing server instance with tool_handlers
max_output: Byte cap applied to returned stdout/stderr (head+tail truncation)
timeout_seconds: Optional wall-clock timeout. On the main thread it bounds the wait
(default 300s); on a worker thread the code runs in a daemon thread that is
abandoned if it overruns (items 8, 24).
argv_list: Optional replacement for sys.argv during the run (items 22, 27)
cwd_override: Optional working directory for the run, restored afterward (item 27)
env_overrides: Optional dict of environment variables merged for the run (item 27)
execution_filename_for_tracebacks: Filename shown in tracebacks / compiled code
synthetic_file_path: Value for exec_globals['__file__'] (set by run_script, item 15)
Returns:
Dict with stdout, stderr, mcp_calls, and other execution info
"""
# Normalize the optional timeout: zero/negative (or non-numeric) means "no explicit
# timeout", so Event.wait()/join() below never receive a nonsensical bound. Main-thread
# runs then fall back to their 300s default; worker runs stay unlimited.
if isinstance(timeout_seconds, bool) or not isinstance(timeout_seconds, (int, float)) or timeout_seconds <= 0:
timeout_seconds = None
# If main thread execution requested, delegate to server's main thread queue
if run_on_main_thread and handler_info and 'responder' in handler_info:
server = handler_info['responder']
if hasattr(server, 'main_thread_queue'):
# Re-entrancy: if we are ALREADY on the thread that drains main_thread_queue
# (server.main_thread_id - the serve_forever loop thread, which is not
# necessarily Python's MainThread because friday.py runs the server on a
# daemon thread), queueing would deadlock waiting on ourselves. Also honor
# the plain MainThread case for deployments that serve on the real main thread.
if (threading.get_ident() == getattr(server, 'main_thread_id', None)
or threading.current_thread() is threading.main_thread()):
MCPLogger.log(TOOL_LOG_NAME, f"Already on main thread, executing directly: session={session_id}")
# Already on the main thread: no daemon/timeout wrapper (that would defeat COM
# affinity), just run it in-place.
return _execute_python_code_impl(code, session_id, persistent, handler_info, max_output, worker_thread_timeout_seconds=None, argv_list=argv_list, cwd_override=cwd_override, env_overrides=env_overrides, execution_filename_for_tracebacks=execution_filename_for_tracebacks, synthetic_file_path=synthetic_file_path)
MCPLogger.log(TOOL_LOG_NAME, f"Delegating to main thread: session={session_id}")
result_container = {}
result_event = threading.Event()
# Shared flag so the (possibly abandoned) main-thread task can log if it finally
# completes after the caller already gave up waiting (item 8).
main_thread_waiter_gave_up_after_timeout_flag = {"gave_up": False}
def execute_on_main_thread():
"""Wrapper to execute on main thread and capture result."""
try:
result = _execute_python_code_impl(code, session_id, persistent, handler_info, max_output, worker_thread_timeout_seconds=None, argv_list=argv_list, cwd_override=cwd_override, env_overrides=env_overrides, execution_filename_for_tracebacks=execution_filename_for_tracebacks, synthetic_file_path=synthetic_file_path)