Compare commits

...

3 Commits

Author SHA1 Message Date
290252c0db feat: Add feedback collection via fastmcp-feedback
Some checks are pending
Build Ghidra Plugin / build (push) Waiting to run
Allows AI clients to submit feedback about tool quality, report issues,
and track statistics. Persists to ~/.ghydramcp/feedback.db (SQLite).

- Add fastmcp-feedback dependency
- Add feedback_enabled / feedback_db_path config fields
- Wire add_feedback_tools() into create_server() with graceful fallback
- Show feedback path in startup banner

Disable with GHYDRA_FEEDBACK=false
2026-01-30 10:09:26 -07:00
70f226f68e feat: Add response size guard with field projection and server-side grep
return_all=True on large binaries (1800+ functions) produced 72K char
responses that exceeded the MCP tool result limit. Instead of truncating,
oversized responses now return a structured summary with sample data,
available fields, and actionable instructions for narrowing the query.

Three layers of filtering:
- Server-side grep: Jython HTTP handlers filter during Ghidra iteration
- Field projection: jq-style key selection strips unneeded fields
- Token budget guard: responses exceeding 8k tokens return a summary

New files: core/filtering.py (project_fields, apply_grep, estimate_and_guard)
Modified: config, pagination, base mixin, all 5 domain mixins, headless server
2026-01-29 16:07:06 -07:00
4c112a2421 feat(headless): Expand Python server to full API parity
Rewrite GhydraMCPServer.py from 348 to 2138 lines, implementing all 45
routes that the MCP client expects. Previously, most endpoints returned
{"error": "Not found"}, breaking tools like data_list, xrefs_list, and
memory_read.

Key changes:
- Regex-based routing table with method-aware dispatch
- Thread-safe Ghidra transactions via threading.Lock()
- Full read endpoints: functions, data, strings, memory, xrefs, structs
- Full write endpoints: rename, comment, signature, create function/data
- Analysis endpoints: callgraph traversal, dataflow, run analysis
- Jython/Python 2 compatible (no f-strings, type hints, or walrus ops)

Tested with Docker build and curl against all major endpoint groups.
MCP client integration verified working.
2026-01-27 16:23:27 -07:00
15 changed files with 2729 additions and 231 deletions

File diff suppressed because it is too large Load Diff

197
docker/README.md Normal file
View File

@ -0,0 +1,197 @@
# GhydraMCP Docker Setup
This directory contains Docker configuration for running GhydraMCP in headless mode.
## Quick Start
```bash
# Build the image
docker build -t ghydramcp:latest -f docker/Dockerfile .
# Analyze a binary
docker run -p 8192:8192 -v /path/to/binaries:/binaries ghydramcp /binaries/sample.exe
# Check API health
curl http://localhost:8192/
```
## Architecture
The Docker container includes:
1. **Ghidra 11.4.2** - Full headless installation
2. **GhydraMCP Extension** - The Java plugin (installed in Extensions/)
3. **GhydraMCPServer.py** - Headless HTTP server (Jython, full API parity)
### Why Two HTTP Servers?
The GhydraMCP plugin (`GhydraMCPPlugin.java`) is a full Ghidra GUI plugin that requires:
- Ghidra's `PluginTool` framework
- `ProgramManager` service for program access
- GUI event handling
These GUI services don't exist in headless mode. Instead, the container uses `GhydraMCPServer.py`, a Jython script that:
- Runs via `analyzeHeadless -postScript`
- Has direct access to `currentProgram` from the script context
- Provides **full API parity** with the GUI plugin (45 routes)
- Supports all read and write operations
### Available Endpoints (Headless Mode)
The headless server implements the complete GhydraMCP HTTP API:
| Category | Endpoints | Description |
|----------|-----------|-------------|
| **Info** | `GET /`, `/info`, `/program` | API info, program metadata |
| **Functions** | `GET /functions`, `/functions/{addr}`, `/functions/by-name/{name}` | List and detail |
| **Decompile** | `GET /functions/{addr}/decompile`, `/functions/by-name/{name}/decompile` | C pseudocode |
| **Disassembly** | `GET /functions/{addr}/disassembly`, `/functions/by-name/{name}/disassembly` | Assembly listing |
| **Data** | `GET /data`, `/strings` | Defined data and strings |
| **Memory** | `GET /memory`, `/memory/blocks` | Read bytes, list segments |
| **Xrefs** | `GET /xrefs` | Cross-references (to/from) |
| **Structs** | `GET /structs` | Data type structures |
| **Symbols** | `GET /symbols`, `/imports`, `/exports` | Symbol tables |
| **Analysis** | `GET /analysis/callgraph`, `/analysis/dataflow` | Static analysis |
| **Write Ops** | `PATCH /functions/*`, `POST /data`, `POST /structs/*` | Rename, annotate, create |
See [GHIDRA_HTTP_API.md](../GHIDRA_HTTP_API.md) for the complete API specification.
## Container Modes
### Headless Mode (Default)
Imports a binary, analyzes it, and starts the HTTP API server:
```bash
docker run -p 8192:8192 \
-v ./samples:/binaries \
ghydramcp /binaries/sample.exe
```
### Server Mode
Opens an existing project and program:
```bash
docker run -p 8192:8192 \
-e GHYDRA_MODE=server \
-v ./projects:/projects \
ghydramcp program_name
```
### Analyze Mode
Imports and analyzes without starting HTTP server:
```bash
docker run \
-e GHYDRA_MODE=analyze \
-v ./samples:/binaries \
-v ./projects:/projects \
ghydramcp /binaries/sample.exe
```
### Shell Mode
Interactive debugging:
```bash
docker run -it \
-e GHYDRA_MODE=shell \
ghydramcp
```
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `GHYDRA_MODE` | `headless` | Container mode (headless, server, analyze, shell) |
| `GHYDRA_PORT` | `8192` | HTTP API port |
| `GHYDRA_MAXMEM` | `2G` | JVM heap memory |
| `PROJECT_DIR` | `/projects` | Ghidra project directory |
| `PROJECT_NAME` | `GhydraMCP` | Ghidra project name |
## Docker Compose
Use docker-compose for easier management:
```bash
# Development mode (hot-reload scripts)
docker compose --profile dev up ghydramcp-dev
# Production mode
docker compose --profile prod up ghydramcp
# Interactive shell
docker compose --profile debug run --rm ghydramcp-shell
```
## MCP Integration
The GhydraMCP Python server includes Docker management tools:
```python
# Check Docker status
await docker_status()
# Start container for a binary
await docker_start(binary_path="/path/to/binary.exe", port=8192)
# Wait for container to be ready
await docker_wait(port=8192, timeout=300)
# Automatic mode - starts container if no Ghidra available
await docker_auto_start(binary_path="/path/to/binary.exe")
# Get container logs
await docker_logs("ghydramcp-server")
# Stop container
await docker_stop("ghydramcp-server")
```
## Building
```bash
# Using Make
make build
# Using Docker directly
docker build -t ghydramcp:latest -f docker/Dockerfile .
# Build with specific Ghidra version
docker build -t ghydramcp:latest \
--build-arg GHIDRA_VERSION=11.4.2 \
--build-arg GHIDRA_DATE=20250826 \
-f docker/Dockerfile .
```
## Troubleshooting
### Container starts but API doesn't respond
Analysis takes time. Monitor progress with:
```bash
docker logs -f ghydramcp-server
```
### Port already in use
Stop existing containers:
```bash
docker stop $(docker ps -q --filter "name=ghydramcp")
```
### Memory issues with large binaries
Increase JVM heap:
```bash
docker run -e GHYDRA_MAXMEM=4G -p 8192:8192 ghydramcp /binaries/large.exe
```
### Permission denied on volumes
The container runs as user `ghidra` (UID 1001). Ensure volume permissions:
```bash
sudo chown -R 1001:1001 /path/to/binaries
```

View File

@ -11,6 +11,7 @@ dependencies = [
"mcp>=1.22.0", "mcp>=1.22.0",
"requests>=2.32.3", "requests>=2.32.3",
"fastmcp>=2.0.0", "fastmcp>=2.0.0",
"fastmcp-feedback>=1.0.0",
] ]
[project.scripts] [project.scripts]

View File

@ -72,9 +72,24 @@ class GhydraConfig:
cursor_ttl_seconds: int = 300 # 5 minutes cursor_ttl_seconds: int = 300 # 5 minutes
max_cursors_per_session: int = 100 max_cursors_per_session: int = 100
# Response size limits (for return_all guard)
max_response_tokens: int = 8000 # Hard budget — guard triggers above this
large_response_threshold: int = 4000 # Warn above this in normal pagination
# Expected API version # Expected API version
expected_api_version: int = 2 expected_api_version: int = 2
# Feedback collection
feedback_enabled: bool = field(
default_factory=lambda: os.environ.get("GHYDRA_FEEDBACK", "true").lower() == "true"
)
feedback_db_path: str = field(
default_factory=lambda: os.environ.get(
"GHYDRA_FEEDBACK_DB",
str(Path.home() / ".ghydramcp" / "feedback.db"),
)
)
# Resource caps for enumeration endpoints # Resource caps for enumeration endpoints
resource_caps: dict = field(default_factory=lambda: { resource_caps: dict = field(default_factory=lambda: {
"functions": 1000, "functions": 1000,

View File

@ -24,6 +24,11 @@ from .progress import (
report_progress, report_progress,
report_step, report_step,
) )
from .filtering import (
project_fields,
apply_grep,
estimate_and_guard,
)
from .logging import ( from .logging import (
log_info, log_info,
log_debug, log_debug,
@ -50,6 +55,10 @@ __all__ = [
"ProgressReporter", "ProgressReporter",
"report_progress", "report_progress",
"report_step", "report_step",
# Filtering
"project_fields",
"apply_grep",
"estimate_and_guard",
# Logging # Logging
"log_info", "log_info",
"log_debug", "log_debug",

View File

@ -0,0 +1,208 @@
"""Field projection and response size guard for GhydraMCP.
Provides jq-style field projection, grep filtering, and token budget
enforcement to prevent oversized MCP tool results.
"""
import json
import re
import time
from typing import Any, Dict, Optional
from ..config import get_config
# Token estimation (same ratio as pagination.py)
TOKEN_ESTIMATION_RATIO = 4.0
def project_fields(items: list, fields: list[str]) -> list:
"""Select only specified keys from each item (jq-style projection).
Works on dicts and strings. For dicts, returns only the requested
keys. For non-dict items (e.g. lines of decompiled code), returns
them unchanged.
Args:
items: List of items to project
fields: List of field names to keep
Returns:
List of projected items
"""
if not fields or not items:
return items
field_set = set(fields)
projected = []
for item in items:
if isinstance(item, dict):
projected.append({k: v for k, v in item.items() if k in field_set})
else:
projected.append(item)
return projected
def apply_grep(items: list, pattern: str, ignorecase: bool = True) -> list:
"""Filter items by regex pattern across all string values.
Searches all string-coercible values in each item. For dicts,
searches all values recursively. For strings, searches directly.
Args:
items: List of items to filter
pattern: Regex pattern string
ignorecase: Case-insensitive matching (default True)
Returns:
Filtered list of matching items
"""
if not pattern or not items:
return items
flags = re.IGNORECASE if ignorecase else 0
compiled = re.compile(pattern, flags)
return [item for item in items if _matches(item, compiled)]
def _matches(item: Any, pattern: re.Pattern, depth: int = 0) -> bool:
"""Check if item matches pattern (recursive for nested structures)."""
if depth > 10:
return False
if isinstance(item, dict):
for value in item.values():
if isinstance(value, str) and pattern.search(value):
return True
elif isinstance(value, (int, float)):
if pattern.search(str(value)):
return True
elif isinstance(value, (dict, list, tuple)):
if _matches(value, pattern, depth + 1):
return True
return False
elif isinstance(item, (list, tuple)):
return any(_matches(i, pattern, depth + 1) for i in item)
elif isinstance(item, str):
return bool(pattern.search(item))
else:
return bool(pattern.search(str(item)))
def _estimate_tokens(data: Any) -> int:
"""Estimate token count from serialized JSON size."""
text = json.dumps(data, default=str)
return int(len(text) / TOKEN_ESTIMATION_RATIO)
def _extract_available_fields(items: list) -> list[str]:
"""Extract the set of field names from the first few dict items."""
fields = set()
for item in items[:5]:
if isinstance(item, dict):
fields.update(item.keys())
# Remove internal/HATEOAS fields
fields.discard("_links")
return sorted(fields)
def estimate_and_guard(
data: list,
tool_name: str,
budget: Optional[int] = None,
query_hints: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Check if data exceeds token budget; return guard response if so.
If data fits within budget, returns None (caller should proceed
normally). If data exceeds budget, returns a structured summary
with instructions for narrowing the query.
Args:
data: The full data list to check
tool_name: Name of the tool (for hint messages)
budget: Token budget override (defaults to config.max_response_tokens)
query_hints: Original query params (for building hint commands)
Returns:
None if data fits within budget, or a guard response dict
"""
config = get_config()
if budget is None:
budget = config.max_response_tokens
estimated = _estimate_tokens(data)
if estimated <= budget:
return None
# Build sample from first 3 items
sample = data[:3]
available_fields = _extract_available_fields(data)
# Build actionable hints based on the tool name
hints = _build_hints(tool_name, available_fields, query_hints)
return {
"success": True,
"guarded": True,
"total_count": len(data),
"estimated_tokens": estimated,
"budget": budget,
"sample": sample,
"available_fields": available_fields,
"message": (
"Response too large (%d items, ~%s tokens, budget: %s). "
"To read this data:\n%s"
) % (
len(data),
_format_tokens(estimated),
_format_tokens(budget),
hints,
),
"timestamp": int(time.time() * 1000),
}
def _format_tokens(n: int) -> str:
"""Format token count for display (e.g. 45000 -> '45k')."""
if n >= 1000:
return "%dk" % (n // 1000)
return str(n)
def _build_hints(
tool_name: str,
available_fields: list[str],
query_hints: Optional[Dict[str, Any]] = None,
) -> str:
"""Build actionable hint text for the guard message."""
lines = []
# Pagination hint
lines.append(
" - Paginate: %s(page_size=50) then cursor_next(cursor_id='...')"
% tool_name
)
# Grep hint
grep_example = "main" if "functions" in tool_name else ".*pattern.*"
lines.append(
" - Filter: %s(grep='%s')" % (tool_name, grep_example)
)
# Fields hint (only if we have dict items with fields)
if available_fields:
short_fields = available_fields[:2]
lines.append(
" - Project: %s(fields=%s)" % (tool_name, short_fields)
)
# Combined hint
if available_fields:
lines.append(
" - Combine: %s(grep='...', fields=%s, return_all=True)"
% (tool_name, available_fields[:2])
)
return "\n".join(lines)

View File

@ -14,6 +14,7 @@ from threading import Lock
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from ..config import get_config from ..config import get_config
from .filtering import project_fields, estimate_and_guard
# ReDoS Protection Configuration # ReDoS Protection Configuration
@ -393,8 +394,9 @@ def paginate_response(
grep: Optional[str] = None, grep: Optional[str] = None,
grep_ignorecase: bool = True, grep_ignorecase: bool = True,
return_all: bool = False, return_all: bool = False,
fields: Optional[List[str]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Create a paginated response with optional grep filtering. """Create a paginated response with optional grep filtering and field projection.
Args: Args:
data: Full result list to paginate data: Full result list to paginate
@ -404,7 +406,8 @@ def paginate_response(
page_size: Items per page (default: 50, max: 500) page_size: Items per page (default: 50, max: 500)
grep: Optional regex pattern to filter results grep: Optional regex pattern to filter results
grep_ignorecase: Case-insensitive grep (default: True) grep_ignorecase: Case-insensitive grep (default: True)
return_all: Bypass pagination and return all results (with warning) return_all: Bypass pagination and return all results (with budget guard)
fields: Optional list of field names to project (jq-style)
Returns: Returns:
dict with pagination metadata and results dict with pagination metadata and results
@ -431,6 +434,19 @@ def paginate_response(
"timestamp": int(time.time() * 1000), "timestamp": int(time.time() * 1000),
} }
# Apply field projection before size estimation
if fields:
filtered_data = project_fields(filtered_data, fields)
# Check token budget — return guard if exceeded
guard = estimate_and_guard(
data=filtered_data,
tool_name=tool_name,
query_hints=query_params,
)
if guard is not None:
return guard
estimated_tokens = estimate_tokens(filtered_data) estimated_tokens = estimate_tokens(filtered_data)
warning = None warning = None
@ -438,7 +454,7 @@ def paginate_response(
warning = f"EXTREMELY LARGE response (~{estimated_tokens:,} tokens)" warning = f"EXTREMELY LARGE response (~{estimated_tokens:,} tokens)"
elif estimated_tokens > 20000: elif estimated_tokens > 20000:
warning = f"VERY LARGE response (~{estimated_tokens:,} tokens)" warning = f"VERY LARGE response (~{estimated_tokens:,} tokens)"
elif estimated_tokens > 8000: elif estimated_tokens > config.large_response_threshold:
warning = f"Large response (~{estimated_tokens:,} tokens)" warning = f"Large response (~{estimated_tokens:,} tokens)"
return { return {
@ -449,16 +465,19 @@ def paginate_response(
"total_count": len(data), "total_count": len(data),
"filtered_count": len(filtered_data), "filtered_count": len(filtered_data),
"grep_pattern": grep, "grep_pattern": grep,
"fields_projected": fields,
"estimated_tokens": estimated_tokens, "estimated_tokens": estimated_tokens,
"warning": warning, "warning": warning,
}, },
"timestamp": int(time.time() * 1000), "timestamp": int(time.time() * 1000),
} }
# Normal pagination flow # Normal pagination flow — apply field projection before cursoring
paginated_data = project_fields(data, fields) if fields else data
try: try:
cursor_id, state = cursor_manager.create_cursor( cursor_id, state = cursor_manager.create_cursor(
data=data, data=paginated_data,
query_params=query_params, query_params=query_params,
tool_name=tool_name, tool_name=tool_name,
session_id=session_id, session_id=session_id,

View File

@ -3,7 +3,7 @@
Provides tools for program analysis operations. Provides tools for program analysis operations.
""" """
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional
from fastmcp import Context from fastmcp import Context
from fastmcp.contrib.mcp_mixin import mcp_tool from fastmcp.contrib.mcp_mixin import mcp_tool
@ -57,6 +57,7 @@ class AnalysisMixin(GhydraMixinBase):
grep: Optional[str] = None, grep: Optional[str] = None,
grep_ignorecase: bool = True, grep_ignorecase: bool = True,
return_all: bool = False, return_all: bool = False,
fields: Optional[List[str]] = None,
ctx: Optional[Context] = None, ctx: Optional[Context] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Get function call graph with edge pagination. """Get function call graph with edge pagination.
@ -70,6 +71,7 @@ class AnalysisMixin(GhydraMixinBase):
grep: Regex pattern to filter edges grep: Regex pattern to filter edges
grep_ignorecase: Case-insensitive grep (default: True) grep_ignorecase: Case-insensitive grep (default: True)
return_all: Return all edges without pagination return_all: Return all edges without pagination
fields: Field names to keep per edge (e.g. ['from', 'to']). Reduces response size.
ctx: FastMCP context (auto-injected) ctx: FastMCP context (auto-injected)
Returns: Returns:
@ -115,7 +117,7 @@ class AnalysisMixin(GhydraMixinBase):
} }
session_id = self._get_session_id(ctx) session_id = self._get_session_id(ctx)
paginated = self.paginate_response( paginated = self.filtered_paginate(
data=edges, data=edges,
query_params=query_params, query_params=query_params,
tool_name="analysis_get_callgraph", tool_name="analysis_get_callgraph",
@ -124,9 +126,10 @@ class AnalysisMixin(GhydraMixinBase):
grep=grep, grep=grep,
grep_ignorecase=grep_ignorecase, grep_ignorecase=grep_ignorecase,
return_all=return_all, return_all=return_all,
fields=fields,
) )
if paginated.get("success"): if paginated.get("success") and not paginated.get("guarded"):
paginated["result"] = { paginated["result"] = {
"root_function": func_id, "root_function": func_id,
"max_depth": max_depth, "max_depth": max_depth,
@ -148,6 +151,7 @@ class AnalysisMixin(GhydraMixinBase):
grep: Optional[str] = None, grep: Optional[str] = None,
grep_ignorecase: bool = True, grep_ignorecase: bool = True,
return_all: bool = False, return_all: bool = False,
fields: Optional[List[str]] = None,
ctx: Optional[Context] = None, ctx: Optional[Context] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Perform data flow analysis with step pagination. """Perform data flow analysis with step pagination.
@ -161,6 +165,7 @@ class AnalysisMixin(GhydraMixinBase):
grep: Regex pattern to filter steps grep: Regex pattern to filter steps
grep_ignorecase: Case-insensitive grep (default: True) grep_ignorecase: Case-insensitive grep (default: True)
return_all: Return all steps without pagination return_all: Return all steps without pagination
fields: Field names to keep per step. Reduces response size.
ctx: FastMCP context (auto-injected) ctx: FastMCP context (auto-injected)
Returns: Returns:
@ -210,7 +215,7 @@ class AnalysisMixin(GhydraMixinBase):
} }
session_id = self._get_session_id(ctx) session_id = self._get_session_id(ctx)
paginated = self.paginate_response( paginated = self.filtered_paginate(
data=steps, data=steps,
query_params=query_params, query_params=query_params,
tool_name="analysis_get_dataflow", tool_name="analysis_get_dataflow",
@ -219,9 +224,11 @@ class AnalysisMixin(GhydraMixinBase):
grep=grep, grep=grep,
grep_ignorecase=grep_ignorecase, grep_ignorecase=grep_ignorecase,
return_all=return_all, return_all=return_all,
fields=fields,
) )
if paginated.get("success"): # Merge metadata into result (skip if guarded)
if paginated.get("success") and not paginated.get("guarded"):
paginated["result"] = { paginated["result"] = {
"start_address": address, "start_address": address,
"direction": direction, "direction": direction,

View File

@ -12,7 +12,7 @@ from fastmcp.contrib.mcp_mixin import MCPMixin
from ..config import get_config from ..config import get_config
from ..core.http_client import safe_get, safe_post, safe_put, safe_patch, safe_delete, simplify_response from ..core.http_client import safe_get, safe_post, safe_put, safe_patch, safe_delete, simplify_response
from ..core.pagination import get_cursor_manager, paginate_response from ..core.pagination import paginate_response
from ..core.logging import log_info, log_debug, log_warning, log_error from ..core.logging import log_info, log_debug, log_warning, log_error
@ -209,8 +209,9 @@ class GhydraMixinBase(MCPMixin):
grep: Optional[str] = None, grep: Optional[str] = None,
grep_ignorecase: bool = True, grep_ignorecase: bool = True,
return_all: bool = False, return_all: bool = False,
fields: Optional[list] = None,
) -> Dict: ) -> Dict:
"""Create paginated response.""" """Create paginated response with optional field projection."""
return paginate_response( return paginate_response(
data=data, data=data,
query_params=query_params, query_params=query_params,
@ -220,6 +221,37 @@ class GhydraMixinBase(MCPMixin):
grep=grep, grep=grep,
grep_ignorecase=grep_ignorecase, grep_ignorecase=grep_ignorecase,
return_all=return_all, return_all=return_all,
fields=fields,
)
def filtered_paginate(
self,
data: list,
query_params: Dict,
tool_name: str,
session_id: str = "default",
page_size: int = 50,
grep: Optional[str] = None,
grep_ignorecase: bool = True,
return_all: bool = False,
fields: Optional[list] = None,
) -> Dict:
"""Paginate with field projection and budget guard.
Convenience wrapper that applies field projection then delegates
to paginate_response. Prefer this over paginate_response for any
tool that could return large result sets.
"""
return self.paginate_response(
data=data,
query_params=query_params,
tool_name=tool_name,
session_id=session_id,
page_size=page_size,
grep=grep,
grep_ignorecase=grep_ignorecase,
return_all=return_all,
fields=fields,
) )
# Async logging helpers # Async logging helpers

View File

@ -3,7 +3,7 @@
Provides tools for data items and strings operations. Provides tools for data items and strings operations.
""" """
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional
from fastmcp import Context from fastmcp import Context
from fastmcp.contrib.mcp_mixin import mcp_tool, mcp_resource from fastmcp.contrib.mcp_mixin import mcp_tool, mcp_resource
@ -34,6 +34,7 @@ class DataMixin(GhydraMixinBase):
grep: Optional[str] = None, grep: Optional[str] = None,
grep_ignorecase: bool = True, grep_ignorecase: bool = True,
return_all: bool = False, return_all: bool = False,
fields: Optional[List[str]] = None,
ctx: Optional[Context] = None, ctx: Optional[Context] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""List defined data items with filtering and cursor-based pagination. """List defined data items with filtering and cursor-based pagination.
@ -48,6 +49,7 @@ class DataMixin(GhydraMixinBase):
grep: Regex pattern to filter results grep: Regex pattern to filter results
grep_ignorecase: Case-insensitive grep (default: True) grep_ignorecase: Case-insensitive grep (default: True)
return_all: Return all results without pagination return_all: Return all results without pagination
fields: Field names to keep (e.g. ['address', 'name']). Reduces response size.
ctx: FastMCP context (auto-injected) ctx: FastMCP context (auto-injected)
Returns: Returns:
@ -91,7 +93,7 @@ class DataMixin(GhydraMixinBase):
} }
session_id = self._get_session_id(ctx) session_id = self._get_session_id(ctx)
return self.paginate_response( return self.filtered_paginate(
data=all_data, data=all_data,
query_params=query_params, query_params=query_params,
tool_name="data_list", tool_name="data_list",
@ -100,6 +102,7 @@ class DataMixin(GhydraMixinBase):
grep=grep, grep=grep,
grep_ignorecase=grep_ignorecase, grep_ignorecase=grep_ignorecase,
return_all=return_all, return_all=return_all,
fields=fields,
) )
@mcp_tool() @mcp_tool()
@ -111,6 +114,7 @@ class DataMixin(GhydraMixinBase):
grep: Optional[str] = None, grep: Optional[str] = None,
grep_ignorecase: bool = True, grep_ignorecase: bool = True,
return_all: bool = False, return_all: bool = False,
fields: Optional[List[str]] = None,
ctx: Optional[Context] = None, ctx: Optional[Context] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""List all defined strings in the binary with pagination. """List all defined strings in the binary with pagination.
@ -122,6 +126,7 @@ class DataMixin(GhydraMixinBase):
grep: Regex pattern to filter results (e.g., "password|key") grep: Regex pattern to filter results (e.g., "password|key")
grep_ignorecase: Case-insensitive grep (default: True) grep_ignorecase: Case-insensitive grep (default: True)
return_all: Return all strings without pagination return_all: Return all strings without pagination
fields: Field names to keep (e.g. ['value', 'address']). Reduces response size.
ctx: FastMCP context (auto-injected) ctx: FastMCP context (auto-injected)
Returns: Returns:
@ -157,7 +162,7 @@ class DataMixin(GhydraMixinBase):
} }
session_id = self._get_session_id(ctx) session_id = self._get_session_id(ctx)
return self.paginate_response( return self.filtered_paginate(
data=result_data, data=result_data,
query_params=query_params, query_params=query_params,
tool_name="data_list_strings", tool_name="data_list_strings",
@ -166,6 +171,7 @@ class DataMixin(GhydraMixinBase):
grep=grep, grep=grep,
grep_ignorecase=grep_ignorecase, grep_ignorecase=grep_ignorecase,
return_all=return_all, return_all=return_all,
fields=fields,
) )
@mcp_tool() @mcp_tool()

View File

@ -3,7 +3,7 @@
Provides tools for function analysis, decompilation, and manipulation. Provides tools for function analysis, decompilation, and manipulation.
""" """
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional
from urllib.parse import quote from urllib.parse import quote
from fastmcp import Context from fastmcp import Context
@ -33,6 +33,7 @@ class FunctionsMixin(GhydraMixinBase):
grep: Optional[str] = None, grep: Optional[str] = None,
grep_ignorecase: bool = True, grep_ignorecase: bool = True,
return_all: bool = False, return_all: bool = False,
fields: Optional[List[str]] = None,
ctx: Optional[Context] = None, ctx: Optional[Context] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""List functions with cursor-based pagination. """List functions with cursor-based pagination.
@ -43,6 +44,7 @@ class FunctionsMixin(GhydraMixinBase):
grep: Regex pattern to filter function names grep: Regex pattern to filter function names
grep_ignorecase: Case-insensitive grep (default: True) grep_ignorecase: Case-insensitive grep (default: True)
return_all: Return all functions without pagination return_all: Return all functions without pagination
fields: Field names to keep (e.g. ['name', 'address']). Reduces response size.
ctx: FastMCP context (auto-injected) ctx: FastMCP context (auto-injected)
Returns: Returns:
@ -67,7 +69,7 @@ class FunctionsMixin(GhydraMixinBase):
query_params = {"tool": "functions_list", "port": port, "grep": grep} query_params = {"tool": "functions_list", "port": port, "grep": grep}
session_id = self._get_session_id(ctx) session_id = self._get_session_id(ctx)
return self.paginate_response( return self.filtered_paginate(
data=functions, data=functions,
query_params=query_params, query_params=query_params,
tool_name="functions_list", tool_name="functions_list",
@ -76,6 +78,7 @@ class FunctionsMixin(GhydraMixinBase):
grep=grep, grep=grep,
grep_ignorecase=grep_ignorecase, grep_ignorecase=grep_ignorecase,
return_all=return_all, return_all=return_all,
fields=fields,
) )
@mcp_tool() @mcp_tool()
@ -129,6 +132,7 @@ class FunctionsMixin(GhydraMixinBase):
grep: Optional[str] = None, grep: Optional[str] = None,
grep_ignorecase: bool = True, grep_ignorecase: bool = True,
return_all: bool = False, return_all: bool = False,
fields: Optional[List[str]] = None,
ctx: Optional[Context] = None, ctx: Optional[Context] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Get decompiled code for a function with line pagination. """Get decompiled code for a function with line pagination.
@ -143,6 +147,7 @@ class FunctionsMixin(GhydraMixinBase):
grep: Regex pattern to filter lines grep: Regex pattern to filter lines
grep_ignorecase: Case-insensitive grep (default: True) grep_ignorecase: Case-insensitive grep (default: True)
return_all: Return all lines without pagination return_all: Return all lines without pagination
fields: Field names to keep (for structured results)
ctx: FastMCP context (auto-injected) ctx: FastMCP context (auto-injected)
Returns: Returns:
@ -194,7 +199,7 @@ class FunctionsMixin(GhydraMixinBase):
} }
session_id = self._get_session_id(ctx) session_id = self._get_session_id(ctx)
paginated = self.paginate_response( paginated = self.filtered_paginate(
data=lines, data=lines,
query_params=query_params, query_params=query_params,
tool_name="functions_decompile", tool_name="functions_decompile",
@ -203,10 +208,11 @@ class FunctionsMixin(GhydraMixinBase):
grep=grep, grep=grep,
grep_ignorecase=grep_ignorecase, grep_ignorecase=grep_ignorecase,
return_all=return_all, return_all=return_all,
fields=fields,
) )
# Convert lines back to text in result # Convert lines back to text in result (skip if guarded)
if paginated.get("success"): if paginated.get("success") and not paginated.get("guarded"):
paginated["result"] = "\n".join(paginated.get("result", [])) paginated["result"] = "\n".join(paginated.get("result", []))
paginated["function_name"] = result.get("name", name or address) paginated["function_name"] = result.get("name", name or address)
@ -222,6 +228,7 @@ class FunctionsMixin(GhydraMixinBase):
grep: Optional[str] = None, grep: Optional[str] = None,
grep_ignorecase: bool = True, grep_ignorecase: bool = True,
return_all: bool = False, return_all: bool = False,
fields: Optional[List[str]] = None,
ctx: Optional[Context] = None, ctx: Optional[Context] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Get disassembly for a function with instruction pagination. """Get disassembly for a function with instruction pagination.
@ -234,6 +241,7 @@ class FunctionsMixin(GhydraMixinBase):
grep: Regex pattern to filter instructions grep: Regex pattern to filter instructions
grep_ignorecase: Case-insensitive grep (default: True) grep_ignorecase: Case-insensitive grep (default: True)
return_all: Return all instructions without pagination return_all: Return all instructions without pagination
fields: Field names to keep (for structured results)
ctx: FastMCP context (auto-injected) ctx: FastMCP context (auto-injected)
Returns: Returns:
@ -284,7 +292,7 @@ class FunctionsMixin(GhydraMixinBase):
} }
session_id = self._get_session_id(ctx) session_id = self._get_session_id(ctx)
paginated = self.paginate_response( paginated = self.filtered_paginate(
data=lines, data=lines,
query_params=query_params, query_params=query_params,
tool_name="functions_disassemble", tool_name="functions_disassemble",
@ -293,10 +301,11 @@ class FunctionsMixin(GhydraMixinBase):
grep=grep, grep=grep,
grep_ignorecase=grep_ignorecase, grep_ignorecase=grep_ignorecase,
return_all=return_all, return_all=return_all,
fields=fields,
) )
# Convert lines back to text # Convert lines back to text (skip if guarded)
if paginated.get("success"): if paginated.get("success") and not paginated.get("guarded"):
paginated["result"] = "\n".join(paginated.get("result", [])) paginated["result"] = "\n".join(paginated.get("result", []))
paginated["function_name"] = result.get("name", name or address) paginated["function_name"] = result.get("name", name or address)

View File

@ -3,7 +3,7 @@
Provides tools for struct data type operations. Provides tools for struct data type operations.
""" """
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional
from fastmcp import Context from fastmcp import Context
from fastmcp.contrib.mcp_mixin import mcp_tool, mcp_resource from fastmcp.contrib.mcp_mixin import mcp_tool, mcp_resource
@ -31,6 +31,7 @@ class StructsMixin(GhydraMixinBase):
grep: Optional[str] = None, grep: Optional[str] = None,
grep_ignorecase: bool = True, grep_ignorecase: bool = True,
return_all: bool = False, return_all: bool = False,
fields: Optional[List[str]] = None,
ctx: Optional[Context] = None, ctx: Optional[Context] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""List all struct data types with cursor-based pagination. """List all struct data types with cursor-based pagination.
@ -42,6 +43,7 @@ class StructsMixin(GhydraMixinBase):
grep: Regex pattern to filter struct names grep: Regex pattern to filter struct names
grep_ignorecase: Case-insensitive grep (default: True) grep_ignorecase: Case-insensitive grep (default: True)
return_all: Return all results without pagination return_all: Return all results without pagination
fields: Field names to keep (e.g. ['name', 'size']). Reduces response size.
ctx: FastMCP context (auto-injected) ctx: FastMCP context (auto-injected)
Returns: Returns:
@ -76,7 +78,7 @@ class StructsMixin(GhydraMixinBase):
} }
session_id = self._get_session_id(ctx) session_id = self._get_session_id(ctx)
return self.paginate_response( return self.filtered_paginate(
data=all_structs, data=all_structs,
query_params=query_params, query_params=query_params,
tool_name="structs_list", tool_name="structs_list",
@ -85,6 +87,7 @@ class StructsMixin(GhydraMixinBase):
grep=grep, grep=grep,
grep_ignorecase=grep_ignorecase, grep_ignorecase=grep_ignorecase,
return_all=return_all, return_all=return_all,
fields=fields,
) )
@mcp_tool() @mcp_tool()
@ -96,6 +99,7 @@ class StructsMixin(GhydraMixinBase):
grep: Optional[str] = None, grep: Optional[str] = None,
grep_ignorecase: bool = True, grep_ignorecase: bool = True,
return_all: bool = False, return_all: bool = False,
project_fields: Optional[List[str]] = None,
ctx: Optional[Context] = None, ctx: Optional[Context] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Get detailed information about a struct with field pagination. """Get detailed information about a struct with field pagination.
@ -107,6 +111,7 @@ class StructsMixin(GhydraMixinBase):
grep: Regex pattern to filter fields grep: Regex pattern to filter fields
grep_ignorecase: Case-insensitive grep (default: True) grep_ignorecase: Case-insensitive grep (default: True)
return_all: Return all fields without pagination return_all: Return all fields without pagination
project_fields: Field names to keep per struct field item. Reduces response size.
ctx: FastMCP context (auto-injected) ctx: FastMCP context (auto-injected)
Returns: Returns:
@ -160,7 +165,7 @@ class StructsMixin(GhydraMixinBase):
} }
# Paginate fields # Paginate fields
paginated = self.paginate_response( paginated = self.filtered_paginate(
data=fields, data=fields,
query_params=query_params, query_params=query_params,
tool_name="structs_get", tool_name="structs_get",
@ -169,10 +174,11 @@ class StructsMixin(GhydraMixinBase):
grep=grep, grep=grep,
grep_ignorecase=grep_ignorecase, grep_ignorecase=grep_ignorecase,
return_all=return_all, return_all=return_all,
fields=project_fields,
) )
# Merge struct metadata with paginated fields # Merge struct metadata with paginated fields (skip if guarded)
if paginated.get("success"): if paginated.get("success") and not paginated.get("guarded"):
paginated["struct_name"] = struct_info.get("name", name) paginated["struct_name"] = struct_info.get("name", name)
paginated["struct_size"] = struct_info.get("size", struct_info.get("length")) paginated["struct_size"] = struct_info.get("size", struct_info.get("length"))
paginated["struct_category"] = struct_info.get("category", struct_info.get("categoryPath")) paginated["struct_category"] = struct_info.get("category", struct_info.get("categoryPath"))

View File

@ -3,7 +3,7 @@
Provides tools for cross-reference (xref) operations. Provides tools for cross-reference (xref) operations.
""" """
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional
from fastmcp import Context from fastmcp import Context
from fastmcp.contrib.mcp_mixin import mcp_tool, mcp_resource from fastmcp.contrib.mcp_mixin import mcp_tool, mcp_resource
@ -32,6 +32,7 @@ class XrefsMixin(GhydraMixinBase):
grep: Optional[str] = None, grep: Optional[str] = None,
grep_ignorecase: bool = True, grep_ignorecase: bool = True,
return_all: bool = False, return_all: bool = False,
fields: Optional[List[str]] = None,
ctx: Optional[Context] = None, ctx: Optional[Context] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""List cross-references with filtering and pagination. """List cross-references with filtering and pagination.
@ -45,6 +46,7 @@ class XrefsMixin(GhydraMixinBase):
grep: Regex pattern to filter results grep: Regex pattern to filter results
grep_ignorecase: Case-insensitive grep (default: True) grep_ignorecase: Case-insensitive grep (default: True)
return_all: Return all results without pagination return_all: Return all results without pagination
fields: Field names to keep (e.g. ['fromAddress', 'toAddress']). Reduces response size.
ctx: FastMCP context (auto-injected) ctx: FastMCP context (auto-injected)
Returns: Returns:
@ -94,7 +96,7 @@ class XrefsMixin(GhydraMixinBase):
} }
session_id = self._get_session_id(ctx) session_id = self._get_session_id(ctx)
return self.paginate_response( return self.filtered_paginate(
data=all_xrefs, data=all_xrefs,
query_params=query_params, query_params=query_params,
tool_name="xrefs_list", tool_name="xrefs_list",
@ -103,6 +105,7 @@ class XrefsMixin(GhydraMixinBase):
grep=grep, grep=grep,
grep_ignorecase=grep_ignorecase, grep_ignorecase=grep_ignorecase,
return_all=return_all, return_all=return_all,
fields=fields,
) )
# Resources # Resources

View File

@ -8,6 +8,7 @@ import signal
import sys import sys
import threading import threading
import time import time
from pathlib import Path
from typing import Optional from typing import Optional
from fastmcp import FastMCP from fastmcp import FastMCP
@ -68,6 +69,18 @@ def create_server(
cursors_mixin.register_all(mcp) cursors_mixin.register_all(mcp)
docker_mixin.register_all(mcp) docker_mixin.register_all(mcp)
# Optional feedback collection
cfg = get_config()
if cfg.feedback_enabled:
try:
from fastmcp_feedback import add_feedback_tools
db_path = Path(cfg.feedback_db_path)
db_path.parent.mkdir(parents=True, exist_ok=True)
add_feedback_tools(mcp, database_url=f"sqlite:///{db_path}")
except ImportError:
pass # fastmcp-feedback not installed — skip silently
return mcp return mcp
@ -132,6 +145,9 @@ def main():
config = get_config() config = get_config()
if config.feedback_enabled:
print(f" 📋 Feedback collection: {config.feedback_db_path}", file=sys.stderr)
# Create and configure the server # Create and configure the server
mcp = create_server() mcp = create_server()

112
uv.lock generated
View File

@ -400,12 +400,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/4b/73c68b0ae9e587f20c5aa13ba5bed9be2bb9248a598555dafcf17df87f70/fastmcp-2.13.2-py3-none-any.whl", hash = "sha256:300c59eb970c235bb9d0575883322922e4f2e2468a3d45e90cbfd6b23b7be245", size = 385643, upload-time = "2025-12-01T18:48:18.515Z" }, { url = "https://files.pythonhosted.org/packages/e5/4b/73c68b0ae9e587f20c5aa13ba5bed9be2bb9248a598555dafcf17df87f70/fastmcp-2.13.2-py3-none-any.whl", hash = "sha256:300c59eb970c235bb9d0575883322922e4f2e2468a3d45e90cbfd6b23b7be245", size = 385643, upload-time = "2025-12-01T18:48:18.515Z" },
] ]
[[package]]
name = "fastmcp-feedback"
version = "2026.1.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastmcp" },
{ name = "pydantic" },
{ name = "sqlalchemy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/73/84/cbd09919ea66ee263e7da6bf07f6389cee7a0afedacd55f9d1e210dea24b/fastmcp_feedback-2026.1.12.1.tar.gz", hash = "sha256:26f75419e7787c982204c62ce78de1f13eec8915f64acee84597389722ed902d", size = 81133, upload-time = "2026-01-16T02:09:13.543Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/ee/327a3f6c7ac5cde56c7c9449dbc6c0ab78b15c06a51ad5645ab880240120/fastmcp_feedback-2026.1.12.1-py3-none-any.whl", hash = "sha256:6a3dec71f3d3eae4eb0102eb0a86aa7853fb0419fb506a5a13d17deaf842c53c", size = 29789, upload-time = "2026-01-16T02:09:11.831Z" },
]
[[package]] [[package]]
name = "ghydramcp" name = "ghydramcp"
version = "2025.12.3" version = "2025.12.3"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "fastmcp" }, { name = "fastmcp" },
{ name = "fastmcp-feedback" },
{ name = "mcp" }, { name = "mcp" },
{ name = "requests" }, { name = "requests" },
] ]
@ -413,10 +428,58 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "fastmcp", specifier = ">=2.0.0" }, { name = "fastmcp", specifier = ">=2.0.0" },
{ name = "fastmcp-feedback", specifier = ">=1.0.0" },
{ name = "mcp", specifier = ">=1.22.0" }, { name = "mcp", specifier = ">=1.22.0" },
{ name = "requests", specifier = ">=2.32.3" }, { name = "requests", specifier = ">=2.32.3" },
] ]
[[package]]
name = "greenlet"
version = "3.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/e8/2e1462c8fdbe0f210feb5ac7ad2d9029af8be3bf45bd9fa39765f821642f/greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c", size = 274974, upload-time = "2026-01-23T15:31:02.891Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a8/530a401419a6b302af59f67aaf0b9ba1015855ea7e56c036b5928793c5bd/greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd", size = 577175, upload-time = "2026-01-23T16:00:56.213Z" },
{ url = "https://files.pythonhosted.org/packages/8e/89/7e812bb9c05e1aaef9b597ac1d0962b9021d2c6269354966451e885c4e6b/greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5", size = 590401, upload-time = "2026-01-23T16:05:26.365Z" },
{ url = "https://files.pythonhosted.org/packages/5c/ae/8d472e1f5ac5efe55c563f3eabb38c98a44b832602e12910750a7c025802/greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2", size = 590272, upload-time = "2026-01-23T15:32:49.411Z" },
{ url = "https://files.pythonhosted.org/packages/a8/51/0fde34bebfcadc833550717eade64e35ec8738e6b097d5d248274a01258b/greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9", size = 1550729, upload-time = "2026-01-23T16:04:20.867Z" },
{ url = "https://files.pythonhosted.org/packages/16/c9/2fb47bee83b25b119d5a35d580807bb8b92480a54b68fef009a02945629f/greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f", size = 1615552, upload-time = "2026-01-23T15:33:45.743Z" },
{ url = "https://files.pythonhosted.org/packages/1f/54/dcf9f737b96606f82f8dd05becfb8d238db0633dd7397d542a296fe9cad3/greenlet-3.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:32e4ca9777c5addcbf42ff3915d99030d8e00173a56f80001fb3875998fe410b", size = 226462, upload-time = "2026-01-23T15:36:50.422Z" },
{ url = "https://files.pythonhosted.org/packages/91/37/61e1015cf944ddd2337447d8e97fb423ac9bc21f9963fb5f206b53d65649/greenlet-3.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:da19609432f353fed186cc1b85e9440db93d489f198b4bdf42ae19cc9d9ac9b4", size = 225715, upload-time = "2026-01-23T15:33:17.298Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" },
{ url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" },
{ url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" },
{ url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" },
{ url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" },
{ url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" },
{ url = "https://files.pythonhosted.org/packages/34/2f/5e0e41f33c69655300a5e54aeb637cf8ff57f1786a3aba374eacc0228c1d/greenlet-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cc98b9c4e4870fa983436afa999d4eb16b12872fab7071423d5262fa7120d57a", size = 227156, upload-time = "2026-01-23T15:34:34.808Z" },
{ url = "https://files.pythonhosted.org/packages/c8/ab/717c58343cf02c5265b531384b248787e04d8160b8afe53d9eec053d7b44/greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1", size = 226403, upload-time = "2026-01-23T15:31:39.372Z" },
{ url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" },
{ url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" },
{ url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" },
{ url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" },
{ url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" },
{ url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" },
{ url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042, upload-time = "2026-01-23T15:33:58.216Z" },
{ url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294, upload-time = "2026-01-23T15:30:52.73Z" },
{ url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" },
{ url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" },
{ url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" },
{ url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" },
{ url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" },
{ url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" },
{ url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125, upload-time = "2026-01-23T15:32:45.265Z" },
{ url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519, upload-time = "2026-01-23T15:31:47.284Z" },
{ url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" },
{ url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" },
{ url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" },
{ url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" },
{ url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" },
{ url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" },
{ url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" },
]
[[package]] [[package]]
name = "h11" name = "h11"
version = "0.16.0" version = "0.16.0"
@ -1061,6 +1124,55 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" },
] ]
[[package]]
name = "sqlalchemy"
version = "2.0.46"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/ac/b42ad16800d0885105b59380ad69aad0cce5a65276e269ce2729a2343b6a/sqlalchemy-2.0.46-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:261c4b1f101b4a411154f1da2b76497d73abbfc42740029205d4d01fa1052684", size = 2154851, upload-time = "2026-01-21T18:27:30.54Z" },
{ url = "https://files.pythonhosted.org/packages/a0/60/d8710068cb79f64d002ebed62a7263c00c8fd95f4ebd4b5be8f7ca93f2bc/sqlalchemy-2.0.46-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:181903fe8c1b9082995325f1b2e84ac078b1189e2819380c2303a5f90e114a62", size = 3311241, upload-time = "2026-01-21T18:32:33.45Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/20c71487c7219ab3aa7421c7c62d93824c97c1460f2e8bb72404b0192d13/sqlalchemy-2.0.46-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:590be24e20e2424a4c3c1b0835e9405fa3d0af5823a1a9fc02e5dff56471515f", size = 3310741, upload-time = "2026-01-21T18:44:57.887Z" },
{ url = "https://files.pythonhosted.org/packages/65/80/d26d00b3b249ae000eee4db206fcfc564bf6ca5030e4747adf451f4b5108/sqlalchemy-2.0.46-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7568fe771f974abadce52669ef3a03150ff03186d8eb82613bc8adc435a03f01", size = 3263116, upload-time = "2026-01-21T18:32:35.044Z" },
{ url = "https://files.pythonhosted.org/packages/da/ee/74dda7506640923821340541e8e45bd3edd8df78664f1f2e0aae8077192b/sqlalchemy-2.0.46-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf7e1e78af38047e08836d33502c7a278915698b7c2145d045f780201679999", size = 3285327, upload-time = "2026-01-21T18:44:59.254Z" },
{ url = "https://files.pythonhosted.org/packages/9f/25/6dcf8abafff1389a21c7185364de145107b7394ecdcb05233815b236330d/sqlalchemy-2.0.46-cp311-cp311-win32.whl", hash = "sha256:9d80ea2ac519c364a7286e8d765d6cd08648f5b21ca855a8017d9871f075542d", size = 2114564, upload-time = "2026-01-21T18:33:15.85Z" },
{ url = "https://files.pythonhosted.org/packages/93/5f/e081490f8523adc0088f777e4ebad3cac21e498ec8a3d4067074e21447a1/sqlalchemy-2.0.46-cp311-cp311-win_amd64.whl", hash = "sha256:585af6afe518732d9ccd3aea33af2edaae4a7aa881af5d8f6f4fe3a368699597", size = 2139233, upload-time = "2026-01-21T18:33:17.528Z" },
{ url = "https://files.pythonhosted.org/packages/b6/35/d16bfa235c8b7caba3730bba43e20b1e376d2224f407c178fbf59559f23e/sqlalchemy-2.0.46-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a9a72b0da8387f15d5810f1facca8f879de9b85af8c645138cba61ea147968c", size = 2153405, upload-time = "2026-01-21T19:05:54.143Z" },
{ url = "https://files.pythonhosted.org/packages/06/6c/3192e24486749862f495ddc6584ed730c0c994a67550ec395d872a2ad650/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2347c3f0efc4de367ba00218e0ae5c4ba2306e47216ef80d6e31761ac97cb0b9", size = 3334702, upload-time = "2026-01-21T18:46:45.384Z" },
{ url = "https://files.pythonhosted.org/packages/ea/a2/b9f33c8d68a3747d972a0bb758c6b63691f8fb8a49014bc3379ba15d4274/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9094c8b3197db12aa6f05c51c05daaad0a92b8c9af5388569847b03b1007fb1b", size = 3347664, upload-time = "2026-01-21T18:40:09.979Z" },
{ url = "https://files.pythonhosted.org/packages/aa/d2/3e59e2a91eaec9db7e8dc6b37b91489b5caeb054f670f32c95bcba98940f/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37fee2164cf21417478b6a906adc1a91d69ae9aba8f9533e67ce882f4bb1de53", size = 3277372, upload-time = "2026-01-21T18:46:47.168Z" },
{ url = "https://files.pythonhosted.org/packages/dd/dd/67bc2e368b524e2192c3927b423798deda72c003e73a1e94c21e74b20a85/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b1e14b2f6965a685c7128bd315e27387205429c2e339eeec55cb75ca4ab0ea2e", size = 3312425, upload-time = "2026-01-21T18:40:11.548Z" },
{ url = "https://files.pythonhosted.org/packages/43/82/0ecd68e172bfe62247e96cb47867c2d68752566811a4e8c9d8f6e7c38a65/sqlalchemy-2.0.46-cp312-cp312-win32.whl", hash = "sha256:412f26bb4ba942d52016edc8d12fb15d91d3cd46b0047ba46e424213ad407bcb", size = 2113155, upload-time = "2026-01-21T18:42:49.748Z" },
{ url = "https://files.pythonhosted.org/packages/bc/2a/2821a45742073fc0331dc132552b30de68ba9563230853437cac54b2b53e/sqlalchemy-2.0.46-cp312-cp312-win_amd64.whl", hash = "sha256:ea3cd46b6713a10216323cda3333514944e510aa691c945334713fca6b5279ff", size = 2140078, upload-time = "2026-01-21T18:42:51.197Z" },
{ url = "https://files.pythonhosted.org/packages/b3/4b/fa7838fe20bb752810feed60e45625a9a8b0102c0c09971e2d1d95362992/sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00", size = 2150268, upload-time = "2026-01-21T19:05:56.621Z" },
{ url = "https://files.pythonhosted.org/packages/46/c1/b34dccd712e8ea846edf396e00973dda82d598cb93762e55e43e6835eba9/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2", size = 3276511, upload-time = "2026-01-21T18:46:49.022Z" },
{ url = "https://files.pythonhosted.org/packages/96/48/a04d9c94753e5d5d096c628c82a98c4793b9c08ca0e7155c3eb7d7db9f24/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee", size = 3292881, upload-time = "2026-01-21T18:40:13.089Z" },
{ url = "https://files.pythonhosted.org/packages/be/f4/06eda6e91476f90a7d8058f74311cb65a2fb68d988171aced81707189131/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad", size = 3224559, upload-time = "2026-01-21T18:46:50.974Z" },
{ url = "https://files.pythonhosted.org/packages/ab/a2/d2af04095412ca6345ac22b33b89fe8d6f32a481e613ffcb2377d931d8d0/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e", size = 3262728, upload-time = "2026-01-21T18:40:14.883Z" },
{ url = "https://files.pythonhosted.org/packages/31/48/1980c7caa5978a3b8225b4d230e69a2a6538a3562b8b31cea679b6933c83/sqlalchemy-2.0.46-cp313-cp313-win32.whl", hash = "sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f", size = 2111295, upload-time = "2026-01-21T18:42:52.366Z" },
{ url = "https://files.pythonhosted.org/packages/2d/54/f8d65bbde3d877617c4720f3c9f60e99bb7266df0d5d78b6e25e7c149f35/sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl", hash = "sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef", size = 2137076, upload-time = "2026-01-21T18:42:53.924Z" },
{ url = "https://files.pythonhosted.org/packages/56/ba/9be4f97c7eb2b9d5544f2624adfc2853e796ed51d2bb8aec90bc94b7137e/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10", size = 3556533, upload-time = "2026-01-21T18:33:06.636Z" },
{ url = "https://files.pythonhosted.org/packages/20/a6/b1fc6634564dbb4415b7ed6419cdfeaadefd2c39cdab1e3aa07a5f2474c2/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764", size = 3523208, upload-time = "2026-01-21T18:45:08.436Z" },
{ url = "https://files.pythonhosted.org/packages/a1/d8/41e0bdfc0f930ff236f86fccd12962d8fa03713f17ed57332d38af6a3782/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b", size = 3464292, upload-time = "2026-01-21T18:33:08.208Z" },
{ url = "https://files.pythonhosted.org/packages/f0/8b/9dcbec62d95bea85f5ecad9b8d65b78cc30fb0ffceeb3597961f3712549b/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447", size = 3473497, upload-time = "2026-01-21T18:45:10.552Z" },
{ url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079, upload-time = "2026-01-21T19:05:58.477Z" },
{ url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216, upload-time = "2026-01-21T18:46:52.634Z" },
{ url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208, upload-time = "2026-01-21T18:40:16.38Z" },
{ url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e", size = 3221994, upload-time = "2026-01-21T18:46:54.622Z" },
{ url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf", size = 3243990, upload-time = "2026-01-21T18:40:18.253Z" },
{ url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908", size = 2114215, upload-time = "2026-01-21T18:42:55.232Z" },
{ url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b", size = 2139867, upload-time = "2026-01-21T18:42:56.474Z" },
{ url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa", size = 3561202, upload-time = "2026-01-21T18:33:10.337Z" },
{ url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863", size = 3526296, upload-time = "2026-01-21T18:45:12.657Z" },
{ url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede", size = 3470008, upload-time = "2026-01-21T18:33:11.725Z" },
{ url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330", size = 3476137, upload-time = "2026-01-21T18:45:15.001Z" },
{ url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" },
]
[[package]] [[package]]
name = "sse-starlette" name = "sse-starlette"
version = "3.0.3" version = "3.0.3"