├── LICENSE ├── README.md ├── docs └── img │ └── pcm.jpg ├── pyproject.toml ├── server.py ├── src ├── backend │ ├── report.py │ └── templates │ │ └── index.html └── ida │ └── mcp.py └── uv.lock /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 rand0m 4 | Copyright (c) 2025 Duncan Ogilvie 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pcm 2 | PCM (MCP but reversed), **MCP for reverse engineering**. 3 | 4 | ## Features 5 | 6 | - Analysis 7 | - IDA 8 | - repl (IDAPython) 9 | - disassembly 10 | - decompilation 11 | - set types 12 | - ... 13 | - Memory 14 | - Engagement reports 15 |  16 | 17 | 18 | Full list of features 19 | 20 | 21 | ``` 22 | - `get_function_by_name(name)`: Get a function by its name. 23 | - `get_function_by_address(address)`: Get a function by its address. 24 | - `get_current_address()`: Get the address currently selected by the user. 25 | - `get_current_function()`: Get the function currently selected by the user. 26 | - `list_functions()`: List all functions in the database. 27 | - `decompile_function(address)`: Decompile a function at the given address using Hex-Rays. 28 | - `disassemble_function(address)`: Get assembly code (address: instruction; comment) for a function. 29 | - `get_entrypoints()`: Get all entrypoints in the binary. 30 | - `get_function_blocks(address)`: Get all basic blocks in a function. 31 | - `get_function_cfg(address)`: Get control flow graph for a function. 32 | - `get_xrefs_to(address)`: Get all cross references to the given address. 33 | - `get_xrefs_from(address)`: Get all cross references from the given address. 34 | - `set_decompiler_comment(address, comment)`: Set a comment for a given address in the function pseudocode. 35 | - `set_disassembly_comment(address, comment)`: Set a comment for a given address in the function disassembly. 36 | - `rename_local_variable(function_address, old_name, new_name)`: Rename a local variable in a function. 37 | - `rename_function(function_address, new_name)`: Rename a function. 38 | - `set_function_prototype(function_address, prototype)`: Set a function's prototype. 39 | - `set_local_variable_type(function_address, variable_name, new_type)`: Set a local variable's type. 40 | - `create_structure_type(name, members, is_union)`: Create a new structure type. 41 | - `get_metadata()`: Get metadata about the current IDB. 42 | - `repl_idapython(content)`: Run IDAPython code and return the results with stdout/stderr captured. 43 | - `add_note(title, content, address, tags)`: Add a new analysis note for the current binary. 44 | - `update_note(note_id, title, content, tags)`: Update an existing note. 45 | - `get_notes(file_md5, address, tag)`: Get analysis notes for a binary. 46 | - `delete_note(note_id)`: Delete an analysis note. 47 | ``` 48 | 49 | 50 | 51 | 52 | ## Installations 53 | 54 | Prerequisites: 55 | - [`uv`](https://github.com/astral-sh/uv) 56 | 57 | 58 | 1. Clone the repository 59 | ``` 60 | git clone https://github.com/rand-tech/pcm 61 | ``` 62 | 1. Add `pcm` to you mcp config 63 | example 64 | ``` 65 | { 66 | "mcpServers": { 67 | "pcm": { 68 | "command": "uv", 69 | "args": [ 70 | "--directory", 71 | "path_to/pcm", 72 | "run", 73 | "server.py" 74 | ] 75 | } 76 | } 77 | } 78 | ``` 79 | 1. Use the MCP 80 | 81 | 82 | **Related projects**: 83 | 84 | - 85 | - 86 | 87 | **Attribution**: 88 | This project is based on [IDA Pro MCP](https://github.com/mrexodia/ida-pro-mcp) by Duncan Ogilvie (@mrexodia). Thank you 89 | 90 | **License**: 91 | This project is licensed under the MIT License - see the LICENSE file for details. The original code is also licensed under the MIT License. 92 | -------------------------------------------------------------------------------- /docs/img/pcm.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rand-tech/pcm/1910649f39c1fb9d7718ca80b45c2ac92ef41fcd/docs/img/pcm.jpg -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "pcm" 3 | version = "0.1.0" 4 | description = "MCP for reverse engineering" 5 | readme = "README.md" 6 | requires-python = ">=3.13" 7 | dependencies = [ 8 | "fastmcp>=0.4.1", 9 | "flask>=3.1.0", 10 | ] 11 | 12 | [project.urls] 13 | Repository = "https://github.com/rand-tech/pcm" 14 | Issues = "https://github.com/rand-tech/pcm/issues" 15 | 16 | [build-system] 17 | requires = ["setuptools"] 18 | build-backend = "setuptools.build_meta" 19 | 20 | [project.scripts] 21 | pcm = "pcm.server:main" 22 | -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import ast 4 | import json 5 | import traceback 6 | import shutil 7 | import http.client 8 | import signal 9 | import asyncio 10 | from typing import List, Optional, Dict, Any 11 | from fastmcp import FastMCP 12 | 13 | # The log_level is necessary for Cline to work: https://github.com/jlowin/fastmcp/issues/81 14 | mcp = FastMCP("IDA Pro", log_level="ERROR") 15 | 16 | jsonrpc_request_id = 1 17 | 18 | 19 | def handle_shutdown(sig, frame): 20 | print("[*] Shutting down MCP server gracefully...") 21 | if hasattr(handle_shutdown, "_is_shutting_down") and handle_shutdown._is_shutting_down: 22 | return 23 | handle_shutdown._is_shutting_down = True 24 | 25 | if hasattr(asyncio, "get_running_loop"): 26 | try: 27 | loop = asyncio.get_running_loop() 28 | for task in asyncio.all_tasks(loop): 29 | task.cancel() 30 | print("[*] All tasks cancelled, shutdown complete.") 31 | except RuntimeError: 32 | pass 33 | import os 34 | os._exit(0) 35 | 36 | 37 | def make_jsonrpc_request(method: str, *args): 38 | """Make a JSON-RPC request to the IDA plugin""" 39 | global jsonrpc_request_id 40 | conn = http.client.HTTPConnection("localhost", 13337) 41 | request = { 42 | "jsonrpc": "2.0", 43 | "method": method, 44 | "params": list(args), 45 | "id": jsonrpc_request_id, 46 | } 47 | jsonrpc_request_id += 1 48 | 49 | try: 50 | conn.request("POST", "/mcp", json.dumps(request), {"Content-Type": "application/json"}) 51 | response = conn.getresponse() 52 | data = json.loads(response.read().decode()) 53 | 54 | if "error" in data: 55 | error = data["error"] 56 | error_message = f"JSON-RPC error {error['code']}: {error['message']}" 57 | if "data" in error and error["data"]: 58 | error_message += f"\nDetails: {error['data']}" 59 | raise Exception(error_message) 60 | 61 | return data.get("result") 62 | except Exception as e: 63 | traceback.print_exc() 64 | raise 65 | finally: 66 | conn.close() 67 | 68 | 69 | class MCPVisitor(ast.NodeVisitor): 70 | def __init__(self): 71 | self.types: dict[str, ast.ClassDef] = {} 72 | self.functions: dict[str, ast.FunctionDef] = {} 73 | self.descriptions: dict[str, str] = {} 74 | 75 | def visit_FunctionDef(self, node): 76 | for decorator in node.decorator_list: 77 | if isinstance(decorator, ast.Name): 78 | if decorator.id == "jsonrpc": 79 | for i, arg in enumerate(node.args.args): 80 | arg_name = arg.arg 81 | arg_type = arg.annotation 82 | if arg_type is None: 83 | raise Exception(f"Missing argument type for {node.name}.{arg_name}") 84 | if isinstance(arg_type, ast.Subscript): 85 | assert isinstance(arg_type.value, ast.Name) 86 | assert arg_type.value.id == "Annotated" 87 | assert isinstance(arg_type.slice, ast.Tuple) 88 | assert len(arg_type.slice.elts) == 2 89 | annot_type = arg_type.slice.elts[0] 90 | annot_description = arg_type.slice.elts[1] 91 | assert isinstance(annot_description, ast.Constant) 92 | node.args.args[i].annotation = ast.Subscript( 93 | value=ast.Name(id="Annotated", ctx=ast.Load()), 94 | slice=ast.Tuple( 95 | elts=[annot_type, ast.Call(func=ast.Name(id="Field", ctx=ast.Load()), args=[], keywords=[ast.keyword(arg="description", value=annot_description)])], ctx=ast.Load() 96 | ), 97 | ctx=ast.Load(), 98 | ) 99 | elif isinstance(arg_type, ast.Name): 100 | pass 101 | else: 102 | raise Exception(f"Unexpected type annotation for {node.name}.{arg_name} -> {type(arg_type)}") 103 | 104 | body_comment = node.body[0] 105 | if isinstance(body_comment, ast.Expr) and isinstance(body_comment.value, ast.Constant): 106 | new_body = [body_comment] 107 | self.descriptions[node.name] = body_comment.value.value 108 | else: 109 | new_body = [] 110 | 111 | call_args = [ast.Constant(value=node.name)] 112 | for arg in node.args.args: 113 | call_args.append(ast.Name(id=arg.arg, ctx=ast.Load())) 114 | new_body.append(ast.Return(value=ast.Call(func=ast.Name(id="make_jsonrpc_request", ctx=ast.Load()), args=call_args, keywords=[]))) 115 | decorator_list = [ast.Call(func=ast.Attribute(value=ast.Name(id="mcp", ctx=ast.Load()), attr="tool", ctx=ast.Load()), args=[], keywords=[])] 116 | node_nobody = ast.FunctionDef(node.name, node.args, new_body, decorator_list, node.returns, node.type_comment, lineno=node.lineno, col_offset=node.col_offset) 117 | self.functions[node.name] = node_nobody 118 | 119 | def visit_ClassDef(self, node): 120 | for base in node.bases: 121 | if isinstance(base, ast.Name): 122 | if base.id == "TypedDict": 123 | self.types[node.name] = node 124 | 125 | 126 | SCRIPT_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'src') 127 | IDA_PLUGIN_PY = os.path.join(SCRIPT_DIR, 'ida', "mcp.py") 128 | GENERATED_PY = os.path.join(SCRIPT_DIR, "server_generated.py") 129 | 130 | # NOTE: This is in the global scope on purpose 131 | with open(IDA_PLUGIN_PY, "r") as f: 132 | code = f.read() 133 | module = ast.parse(code, IDA_PLUGIN_PY) 134 | visitor = MCPVisitor() 135 | visitor.visit(module) 136 | code = """# NOTE: This file has been automatically generated, do not modify! 137 | from typing import Annotated, Optional, TypedDict, List 138 | from pydantic import Field 139 | 140 | """ 141 | for type in visitor.types.values(): 142 | code += ast.unparse(type) 143 | code += "\n\n" 144 | for function in visitor.functions.values(): 145 | code += ast.unparse(function) 146 | code += "\n\n" 147 | with open(GENERATED_PY, "w") as f: 148 | f.write(code) 149 | exec(compile(code, GENERATED_PY, "exec")) 150 | 151 | 152 | def main(): 153 | import argparse 154 | parser = argparse.ArgumentParser(description="MCP server for IDA Pro") 155 | parser.add_argument("--generate-only", action="store_true", help="Generate the IDA plugin code and exit") 156 | parser.add_argument("--install-plugin", action="store_true", help="Install the IDA plugin") 157 | args = parser.parse_args() 158 | 159 | if args.generate_only: 160 | print(f"[*] Generating IDA plugin code...", file=sys.stderr) 161 | for function in visitor.functions.values(): 162 | signature = function.name + "(" 163 | for i, arg in enumerate(function.args.args): 164 | if i > 0: 165 | signature += ", " 166 | signature += arg.arg 167 | signature += ")" 168 | description = visitor.descriptions.get(function.name, "") 169 | if description[-1] != ".": 170 | description += "." 171 | print(f"- `{signature}`: {description}") 172 | sys.exit(0) 173 | elif args.install_plugin: 174 | print(f"[*] Installing IDA plugin...", file=sys.stderr) 175 | if sys.platform == "win32": 176 | ida_plugin_folder = os.path.join(os.getenv("APPDATA"), "Hex-Rays", "IDA Pro", "plugins") 177 | else: 178 | ida_plugin_folder = os.path.join(os.path.expanduser("~"), ".idapro", "plugins") 179 | plugin_destination = os.path.join(ida_plugin_folder, "pcm.py") 180 | if input(f"Installing IDA plugin to {plugin_destination}, proceed? [Y/n] ").lower() == "n": 181 | sys.exit(1) 182 | if not os.path.exists(ida_plugin_folder): 183 | os.makedirs(ida_plugin_folder) 184 | shutil.copy(IDA_PLUGIN_PY, plugin_destination) 185 | print(f"Installed plugin: {plugin_destination}") 186 | sys.exit(0) 187 | signal.signal(signal.SIGINT, handle_shutdown) 188 | signal.signal(signal.SIGTERM, handle_shutdown) 189 | 190 | print('[*] Starting MCP server...') 191 | try: 192 | mcp.run(transport="stdio") 193 | except KeyboardInterrupt: 194 | print("[*] Received keyboard interrupt, shutting down...") 195 | except Exception as e: 196 | print(f"[!] Error: {e}") 197 | traceback.print_exc() 198 | finally: 199 | print("[*] MCP server stopped.") 200 | 201 | 202 | if __name__ == "__main__": 203 | main() 204 | -------------------------------------------------------------------------------- /src/backend/report.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | import sqlite3 4 | from pathlib import Path 5 | from typing import Any, Dict, List, Optional 6 | 7 | from flask import Flask, abort, jsonify, render_template, request 8 | 9 | app = Flask(__name__) 10 | 11 | 12 | def get_db_path(): 13 | user_dir = Path.home() 14 | if os.name == 'nt': # Windows 15 | db_path = user_dir / "AppData" / "Local" / "IDA_MCP" 16 | else: # Linux/Mac 17 | db_path = user_dir / ".ida_mcp" 18 | 19 | db_path.mkdir(exist_ok=True) 20 | db_file = db_path / "analysis_notes.db" 21 | return str(db_file) 22 | 23 | 24 | NOTES_DB = get_db_path() 25 | 26 | 27 | def dict_factory(cursor, row): 28 | """Convert sqlite row to dictionary""" 29 | d = {} 30 | for idx, col in enumerate(cursor.description): 31 | d[col[0]] = row[idx] 32 | return d 33 | 34 | 35 | def get_connection(): 36 | """Get database connection with row factory set to return dictionaries""" 37 | conn = sqlite3.connect(NOTES_DB) 38 | conn.row_factory = dict_factory 39 | return conn 40 | 41 | 42 | @app.route('/') 43 | def index(): 44 | """Main page showing list of analyzed files""" 45 | return render_template('index.html') 46 | 47 | 48 | @app.route('/api/files') 49 | def list_files(): 50 | """API endpoint to get list of all analyzed files""" 51 | try: 52 | conn = get_connection() 53 | cursor = conn.cursor() 54 | 55 | cursor.execute( 56 | """ 57 | SELECT f.*, 58 | COUNT(n.id) as note_count, 59 | MAX(n.timestamp) as last_note_timestamp 60 | FROM files f 61 | LEFT JOIN notes n ON f.md5 = n.file_md5 62 | GROUP BY f.md5 63 | ORDER BY f.last_accessed DESC 64 | """ 65 | ) 66 | 67 | files = cursor.fetchall() 68 | 69 | for file in files: 70 | if file['last_accessed']: 71 | file['last_accessed_formatted'] = datetime.datetime.fromtimestamp(file['last_accessed']).strftime('%Y-%m-%d %H:%M:%S') 72 | if file['last_note_timestamp']: 73 | file['last_note_formatted'] = datetime.datetime.fromtimestamp(file['last_note_timestamp']).strftime('%Y-%m-%d %H:%M:%S') 74 | 75 | conn.close() 76 | return jsonify(files) 77 | except Exception as e: 78 | return jsonify({"error": str(e)}), 500 79 | 80 | 81 | @app.route('/api/files//notes') 82 | def get_file_notes(md5): 83 | """API endpoint to get notes for a specific file""" 84 | try: 85 | conn = get_connection() 86 | cursor = conn.cursor() 87 | 88 | cursor.execute("SELECT * FROM files WHERE md5 = ?", (md5,)) 89 | file_info = cursor.fetchone() 90 | 91 | if not file_info: 92 | conn.close() 93 | return jsonify({"error": "File not found"}), 404 94 | 95 | # Get notes 96 | cursor.execute( 97 | """ 98 | SELECT * FROM notes 99 | WHERE file_md5 = ? 100 | ORDER BY timestamp DESC 101 | """, 102 | (md5,), 103 | ) 104 | 105 | notes = cursor.fetchall() 106 | 107 | # Format timestamps and parse tags 108 | for note in notes: 109 | note['timestamp_formatted'] = datetime.datetime.fromtimestamp(note['timestamp']).strftime('%Y-%m-%d %H:%M:%S') 110 | if note['tags']: 111 | note['tags_list'] = [tag.strip() for tag in note['tags'].split(',')] 112 | else: 113 | note['tags_list'] = [] 114 | 115 | conn.close() 116 | return jsonify({"file": file_info, "notes": notes}) 117 | except Exception as e: 118 | return jsonify({"error": str(e)}), 500 119 | 120 | 121 | @app.route('/api/notes/') 122 | def get_note_detail(note_id): 123 | """API endpoint to get details for a specific note""" 124 | try: 125 | conn = get_connection() 126 | cursor = conn.cursor() 127 | 128 | cursor.execute( 129 | """ 130 | SELECT n.*, f.name as file_name, f.path as file_path 131 | FROM notes n 132 | JOIN files f ON n.file_md5 = f.md5 133 | WHERE n.id = ? 134 | """, 135 | (note_id,), 136 | ) 137 | 138 | note = cursor.fetchone() 139 | 140 | if not note: 141 | conn.close() 142 | return jsonify({"error": "Note not found"}), 404 143 | 144 | note['timestamp_formatted'] = datetime.datetime.fromtimestamp(note['timestamp']).strftime('%Y-%m-%d %H:%M:%S') 145 | if note['tags']: 146 | note['tags_list'] = [tag.strip() for tag in note['tags'].split(',')] 147 | else: 148 | note['tags_list'] = [] 149 | 150 | conn.close() 151 | return jsonify(note) 152 | except Exception as e: 153 | return jsonify({"error": str(e)}), 500 154 | 155 | 156 | @app.route('/api/tags') 157 | def get_all_tags(): 158 | """API endpoint to get all unique tags used across notes""" 159 | try: 160 | conn = get_connection() 161 | cursor = conn.cursor() 162 | 163 | cursor.execute("SELECT tags FROM notes WHERE tags IS NOT NULL AND tags != ''") 164 | tag_rows = cursor.fetchall() 165 | 166 | # Process tags 167 | all_tags = set() 168 | for row in tag_rows: 169 | tags = row['tags'].split(',') 170 | for tag in tags: 171 | tag = tag.strip() 172 | if tag: 173 | all_tags.add(tag) 174 | 175 | conn.close() 176 | return jsonify(sorted(list(all_tags))) 177 | except Exception as e: 178 | return jsonify({"error": str(e)}), 500 179 | 180 | 181 | @app.route('/api/search') 182 | def search_notes(): 183 | """API endpoint to search notes by query term and/or tag""" 184 | try: 185 | query = request.args.get('q', '') 186 | tag = request.args.get('tag', '') 187 | 188 | conn = get_connection() 189 | cursor = conn.cursor() 190 | 191 | params = [] 192 | sql = """ 193 | SELECT n.*, f.name as file_name 194 | FROM notes n 195 | JOIN files f ON n.file_md5 = f.md5 196 | WHERE 1=1 197 | """ 198 | 199 | if query: 200 | sql += " AND (n.title LIKE ? OR n.content LIKE ?)" 201 | params.extend([f'%{query}%', f'%{query}%']) 202 | 203 | if tag: 204 | sql += " AND n.tags LIKE ?" 205 | params.append(f'%{tag}%') 206 | 207 | sql += " ORDER BY n.timestamp DESC" 208 | 209 | cursor.execute(sql, params) 210 | notes = cursor.fetchall() 211 | 212 | # Format timestamps and parse tags 213 | for note in notes: 214 | note['timestamp_formatted'] = datetime.datetime.fromtimestamp(note['timestamp']).strftime('%Y-%m-%d %H:%M:%S') 215 | if note['tags']: 216 | note['tags_list'] = [tag.strip() for tag in note['tags'].split(',')] 217 | else: 218 | note['tags_list'] = [] 219 | 220 | conn.close() 221 | return jsonify(notes) 222 | except Exception as e: 223 | return jsonify({"error": str(e)}), 500 224 | 225 | 226 | if __name__ == '__main__': 227 | print(f"Database path: {NOTES_DB}") 228 | 229 | try: 230 | conn = get_connection() 231 | cursor = conn.cursor() 232 | 233 | cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND (name='notes' OR name='files')") 234 | tables = cursor.fetchall() 235 | table_names = [t['name'] for t in tables] 236 | 237 | if 'notes' not in table_names or 'files' not in table_names: 238 | print("Warning: Database exists but required tables are missing.") 239 | print("Make sure the IDA plugin has been run at least once to initialize the database.") 240 | else: 241 | # Get some basic stats 242 | cursor.execute("SELECT COUNT(*) as count FROM files") 243 | file_count = cursor.fetchone()['count'] 244 | 245 | cursor.execute("SELECT COUNT(*) as count FROM notes") 246 | note_count = cursor.fetchone()['count'] 247 | 248 | print(f"Database contains {file_count} files and {note_count} notes.") 249 | 250 | conn.close() 251 | except Exception as e: 252 | print(f"Warning: Could not verify database: {str(e)}") 253 | print("Database will be created if it doesn't exist when you add your first note.") 254 | 255 | # Run the Flask app with debug mode enabled 256 | print("Starting web server...") 257 | PORT = 8000 258 | print(f"Open http://localhost:{PORT} in your web browser to view the reports") 259 | app.run(debug=True, host='localhost', port=PORT) 260 | -------------------------------------------------------------------------------- /src/backend/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Engagement Report 8 | 9 | 10 | 104 | 105 | 106 | 107 | 108 | 109 | Engagement Report 110 | 111 | 112 | 113 | 114 | 115 | 116 | Files 117 | 118 | 119 | Search 120 | 121 | 122 | Tags 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | Analyzed Files 133 | 134 | 135 | Loading... 136 | 137 | Loading files... 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | Back to Files 148 | 149 | 150 | 151 | 152 | File Information 153 | 154 | 155 | Path: 156 | MD5: 157 | SHA256: 158 | 159 | 160 | Size: 161 | Base Address: 162 | Last Accessed: 163 | 164 | 165 | 166 | 167 | Notes 168 | 169 | 170 | Loading... 171 | 172 | Loading notes... 173 | 174 | 175 | 176 | 177 | 178 | 179 | Search Notes 180 | 181 | 182 | 183 | 185 | 186 | Search 187 | 188 | 189 | 190 | 191 | 192 | All Tags 193 | 194 | 195 | 196 | 197 | 198 | Loading... 199 | 200 | Searching... 201 | 202 | 203 | 204 | 205 | 206 | 207 | All Tags 208 | 209 | 210 | Loading... 211 | 212 | Loading tags... 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | Note Title 223 | 224 | 225 | 226 | 227 | Address: 228 | 229 | 230 | Created: 231 | 232 | 233 | Tags: 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 808 | 809 | 810 | -------------------------------------------------------------------------------- /src/ida/mcp.py: -------------------------------------------------------------------------------- 1 | import json 2 | import struct 3 | import threading 4 | import http.server 5 | import os 6 | import time 7 | import sqlite3 8 | from urllib.parse import urlparse 9 | from typing import Dict, Any, Callable, get_type_hints, TypedDict, Optional, Annotated, List 10 | from pathlib import Path 11 | 12 | PLUGIN_NAME = 'pcm' 13 | PLUGIN_HOTKEY = "Ctrl-Alt-M" 14 | 15 | class JSONRPCError(Exception): 16 | def __init__(self, code: int, message: str, data: Any = None): 17 | self.code = code 18 | self.message = message 19 | self.data = data 20 | 21 | 22 | class RPCRegistry: 23 | def __init__(self): 24 | self.methods: Dict[str, Callable] = {} 25 | 26 | def register(self, func: Callable) -> Callable: 27 | self.methods[func.__name__] = func 28 | return func 29 | 30 | def dispatch(self, method: str, params: Any) -> Any: 31 | if method not in self.methods: 32 | raise JSONRPCError(-32601, f"Method '{method}' not found") 33 | 34 | func = self.methods[method] 35 | hints = get_type_hints(func) 36 | 37 | hints.pop("return", None) 38 | 39 | if isinstance(params, list): 40 | if len(params) != len(hints): 41 | raise JSONRPCError(-32602, f"Invalid params: expected {len(hints)} arguments, got {len(params)}") 42 | converted_params = [] 43 | for value, (param_name, expected_type) in zip(params, hints.items()): 44 | try: 45 | if not isinstance(value, expected_type): 46 | value = expected_type(value) 47 | converted_params.append(value) 48 | except (ValueError, TypeError): 49 | raise JSONRPCError(-32602, f"Invalid type for parameter '{param_name}': expected {expected_type.__name__}") 50 | return func(*converted_params) 51 | elif isinstance(params, dict): 52 | if set(params.keys()) != set(hints.keys()): 53 | raise JSONRPCError(-32602, f"Invalid params: expected {list(hints.keys())}") 54 | 55 | converted_params = {} 56 | for param_name, expected_type in hints.items(): 57 | value = params.get(param_name) 58 | try: 59 | if not isinstance(value, expected_type): 60 | value = expected_type(value) 61 | converted_params[param_name] = value 62 | except (ValueError, TypeError): 63 | raise JSONRPCError(-32602, f"Invalid type for parameter '{param_name}': expected {expected_type.__name__}") 64 | 65 | return func(**converted_params) 66 | else: 67 | raise JSONRPCError(-32600, "Invalid Request: params must be array or object") 68 | 69 | 70 | rpc_registry = RPCRegistry() 71 | 72 | 73 | def jsonrpc(func: Callable) -> Callable: 74 | """Decorator to register a function as a JSON-RPC method""" 75 | global rpc_registry 76 | return rpc_registry.register(func) 77 | 78 | 79 | class JSONRPCRequestHandler(http.server.BaseHTTPRequestHandler): 80 | def send_jsonrpc_error(self, code: int, message: str, id: Any = None): 81 | response = {"jsonrpc": "2.0", "error": {"code": code, "message": message}} 82 | if id is not None: 83 | response["id"] = id 84 | response_body = json.dumps(response).encode("utf-8") 85 | self.send_response(200) 86 | self.send_header("Content-Type", "application/json") 87 | self.send_header("Content-Length", len(response_body)) 88 | self.end_headers() 89 | self.wfile.write(response_body) 90 | 91 | def do_POST(self): 92 | global rpc_registry 93 | import traceback 94 | 95 | parsed_path = urlparse(self.path) 96 | if parsed_path.path != "/mcp": 97 | self.send_jsonrpc_error(-32098, "Invalid endpoint", None) 98 | return 99 | 100 | content_length = int(self.headers.get("Content-Length", 0)) 101 | if content_length == 0: 102 | self.send_jsonrpc_error(-32700, "Parse error: missing request body", None) 103 | return 104 | 105 | request_body = self.rfile.read(content_length) 106 | try: 107 | request = json.loads(request_body) 108 | except json.JSONDecodeError: 109 | self.send_jsonrpc_error(-32700, "Parse error: invalid JSON", None) 110 | return 111 | 112 | # Prepare the response 113 | response = {"jsonrpc": "2.0"} 114 | if request.get("id") is not None: 115 | response["id"] = request.get("id") 116 | 117 | try: 118 | # Basic JSON-RPC validation 119 | if not isinstance(request, dict): 120 | raise JSONRPCError(-32600, "Invalid Request") 121 | if request.get("jsonrpc") != "2.0": 122 | raise JSONRPCError(-32600, "Invalid JSON-RPC version") 123 | if "method" not in request: 124 | raise JSONRPCError(-32600, "Method not specified") 125 | 126 | # Dispatch the method 127 | result = rpc_registry.dispatch(request["method"], request.get("params", [])) 128 | response["result"] = result 129 | 130 | except JSONRPCError as e: 131 | response["error"] = {"code": e.code, "message": e.message} 132 | if e.data is not None: 133 | response["error"]["data"] = e.data 134 | except IDAError as e: 135 | response["error"] = { 136 | "code": -32000, 137 | "message": e.message, 138 | } 139 | except Exception as e: 140 | traceback.print_exc() 141 | response["error"] = { 142 | "code": -32603, 143 | "message": "Internal error", 144 | "data": traceback.format_exc(), 145 | } 146 | 147 | try: 148 | response_body = json.dumps(response).encode("utf-8") 149 | except Exception as e: 150 | traceback.print_exc() 151 | response_body = json.dumps( 152 | { 153 | "error": { 154 | "code": -32603, 155 | "message": "Internal error", 156 | "data": traceback.format_exc(), 157 | } 158 | } 159 | ).encode("utf-8") 160 | 161 | self.send_response(200) 162 | self.send_header("Content-Type", "application/json") 163 | self.send_header("Content-Length", len(response_body)) 164 | self.end_headers() 165 | self.wfile.write(response_body) 166 | 167 | def log_message(self, format, *args): 168 | # Suppress logging 169 | pass 170 | 171 | 172 | class MCPHTTPServer(http.server.HTTPServer): 173 | allow_reuse_address = False 174 | 175 | 176 | class Server: 177 | HOST = "localhost" 178 | PORT = 13337 179 | 180 | def __init__(self): 181 | self.server = None 182 | self.server_thread = None 183 | self.running = False 184 | 185 | def start(self): 186 | if self.running: 187 | print(f"[{PLUGIN_NAME}] Server is already running") 188 | return 189 | 190 | self.server_thread = threading.Thread(target=self._run_server, daemon=True) 191 | self.running = True 192 | self.server_thread.start() 193 | 194 | def stop(self): 195 | if not self.running: 196 | return 197 | 198 | self.running = False 199 | if self.server: 200 | self.server.shutdown() 201 | self.server.server_close() 202 | if self.server_thread: 203 | self.server_thread.join() 204 | self.server = None 205 | print(f"[{PLUGIN_NAME}] Server stopped") 206 | 207 | def _run_server(self): 208 | try: 209 | # Create server in the thread to handle binding 210 | self.server = MCPHTTPServer((Server.HOST, Server.PORT), JSONRPCRequestHandler) 211 | print(f"[{PLUGIN_NAME}] Server started at http://{Server.HOST}:{Server.PORT}") 212 | self.server.serve_forever() 213 | except OSError as e: 214 | if e.errno == 98 or e.errno == 10048: # Port already in use (Linux/Windows) 215 | print(f"[{PLUGIN_NAME}] Error: Port 13337 is already in use") 216 | else: 217 | print(f"[{PLUGIN_NAME}] Server error: {e}") 218 | self.running = False 219 | except Exception as e: 220 | print(f"[{PLUGIN_NAME}] Server error: {e}") 221 | finally: 222 | self.running = False 223 | 224 | 225 | # A module that helps with writing thread safe ida code. 226 | # Based on: 227 | # https://web.archive.org/web/20160305190440/http://www.williballenthin.com/blog/2015/09/04/idapython-synchronization-decorator/ 228 | import logging 229 | import queue 230 | import traceback 231 | import functools 232 | 233 | # import idapro 234 | import ida_pro 235 | import ida_hexrays 236 | import ida_kernwin 237 | import ida_funcs 238 | import ida_entry 239 | import ida_gdl 240 | import ida_graph 241 | import ida_lines 242 | import ida_idaapi 243 | import ida_name 244 | import ida_segment 245 | import ida_xref 246 | import ida_typeinf 247 | import idc 248 | import idaapi 249 | import idautils 250 | import ida_nalt 251 | import ida_bytes 252 | 253 | 254 | class IDAError(Exception): 255 | def __init__(self, message: str): 256 | super().__init__(message) 257 | 258 | @property 259 | def message(self) -> str: 260 | return self.args[0] 261 | 262 | 263 | class IDASyncError(Exception): 264 | pass 265 | 266 | 267 | # Important note: Always make sure the return value from your function f is a 268 | # copy of the data you have gotten from IDA, and not the original data. 269 | # 270 | # Example: 271 | # -------- 272 | # 273 | # Do this: 274 | # 275 | # @idaread 276 | # def ts_Functions(): 277 | # return list(idautils.Functions()) 278 | # 279 | # Don't do this: 280 | # 281 | # @idaread 282 | # def ts_Functions(): 283 | # return idautils.Functions() 284 | # 285 | 286 | logger = logging.getLogger(__name__) 287 | 288 | 289 | # Enum for safety modes. Higher means safer: 290 | class IDASafety: 291 | ida_kernwin.MFF_READ 292 | SAFE_NONE = ida_kernwin.MFF_FAST 293 | SAFE_READ = ida_kernwin.MFF_READ 294 | SAFE_WRITE = ida_kernwin.MFF_WRITE 295 | 296 | 297 | call_stack = queue.LifoQueue() 298 | 299 | 300 | def sync_wrapper(ff, safety_mode: IDASafety): 301 | """ 302 | Call a function ff with a specific IDA safety_mode. 303 | """ 304 | # logger.debug('sync_wrapper: {}, {}'.format(ff.__name__, safety_mode)) 305 | 306 | if safety_mode not in [IDASafety.SAFE_READ, IDASafety.SAFE_WRITE]: 307 | error_str = 'Invalid safety mode {} over function {}'.format(safety_mode, ff.__name__) 308 | logger.error(error_str) 309 | raise IDASyncError(error_str) 310 | 311 | # No safety level is set up: 312 | res_container = queue.Queue() 313 | 314 | def runned(): 315 | # logger.debug('Inside runned') 316 | 317 | # Make sure that we are not already inside a sync_wrapper: 318 | if not call_stack.empty(): 319 | last_func_name = call_stack.get() 320 | error_str = ('Call stack is not empty while calling the ' 'function {} from {}').format(ff.__name__, last_func_name) 321 | # logger.error(error_str) 322 | raise IDASyncError(error_str) 323 | 324 | call_stack.put((ff.__name__)) 325 | try: 326 | res_container.put(ff()) 327 | except Exception as x: 328 | res_container.put(x) 329 | finally: 330 | call_stack.get() 331 | # logger.debug('Finished runned') 332 | 333 | ret_val = idaapi.execute_sync(runned, safety_mode) 334 | res = res_container.get() 335 | if isinstance(res, Exception): 336 | raise res 337 | return res 338 | 339 | 340 | def idawrite(f): 341 | """ 342 | decorator for marking a function as modifying the IDB. 343 | schedules a request to be made in the main IDA loop to avoid IDB corruption. 344 | """ 345 | 346 | @functools.wraps(f) 347 | def wrapper(*args, **kwargs): 348 | ff = functools.partial(f, *args, **kwargs) 349 | ff.__name__ = f.__name__ 350 | return sync_wrapper(ff, idaapi.MFF_WRITE) 351 | 352 | return wrapper 353 | 354 | 355 | def idaread(f): 356 | """ 357 | decorator for marking a function as reading from the IDB. 358 | schedules a request to be made in the main IDA loop to avoid 359 | inconsistent results. 360 | MFF_READ constant via: http://www.openrce.org/forums/posts/1827 361 | """ 362 | 363 | @functools.wraps(f) 364 | def wrapper(*args, **kwargs): 365 | ff = functools.partial(f, *args, **kwargs) 366 | ff.__name__ = f.__name__ 367 | return sync_wrapper(ff, idaapi.MFF_READ) 368 | 369 | return wrapper 370 | 371 | 372 | def init_notes_db(): 373 | user_dir = Path.home() 374 | if os.name == 'nt': 375 | db_path = user_dir / "AppData" / "Local" / "IDA_MCP" 376 | else: 377 | db_path = user_dir / ".ida_mcp" 378 | 379 | db_path.mkdir(exist_ok=True) 380 | db_file = db_path / "analysis_notes.db" 381 | 382 | conn = sqlite3.connect(str(db_file)) 383 | cursor = conn.cursor() 384 | 385 | cursor.execute( 386 | ''' 387 | CREATE TABLE IF NOT EXISTS notes ( 388 | id INTEGER PRIMARY KEY, 389 | file_md5 TEXT NOT NULL, 390 | address TEXT, 391 | title TEXT NOT NULL, 392 | content TEXT NOT NULL, 393 | timestamp INTEGER NOT NULL, 394 | tags TEXT 395 | ) 396 | ''' 397 | ) 398 | 399 | cursor.execute( 400 | ''' 401 | CREATE TABLE IF NOT EXISTS files ( 402 | md5 TEXT PRIMARY KEY, 403 | path TEXT NOT NULL, 404 | name TEXT NOT NULL, 405 | base_addr TEXT, 406 | size TEXT, 407 | sha256 TEXT, 408 | crc32 TEXT, 409 | filesize TEXT, 410 | last_accessed INTEGER 411 | ) 412 | ''' 413 | ) 414 | 415 | conn.commit() 416 | conn.close() 417 | 418 | return str(db_file) 419 | 420 | 421 | NOTES_DB = init_notes_db() 422 | 423 | 424 | # Type definitions 425 | class Function(TypedDict): 426 | start_address: int 427 | end_address: int 428 | name: str 429 | prototype: str 430 | 431 | 432 | class Entrypoint(TypedDict): 433 | address: int 434 | name: str 435 | ordinal: int 436 | 437 | 438 | class Block(TypedDict): 439 | start_address: int 440 | end_address: int 441 | type: str 442 | successor_addresses: List[int] 443 | 444 | 445 | class CFGNode(TypedDict): 446 | id: int 447 | start_address: int 448 | end_address: int 449 | type: str 450 | successors: List[int] 451 | 452 | 453 | class XrefEntry(TypedDict): 454 | from_address: int 455 | to_address: int 456 | type: str 457 | function_name: str 458 | 459 | 460 | class Type(TypedDict): 461 | name: str 462 | definition: str 463 | size: int 464 | 465 | 466 | class Note(TypedDict): 467 | id: int 468 | file_md5: str 469 | address: Optional[str] 470 | title: str 471 | content: str 472 | timestamp: int 473 | tags: Optional[str] 474 | 475 | 476 | class FileInfo(TypedDict): 477 | md5: str 478 | path: str 479 | name: str 480 | base_addr: str 481 | size: str 482 | sha256: str 483 | crc32: str 484 | filesize: str 485 | last_accessed: int 486 | 487 | 488 | class Metadata(TypedDict): 489 | path: str 490 | module: str 491 | base: str 492 | size: str 493 | md5: str 494 | sha256: str 495 | crc32: str 496 | filesize: str 497 | 498 | 499 | def get_function(address: int) -> Optional[Function]: 500 | fn = idaapi.get_func(address) 501 | if fn is None: 502 | raise IDAError(f"No function found at address {address}") 503 | # NOTE: You need IDA 9.0 SP1 or newer for this 504 | prototype: ida_typeinf.tinfo_t = fn.get_prototype() 505 | if prototype is not None: 506 | prototype = str(prototype) 507 | return { 508 | "start_address": fn.start_ea, 509 | "end_address": fn.end_ea, 510 | "name": fn.name, 511 | "prototype": prototype, 512 | } 513 | 514 | 515 | def get_image_size(): 516 | import ida_ida 517 | 518 | omin_ea = ida_ida.inf_get_omin_ea() 519 | omax_ea = ida_ida.inf_get_omax_ea() 520 | # Bad heuristic for image size (bad if the relocations are the last section) 521 | image_size = omax_ea - omin_ea 522 | # Try to extract it from the PE header 523 | header = idautils.peutils_t().header() 524 | if header and header[:4] == b"PE\0\0": 525 | image_size = struct.unpack(" ida_hexrays.cfunc_t: 530 | if not ida_hexrays.init_hexrays_plugin(): 531 | raise IDAError("Hex-Rays decompiler is not available") 532 | error = ida_hexrays.hexrays_failure_t() 533 | cfunc: ida_hexrays.cfunc_t = ida_hexrays.decompile_func(address, error, ida_hexrays.DECOMP_WARNINGS) 534 | if not cfunc: 535 | message = f"Decompilation failed at {address}" 536 | if error.str: 537 | message += f": {error.str}" 538 | if error.errea != idaapi.BADADDR: 539 | message += f" (address: {error.errea})" 540 | raise IDAError(message) 541 | return cfunc 542 | 543 | 544 | def refresh_decompiler_widget(): 545 | widget = ida_kernwin.get_current_widget() 546 | if widget is not None: 547 | vu = ida_hexrays.get_widget_vdui(widget) 548 | if vu is not None: 549 | vu.refresh_ctext() 550 | 551 | 552 | def refresh_decompiler_ctext(function_address: int): 553 | error = ida_hexrays.hexrays_failure_t() 554 | cfunc: ida_hexrays.cfunc_t = ida_hexrays.decompile_func(function_address, error, ida_hexrays.DECOMP_WARNINGS) 555 | if cfunc: 556 | cfunc.refresh_func_ctext() 557 | 558 | 559 | class my_modifier_t(ida_hexrays.user_lvar_modifier_t): 560 | def __init__(self, var_name: str, new_type: ida_typeinf.tinfo_t): 561 | ida_hexrays.user_lvar_modifier_t.__init__(self) 562 | self.var_name = var_name 563 | self.new_type = new_type 564 | 565 | def modify_lvars(self, lvars): 566 | for lvar_saved in lvars.lvvec: 567 | lvar_saved: ida_hexrays.lvar_saved_info_t 568 | if lvar_saved.name == self.var_name: 569 | lvar_saved.type = self.new_type 570 | return True 571 | return False 572 | 573 | 574 | # 575 | # Function and code analysis functions 576 | # 577 | 578 | 579 | @jsonrpc 580 | @idaread 581 | def get_function_by_name(name: Annotated[str, "Name of the function to get"]) -> Function: 582 | """Get a function by its name""" 583 | function_address = ida_name.get_name_ea(ida_idaapi.BADADDR, name) 584 | if function_address == ida_idaapi.BADADDR: 585 | raise IDAError(f"No function found with name {name}") 586 | return get_function(function_address) 587 | 588 | 589 | @jsonrpc 590 | @idaread 591 | def get_function_by_address(address: Annotated[int, "Address of the function to get"]) -> Function: 592 | """Get a function by its address""" 593 | return get_function(address) 594 | 595 | 596 | @jsonrpc 597 | @idaread 598 | def get_current_address() -> int: 599 | """Get the address currently selected by the user""" 600 | return idaapi.get_screen_ea() 601 | 602 | 603 | @jsonrpc 604 | @idaread 605 | def get_current_function() -> Optional[Function]: 606 | """Get the function currently selected by the user""" 607 | return get_function(idaapi.get_screen_ea()) 608 | 609 | 610 | @jsonrpc 611 | @idaread 612 | def list_functions() -> list[Function]: 613 | """List all functions in the database""" 614 | return [get_function(address) for address in idautils.Functions()] 615 | 616 | 617 | @jsonrpc 618 | @idaread 619 | def decompile_function(address: Annotated[int, "Address of the function to decompile"]) -> str: 620 | """Decompile a function at the given address using Hex-Rays""" 621 | cfunc = decompile_checked(address) 622 | sv = cfunc.get_pseudocode() 623 | cfunc.get_eamap() 624 | pseudocode = "" 625 | for i, sl in enumerate(sv): 626 | sl: ida_kernwin.simpleline_t 627 | item = ida_hexrays.ctree_item_t() 628 | addr = None if i > 0 else cfunc.entry_ea 629 | if cfunc.get_line_item(sl.line, 1, False, None, item, None): 630 | ds = item.dstr().split(": ") 631 | if len(ds) == 2 and ds[0] is not None and ds[0] != "": 632 | addr = int(ds[0], 16) 633 | line = ida_lines.tag_remove(sl.line) 634 | if len(pseudocode) > 0: 635 | pseudocode += "\n" 636 | if addr is None: 637 | pseudocode += f"/* line: {i} */ {line}" 638 | else: 639 | pseudocode += f"/* line: {i}, address: {addr:#x} */ {line}" 640 | 641 | return pseudocode 642 | 643 | 644 | @jsonrpc 645 | @idaread 646 | def disassemble_function(address: Annotated[int, "Address of the function to disassemble"]) -> str: 647 | """Get assembly code (address: instruction; comment) for a function""" 648 | func = idaapi.get_func(address) 649 | if not func: 650 | raise IDAError(f"No function found at address {address}") 651 | 652 | # TODO: add labels 653 | disassembly = "" 654 | for address in ida_funcs.func_item_iterator_t(func): 655 | if len(disassembly) > 0: 656 | disassembly += "\n" 657 | disassembly += f"{address}: " 658 | disassembly += idaapi.generate_disasm_line(address, idaapi.GENDSM_REMOVE_TAGS) 659 | comment = idaapi.get_cmt(address, False) 660 | if not comment: 661 | comment = idaapi.get_cmt(address, True) 662 | if comment: 663 | disassembly += f"; {comment}" 664 | return disassembly 665 | 666 | 667 | @jsonrpc 668 | @idaread 669 | def get_entrypoints() -> List[Entrypoint]: 670 | """Get all entrypoints in the binary""" 671 | entrypoints = [] 672 | 673 | for i in range(ida_entry.get_entry_qty()): 674 | ordinal = i 675 | address = ida_entry.get_entry(ordinal) 676 | name = ida_name.get_name(address) 677 | 678 | entrypoints.append({"address": address, "name": name if name else f"entry_{ordinal}", "ordinal": ordinal}) 679 | 680 | return entrypoints 681 | 682 | 683 | @jsonrpc 684 | @idaread 685 | def get_function_blocks(address: Annotated[int, "Address of the function to get blocks for"]) -> List[Block]: 686 | """Get all basic blocks in a function""" 687 | func = idaapi.get_func(address) 688 | if not func: 689 | raise IDAError(f"No function found at address {address}") 690 | 691 | # Get control flow graph 692 | flow_chart = ida_gdl.FlowChart(func) 693 | blocks = [] 694 | 695 | for block in flow_chart: 696 | successor_addresses = [] 697 | for succ_idx in range(block.succ()): 698 | succ_block = block.succ(succ_idx) 699 | successor_addresses.append(succ_block.start_ea) 700 | 701 | blocks.append({"start_address": block.start_ea, "end_address": block.end_ea, "type": "block", "successor_addresses": successor_addresses}) # Default block type 702 | 703 | return blocks 704 | 705 | 706 | @jsonrpc 707 | @idaread 708 | def get_function_cfg(address: Annotated[int, "Address of the function to get CFG for"]) -> List[CFGNode]: 709 | """Get control flow graph for a function""" 710 | func = idaapi.get_func(address) 711 | if not func: 712 | raise IDAError(f"No function found at address {address}") 713 | 714 | # Get control flow graph 715 | flow_chart = ida_gdl.FlowChart(func) 716 | nodes = [] 717 | 718 | for i, block in enumerate(flow_chart): 719 | successors = [] 720 | for succ_idx in range(block.succ()): 721 | succ_block = block.succ(succ_idx) 722 | # Store the block ID as successor 723 | successors.append(succ_block.id) 724 | 725 | # Determine block type 726 | block_type = "normal" 727 | if i == 0: 728 | block_type = "entry" 729 | elif block.succ() == 0: 730 | block_type = "exit" 731 | 732 | nodes.append({"id": block.id, "start_address": block.start_ea, "end_address": block.end_ea, "type": block_type, "successors": successors}) 733 | 734 | return nodes 735 | 736 | 737 | @jsonrpc 738 | @idaread 739 | def get_xrefs_to(address: Annotated[int, "Address to get xrefs to"]) -> List[XrefEntry]: 740 | """Get all cross references to the given address""" 741 | xrefs = [ 742 | { 743 | 'from_address': xref.frm, 744 | 'to_address': xref.to, 745 | 'type': idautils.XrefTypeName(xref.type), 746 | 'function_name': ida_funcs.get_func_name(xref.frm) if xref.frm else 'global', 747 | } 748 | for xref in idautils.XrefsTo(address) 749 | ] 750 | return xrefs 751 | 752 | 753 | @jsonrpc 754 | @idaread 755 | def get_xrefs_from(address: Annotated[int, "Address to get xrefs from"]) -> List[XrefEntry]: 756 | """Get all cross references from the given address""" 757 | xrefs = [ 758 | { 759 | 'from_address': xref.frm, 760 | 'to_address': xref.to, 761 | 'type': idautils.XrefTypeName(xref.type), 762 | 'function_name': ida_funcs.get_func_name(xref.frm) if xref.frm else 'global', 763 | } 764 | for xref in idautils.XrefsFrom(address) 765 | ] 766 | return xrefs 767 | 768 | 769 | # 770 | # Modification functions 771 | # 772 | 773 | 774 | @jsonrpc 775 | @idawrite 776 | def set_decompiler_comment(address: Annotated[int, "Address in the function to set the comment for"], comment: Annotated[str, "Comment text (not shown in the disassembly)"]): 777 | """Set a comment for a given address in the function pseudocode""" 778 | 779 | # Reference: https://cyber.wtf/2019/03/22/using-ida-python-to-analyze-trickbot/ 780 | # Check if the address corresponds to a line 781 | cfunc = decompile_checked(address) 782 | 783 | # Special case for function entry comments 784 | if address == cfunc.entry_ea: 785 | idc.set_func_cmt(address, comment, True) 786 | cfunc.refresh_func_ctext() 787 | return 788 | 789 | eamap = cfunc.get_eamap() 790 | if address not in eamap: 791 | raise IDAError(f"Failed to set comment at {address}") 792 | nearest_ea = eamap[address][0].ea 793 | 794 | # Remove existing orphan comments 795 | if cfunc.has_orphan_cmts(): 796 | cfunc.del_orphan_cmts() 797 | cfunc.save_user_cmts() 798 | 799 | # Set the comment by trying all possible item types 800 | tl = idaapi.treeloc_t() 801 | tl.ea = nearest_ea 802 | for itp in range(idaapi.ITP_SEMI, idaapi.ITP_COLON): 803 | tl.itp = itp 804 | cfunc.set_user_cmt(tl, comment) 805 | cfunc.save_user_cmts() 806 | cfunc.refresh_func_ctext() 807 | if not cfunc.has_orphan_cmts(): 808 | return 809 | cfunc.del_orphan_cmts() 810 | cfunc.save_user_cmts() 811 | raise IDAError(f"Failed to set comment at {address}") 812 | 813 | 814 | @jsonrpc 815 | @idawrite 816 | def set_disassembly_comment(address: Annotated[int, "Address in the function to set the comment for"], comment: Annotated[str, "Comment text (not shown in the pseudocode)"]): 817 | """Set a comment for a given address in the function disassembly""" 818 | if not idaapi.set_cmt(address, comment, False): 819 | raise IDAError(f"Failed to set comment at {address}") 820 | 821 | 822 | @jsonrpc 823 | @idawrite 824 | def rename_local_variable( 825 | function_address: Annotated[int, "Address of the function containing the variable"], old_name: Annotated[str, "Current name of the variable"], new_name: Annotated[str, "New name for the variable"] 826 | ): 827 | """Rename a local variable in a function""" 828 | func = idaapi.get_func(function_address) 829 | if not func: 830 | raise IDAError(f"No function found at address {function_address}") 831 | if not ida_hexrays.rename_lvar(func.start_ea, old_name, new_name): 832 | raise IDAError(f"Failed to rename local variable {old_name} in function at {func.start_ea}") 833 | refresh_decompiler_ctext(func.start_ea) 834 | return True 835 | 836 | 837 | @jsonrpc 838 | @idawrite 839 | def rename_function(function_address: Annotated[int, "Address of the function to rename"], new_name: Annotated[str, "New name for the function"]): 840 | """Rename a function""" 841 | fn = idaapi.get_func(function_address) 842 | if not fn: 843 | raise IDAError(f"No function found at address {function_address}") 844 | result = idaapi.set_name(fn.start_ea, new_name) 845 | refresh_decompiler_ctext(fn.start_ea) 846 | return result 847 | 848 | 849 | @jsonrpc 850 | @idawrite 851 | def set_function_prototype(function_address: Annotated[int, "Address of the function"], prototype: Annotated[str, "New function prototype"]) -> bool: 852 | """Set a function's prototype""" 853 | fn = idaapi.get_func(function_address) 854 | if not fn: 855 | raise IDAError(f"No function found at address {function_address}") 856 | try: 857 | tif = ida_typeinf.tinfo_t() 858 | if not tif.get_named_type(ida_typeinf.get_idati(), prototype): 859 | if not tif.create_func(prototype): 860 | raise IDAError(f"Failed to parse prototype string: {prototype}") 861 | if not ida_typeinf.apply_tinfo(fn.start_ea, tif, ida_typeinf.TINFO_DEFINITE): 862 | raise IDAError(f"Failed to apply type") 863 | refresh_decompiler_ctext(fn.start_ea) 864 | return True 865 | except Exception as e: 866 | raise IDAError(f"Failed to parse prototype string: {prototype}. Error: {str(e)}") 867 | 868 | 869 | @jsonrpc 870 | @idawrite 871 | def set_local_variable_type( 872 | function_address: Annotated[int, "Address of the function containing the variable"], variable_name: Annotated[str, "Name of the variable"], new_type: Annotated[str, "New type for the variable"] 873 | ) -> bool: 874 | """Set a local variable's type""" 875 | try: 876 | new_tif = ida_typeinf.tinfo_t() 877 | if not new_tif.get_named_type(ida_typeinf.get_idati(), new_type): 878 | raise IDAError(f"Failed to parse type: {new_type}") 879 | except Exception as e: 880 | raise IDAError(f"Failed to parse type: {new_type}. Error: {str(e)}") 881 | 882 | fn = idaapi.get_func(function_address) 883 | if not fn: 884 | raise IDAError(f"No function found at address {function_address}") 885 | if not ida_hexrays.rename_lvar(fn.start_ea, variable_name, variable_name): 886 | raise IDAError(f"Failed to find local variable: {variable_name}") 887 | 888 | try: 889 | modifier = my_modifier_t(variable_name, new_tif) 890 | if not ida_hexrays.modify_user_lvars(fn.start_ea, modifier): 891 | raise IDAError(f"Failed to modify local variable: {variable_name}") 892 | refresh_decompiler_ctext(fn.start_ea) 893 | return True 894 | except Exception as e: 895 | raise IDAError(f"Failed to modify local variable: {variable_name}. Error: {str(e)}") 896 | 897 | 898 | @jsonrpc 899 | @idawrite 900 | def create_structure_type( 901 | name: Annotated[str, "Name of the new structure"], 902 | members: Annotated[List[Dict[str, str]], "List of structure members with name and type"], 903 | is_union: Annotated[bool, "Whether this is a union (True) or struct (False)"] = False, 904 | ) -> bool: 905 | """Create a new structure type""" 906 | try: 907 | # Check if structure with this name already exists 908 | existing_id = idc.get_struc_id(name) 909 | if existing_id != ida_idaapi.BADADDR: 910 | idc.del_struc(idc.get_struc(existing_id)) 911 | 912 | # Create new structure 913 | sid = idc.add_struc(ida_idaapi.BADADDR, name, is_union) 914 | if sid == ida_idaapi.BADADDR: 915 | raise IDAError(f"Failed to create structure {name}") 916 | 917 | sptr = idc.get_struc(sid) 918 | if not sptr: 919 | raise IDAError(f"Failed to get structure pointer for {name}") 920 | 921 | # Add members to structure 922 | for member in members: 923 | member_name = member.get("name", "") 924 | member_type = member.get("type", "") 925 | member_offset = -1 # Let IDA choose the next offset 926 | 927 | tif = ida_typeinf.tinfo_t() 928 | if not tif.get_named_type(ida_typeinf.get_idati(), member_type): 929 | # Try to create a basic type 930 | if not ida_typeinf.parse_decl(tif, ida_typeinf.get_idati(), f"{member_type};", ida_typeinf.PT_SIL): 931 | raise IDAError(f"Failed to parse type {member_type} for member {member_name}") 932 | 933 | # Add member 934 | if idc.add_struc_member(sptr, member_name, member_offset, ida_bytes.byteflag(), None, ida_typeinf.get_type_size(ida_typeinf.get_idati(), tif)) != 0: 935 | raise IDAError(f"Failed to add member {member_name} to structure {name}") 936 | 937 | # Set member type 938 | member_idx = idc.get_member_by_name(sptr, member_name) 939 | if member_idx is None: 940 | raise IDAError(f"Failed to get member index for {member_name}") 941 | 942 | member_ptr = idc.get_member(sptr, member_idx) 943 | if member_ptr is None: 944 | raise IDAError(f"Failed to get member pointer for {member_name}") 945 | 946 | if not ida_typeinf.set_member_tinfo(ida_typeinf.get_idati(), sptr, member_ptr, 0, tif, ida_typeinf.SET_MEMTI_COMPATIBLE): 947 | raise IDAError(f"Failed to set type for member {member_name}") 948 | 949 | return True 950 | except Exception as e: 951 | raise IDAError(f"Failed to create structure {name}. Error: {str(e)}") 952 | 953 | 954 | @jsonrpc 955 | @idaread 956 | def get_metadata() -> Metadata: 957 | """Get metadata about the current IDB""" 958 | return { 959 | "path": idaapi.get_input_file_path(), 960 | "module": idaapi.get_root_filename(), 961 | "base": hex(idaapi.get_imagebase()), 962 | "size": hex(get_image_size()), 963 | "md5": ida_nalt.retrieve_input_file_md5().hex(), 964 | "sha256": ida_nalt.retrieve_input_file_sha256().hex(), 965 | "crc32": hex(ida_nalt.retrieve_input_file_crc32()), 966 | "filesize": hex(ida_nalt.retrieve_input_file_size()), 967 | } 968 | 969 | 970 | @jsonrpc 971 | @idawrite 972 | def repl_idapython(content: Annotated[str, "IDAPython code to run"]) -> str: 973 | """Run IDAPython code and return the results with stdout/stderr captured.""" 974 | import sys 975 | import io 976 | import traceback 977 | 978 | stdout_capture, stderr_capture = io.StringIO(), io.StringIO() 979 | original_stdout, original_stderr = sys.stdout, sys.stderr 980 | sys.stdout, sys.stderr = stdout_capture, stderr_capture 981 | try: 982 | exec(content, globals()) 983 | result = "Success" 984 | except Exception as e: 985 | result = f"Error: {str(e)}\n{traceback.format_exc()}" 986 | finally: 987 | sys.stdout, sys.stderr = original_stdout, original_stderr 988 | 989 | response = "" 990 | if stdout_output := stdout_capture.getvalue(): 991 | response += f"\n{stdout_output}\n\n" 992 | if stderr_output := stderr_capture.getvalue(): 993 | response += f"\n{stderr_output}\n\n" 994 | if not stdout_output and not stderr_output: 995 | response += f"{result}" 996 | return response 997 | 998 | 999 | # 1000 | # Notes and multi-binary support functions 1001 | # 1002 | 1003 | 1004 | @jsonrpc 1005 | def add_note( 1006 | title: Annotated[str, "Title of the note"], 1007 | content: Annotated[str, "Content of the note"], 1008 | address: Annotated[Optional[int], "Address this note is related to (optional)"] = None, 1009 | tags: Annotated[Optional[str], "Comma-separated tags for this note"] = None, 1010 | ) -> int: 1011 | """Add a new analysis note for the current binary""" 1012 | 1013 | # Get current file metadata 1014 | metadata = get_metadata() 1015 | file_md5 = metadata["md5"] 1016 | 1017 | # Store file info if not already present 1018 | conn = sqlite3.connect(NOTES_DB) 1019 | cursor = conn.cursor() 1020 | 1021 | cursor.execute("SELECT * FROM files WHERE md5 = ?", (file_md5,)) 1022 | if not cursor.fetchone(): 1023 | cursor.execute( 1024 | "INSERT INTO files (md5, path, name, base_addr, size, sha256, crc32, filesize, last_accessed) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", 1025 | (file_md5, metadata["path"], metadata["module"], metadata["base"], metadata["size"], metadata["sha256"], metadata["crc32"], metadata["filesize"], int(time.time())), 1026 | ) 1027 | else: 1028 | # Update last accessed time 1029 | cursor.execute("UPDATE files SET last_accessed = ? WHERE md5 = ?", (int(time.time()), file_md5)) 1030 | 1031 | # Add note 1032 | timestamp = int(time.time()) 1033 | address_str = hex(address) if address is not None else None 1034 | 1035 | cursor.execute("INSERT INTO notes (file_md5, address, title, content, timestamp, tags) VALUES (?, ?, ?, ?, ?, ?)", (file_md5, address_str, title, content, timestamp, tags)) 1036 | 1037 | note_id = cursor.lastrowid 1038 | conn.commit() 1039 | conn.close() 1040 | 1041 | return note_id 1042 | 1043 | 1044 | @jsonrpc 1045 | def update_note( 1046 | note_id: Annotated[int, "ID of the note to update"], 1047 | title: Annotated[Optional[str], "New title (or None to keep current)"] = None, 1048 | content: Annotated[Optional[str], "New content (or None to keep current)"] = None, 1049 | tags: Annotated[Optional[str], "New tags (or None to keep current)"] = None, 1050 | ) -> bool: 1051 | """Update an existing note""" 1052 | 1053 | conn = sqlite3.connect(NOTES_DB) 1054 | cursor = conn.cursor() 1055 | 1056 | # Get current note 1057 | cursor.execute("SELECT * FROM notes WHERE id = ?", (note_id,)) 1058 | note = cursor.fetchone() 1059 | if not note: 1060 | conn.close() 1061 | raise IDAError(f"Note with ID {note_id} not found") 1062 | 1063 | # Build update query 1064 | update_parts = [] 1065 | params = [] 1066 | 1067 | if title is not None: 1068 | update_parts.append("title = ?") 1069 | params.append(title) 1070 | 1071 | if content is not None: 1072 | update_parts.append("content = ?") 1073 | params.append(content) 1074 | 1075 | if tags is not None: 1076 | update_parts.append("tags = ?") 1077 | params.append(tags) 1078 | 1079 | if not update_parts: 1080 | conn.close() 1081 | return False # Nothing to update 1082 | 1083 | # Update timestamp 1084 | update_parts.append("timestamp = ?") 1085 | params.append(int(time.time())) 1086 | 1087 | # Execute update 1088 | params.append(note_id) 1089 | cursor.execute(f"UPDATE notes SET {', '.join(update_parts)} WHERE id = ?", params) 1090 | 1091 | conn.commit() 1092 | conn.close() 1093 | 1094 | return True 1095 | 1096 | 1097 | @jsonrpc 1098 | def get_notes( 1099 | file_md5: Annotated[Optional[str], "MD5 of file to get notes for (or None for current file)"] = None, 1100 | address: Annotated[Optional[int], "Get notes for specific address (optional)"] = None, 1101 | tag: Annotated[Optional[str], "Filter notes by tag (optional)"] = None, 1102 | ) -> List[Note]: 1103 | """Get analysis notes for a binary""" 1104 | 1105 | # If no file_md5 specified, use current file 1106 | if file_md5 is None: 1107 | metadata = get_metadata() 1108 | file_md5 = metadata["md5"] 1109 | 1110 | conn = sqlite3.connect(NOTES_DB) 1111 | conn.row_factory = sqlite3.Row 1112 | cursor = conn.cursor() 1113 | 1114 | query = "SELECT * FROM notes WHERE file_md5 = ?" 1115 | params = [file_md5] 1116 | 1117 | if address is not None: 1118 | query += " AND address = ?" 1119 | params.append(hex(address)) 1120 | 1121 | if tag is not None: 1122 | # Search for tag in comma-separated list 1123 | query += " AND tags LIKE ?" 1124 | params.append(f"%{tag}%") 1125 | 1126 | query += " ORDER BY timestamp DESC" 1127 | 1128 | cursor.execute(query, params) 1129 | notes = [dict(row) for row in cursor.fetchall()] 1130 | 1131 | conn.close() 1132 | 1133 | return notes 1134 | 1135 | 1136 | @jsonrpc 1137 | def delete_note(note_id: Annotated[int, "ID of the note to delete"]) -> bool: 1138 | """Delete an analysis note""" 1139 | 1140 | conn = sqlite3.connect(NOTES_DB) 1141 | cursor = conn.cursor() 1142 | 1143 | cursor.execute("DELETE FROM notes WHERE id = ?", (note_id,)) 1144 | deleted = cursor.rowcount > 0 1145 | 1146 | conn.commit() 1147 | conn.close() 1148 | 1149 | return deleted 1150 | 1151 | 1152 | @jsonrpc 1153 | def list_analyzed_files() -> List[FileInfo]: 1154 | """List all previously analyzed files""" 1155 | 1156 | conn = sqlite3.connect(NOTES_DB) 1157 | conn.row_factory = sqlite3.Row 1158 | cursor = conn.cursor() 1159 | 1160 | cursor.execute("SELECT * FROM files ORDER BY last_accessed DESC") 1161 | files = [dict(row) for row in cursor.fetchall()] 1162 | 1163 | conn.close() 1164 | 1165 | return files 1166 | 1167 | 1168 | class MCP(idaapi.plugin_t): 1169 | flags = idaapi.PLUGIN_KEEP 1170 | comment = "Model Context Protocol Plugin" 1171 | help = "Enables MCP integration for remotely controlling IDA Pro" 1172 | wanted_name = PLUGIN_NAME 1173 | wanted_hotkey = PLUGIN_HOTKEY 1174 | 1175 | def init(self): 1176 | self.server = Server() 1177 | print(f"[{PLUGIN_NAME}] Plugin loaded, use Edit -> Plugins -> {PLUGIN_NAME} ({PLUGIN_HOTKEY}) to start the server") 1178 | return idaapi.PLUGIN_KEEP 1179 | 1180 | def run(self, args): 1181 | self.server.start() 1182 | 1183 | def term(self): 1184 | self.server.stop() 1185 | 1186 | 1187 | def PLUGIN_ENTRY(): 1188 | return MCP() 1189 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 1 3 | requires-python = ">=3.13" 4 | 5 | [[package]] 6 | name = "annotated-types" 7 | version = "0.7.0" 8 | source = { registry = "https://pypi.org/simple" } 9 | sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } 10 | wheels = [ 11 | { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, 12 | ] 13 | 14 | [[package]] 15 | name = "anyio" 16 | version = "4.9.0" 17 | source = { registry = "https://pypi.org/simple" } 18 | dependencies = [ 19 | { name = "idna" }, 20 | { name = "sniffio" }, 21 | ] 22 | sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } 23 | wheels = [ 24 | { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, 25 | ] 26 | 27 | [[package]] 28 | name = "blinker" 29 | version = "1.9.0" 30 | source = { registry = "https://pypi.org/simple" } 31 | sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460 } 32 | wheels = [ 33 | { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 }, 34 | ] 35 | 36 | [[package]] 37 | name = "certifi" 38 | version = "2025.1.31" 39 | source = { registry = "https://pypi.org/simple" } 40 | sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } 41 | wheels = [ 42 | { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, 43 | ] 44 | 45 | [[package]] 46 | name = "click" 47 | version = "8.1.8" 48 | source = { registry = "https://pypi.org/simple" } 49 | dependencies = [ 50 | { name = "colorama", marker = "sys_platform == 'win32'" }, 51 | ] 52 | sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } 53 | wheels = [ 54 | { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, 55 | ] 56 | 57 | [[package]] 58 | name = "colorama" 59 | version = "0.4.6" 60 | source = { registry = "https://pypi.org/simple" } 61 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 62 | wheels = [ 63 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 64 | ] 65 | 66 | [[package]] 67 | name = "fastmcp" 68 | version = "0.4.1" 69 | source = { registry = "https://pypi.org/simple" } 70 | dependencies = [ 71 | { name = "httpx" }, 72 | { name = "mcp" }, 73 | { name = "pydantic" }, 74 | { name = "pydantic-settings" }, 75 | { name = "python-dotenv" }, 76 | { name = "typer" }, 77 | ] 78 | sdist = { url = "https://files.pythonhosted.org/packages/6f/84/17b549133263d7ee77141970769bbc401525526bf1af043ea6842bce1a55/fastmcp-0.4.1.tar.gz", hash = "sha256:713ad3b8e4e04841c9e2f3ca022b053adb89a286ceffad0d69ae7b56f31cbe64", size = 785575 } 79 | wheels = [ 80 | { url = "https://files.pythonhosted.org/packages/79/0b/008a340435fe8f0879e9d608f48af2737ad48440e09bd33b83b3fd03798b/fastmcp-0.4.1-py3-none-any.whl", hash = "sha256:664b42c376fb89ec90a50c9433f5a1f4d24f36696d6c41b024b427ae545f9619", size = 35282 }, 81 | ] 82 | 83 | [[package]] 84 | name = "flask" 85 | version = "3.1.0" 86 | source = { registry = "https://pypi.org/simple" } 87 | dependencies = [ 88 | { name = "blinker" }, 89 | { name = "click" }, 90 | { name = "itsdangerous" }, 91 | { name = "jinja2" }, 92 | { name = "werkzeug" }, 93 | ] 94 | sdist = { url = "https://files.pythonhosted.org/packages/89/50/dff6380f1c7f84135484e176e0cac8690af72fa90e932ad2a0a60e28c69b/flask-3.1.0.tar.gz", hash = "sha256:5f873c5184c897c8d9d1b05df1e3d01b14910ce69607a117bd3277098a5836ac", size = 680824 } 95 | wheels = [ 96 | { url = "https://files.pythonhosted.org/packages/af/47/93213ee66ef8fae3b93b3e29206f6b251e65c97bd91d8e1c5596ef15af0a/flask-3.1.0-py3-none-any.whl", hash = "sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136", size = 102979 }, 97 | ] 98 | 99 | [[package]] 100 | name = "h11" 101 | version = "0.14.0" 102 | source = { registry = "https://pypi.org/simple" } 103 | sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } 104 | wheels = [ 105 | { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, 106 | ] 107 | 108 | [[package]] 109 | name = "httpcore" 110 | version = "1.0.7" 111 | source = { registry = "https://pypi.org/simple" } 112 | dependencies = [ 113 | { name = "certifi" }, 114 | { name = "h11" }, 115 | ] 116 | sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } 117 | wheels = [ 118 | { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, 119 | ] 120 | 121 | [[package]] 122 | name = "httpx" 123 | version = "0.28.1" 124 | source = { registry = "https://pypi.org/simple" } 125 | dependencies = [ 126 | { name = "anyio" }, 127 | { name = "certifi" }, 128 | { name = "httpcore" }, 129 | { name = "idna" }, 130 | ] 131 | sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } 132 | wheels = [ 133 | { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, 134 | ] 135 | 136 | [[package]] 137 | name = "httpx-sse" 138 | version = "0.4.0" 139 | source = { registry = "https://pypi.org/simple" } 140 | sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } 141 | wheels = [ 142 | { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, 143 | ] 144 | 145 | [[package]] 146 | name = "idna" 147 | version = "3.10" 148 | source = { registry = "https://pypi.org/simple" } 149 | sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } 150 | wheels = [ 151 | { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, 152 | ] 153 | 154 | [[package]] 155 | name = "itsdangerous" 156 | version = "2.2.0" 157 | source = { registry = "https://pypi.org/simple" } 158 | sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 } 159 | wheels = [ 160 | { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 }, 161 | ] 162 | 163 | [[package]] 164 | name = "jinja2" 165 | version = "3.1.6" 166 | source = { registry = "https://pypi.org/simple" } 167 | dependencies = [ 168 | { name = "markupsafe" }, 169 | ] 170 | sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } 171 | wheels = [ 172 | { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, 173 | ] 174 | 175 | [[package]] 176 | name = "markdown-it-py" 177 | version = "3.0.0" 178 | source = { registry = "https://pypi.org/simple" } 179 | dependencies = [ 180 | { name = "mdurl" }, 181 | ] 182 | sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } 183 | wheels = [ 184 | { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, 185 | ] 186 | 187 | [[package]] 188 | name = "markupsafe" 189 | version = "3.0.2" 190 | source = { registry = "https://pypi.org/simple" } 191 | sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } 192 | wheels = [ 193 | { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, 194 | { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, 195 | { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, 196 | { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, 197 | { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, 198 | { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, 199 | { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, 200 | { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, 201 | { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, 202 | { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, 203 | { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, 204 | { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, 205 | { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, 206 | { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, 207 | { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, 208 | { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, 209 | { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, 210 | { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, 211 | { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, 212 | { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, 213 | ] 214 | 215 | [[package]] 216 | name = "mcp" 217 | version = "1.5.0" 218 | source = { registry = "https://pypi.org/simple" } 219 | dependencies = [ 220 | { name = "anyio" }, 221 | { name = "httpx" }, 222 | { name = "httpx-sse" }, 223 | { name = "pydantic" }, 224 | { name = "pydantic-settings" }, 225 | { name = "sse-starlette" }, 226 | { name = "starlette" }, 227 | { name = "uvicorn" }, 228 | ] 229 | sdist = { url = "https://files.pythonhosted.org/packages/6d/c9/c55764824e893fdebe777ac7223200986a275c3191dba9169f8eb6d7c978/mcp-1.5.0.tar.gz", hash = "sha256:5b2766c05e68e01a2034875e250139839498c61792163a7b221fc170c12f5aa9", size = 159128 } 230 | wheels = [ 231 | { url = "https://files.pythonhosted.org/packages/c1/d1/3ff566ecf322077d861f1a68a1ff025cad337417bd66ad22a7c6f7dfcfaf/mcp-1.5.0-py3-none-any.whl", hash = "sha256:51c3f35ce93cb702f7513c12406bbea9665ef75a08db909200b07da9db641527", size = 73734 }, 232 | ] 233 | 234 | [[package]] 235 | name = "mdurl" 236 | version = "0.1.2" 237 | source = { registry = "https://pypi.org/simple" } 238 | sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } 239 | wheels = [ 240 | { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, 241 | ] 242 | 243 | [[package]] 244 | name = "pcm" 245 | version = "0.1.0" 246 | source = { editable = "." } 247 | dependencies = [ 248 | { name = "fastmcp" }, 249 | { name = "flask" }, 250 | ] 251 | 252 | [package.metadata] 253 | requires-dist = [ 254 | { name = "fastmcp", specifier = ">=0.4.1" }, 255 | { name = "flask", specifier = ">=3.1.0" }, 256 | ] 257 | 258 | [[package]] 259 | name = "pydantic" 260 | version = "2.10.6" 261 | source = { registry = "https://pypi.org/simple" } 262 | dependencies = [ 263 | { name = "annotated-types" }, 264 | { name = "pydantic-core" }, 265 | { name = "typing-extensions" }, 266 | ] 267 | sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 } 268 | wheels = [ 269 | { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 }, 270 | ] 271 | 272 | [[package]] 273 | name = "pydantic-core" 274 | version = "2.27.2" 275 | source = { registry = "https://pypi.org/simple" } 276 | dependencies = [ 277 | { name = "typing-extensions" }, 278 | ] 279 | sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } 280 | wheels = [ 281 | { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, 282 | { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, 283 | { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, 284 | { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, 285 | { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, 286 | { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, 287 | { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, 288 | { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, 289 | { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, 290 | { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, 291 | { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, 292 | { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, 293 | { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, 294 | { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, 295 | ] 296 | 297 | [[package]] 298 | name = "pydantic-settings" 299 | version = "2.8.1" 300 | source = { registry = "https://pypi.org/simple" } 301 | dependencies = [ 302 | { name = "pydantic" }, 303 | { name = "python-dotenv" }, 304 | ] 305 | sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 } 306 | wheels = [ 307 | { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 }, 308 | ] 309 | 310 | [[package]] 311 | name = "pygments" 312 | version = "2.19.1" 313 | source = { registry = "https://pypi.org/simple" } 314 | sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } 315 | wheels = [ 316 | { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, 317 | ] 318 | 319 | [[package]] 320 | name = "python-dotenv" 321 | version = "1.1.0" 322 | source = { registry = "https://pypi.org/simple" } 323 | sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 } 324 | wheels = [ 325 | { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 }, 326 | ] 327 | 328 | [[package]] 329 | name = "rich" 330 | version = "13.9.4" 331 | source = { registry = "https://pypi.org/simple" } 332 | dependencies = [ 333 | { name = "markdown-it-py" }, 334 | { name = "pygments" }, 335 | ] 336 | sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } 337 | wheels = [ 338 | { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, 339 | ] 340 | 341 | [[package]] 342 | name = "shellingham" 343 | version = "1.5.4" 344 | source = { registry = "https://pypi.org/simple" } 345 | sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } 346 | wheels = [ 347 | { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, 348 | ] 349 | 350 | [[package]] 351 | name = "sniffio" 352 | version = "1.3.1" 353 | source = { registry = "https://pypi.org/simple" } 354 | sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } 355 | wheels = [ 356 | { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, 357 | ] 358 | 359 | [[package]] 360 | name = "sse-starlette" 361 | version = "2.2.1" 362 | source = { registry = "https://pypi.org/simple" } 363 | dependencies = [ 364 | { name = "anyio" }, 365 | { name = "starlette" }, 366 | ] 367 | sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } 368 | wheels = [ 369 | { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, 370 | ] 371 | 372 | [[package]] 373 | name = "starlette" 374 | version = "0.46.1" 375 | source = { registry = "https://pypi.org/simple" } 376 | dependencies = [ 377 | { name = "anyio" }, 378 | ] 379 | sdist = { url = "https://files.pythonhosted.org/packages/04/1b/52b27f2e13ceedc79a908e29eac426a63465a1a01248e5f24aa36a62aeb3/starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230", size = 2580102 } 380 | wheels = [ 381 | { url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995 }, 382 | ] 383 | 384 | [[package]] 385 | name = "typer" 386 | version = "0.15.2" 387 | source = { registry = "https://pypi.org/simple" } 388 | dependencies = [ 389 | { name = "click" }, 390 | { name = "rich" }, 391 | { name = "shellingham" }, 392 | { name = "typing-extensions" }, 393 | ] 394 | sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711 } 395 | wheels = [ 396 | { url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061 }, 397 | ] 398 | 399 | [[package]] 400 | name = "typing-extensions" 401 | version = "4.13.0" 402 | source = { registry = "https://pypi.org/simple" } 403 | sdist = { url = "https://files.pythonhosted.org/packages/0e/3e/b00a62db91a83fff600de219b6ea9908e6918664899a2d85db222f4fbf19/typing_extensions-4.13.0.tar.gz", hash = "sha256:0a4ac55a5820789d87e297727d229866c9650f6521b64206413c4fbada24d95b", size = 106520 } 404 | wheels = [ 405 | { url = "https://files.pythonhosted.org/packages/e0/86/39b65d676ec5732de17b7e3c476e45bb80ec64eb50737a8dce1a4178aba1/typing_extensions-4.13.0-py3-none-any.whl", hash = "sha256:c8dd92cc0d6425a97c18fbb9d1954e5ff92c1ca881a309c45f06ebc0b79058e5", size = 45683 }, 406 | ] 407 | 408 | [[package]] 409 | name = "uvicorn" 410 | version = "0.34.0" 411 | source = { registry = "https://pypi.org/simple" } 412 | dependencies = [ 413 | { name = "click" }, 414 | { name = "h11" }, 415 | ] 416 | sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } 417 | wheels = [ 418 | { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, 419 | ] 420 | 421 | [[package]] 422 | name = "werkzeug" 423 | version = "3.1.3" 424 | source = { registry = "https://pypi.org/simple" } 425 | dependencies = [ 426 | { name = "markupsafe" }, 427 | ] 428 | sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925 } 429 | wheels = [ 430 | { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498 }, 431 | ] 432 | --------------------------------------------------------------------------------
Loading files...
Path:
MD5:
SHA256:
Size:
Base Address:
Last Accessed:
Loading notes...
Searching...
Loading tags...