roblox-studio-physical-operation-mcp 0.1.0__tar.gz → 0.1.1__tar.gz

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.
Files changed (30) hide show
  1. {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/.gitignore +3 -1
  2. {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/PKG-INFO +1 -1
  3. {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/pyproject.toml +1 -1
  4. {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/roblox_studio_physical_operation_mcp/log_utils.py +467 -467
  5. {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/roblox_studio_physical_operation_mcp/server.py +1 -1
  6. roblox_studio_physical_operation_mcp-0.1.1/roblox_studio_physical_operation_mcp/templates/pause.png +0 -0
  7. roblox_studio_physical_operation_mcp-0.1.1/roblox_studio_physical_operation_mcp/templates/play.png +0 -0
  8. roblox_studio_physical_operation_mcp-0.1.1/roblox_studio_physical_operation_mcp/templates/stop.png +0 -0
  9. roblox_studio_physical_operation_mcp-0.1.1/tests/default.project.json +27 -0
  10. roblox_studio_physical_operation_mcp-0.1.1/tests/game.rbxl +0 -0
  11. roblox_studio_physical_operation_mcp-0.1.1/tests/src/TestScript.server.lua +32 -0
  12. roblox_studio_physical_operation_mcp-0.1.1/tests/test_fix_verify.py +77 -0
  13. roblox_studio_physical_operation_mcp-0.1.1/tests/test_warn_log.py +100 -0
  14. roblox_studio_physical_operation_mcp-0.1.0/tests/game.rbxl +0 -0
  15. {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/.github/workflows/publish.yml +0 -0
  16. {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/LICENSE +0 -0
  17. {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/README.md +0 -0
  18. {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/requirements.txt +0 -0
  19. {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/roblox_studio_physical_operation_mcp/__init__.py +0 -0
  20. {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/roblox_studio_physical_operation_mcp/__main__.py +0 -0
  21. {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/roblox_studio_physical_operation_mcp/log_filter.py +0 -0
  22. {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/roblox_studio_physical_operation_mcp/studio_manager.py +0 -0
  23. {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/roblox_studio_physical_operation_mcp/toolbar_detector.py +0 -0
  24. {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/roblox_studio_physical_operation_mcp/windows_utils.py +0 -0
  25. {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/run_server.py +0 -0
  26. {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/tests/README.md +0 -0
  27. {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/tests/test_full.py +0 -0
  28. {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/tests/test_postmessage.py +0 -0
  29. {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/tests/test_sendinput.py +0 -0
  30. {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/tests/test_window_capture.py +0 -0
@@ -4,12 +4,14 @@ __pycache__/
4
4
  *.pyo
5
5
 
6
6
  *.rbxl.lock
7
- *.png
8
7
  dist/
8
+
9
9
  # Test outputs
10
10
  tests/capture_test_output/
11
11
  tests/screenshots/
12
12
  screenshots/
13
+ *.png
14
+ !roblox_studio_physical_operation_mcp/templates/*.png
13
15
 
14
16
  # Lock files
15
17
  *.lock
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: roblox-studio-physical-operation-mcp
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: MCP server for controlling Roblox Studio - toolbar detection, game control, log analysis
5
5
  Project-URL: Homepage, https://github.com/white-dragon-tools/roblox-studio-physical-operation-mcp
6
6
  Project-URL: Repository, https://github.com/white-dragon-tools/roblox-studio-physical-operation-mcp
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "roblox-studio-physical-operation-mcp"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "MCP server for controlling Roblox Studio - toolbar detection, game control, log analysis"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,467 +1,467 @@
1
- """
2
- 日志工具模块: 日志读取、搜索等
3
-
4
- 优化:
5
- - 从文件末尾倒序读取,适合大文件
6
- - 支持过滤特定类别 (如 FLog::Output)
7
- """
8
-
9
- import os
10
- import re
11
- from typing import Optional, Generator
12
- from dataclasses import dataclass
13
-
14
- LOG_DIR = os.path.expandvars(r"%LOCALAPPDATA%\Roblox\logs")
15
-
16
- # 默认只读取这些类别的日志
17
- DEFAULT_CATEGORIES = ["FLog::Output"]
18
-
19
-
20
- @dataclass
21
- class LogEntry:
22
- timestamp: str
23
- level: str
24
- category: str
25
- message: str
26
- raw: str
27
-
28
-
29
- def parse_log_line(line: str) -> Optional[LogEntry]:
30
- """解析单行日志"""
31
- # 格式: 2026-02-03T08:52:02.095Z,128.095795,1996c,12 [DFLog::HttpTraceError] message
32
- # 或: 2026-02-03T08:52:04.244Z,130.244095,12f4,6,Info [FLog::...] message
33
- match = re.match(
34
- r'^(\d{4}-\d{2}-\d{2}T[\d:.]+Z),[\d.]+,[a-f0-9]+,\d+(?:,(\w+))?\s*\[([^\]]+)\]\s*(.*)$',
35
- line
36
- )
37
- if match:
38
- return LogEntry(
39
- timestamp=match.group(1),
40
- level=match.group(2) or "Info",
41
- category=match.group(3),
42
- message=match.group(4),
43
- raw=line
44
- )
45
- return None
46
-
47
-
48
- def read_file_reverse(file_path: str, chunk_size: int = 8192) -> Generator[str, None, None]:
49
- """
50
- 从文件末尾倒序读取行
51
-
52
- 对于大文件,这比读取整个文件再 reverse 更高效
53
- """
54
- with open(file_path, 'rb') as f:
55
- # 移动到文件末尾
56
- f.seek(0, 2)
57
- file_size = f.tell()
58
-
59
- buffer = b''
60
- position = file_size
61
-
62
- while position > 0:
63
- # 计算读取位置
64
- read_size = min(chunk_size, position)
65
- position -= read_size
66
- f.seek(position)
67
-
68
- # 读取并拼接
69
- chunk = f.read(read_size)
70
- buffer = chunk + buffer
71
-
72
- # 按行分割
73
- lines = buffer.split(b'\n')
74
-
75
- # 最后一个可能不完整,保留到下次
76
- buffer = lines[0]
77
-
78
- # 倒序返回完整的行
79
- for line in reversed(lines[1:]):
80
- line_str = line.decode('utf-8', errors='ignore').strip()
81
- if line_str:
82
- yield line_str
83
-
84
- # 处理剩余的 buffer
85
- if buffer:
86
- line_str = buffer.decode('utf-8', errors='ignore').strip()
87
- if line_str:
88
- yield line_str
89
-
90
-
91
- from .log_filter import should_exclude
92
-
93
-
94
- def get_logs_from_line(
95
- log_path: str,
96
- after_line: int = None,
97
- before_line: int = None,
98
- timestamps: bool = False,
99
- categories: list[str] = None,
100
- apply_filter: bool = True
101
- ) -> dict:
102
- """
103
- 从指定行范围读取日志
104
-
105
- Args:
106
- log_path: 日志文件路径
107
- after_line: 从哪一行之后开始读取,None 表示从头开始
108
- before_line: 到哪一行之前结束,None 表示到末尾
109
- timestamps: 是否附加时间戳
110
- categories: 只返回这些类别的日志,默认 ["FLog::Output"]
111
- apply_filter: 是否应用过滤规则排除 Studio 内部日志
112
-
113
- Returns:
114
- {
115
- "logs": "日志文本",
116
- "start_line": 起始行号,
117
- "last_line": 最后行号,
118
- "remaining": 剩余有效日志行数,
119
- "has_more": 是否还有更多
120
- }
121
- """
122
- MAX_BYTES = 32000
123
-
124
- if not os.path.exists(log_path):
125
- return {"logs": "", "start_line": 0, "last_line": 0, "remaining": 0, "has_more": False}
126
-
127
- if categories is None:
128
- categories = DEFAULT_CATEGORIES
129
-
130
- start_line = None
131
- last_line = 0
132
- current_bytes = 0
133
- log_lines = []
134
- remaining = 0
135
- bytes_exceeded = False
136
-
137
- try:
138
- with open(log_path, 'r', encoding='utf-8', errors='ignore') as f:
139
- for line_num, line in enumerate(f, 1):
140
- # 跳过 after_line 之前的行
141
- if after_line is not None and line_num <= after_line:
142
- continue
143
-
144
- # 停止在 before_line
145
- if before_line is not None and line_num >= before_line:
146
- break
147
-
148
- line = line.strip()
149
- if not line:
150
- continue
151
-
152
- entry = parse_log_line(line)
153
- if not entry:
154
- continue
155
-
156
- # 类别过滤
157
- if categories and entry.category not in categories:
158
- continue
159
-
160
- # 应用排除规则
161
- if apply_filter and should_exclude(entry.message):
162
- continue
163
-
164
- # 这是一条有效日志
165
- remaining += 1
166
-
167
- # 如果已超过字节限制,只统计不添加
168
- if bytes_exceeded:
169
- continue
170
-
171
- # 格式化输出
172
- if timestamps:
173
- time_part = entry.timestamp[11:19]
174
- output_line = f"[{time_part}] {entry.message}"
175
- else:
176
- output_line = entry.message
177
-
178
- line_bytes = len(output_line.encode('utf-8')) + 1
179
-
180
- # 检查是否超过字节限制
181
- if current_bytes + line_bytes > MAX_BYTES and log_lines:
182
- bytes_exceeded = True
183
- continue
184
-
185
- if start_line is None:
186
- start_line = line_num
187
-
188
- log_lines.append(output_line)
189
- last_line = line_num
190
- current_bytes += line_bytes
191
-
192
- except Exception:
193
- pass
194
-
195
- returned_count = len(log_lines)
196
- return {
197
- "logs": "\n".join(log_lines),
198
- "start_line": start_line or 0,
199
- "last_line": last_line,
200
- "remaining": remaining - returned_count,
201
- "has_more": remaining > returned_count
202
- }
203
-
204
-
205
- def get_recent_logs(
206
- log_path: str,
207
- limit: int = 100,
208
- categories: list[str] = None,
209
- min_level: Optional[str] = None,
210
- apply_filter: bool = True
211
- ) -> list[LogEntry]:
212
- """
213
- 从日志文件末尾读取最近的日志
214
-
215
- Args:
216
- log_path: 日志文件路径
217
- limit: 返回的最大条数
218
- categories: 只返回这些类别的日志,默认 ["FLog::Output"]
219
- min_level: 最低日志级别过滤
220
- apply_filter: 是否应用过滤规则排除 Studio 内部日志,默认 True
221
-
222
- Returns:
223
- 日志条目列表 (按时间正序)
224
- """
225
- if not os.path.exists(log_path):
226
- return []
227
-
228
- if categories is None:
229
- categories = DEFAULT_CATEGORIES
230
-
231
- entries = []
232
- try:
233
- for line in read_file_reverse(log_path):
234
- entry = parse_log_line(line)
235
- if not entry:
236
- continue
237
-
238
- # 类别过滤
239
- if categories and entry.category not in categories:
240
- continue
241
-
242
- # 级别过滤
243
- if min_level and entry.level.lower() != min_level.lower():
244
- continue
245
-
246
- # 应用排除规则
247
- if apply_filter and should_exclude(entry.message):
248
- continue
249
-
250
- entries.append(entry)
251
- if len(entries) >= limit:
252
- break
253
- except Exception:
254
- pass
255
-
256
- # 返回正序 (最旧的在前)
257
- return list(reversed(entries))
258
-
259
-
260
- def get_all_logs(
261
- log_path: str,
262
- limit: int = 100,
263
- min_level: Optional[str] = None
264
- ) -> list[LogEntry]:
265
- """
266
- 获取所有类别的日志 (不过滤类别)
267
- """
268
- return get_recent_logs(log_path, limit, categories=[], min_level=min_level)
269
-
270
-
271
- def search_logs(
272
- log_path: str,
273
- pattern: str,
274
- limit: int = 50,
275
- categories: list[str] = None
276
- ) -> list[LogEntry]:
277
- """
278
- 在日志中搜索匹配的条目
279
-
280
- Args:
281
- log_path: 日志文件路径
282
- pattern: 正则表达式模式
283
- limit: 返回的最大条数
284
- categories: 只搜索这些类别,None 表示搜索所有
285
- """
286
- if not os.path.exists(log_path):
287
- return []
288
-
289
- entries = []
290
- regex = re.compile(pattern, re.IGNORECASE)
291
-
292
- try:
293
- for line in read_file_reverse(log_path):
294
- if not regex.search(line):
295
- continue
296
-
297
- entry = parse_log_line(line)
298
- if not entry:
299
- continue
300
-
301
- if categories and entry.category not in categories:
302
- continue
303
-
304
- entries.append(entry)
305
- if len(entries) >= limit:
306
- break
307
- except Exception:
308
- pass
309
-
310
- return list(reversed(entries))
311
-
312
-
313
- def search_logs_from_line(
314
- log_path: str,
315
- pattern: str,
316
- after_line: int = None,
317
- before_line: int = None,
318
- timestamps: bool = False,
319
- categories: list[str] = None,
320
- apply_filter: bool = True
321
- ) -> dict:
322
- """
323
- 在指定行范围内搜索日志
324
-
325
- Args:
326
- log_path: 日志文件路径
327
- pattern: 正则表达式模式
328
- after_line: 从哪一行之后开始搜索
329
- before_line: 到哪一行之前结束
330
- timestamps: 是否附加时间戳
331
- categories: 只搜索这些类别,默认 ["FLog::Output"]
332
- apply_filter: 是否应用过滤规则
333
-
334
- Returns:
335
- {
336
- "logs": "匹配的日志文本",
337
- "start_line": 起始行号,
338
- "last_line": 最后行号,
339
- "match_count": 匹配条数,
340
- "remaining": 剩余匹配数,
341
- "has_more": 是否还有更多
342
- }
343
- """
344
- MAX_BYTES = 32000
345
-
346
- if not os.path.exists(log_path):
347
- return {"logs": "", "start_line": 0, "last_line": 0, "match_count": 0, "remaining": 0, "has_more": False}
348
-
349
- if categories is None:
350
- categories = DEFAULT_CATEGORIES
351
-
352
- try:
353
- regex = re.compile(pattern, re.IGNORECASE)
354
- except re.error:
355
- return {"error": f"Invalid regex pattern: {pattern}"}
356
-
357
- start_line = None
358
- last_line = 0
359
- current_bytes = 0
360
- log_lines = []
361
- match_count = 0
362
- bytes_exceeded = False
363
-
364
- try:
365
- with open(log_path, 'r', encoding='utf-8', errors='ignore') as f:
366
- for line_num, line in enumerate(f, 1):
367
- if after_line is not None and line_num <= after_line:
368
- continue
369
-
370
- if before_line is not None and line_num >= before_line:
371
- break
372
-
373
- line = line.strip()
374
- if not line:
375
- continue
376
-
377
- entry = parse_log_line(line)
378
- if not entry:
379
- continue
380
-
381
- if categories and entry.category not in categories:
382
- continue
383
-
384
- if apply_filter and should_exclude(entry.message):
385
- continue
386
-
387
- # 正则匹配
388
- if not regex.search(entry.message):
389
- continue
390
-
391
- match_count += 1
392
-
393
- if bytes_exceeded:
394
- continue
395
-
396
- if timestamps:
397
- time_part = entry.timestamp[11:19]
398
- output_line = f"{line_num}|[{time_part}] {entry.message}"
399
- else:
400
- output_line = f"{line_num}|{entry.message}"
401
-
402
- line_bytes = len(output_line.encode('utf-8')) + 1
403
-
404
- if current_bytes + line_bytes > MAX_BYTES and log_lines:
405
- bytes_exceeded = True
406
- continue
407
-
408
- if start_line is None:
409
- start_line = line_num
410
-
411
- log_lines.append(output_line)
412
- last_line = line_num
413
- current_bytes += line_bytes
414
-
415
- except Exception:
416
- pass
417
-
418
- returned_count = len(log_lines)
419
- return {
420
- "logs": "\n".join(log_lines),
421
- "start_line": start_line or 0,
422
- "last_line": last_line,
423
- "match_count": returned_count,
424
- "remaining": match_count - returned_count,
425
- "has_more": match_count > returned_count
426
- }
427
-
428
-
429
- def find_latest_studio_log() -> Optional[str]:
430
- """查找最新的 Studio 日志文件"""
431
- if not os.path.exists(LOG_DIR):
432
- return None
433
-
434
- studio_logs = []
435
- for f in os.listdir(LOG_DIR):
436
- if "Studio" in f and f.endswith(".log"):
437
- path = os.path.join(LOG_DIR, f)
438
- studio_logs.append((path, os.path.getmtime(path)))
439
-
440
- if not studio_logs:
441
- return None
442
-
443
- studio_logs.sort(key=lambda x: x[1], reverse=True)
444
- return studio_logs[0][0]
445
-
446
-
447
- def clean_old_logs(days: int = 7) -> int:
448
- """清理超过指定天数的旧日志"""
449
- if not os.path.exists(LOG_DIR):
450
- return 0
451
-
452
- from datetime import datetime
453
- count = 0
454
- now = datetime.now().timestamp()
455
- threshold = days * 24 * 60 * 60
456
-
457
- for f in os.listdir(LOG_DIR):
458
- if f.endswith(".log"):
459
- path = os.path.join(LOG_DIR, f)
460
- try:
461
- if now - os.path.getmtime(path) > threshold:
462
- os.remove(path)
463
- count += 1
464
- except Exception:
465
- pass
466
-
467
- return count
1
+ """
2
+ 日志工具模块: 日志读取、搜索等
3
+
4
+ 优化:
5
+ - 从文件末尾倒序读取,适合大文件
6
+ - 支持过滤特定类别 (如 FLog::Output)
7
+ """
8
+
9
+ import os
10
+ import re
11
+ from typing import Optional, Generator
12
+ from dataclasses import dataclass
13
+
14
+ LOG_DIR = os.path.expandvars(r"%LOCALAPPDATA%\Roblox\logs")
15
+
16
+ # 默认读取这些类别的日志(包含用户脚本的 print 和 warn 输出)
17
+ DEFAULT_CATEGORIES = ["FLog::Output", "FLog::Warning"]
18
+
19
+
20
+ @dataclass
21
+ class LogEntry:
22
+ timestamp: str
23
+ level: str
24
+ category: str
25
+ message: str
26
+ raw: str
27
+
28
+
29
+ def parse_log_line(line: str) -> Optional[LogEntry]:
30
+ """解析单行日志"""
31
+ # 格式: 2026-02-03T08:52:02.095Z,128.095795,1996c,12 [DFLog::HttpTraceError] message
32
+ # 或: 2026-02-03T08:52:04.244Z,130.244095,12f4,6,Info [FLog::...] message
33
+ match = re.match(
34
+ r'^(\d{4}-\d{2}-\d{2}T[\d:.]+Z),[\d.]+,[a-f0-9]+,\d+(?:,(\w+))?\s*\[([^\]]+)\]\s*(.*)$',
35
+ line
36
+ )
37
+ if match:
38
+ return LogEntry(
39
+ timestamp=match.group(1),
40
+ level=match.group(2) or "Info",
41
+ category=match.group(3),
42
+ message=match.group(4),
43
+ raw=line
44
+ )
45
+ return None
46
+
47
+
48
+ def read_file_reverse(file_path: str, chunk_size: int = 8192) -> Generator[str, None, None]:
49
+ """
50
+ 从文件末尾倒序读取行
51
+
52
+ 对于大文件,这比读取整个文件再 reverse 更高效
53
+ """
54
+ with open(file_path, 'rb') as f:
55
+ # 移动到文件末尾
56
+ f.seek(0, 2)
57
+ file_size = f.tell()
58
+
59
+ buffer = b''
60
+ position = file_size
61
+
62
+ while position > 0:
63
+ # 计算读取位置
64
+ read_size = min(chunk_size, position)
65
+ position -= read_size
66
+ f.seek(position)
67
+
68
+ # 读取并拼接
69
+ chunk = f.read(read_size)
70
+ buffer = chunk + buffer
71
+
72
+ # 按行分割
73
+ lines = buffer.split(b'\n')
74
+
75
+ # 最后一个可能不完整,保留到下次
76
+ buffer = lines[0]
77
+
78
+ # 倒序返回完整的行
79
+ for line in reversed(lines[1:]):
80
+ line_str = line.decode('utf-8', errors='ignore').strip()
81
+ if line_str:
82
+ yield line_str
83
+
84
+ # 处理剩余的 buffer
85
+ if buffer:
86
+ line_str = buffer.decode('utf-8', errors='ignore').strip()
87
+ if line_str:
88
+ yield line_str
89
+
90
+
91
+ from .log_filter import should_exclude
92
+
93
+
94
+ def get_logs_from_line(
95
+ log_path: str,
96
+ after_line: int = None,
97
+ before_line: int = None,
98
+ timestamps: bool = False,
99
+ categories: list[str] = None,
100
+ apply_filter: bool = True
101
+ ) -> dict:
102
+ """
103
+ 从指定行范围读取日志
104
+
105
+ Args:
106
+ log_path: 日志文件路径
107
+ after_line: 从哪一行之后开始读取,None 表示从头开始
108
+ before_line: 到哪一行之前结束,None 表示到末尾
109
+ timestamps: 是否附加时间戳
110
+ categories: 只返回这些类别的日志,默认 ["FLog::Output"]
111
+ apply_filter: 是否应用过滤规则排除 Studio 内部日志
112
+
113
+ Returns:
114
+ {
115
+ "logs": "日志文本",
116
+ "start_line": 起始行号,
117
+ "last_line": 最后行号,
118
+ "remaining": 剩余有效日志行数,
119
+ "has_more": 是否还有更多
120
+ }
121
+ """
122
+ MAX_BYTES = 32000
123
+
124
+ if not os.path.exists(log_path):
125
+ return {"logs": "", "start_line": 0, "last_line": 0, "remaining": 0, "has_more": False}
126
+
127
+ if categories is None:
128
+ categories = DEFAULT_CATEGORIES
129
+
130
+ start_line = None
131
+ last_line = 0
132
+ current_bytes = 0
133
+ log_lines = []
134
+ remaining = 0
135
+ bytes_exceeded = False
136
+
137
+ try:
138
+ with open(log_path, 'r', encoding='utf-8', errors='ignore') as f:
139
+ for line_num, line in enumerate(f, 1):
140
+ # 跳过 after_line 之前的行
141
+ if after_line is not None and line_num <= after_line:
142
+ continue
143
+
144
+ # 停止在 before_line
145
+ if before_line is not None and line_num >= before_line:
146
+ break
147
+
148
+ line = line.strip()
149
+ if not line:
150
+ continue
151
+
152
+ entry = parse_log_line(line)
153
+ if not entry:
154
+ continue
155
+
156
+ # 类别过滤
157
+ if categories and entry.category not in categories:
158
+ continue
159
+
160
+ # 应用排除规则
161
+ if apply_filter and should_exclude(entry.message):
162
+ continue
163
+
164
+ # 这是一条有效日志
165
+ remaining += 1
166
+
167
+ # 如果已超过字节限制,只统计不添加
168
+ if bytes_exceeded:
169
+ continue
170
+
171
+ # 格式化输出
172
+ if timestamps:
173
+ time_part = entry.timestamp[11:19]
174
+ output_line = f"[{time_part}] {entry.message}"
175
+ else:
176
+ output_line = entry.message
177
+
178
+ line_bytes = len(output_line.encode('utf-8')) + 1
179
+
180
+ # 检查是否超过字节限制
181
+ if current_bytes + line_bytes > MAX_BYTES and log_lines:
182
+ bytes_exceeded = True
183
+ continue
184
+
185
+ if start_line is None:
186
+ start_line = line_num
187
+
188
+ log_lines.append(output_line)
189
+ last_line = line_num
190
+ current_bytes += line_bytes
191
+
192
+ except Exception:
193
+ pass
194
+
195
+ returned_count = len(log_lines)
196
+ return {
197
+ "logs": "\n".join(log_lines),
198
+ "start_line": start_line or 0,
199
+ "last_line": last_line,
200
+ "remaining": remaining - returned_count,
201
+ "has_more": remaining > returned_count
202
+ }
203
+
204
+
205
+ def get_recent_logs(
206
+ log_path: str,
207
+ limit: int = 100,
208
+ categories: list[str] = None,
209
+ min_level: Optional[str] = None,
210
+ apply_filter: bool = True
211
+ ) -> list[LogEntry]:
212
+ """
213
+ 从日志文件末尾读取最近的日志
214
+
215
+ Args:
216
+ log_path: 日志文件路径
217
+ limit: 返回的最大条数
218
+ categories: 只返回这些类别的日志,默认 ["FLog::Output"]
219
+ min_level: 最低日志级别过滤
220
+ apply_filter: 是否应用过滤规则排除 Studio 内部日志,默认 True
221
+
222
+ Returns:
223
+ 日志条目列表 (按时间正序)
224
+ """
225
+ if not os.path.exists(log_path):
226
+ return []
227
+
228
+ if categories is None:
229
+ categories = DEFAULT_CATEGORIES
230
+
231
+ entries = []
232
+ try:
233
+ for line in read_file_reverse(log_path):
234
+ entry = parse_log_line(line)
235
+ if not entry:
236
+ continue
237
+
238
+ # 类别过滤
239
+ if categories and entry.category not in categories:
240
+ continue
241
+
242
+ # 级别过滤
243
+ if min_level and entry.level.lower() != min_level.lower():
244
+ continue
245
+
246
+ # 应用排除规则
247
+ if apply_filter and should_exclude(entry.message):
248
+ continue
249
+
250
+ entries.append(entry)
251
+ if len(entries) >= limit:
252
+ break
253
+ except Exception:
254
+ pass
255
+
256
+ # 返回正序 (最旧的在前)
257
+ return list(reversed(entries))
258
+
259
+
260
+ def get_all_logs(
261
+ log_path: str,
262
+ limit: int = 100,
263
+ min_level: Optional[str] = None
264
+ ) -> list[LogEntry]:
265
+ """
266
+ 获取所有类别的日志 (不过滤类别)
267
+ """
268
+ return get_recent_logs(log_path, limit, categories=[], min_level=min_level)
269
+
270
+
271
+ def search_logs(
272
+ log_path: str,
273
+ pattern: str,
274
+ limit: int = 50,
275
+ categories: list[str] = None
276
+ ) -> list[LogEntry]:
277
+ """
278
+ 在日志中搜索匹配的条目
279
+
280
+ Args:
281
+ log_path: 日志文件路径
282
+ pattern: 正则表达式模式
283
+ limit: 返回的最大条数
284
+ categories: 只搜索这些类别,None 表示搜索所有
285
+ """
286
+ if not os.path.exists(log_path):
287
+ return []
288
+
289
+ entries = []
290
+ regex = re.compile(pattern, re.IGNORECASE)
291
+
292
+ try:
293
+ for line in read_file_reverse(log_path):
294
+ if not regex.search(line):
295
+ continue
296
+
297
+ entry = parse_log_line(line)
298
+ if not entry:
299
+ continue
300
+
301
+ if categories and entry.category not in categories:
302
+ continue
303
+
304
+ entries.append(entry)
305
+ if len(entries) >= limit:
306
+ break
307
+ except Exception:
308
+ pass
309
+
310
+ return list(reversed(entries))
311
+
312
+
313
+ def search_logs_from_line(
314
+ log_path: str,
315
+ pattern: str,
316
+ after_line: int = None,
317
+ before_line: int = None,
318
+ timestamps: bool = False,
319
+ categories: list[str] = None,
320
+ apply_filter: bool = True
321
+ ) -> dict:
322
+ """
323
+ 在指定行范围内搜索日志
324
+
325
+ Args:
326
+ log_path: 日志文件路径
327
+ pattern: 正则表达式模式
328
+ after_line: 从哪一行之后开始搜索
329
+ before_line: 到哪一行之前结束
330
+ timestamps: 是否附加时间戳
331
+ categories: 只搜索这些类别,默认 ["FLog::Output"]
332
+ apply_filter: 是否应用过滤规则
333
+
334
+ Returns:
335
+ {
336
+ "logs": "匹配的日志文本",
337
+ "start_line": 起始行号,
338
+ "last_line": 最后行号,
339
+ "match_count": 匹配条数,
340
+ "remaining": 剩余匹配数,
341
+ "has_more": 是否还有更多
342
+ }
343
+ """
344
+ MAX_BYTES = 32000
345
+
346
+ if not os.path.exists(log_path):
347
+ return {"logs": "", "start_line": 0, "last_line": 0, "match_count": 0, "remaining": 0, "has_more": False}
348
+
349
+ if categories is None:
350
+ categories = DEFAULT_CATEGORIES
351
+
352
+ try:
353
+ regex = re.compile(pattern, re.IGNORECASE)
354
+ except re.error:
355
+ return {"error": f"Invalid regex pattern: {pattern}"}
356
+
357
+ start_line = None
358
+ last_line = 0
359
+ current_bytes = 0
360
+ log_lines = []
361
+ match_count = 0
362
+ bytes_exceeded = False
363
+
364
+ try:
365
+ with open(log_path, 'r', encoding='utf-8', errors='ignore') as f:
366
+ for line_num, line in enumerate(f, 1):
367
+ if after_line is not None and line_num <= after_line:
368
+ continue
369
+
370
+ if before_line is not None and line_num >= before_line:
371
+ break
372
+
373
+ line = line.strip()
374
+ if not line:
375
+ continue
376
+
377
+ entry = parse_log_line(line)
378
+ if not entry:
379
+ continue
380
+
381
+ if categories and entry.category not in categories:
382
+ continue
383
+
384
+ if apply_filter and should_exclude(entry.message):
385
+ continue
386
+
387
+ # 正则匹配
388
+ if not regex.search(entry.message):
389
+ continue
390
+
391
+ match_count += 1
392
+
393
+ if bytes_exceeded:
394
+ continue
395
+
396
+ if timestamps:
397
+ time_part = entry.timestamp[11:19]
398
+ output_line = f"{line_num}|[{time_part}] {entry.message}"
399
+ else:
400
+ output_line = f"{line_num}|{entry.message}"
401
+
402
+ line_bytes = len(output_line.encode('utf-8')) + 1
403
+
404
+ if current_bytes + line_bytes > MAX_BYTES and log_lines:
405
+ bytes_exceeded = True
406
+ continue
407
+
408
+ if start_line is None:
409
+ start_line = line_num
410
+
411
+ log_lines.append(output_line)
412
+ last_line = line_num
413
+ current_bytes += line_bytes
414
+
415
+ except Exception:
416
+ pass
417
+
418
+ returned_count = len(log_lines)
419
+ return {
420
+ "logs": "\n".join(log_lines),
421
+ "start_line": start_line or 0,
422
+ "last_line": last_line,
423
+ "match_count": returned_count,
424
+ "remaining": match_count - returned_count,
425
+ "has_more": match_count > returned_count
426
+ }
427
+
428
+
429
+ def find_latest_studio_log() -> Optional[str]:
430
+ """查找最新的 Studio 日志文件"""
431
+ if not os.path.exists(LOG_DIR):
432
+ return None
433
+
434
+ studio_logs = []
435
+ for f in os.listdir(LOG_DIR):
436
+ if "Studio" in f and f.endswith(".log"):
437
+ path = os.path.join(LOG_DIR, f)
438
+ studio_logs.append((path, os.path.getmtime(path)))
439
+
440
+ if not studio_logs:
441
+ return None
442
+
443
+ studio_logs.sort(key=lambda x: x[1], reverse=True)
444
+ return studio_logs[0][0]
445
+
446
+
447
+ def clean_old_logs(days: int = 7) -> int:
448
+ """清理超过指定天数的旧日志"""
449
+ if not os.path.exists(LOG_DIR):
450
+ return 0
451
+
452
+ from datetime import datetime
453
+ count = 0
454
+ now = datetime.now().timestamp()
455
+ threshold = days * 24 * 60 * 60
456
+
457
+ for f in os.listdir(LOG_DIR):
458
+ if f.endswith(".log"):
459
+ path = os.path.join(LOG_DIR, f)
460
+ try:
461
+ if now - os.path.getmtime(path) > threshold:
462
+ os.remove(path)
463
+ count += 1
464
+ except Exception:
465
+ pass
466
+
467
+ return count
@@ -18,7 +18,7 @@ from .windows_utils import (
18
18
  from .log_utils import get_recent_logs, search_logs, clean_old_logs
19
19
  from .toolbar_detector import detect_toolbar_state, detect_toolbar_state_with_debug
20
20
 
21
- mcp = FastMCP("roblox-studio-mcp")
21
+ mcp = FastMCP("roblox-studio-physical-operation-mcp")
22
22
 
23
23
  # 截图输出目录 (系统临时文件夹)
24
24
  import tempfile
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "LogTest",
3
+ "tree": {
4
+ "$className": "DataModel",
5
+ "ServerScriptService": {
6
+ "$className": "ServerScriptService",
7
+ "TestScript": {
8
+ "$path": "src/TestScript.server.lua"
9
+ }
10
+ },
11
+ "StarterPlayer": {
12
+ "$className": "StarterPlayer",
13
+ "StarterPlayerScripts": {
14
+ "$className": "StarterPlayerScripts"
15
+ }
16
+ },
17
+ "Workspace": {
18
+ "$className": "Workspace",
19
+ "$properties": {
20
+ "FilteringEnabled": true
21
+ }
22
+ },
23
+ "ReplicatedStorage": {
24
+ "$className": "ReplicatedStorage"
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,32 @@
1
+ -- 测试脚本:验证 print 和 warn 日志是否都能被 MCP 捕获
2
+ -- Issue #1: 看不到 roblox 运行后的警告日志
3
+
4
+ print("========== 日志测试开始 ==========")
5
+
6
+ -- 测试 print (普通输出)
7
+ print("[TEST] 这是一条 print 消息 - 应该能看到")
8
+
9
+ -- 测试 warn (警告输出)
10
+ warn("[TEST] 这是一条 warn 警告消息 - 需要验证是否能看到")
11
+
12
+ -- 测试多条消息
13
+ for i = 1, 3 do
14
+ print("[TEST] print 循环消息 #" .. i)
15
+ warn("[TEST] warn 循环警告 #" .. i)
16
+ end
17
+
18
+ -- 测试 error (不会中断脚本的方式)
19
+ print("[TEST] 准备测试 error 输出...")
20
+
21
+ -- 使用 pcall 包装 error,避免脚本中断
22
+ local success, err = pcall(function()
23
+ error("[TEST] 这是一条 error 错误消息 - 需要验证是否能看到")
24
+ end)
25
+
26
+ if not success then
27
+ print("[TEST] pcall 捕获到错误: " .. tostring(err))
28
+ end
29
+
30
+ print("========== 日志测试结束 ==========")
31
+ print("[TEST] 如果你只能看到 print 消息,说明 warn 被过滤了")
32
+ print("[TEST] 如果你能看到 warn 消息,说明问题已修复")
@@ -0,0 +1,77 @@
1
+ """
2
+ 测试脚本:验证 warn 日志修复
3
+ Issue #1: 看不到 roblox 运行后的警告日志
4
+ """
5
+
6
+ import sys
7
+ import os
8
+ import time
9
+
10
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
11
+
12
+ from roblox_studio_physical_operation_mcp.server import (
13
+ studio_list, game_start, game_stop, logs_get
14
+ )
15
+
16
+ PLACE_PATH = os.path.join(os.path.dirname(__file__), "game.rbxl")
17
+
18
+
19
+ def main():
20
+ print("=" * 60)
21
+ print("Issue #1 Fix Verification")
22
+ print("=" * 60)
23
+
24
+ # 查找我们的测试实例
25
+ instances = studio_list()
26
+ our_instance = None
27
+ for inst in instances:
28
+ if inst.get("place_path") and "game.rbxl" in inst.get("place_path", ""):
29
+ our_instance = inst
30
+ break
31
+
32
+ if not our_instance:
33
+ print("Error: Test game.rbxl not running!")
34
+ print(f"Running instances: {instances}")
35
+ return
36
+
37
+ print(f"Found test instance: PID={our_instance['pid']}")
38
+
39
+ # 重新开始游戏
40
+ print("\n[1] Restart game (F5)...")
41
+ game_stop(place_path=PLACE_PATH)
42
+ time.sleep(2)
43
+ game_start(place_path=PLACE_PATH)
44
+ time.sleep(5)
45
+
46
+ # 获取日志
47
+ print("\n[2] Get logs (after fix)...")
48
+ logs = logs_get(place_path=PLACE_PATH)
49
+ log_text = logs.get("logs", "")
50
+
51
+ print("-" * 40)
52
+ # 只打印测试相关的行
53
+ for line in log_text.split("\n"):
54
+ if "[TEST]" in line or "=====" in line:
55
+ print(line)
56
+ print("-" * 40)
57
+
58
+ # 验证
59
+ print("\n[3] Verification:")
60
+ has_print = "print" in log_text and "[TEST]" in log_text
61
+ has_warn = "warn" in log_text and "[TEST]" in log_text
62
+
63
+ print(f" print messages: {'VISIBLE' if has_print else 'NOT VISIBLE'}")
64
+ print(f" warn messages: {'VISIBLE' if has_warn else 'NOT VISIBLE'}")
65
+
66
+ if has_print and has_warn:
67
+ print("\n SUCCESS: Issue #1 is FIXED!")
68
+ elif has_print and not has_warn:
69
+ print("\n FAILED: warn still filtered!")
70
+ else:
71
+ print("\n UNKNOWN: Check output above")
72
+
73
+ print("\n" + "=" * 60)
74
+
75
+
76
+ if __name__ == "__main__":
77
+ main()
@@ -0,0 +1,100 @@
1
+ """
2
+ 测试脚本:验证 warn 日志是否能被捕获
3
+ Issue #1: 看不到 roblox 运行后的警告日志
4
+ """
5
+
6
+ import sys
7
+ import os
8
+ import time
9
+
10
+ # 添加项目路径
11
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
12
+
13
+ from roblox_studio_physical_operation_mcp.server import (
14
+ studio_list, studio_open, studio_query,
15
+ game_start, game_stop, logs_get, modal_close
16
+ )
17
+
18
+ PLACE_PATH = os.path.join(os.path.dirname(__file__), "game.rbxl")
19
+
20
+
21
+ def main():
22
+ print("=" * 60)
23
+ print("Issue #1 测试: 验证 warn 日志是否能被捕获")
24
+ print("=" * 60)
25
+
26
+ # 1. 检查 Studio 是否已运行
27
+ print("\n[1] 检查 Studio 实例...")
28
+ instances = studio_list()
29
+ print(f" 当前实例: {instances}")
30
+
31
+ # 2. 打开 Studio
32
+ print(f"\n[2] 打开 Studio: {PLACE_PATH}")
33
+ result = studio_open(PLACE_PATH)
34
+ print(f" 结果: {result}")
35
+
36
+ # 3. 等待加载
37
+ print("\n[3] 等待 Studio 加载 (8秒)...")
38
+ time.sleep(8)
39
+
40
+ # 4. 关闭弹窗
41
+ print("\n[4] 关闭弹窗...")
42
+ result = modal_close(place_path=PLACE_PATH)
43
+ print(f" 结果: {result}")
44
+
45
+ # 5. 查询状态
46
+ print("\n[5] 查询 Studio 状态...")
47
+ status = studio_query(place_path=PLACE_PATH)
48
+ print(f" 状态: {status}")
49
+
50
+ if not status.get("active"):
51
+ print(" 错误: Studio 未运行!")
52
+ return
53
+
54
+ # 6. 开始游戏
55
+ print("\n[6] 开始游戏 (F5)...")
56
+ result = game_start(place_path=PLACE_PATH)
57
+ print(f" 结果: {result}")
58
+
59
+ # 7. 等待脚本执行
60
+ print("\n[7] 等待脚本执行 (5秒)...")
61
+ time.sleep(5)
62
+
63
+ # 8. 获取日志
64
+ print("\n[8] 获取日志 (默认过滤)...")
65
+ logs = logs_get(place_path=PLACE_PATH)
66
+ print(f" 日志结果:")
67
+ print("-" * 40)
68
+ print(logs.get("logs", "无日志"))
69
+ print("-" * 40)
70
+ print(f" last_line: {logs.get('last_line')}")
71
+ print(f" remaining: {logs.get('remaining')}")
72
+
73
+ # 9. 分析结果
74
+ print("\n[9] 分析结果...")
75
+ log_text = logs.get("logs", "")
76
+ has_print = "[TEST] 这是一条 print 消息" in log_text
77
+ has_warn = "[TEST] 这是一条 warn 警告消息" in log_text
78
+
79
+ print(f" print 消息: {'✓ 可见' if has_print else '✗ 不可见'}")
80
+ print(f" warn 消息: {'✓ 可见' if has_warn else '✗ 不可见'}")
81
+
82
+ if has_print and not has_warn:
83
+ print("\n ⚠️ 问题确认: warn 日志被过滤了!")
84
+ elif has_print and has_warn:
85
+ print("\n ✓ 正常: print 和 warn 都可见")
86
+ else:
87
+ print("\n ? 异常: 需要检查日志输出")
88
+
89
+ # 10. 停止游戏
90
+ print("\n[10] 停止游戏 (Shift+F5)...")
91
+ result = game_stop(place_path=PLACE_PATH)
92
+ print(f" 结果: {result}")
93
+
94
+ print("\n" + "=" * 60)
95
+ print("测试完成")
96
+ print("=" * 60)
97
+
98
+
99
+ if __name__ == "__main__":
100
+ main()