aicodestat 0.0.1__py3-none-any.whl

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.
local_mcp_server.py ADDED
@@ -0,0 +1,260 @@
1
+ """应用入口,使用 FastMCP 框架实现 MCP Server,通过 HTTP 后台服务暴露接口"""
2
+ import argparse
3
+ import sys
4
+ import logging
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Optional, Any, Dict
8
+ from config import get_server_config, get_database_config
9
+ from logging_config import setup_logging
10
+ from storage.db import get_db
11
+ from storage.scheduler import start_scheduler, stop_scheduler
12
+ from storage.models import (
13
+ save_before_edit,
14
+ get_before_edit,
15
+ delete_before_edit,
16
+ save_session_summary,
17
+ save_code_diff_lines,
18
+ )
19
+ from compute.diff_engine import extract_diff_lines
20
+ from utils.port_utils import find_available_port
21
+
22
+ # 设置日志(初始化为INFO级别,main函数中会根据配置调整)
23
+ logger = setup_logging(log_level="INFO", module_name="mcp_server")
24
+
25
+
26
+ def _import_fastmcp():
27
+ """Import FastMCP from the official MCP SDK, avoiding local mcp/ shadowing."""
28
+ original_sys_path = list(sys.path)
29
+ try:
30
+ project_root = os.path.abspath(os.path.dirname(__file__))
31
+ sys.path = [p for p in sys.path if p not in ("", ".", project_root)]
32
+ from mcp.server.fastmcp import FastMCP # type: ignore
33
+ return FastMCP
34
+ finally:
35
+ sys.path = original_sys_path
36
+
37
+
38
+ def create_mcp_app(host: str = "127.0.0.1", port: int = 8000):
39
+ """创建 FastMCP 应用并注册工具"""
40
+ FastMCP = _import_fastmcp()
41
+ # FastMCP 构造函数接受 host, port, streamable_http_path 等参数
42
+ # streamable_http_path 默认为 '/mcp',但 Cursor 可能请求根路径,所以设置为 '/'
43
+ mcp_app = FastMCP(
44
+ "local-code-stat",
45
+ host=host,
46
+ port=port,
47
+ streamable_http_path="/" # 设置为根路径,兼容 Cursor 的请求
48
+ )
49
+
50
+ @mcp_app.tool()
51
+ def RecordBeforeEdit(session_id: str, file_path: str, code_before: str) -> Dict[str, Any]:
52
+ """
53
+ Record the complete code content before file editing (temporary local storage, will be cleaned up automatically, no redundant retention)
54
+
55
+ Args:
56
+ session_id: Current session ID, generated by Agent, must match RecordAfterEdit's session_id
57
+ file_path: Absolute path of the target file
58
+ code_before: Complete code content before editing (preserve original format, blank lines, ensure accurate line numbers)
59
+
60
+ Returns:
61
+ Dictionary containing status and message
62
+ """
63
+ if not session_id or not session_id.strip():
64
+ return {"status": "error", "message": "session_id cannot be empty"}
65
+ if not file_path or not file_path.strip():
66
+ return {"status": "error", "message": "file_path cannot be empty"}
67
+ if code_before is None or code_before == "":
68
+ return {"status": "error", "message": "code_before cannot be empty"}
69
+
70
+ success = save_before_edit(session_id=session_id, file_path=file_path, code_before=code_before)
71
+ if not success:
72
+ return {"status": "error", "message": "Failed to save code before editing"}
73
+
74
+ logger.info(f"RecordBeforeEdit: session={session_id}, file={file_path}")
75
+ return {"status": "success", "message": "Code before editing recorded successfully"}
76
+
77
+ @mcp_app.tool()
78
+ def RecordAfterEdit(
79
+ session_id: str,
80
+ file_path: str,
81
+ code_after: str,
82
+ session_info: Optional[str] = None,
83
+ ) -> Dict[str, Any]:
84
+ """
85
+ Record the complete code content after file editing, extract specific diff lines (added/modified) with line numbers, clean up temporary data, and retain only diff information
86
+
87
+ Args:
88
+ session_id: Current session ID, must match RecordBeforeEdit's session_id to associate the same editing operation
89
+ file_path: Absolute path of the target file, must match RecordBeforeEdit's file_path
90
+ code_after: Complete code content after editing (preserve original format, blank lines, ensure accurate line numbers)
91
+ session_info: Optional session supplementary information (e.g., user instructions, Agent type, operation time) for subsequent statistical filtering
92
+
93
+ Returns:
94
+ Dictionary containing status, message, and data
95
+ """
96
+ if not session_id or not session_id.strip():
97
+ return {"status": "error", "message": "session_id cannot be empty"}
98
+ if not file_path or not file_path.strip():
99
+ return {"status": "error", "message": "file_path cannot be empty"}
100
+ if code_after is None or code_after == "":
101
+ return {"status": "error", "message": "code_after cannot be empty"}
102
+
103
+ code_before = get_before_edit(session_id, file_path)
104
+ if code_before is None:
105
+ return {
106
+ "status": "error",
107
+ "message": (
108
+ f"Code record before editing not found, please call RecordBeforeEdit Tool first "
109
+ f"(session_id={session_id}, file_path={file_path})"
110
+ ),
111
+ }
112
+
113
+ diff_lines = extract_diff_lines(code_before, code_after)
114
+ add_lines_count = sum(1 for d in diff_lines if d["diff_type"] == "add")
115
+ modify_lines_count = sum(1 for d in diff_lines if d["diff_type"] == "modify")
116
+ total_lines_after = len(code_after.split("\n"))
117
+
118
+ success = save_session_summary(
119
+ session_id=session_id,
120
+ file_path=file_path,
121
+ add_lines_count=add_lines_count,
122
+ modify_lines_count=modify_lines_count,
123
+ total_lines_after=total_lines_after,
124
+ session_info=session_info,
125
+ )
126
+ if not success:
127
+ return {"status": "error", "message": "Failed to save session summary"}
128
+
129
+ if diff_lines:
130
+ ok = save_code_diff_lines(session_id=session_id, file_path=file_path, diff_lines=diff_lines)
131
+ if not ok:
132
+ logger.warning("Failed to save diff lines details, but session summary has been saved")
133
+
134
+ delete_before_edit(session_id, file_path)
135
+
136
+ logger.info(
137
+ f"RecordAfterEdit: session={session_id}, file={file_path}, "
138
+ f"add={add_lines_count}, modify={modify_lines_count}, total_diff={len(diff_lines)}"
139
+ )
140
+ return {
141
+ "status": "success",
142
+ "message": "Code after editing recorded successfully, diff lines extracted and stored",
143
+ "data": {
144
+ "add_lines_count": add_lines_count,
145
+ "modify_lines_count": modify_lines_count,
146
+ "total_diff_lines": len(diff_lines),
147
+ "total_lines_after": total_lines_after,
148
+ },
149
+ }
150
+
151
+ return mcp_app
152
+
153
+
154
+ def main():
155
+ """主函数:启动 FastMCP HTTP 服务器"""
156
+ parser = argparse.ArgumentParser(description="本地MCP Server AI代码统计系统(基于FastMCP)")
157
+ parser.add_argument(
158
+ "command",
159
+ choices=["start"],
160
+ help="启动服务器"
161
+ )
162
+ parser.add_argument(
163
+ "--host",
164
+ type=str,
165
+ help="服务器监听地址(默认从config.json读取)"
166
+ )
167
+ parser.add_argument(
168
+ "--port",
169
+ type=int,
170
+ help="服务器监听端口(默认从config.json读取)"
171
+ )
172
+ parser.add_argument(
173
+ "--daemon",
174
+ action="store_true",
175
+ help="后台运行(Windows下使用简单后台进程,Unix下建议使用nohup或systemd)"
176
+ )
177
+
178
+ args = parser.parse_args()
179
+
180
+ if args.command == "start":
181
+ # 初始化数据库和定时任务
182
+ try:
183
+ db = get_db()
184
+ db.initialize()
185
+ logger.info("Database initialized successfully")
186
+
187
+ start_scheduler()
188
+ logger.info("Scheduler started (auto backup and cleanup)")
189
+ except Exception as e:
190
+ logger.error(f"Failed to initialize: {e}", exc_info=True)
191
+ sys.exit(1)
192
+
193
+ # 获取配置
194
+ server_config = get_server_config()
195
+ host = args.host or server_config["host"]
196
+ preferred_port = args.port or server_config["port"]
197
+ log_level = server_config.get("log_level", "info")
198
+ log_file = server_config.get("log_file")
199
+
200
+ # 检查端口是否可用,如果被占用则选择随机端口
201
+ try:
202
+ port = find_available_port(host, preferred_port)
203
+ if port != preferred_port:
204
+ logger.warning(f"Port {preferred_port} is in use, using port {port} instead")
205
+ except RuntimeError as e:
206
+ logger.error(f"Failed to find available port: {e}")
207
+ sys.exit(1)
208
+
209
+ # 设置日志(如果配置了日志文件,添加文件handler)
210
+ if log_file:
211
+ # 添加文件handler到现有logger
212
+ from pathlib import Path
213
+ log_path = Path(log_file)
214
+ log_path.parent.mkdir(parents=True, exist_ok=True)
215
+ file_handler = logging.FileHandler(log_file, encoding='utf-8')
216
+ file_handler.setLevel(getattr(logging, log_level.upper(), logging.INFO))
217
+ formatter = logging.Formatter(
218
+ fmt='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
219
+ datefmt='%Y-%m-%d %H:%M:%S'
220
+ )
221
+ file_handler.setFormatter(formatter)
222
+ logger.addHandler(file_handler)
223
+ logger.info(f"Logging to file: {log_file}")
224
+
225
+ # 设置日志级别
226
+ logger.setLevel(getattr(logging, log_level.upper(), logging.INFO))
227
+
228
+ # 创建 FastMCP 应用(传入 host 和 port)
229
+ mcp_app = create_mcp_app(host=host, port=port)
230
+
231
+ logger.info(f"Starting FastMCP Server on {host}:{port} (transport: streamable-http)")
232
+ logger.info("MCP Tools available:")
233
+ logger.info(" - RecordBeforeEdit: Record code before file editing")
234
+ logger.info(" - RecordAfterEdit: Record code after file editing and extract diff lines")
235
+
236
+ if args.daemon:
237
+ # 后台运行(简单实现,生产环境建议使用systemd/supervisor等)
238
+ logger.warning("Daemon mode is a simple implementation. For production, use systemd/supervisor/nohup.")
239
+ import multiprocessing
240
+
241
+ def run_server():
242
+ mcp_app.run(transport="streamable-http")
243
+
244
+ process = multiprocessing.Process(target=run_server, daemon=True)
245
+ process.start()
246
+ logger.info(f"Server started in background (PID: {process.pid})")
247
+ logger.info("To stop the server, use: kill <PID>")
248
+ else:
249
+ # 前台运行 FastMCP HTTP 服务器
250
+ try:
251
+ mcp_app.run(transport="streamable-http")
252
+ except KeyboardInterrupt:
253
+ logger.info("Server stopped by user")
254
+ stop_scheduler()
255
+ logger.info("Scheduler stopped")
256
+
257
+
258
+ if __name__ == "__main__":
259
+ main()
260
+
logging_config.py ADDED
@@ -0,0 +1,68 @@
1
+ """统一日志配置模块"""
2
+ import logging
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+
8
+ def setup_logging(
9
+ log_level: str = "INFO",
10
+ log_file: Optional[str] = None,
11
+ module_name: Optional[str] = None,
12
+ stream: Optional[object] = None
13
+ ) -> logging.Logger:
14
+ """
15
+ 设置日志配置
16
+
17
+ Args:
18
+ log_level: 日志级别 (DEBUG, INFO, WARNING, ERROR, CRITICAL)
19
+ log_file: 日志文件路径(可选)
20
+ module_name: 模块名称(用于logger命名)
21
+
22
+ Returns:
23
+ 配置好的logger实例
24
+ """
25
+ # 转换日志级别
26
+ numeric_level = getattr(logging, log_level.upper(), logging.INFO)
27
+
28
+ # 创建formatter
29
+ formatter = logging.Formatter(
30
+ fmt='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
31
+ datefmt='%Y-%m-%d %H:%M:%S'
32
+ )
33
+
34
+ # 创建logger
35
+ logger_name = module_name if module_name else __name__
36
+ logger = logging.getLogger(logger_name)
37
+ logger.setLevel(numeric_level)
38
+
39
+ # 清除已有的handlers
40
+ logger.handlers.clear()
41
+
42
+ # 控制台handler
43
+ # IMPORTANT:
44
+ # - MCP stdio mode uses stdout for JSON-RPC. Writing logs to stdout will break the protocol.
45
+ # - Default to stderr to be safe for both CLI and MCP.
46
+ if stream is None:
47
+ stream = sys.stderr
48
+ console_handler = logging.StreamHandler(stream)
49
+ console_handler.setLevel(numeric_level)
50
+ console_handler.setFormatter(formatter)
51
+ logger.addHandler(console_handler)
52
+
53
+ # 文件handler(如果指定)
54
+ if log_file:
55
+ log_path = Path(log_file)
56
+ log_path.parent.mkdir(parents=True, exist_ok=True)
57
+ file_handler = logging.FileHandler(log_file, encoding='utf-8')
58
+ file_handler.setLevel(numeric_level)
59
+ file_handler.setFormatter(formatter)
60
+ logger.addHandler(file_handler)
61
+
62
+ return logger
63
+
64
+
65
+ def get_logger(name: str) -> logging.Logger:
66
+ """获取指定名称的logger"""
67
+ return logging.getLogger(name)
68
+
main.py ADDED
@@ -0,0 +1,164 @@
1
+ """统一入口:支持启动服务器或CLI"""
2
+ import argparse
3
+ import sys
4
+
5
+
6
+ def main():
7
+ """主入口函数"""
8
+ parser = argparse.ArgumentParser(
9
+ description="本地MCP Server AI代码统计系统",
10
+ formatter_class=argparse.RawDescriptionHelpFormatter,
11
+ epilog="""
12
+ 示例:
13
+ %(prog)s server start # 启动MCP服务器
14
+ %(prog)s server start --port 8080 # 指定端口启动服务器
15
+ %(prog)s cli # 启动交互式CLI
16
+ %(prog)s cli --session <id> # 快捷查询会话
17
+ %(prog)s cli --file <path> # 快捷查询文件
18
+ """
19
+ )
20
+
21
+ subparsers = parser.add_subparsers(dest='command', help='可用命令')
22
+
23
+ # Server子命令
24
+ server_parser = subparsers.add_parser('server', help='启动MCP服务器')
25
+ server_parser.add_argument(
26
+ 'action',
27
+ choices=['start'],
28
+ help='启动服务器'
29
+ )
30
+ server_parser.add_argument(
31
+ '--host',
32
+ type=str,
33
+ help='服务器监听地址(默认从config.json读取)'
34
+ )
35
+ server_parser.add_argument(
36
+ '--port',
37
+ type=int,
38
+ help='服务器监听端口(默认从config.json读取)'
39
+ )
40
+ server_parser.add_argument(
41
+ '--daemon',
42
+ action='store_true',
43
+ help='后台运行'
44
+ )
45
+
46
+ # CLI子命令
47
+ cli_parser = subparsers.add_parser('cli', help='启动交互式CLI')
48
+ cli_parser.add_argument(
49
+ '--session',
50
+ type=str,
51
+ help='按会话ID查询(快捷命令)'
52
+ )
53
+ cli_parser.add_argument(
54
+ '--file',
55
+ type=str,
56
+ help='按文件路径查询(快捷命令)'
57
+ )
58
+ cli_parser.add_argument(
59
+ '--project',
60
+ type=str,
61
+ help='按项目根目录查询(快捷命令)'
62
+ )
63
+ cli_parser.add_argument(
64
+ '--export',
65
+ type=str,
66
+ choices=['json', 'csv'],
67
+ help='导出格式(需配合--session/--file/--project使用)'
68
+ )
69
+ cli_parser.add_argument(
70
+ '--output',
71
+ type=str,
72
+ help='导出文件路径(需配合--export使用)'
73
+ )
74
+
75
+ args = parser.parse_args()
76
+
77
+ if not args.command:
78
+ parser.print_help()
79
+ sys.exit(1)
80
+
81
+ if args.command == 'server':
82
+ # 启动服务器
83
+ if args.action == 'start':
84
+ import uvicorn
85
+ import logging
86
+ from config import get_server_config
87
+ from logging_config import setup_logging
88
+ from storage.db import get_db
89
+ from storage.scheduler import start_scheduler
90
+ from local_mcp_server import app
91
+
92
+ logger = setup_logging(log_level="INFO", module_name="mcp_server")
93
+
94
+ # 初始化数据库
95
+ try:
96
+ db = get_db()
97
+ db.initialize()
98
+ logger.info("Database initialized successfully")
99
+ start_scheduler()
100
+ logger.info("Scheduler started (auto backup and cleanup)")
101
+ except Exception as e:
102
+ logger.error(f"Failed to initialize: {e}")
103
+ sys.exit(1)
104
+
105
+ # 获取配置
106
+ server_config = get_server_config()
107
+ host = args.host or server_config["host"]
108
+ preferred_port = args.port or server_config["port"]
109
+ log_level = server_config.get("log_level", "info")
110
+
111
+ # 检查端口是否可用,如果被占用则选择随机端口
112
+ from utils.port_utils import find_available_port
113
+ try:
114
+ port = find_available_port(host, preferred_port)
115
+ if port != preferred_port:
116
+ logger.warning(f"Port {preferred_port} is in use, using port {port} instead")
117
+ except RuntimeError as e:
118
+ logger.error(f"Failed to find available port: {e}")
119
+ sys.exit(1)
120
+
121
+ logger.setLevel(getattr(logging, log_level.upper(), logging.INFO))
122
+ logger.info(f"Starting MCP Server on {host}:{port}")
123
+ logger.info("MCP Tools available at:")
124
+ logger.info(f" GET http://{host}:{port}/mcp/tools (Tool discovery)")
125
+ logger.info(f" POST http://{host}:{port}/mcp/record_before")
126
+ logger.info(f" POST http://{host}:{port}/mcp/record_after")
127
+
128
+ if args.daemon:
129
+ logger.warning("Daemon mode is a simple implementation. For production, use systemd/supervisor/nohup.")
130
+ import multiprocessing
131
+
132
+ def run_server():
133
+ uvicorn.run(app, host=host, port=port, log_level=log_level)
134
+
135
+ process = multiprocessing.Process(target=run_server, daemon=True)
136
+ process.start()
137
+ logger.info(f"Server started in background (PID: {process.pid})")
138
+ logger.info("To stop the server, use: kill <PID>")
139
+ else:
140
+ uvicorn.run(app, host=host, port=port, log_level=log_level)
141
+
142
+ elif args.command == 'cli':
143
+ # 启动CLI
144
+ from cli.main import main as cli_main
145
+ # 设置快捷命令参数
146
+ if args.session or args.file or args.project:
147
+ # 快捷命令模式,需要重新设置sys.argv
148
+ sys.argv = ['cli/main.py']
149
+ if args.session:
150
+ sys.argv.extend(['--session', args.session])
151
+ if args.file:
152
+ sys.argv.extend(['--file', args.file])
153
+ if args.project:
154
+ sys.argv.extend(['--project', args.project])
155
+ if args.export:
156
+ sys.argv.extend(['--export', args.export])
157
+ if args.output:
158
+ sys.argv.extend(['--output', args.output])
159
+ cli_main()
160
+
161
+
162
+ if __name__ == '__main__':
163
+ main()
164
+
mcp/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """MCP Tool实现模块"""
2
+
mcp/agent_adapter.py ADDED
@@ -0,0 +1,69 @@
1
+ """可选,封装不同Agent字段名差异的映射"""
2
+ from typing import Dict, Any, Optional
3
+
4
+ # Agent参数映射表
5
+ AGENT_FIELD_MAPPINGS = {
6
+ "cursor": {
7
+ "file_path": "file_path",
8
+ "target_file": "file_path", # 别名
9
+ },
10
+ "claude": {
11
+ "file_path": "file_path",
12
+ "target_file": "file_path",
13
+ },
14
+ "trea": {
15
+ "file_path": "file_path",
16
+ },
17
+ "qoder": {
18
+ "file_path": "file_path",
19
+ }
20
+ }
21
+
22
+
23
+ def normalize_request_params(agent_type: str, params: Dict[str, Any]) -> Dict[str, Any]:
24
+ """
25
+ 标准化不同Agent的请求参数
26
+
27
+ Args:
28
+ agent_type: Agent类型(cursor/claude/trea/qoder)
29
+ params: 原始请求参数
30
+
31
+ Returns:
32
+ 标准化后的参数
33
+ """
34
+ agent_type_lower = agent_type.lower()
35
+ mapping = AGENT_FIELD_MAPPINGS.get(agent_type_lower, {})
36
+
37
+ normalized = params.copy()
38
+
39
+ # 字段映射
40
+ for alias, target_field in mapping.items():
41
+ if alias in normalized and alias != target_field:
42
+ if target_field not in normalized:
43
+ normalized[target_field] = normalized[alias]
44
+ del normalized[alias]
45
+
46
+ return normalized
47
+
48
+
49
+ def detect_agent_type(session_info: Optional[str]) -> str:
50
+ """
51
+ 从session_info中检测Agent类型
52
+
53
+ Args:
54
+ session_info: 会话信息字符串
55
+
56
+ Returns:
57
+ Agent类型(默认返回"unknown")
58
+ """
59
+ if not session_info:
60
+ return "unknown"
61
+
62
+ session_info_lower = session_info.lower()
63
+
64
+ for agent_type in ["cursor", "claude", "trea", "qoder"]:
65
+ if agent_type in session_info_lower:
66
+ return agent_type
67
+
68
+ return "unknown"
69
+
mcp/api_schemas.py ADDED
@@ -0,0 +1,26 @@
1
+ """请求/响应 Pydantic 模型"""
2
+ from pydantic import BaseModel, Field
3
+ from typing import Optional
4
+
5
+
6
+ class RecordBeforeEditRequest(BaseModel):
7
+ """RecordBeforeEdit Tool请求模型"""
8
+ session_id: str = Field(..., description="当前会话ID,由Agent生成,需与RecordAfterEdit的session_id一致")
9
+ file_path: str = Field(..., description="目标文件的绝对路径")
10
+ code_before: str = Field(..., description="文件编辑前的完整代码内容")
11
+
12
+
13
+ class RecordAfterEditRequest(BaseModel):
14
+ """RecordAfterEdit Tool请求模型"""
15
+ session_id: str = Field(..., description="当前会话ID,需与RecordBeforeEdit的session_id一致")
16
+ file_path: str = Field(..., description="目标文件的绝对路径,需与RecordBeforeEdit的file_path一致")
17
+ code_after: str = Field(..., description="文件编辑后的完整代码内容")
18
+ session_info: Optional[str] = Field(None, description="会话补充信息(如用户指令、Agent类型、操作时间)")
19
+
20
+
21
+ class MCPResponse(BaseModel):
22
+ """MCP Tool统一响应模型"""
23
+ status: str = Field(..., description="状态:success 或 error")
24
+ message: str = Field(..., description="响应消息")
25
+ data: Optional[dict] = Field(None, description="响应数据(可选)")
26
+