agentvee-mcp 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentvee_mcp-0.1.2/.gitignore +4 -0
- agentvee_mcp-0.1.2/PKG-INFO +137 -0
- agentvee_mcp-0.1.2/README.md +118 -0
- agentvee_mcp-0.1.2/agentvee_mcp/__init__.py +3 -0
- agentvee_mcp-0.1.2/agentvee_mcp/__main__.py +3 -0
- agentvee_mcp-0.1.2/agentvee_mcp/api_client.py +175 -0
- agentvee_mcp-0.1.2/agentvee_mcp/auth.py +18 -0
- agentvee_mcp-0.1.2/agentvee_mcp/config.py +107 -0
- agentvee_mcp-0.1.2/agentvee_mcp/http.py +18 -0
- agentvee_mcp-0.1.2/agentvee_mcp/security/__init__.py +0 -0
- agentvee_mcp-0.1.2/agentvee_mcp/security/audit.py +25 -0
- agentvee_mcp-0.1.2/agentvee_mcp/security/metrics.py +66 -0
- agentvee_mcp-0.1.2/agentvee_mcp/security/sessions.py +9 -0
- agentvee_mcp-0.1.2/agentvee_mcp/security/throttle.py +56 -0
- agentvee_mcp-0.1.2/agentvee_mcp/security/wait_gate.py +37 -0
- agentvee_mcp-0.1.2/agentvee_mcp/server.py +21 -0
- agentvee_mcp-0.1.2/agentvee_mcp/stdio.py +49 -0
- agentvee_mcp-0.1.2/agentvee_mcp/tools/__init__.py +0 -0
- agentvee_mcp-0.1.2/agentvee_mcp/tools/download_url.py +67 -0
- agentvee_mcp-0.1.2/agentvee_mcp/tools/list_on_marketplace.py +95 -0
- agentvee_mcp-0.1.2/agentvee_mcp/tools/upload_and_wait.py +231 -0
- agentvee_mcp-0.1.2/agentvee_mcp/tools/upload_file.py +110 -0
- agentvee_mcp-0.1.2/agentvee_mcp/tools/upload_status.py +68 -0
- agentvee_mcp-0.1.2/agentvee_mcp/tools/upload_url.py +83 -0
- agentvee_mcp-0.1.2/agentvee_mcp/validation/__init__.py +0 -0
- agentvee_mcp-0.1.2/agentvee_mcp/validation/base64_content.py +52 -0
- agentvee_mcp-0.1.2/agentvee_mcp/validation/file_path.py +42 -0
- agentvee_mcp-0.1.2/agentvee_mcp/validation/upload_id.py +16 -0
- agentvee_mcp-0.1.2/agentvee_mcp/validation/url.py +40 -0
- agentvee_mcp-0.1.2/pyproject.toml +32 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentvee-mcp
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: MCP server for AgentVee — file uploads for AI agents
|
|
5
|
+
Project-URL: Homepage, https://agentvee.io
|
|
6
|
+
Project-URL: Repository, https://github.com/agentvee/agentvee
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: agentvee,ai-agents,file-upload,mcp
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Requires-Dist: httpx>=0.27.0
|
|
17
|
+
Requires-Dist: mcp>=1.0.0
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# crabtransfer-mcp
|
|
21
|
+
|
|
22
|
+
MCP server for [CrabTransfer](https://crabtransfer.com) — file uploads for AI agents with local `filePath` support.
|
|
23
|
+
|
|
24
|
+
> **Note:** This is currently a test version. Full launch coming soon.
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
1. **Get an API key** at [crabtransfer.com/dashboard](https://crabtransfer.com/dashboard)
|
|
29
|
+
|
|
30
|
+
1. **Add to your MCP config** (e.g. `~/.cursor/mcp.json`):
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"mcpServers": {
|
|
35
|
+
"crabtransfer": {
|
|
36
|
+
"command": "uvx",
|
|
37
|
+
"args": ["crabtransfer-mcp"],
|
|
38
|
+
"env": {
|
|
39
|
+
"CRABTRANSFER_API_KEY": "your-api-key-here"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Or with `pip`:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"mcpServers": {
|
|
51
|
+
"crabtransfer": {
|
|
52
|
+
"command": "python",
|
|
53
|
+
"args": ["-m", "crabtransfer_mcp"],
|
|
54
|
+
"env": {
|
|
55
|
+
"CRABTRANSFER_API_KEY": "your-api-key-here"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
1. **Restart your MCP client** (Cursor, Claude Desktop, etc.)
|
|
63
|
+
|
|
64
|
+
## Installation
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install crabtransfer-mcp
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Or with uv:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
uvx crabtransfer-mcp --help
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Supported Clients
|
|
77
|
+
|
|
78
|
+
Any MCP client that supports stdio transport:
|
|
79
|
+
|
|
80
|
+
- [Cursor](https://cursor.sh)
|
|
81
|
+
- [Claude Desktop](https://claude.ai/download)
|
|
82
|
+
- [Cline](https://github.com/cline/cline)
|
|
83
|
+
- [Continue](https://continue.dev)
|
|
84
|
+
|
|
85
|
+
## Available Tools
|
|
86
|
+
|
|
87
|
+
| Tool | Description |
|
|
88
|
+
| ------------------- | ---------------------------------------------------- |
|
|
89
|
+
| `upload_and_wait` | Upload a file and wait for a shareable download link |
|
|
90
|
+
| `upload_file` | Upload a file (returns upload ID for status polling) |
|
|
91
|
+
| `upload_from_url` | Upload a file from a public URL |
|
|
92
|
+
| `get_upload_status` | Check the status of an upload |
|
|
93
|
+
| `get_download_url` | Get the download URL for a completed upload |
|
|
94
|
+
|
|
95
|
+
### Upload Methods
|
|
96
|
+
|
|
97
|
+
Each upload tool supports three mutually exclusive input methods:
|
|
98
|
+
|
|
99
|
+
- **`filePath`** — Local file path (stdio only, most efficient for local files — zero tokens)
|
|
100
|
+
- **`url`** — Public URL for the server to fetch
|
|
101
|
+
- **`content`** — Base64-encoded file content (fallback, uses tokens)
|
|
102
|
+
|
|
103
|
+
## Environment Variables
|
|
104
|
+
|
|
105
|
+
| Variable | Required | Default | Description |
|
|
106
|
+
| --------------------------- | -------- | ------------------------------------------ | ------------------------- |
|
|
107
|
+
| `CRABTRANSFER_API_KEY` | Yes | — | Your CrabTransfer API key |
|
|
108
|
+
| `CRABTRANSFER_API_BASE_URL` | No | `https://crabtransfer-api-develop.fly.dev` | API base URL |
|
|
109
|
+
|
|
110
|
+
## CLI
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
crabtransfer-mcp --help
|
|
114
|
+
crabtransfer-mcp --version
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## HTTP Transport
|
|
118
|
+
|
|
119
|
+
To run as an HTTP server (Streamable HTTP):
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
crabtransfer-mcp-http
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Or:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
python -m crabtransfer_mcp.http
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Also Available
|
|
132
|
+
|
|
133
|
+
- **Node.js:** `npx -y @crabtransfer/mcp` ([npm](https://www.npmjs.com/package/@crabtransfer/mcp))
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# crabtransfer-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for [CrabTransfer](https://crabtransfer.com) — file uploads for AI agents with local `filePath` support.
|
|
4
|
+
|
|
5
|
+
> **Note:** This is currently a test version. Full launch coming soon.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
1. **Get an API key** at [crabtransfer.com/dashboard](https://crabtransfer.com/dashboard)
|
|
10
|
+
|
|
11
|
+
1. **Add to your MCP config** (e.g. `~/.cursor/mcp.json`):
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"mcpServers": {
|
|
16
|
+
"crabtransfer": {
|
|
17
|
+
"command": "uvx",
|
|
18
|
+
"args": ["crabtransfer-mcp"],
|
|
19
|
+
"env": {
|
|
20
|
+
"CRABTRANSFER_API_KEY": "your-api-key-here"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or with `pip`:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"mcpServers": {
|
|
32
|
+
"crabtransfer": {
|
|
33
|
+
"command": "python",
|
|
34
|
+
"args": ["-m", "crabtransfer_mcp"],
|
|
35
|
+
"env": {
|
|
36
|
+
"CRABTRANSFER_API_KEY": "your-api-key-here"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
1. **Restart your MCP client** (Cursor, Claude Desktop, etc.)
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install crabtransfer-mcp
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or with uv:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uvx crabtransfer-mcp --help
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Supported Clients
|
|
58
|
+
|
|
59
|
+
Any MCP client that supports stdio transport:
|
|
60
|
+
|
|
61
|
+
- [Cursor](https://cursor.sh)
|
|
62
|
+
- [Claude Desktop](https://claude.ai/download)
|
|
63
|
+
- [Cline](https://github.com/cline/cline)
|
|
64
|
+
- [Continue](https://continue.dev)
|
|
65
|
+
|
|
66
|
+
## Available Tools
|
|
67
|
+
|
|
68
|
+
| Tool | Description |
|
|
69
|
+
| ------------------- | ---------------------------------------------------- |
|
|
70
|
+
| `upload_and_wait` | Upload a file and wait for a shareable download link |
|
|
71
|
+
| `upload_file` | Upload a file (returns upload ID for status polling) |
|
|
72
|
+
| `upload_from_url` | Upload a file from a public URL |
|
|
73
|
+
| `get_upload_status` | Check the status of an upload |
|
|
74
|
+
| `get_download_url` | Get the download URL for a completed upload |
|
|
75
|
+
|
|
76
|
+
### Upload Methods
|
|
77
|
+
|
|
78
|
+
Each upload tool supports three mutually exclusive input methods:
|
|
79
|
+
|
|
80
|
+
- **`filePath`** — Local file path (stdio only, most efficient for local files — zero tokens)
|
|
81
|
+
- **`url`** — Public URL for the server to fetch
|
|
82
|
+
- **`content`** — Base64-encoded file content (fallback, uses tokens)
|
|
83
|
+
|
|
84
|
+
## Environment Variables
|
|
85
|
+
|
|
86
|
+
| Variable | Required | Default | Description |
|
|
87
|
+
| --------------------------- | -------- | ------------------------------------------ | ------------------------- |
|
|
88
|
+
| `CRABTRANSFER_API_KEY` | Yes | — | Your CrabTransfer API key |
|
|
89
|
+
| `CRABTRANSFER_API_BASE_URL` | No | `https://crabtransfer-api-develop.fly.dev` | API base URL |
|
|
90
|
+
|
|
91
|
+
## CLI
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
crabtransfer-mcp --help
|
|
95
|
+
crabtransfer-mcp --version
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## HTTP Transport
|
|
99
|
+
|
|
100
|
+
To run as an HTTP server (Streamable HTTP):
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
crabtransfer-mcp-http
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Or:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
python -m crabtransfer_mcp.http
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Also Available
|
|
113
|
+
|
|
114
|
+
- **Node.js:** `npx -y @crabtransfer/mcp` ([npm](https://www.npmjs.com/package/@crabtransfer/mcp))
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .config import mcp_config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ApiOk:
|
|
13
|
+
ok: bool
|
|
14
|
+
status: int
|
|
15
|
+
data: dict[str, Any]
|
|
16
|
+
|
|
17
|
+
def __init__(self, status: int, data: dict[str, Any]):
|
|
18
|
+
self.ok = True
|
|
19
|
+
self.status = status
|
|
20
|
+
self.data = data
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ApiErr:
|
|
25
|
+
ok: bool
|
|
26
|
+
status: int
|
|
27
|
+
code: str
|
|
28
|
+
message: str
|
|
29
|
+
retry_after_sec: int | None = None
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
status: int,
|
|
34
|
+
code: str,
|
|
35
|
+
message: str,
|
|
36
|
+
retry_after_sec: int | None = None,
|
|
37
|
+
):
|
|
38
|
+
self.ok = False
|
|
39
|
+
self.status = status
|
|
40
|
+
self.code = code
|
|
41
|
+
self.message = message
|
|
42
|
+
self.retry_after_sec = retry_after_sec
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
ApiResult = ApiOk | ApiErr
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _map_error_message(
|
|
49
|
+
status: int,
|
|
50
|
+
code: str | None,
|
|
51
|
+
server_message: str | None,
|
|
52
|
+
) -> str:
|
|
53
|
+
mapping: dict[str, str] = {
|
|
54
|
+
"rate_limit_exceeded": server_message or "Rate limit exceeded. Please wait and retry.",
|
|
55
|
+
"unauthorized": "Invalid or missing API key.",
|
|
56
|
+
"forbidden": "API key does not have required permissions.",
|
|
57
|
+
"size_limit_exceeded": f"File exceeds {mcp_config.max_file_size_bytes // (1024 * 1024)} MB limit.",
|
|
58
|
+
"blocked_mime_type": server_message or "File type not allowed.",
|
|
59
|
+
"empty_file": "File cannot be empty.",
|
|
60
|
+
"upload_worker_unavailable": "Upload service temporarily unavailable. Please retry.",
|
|
61
|
+
"storage_degraded": "Storage backend temporarily degraded. Please retry in 30s.",
|
|
62
|
+
"concurrency_limit_reached": "Too many concurrent uploads. Please wait and retry.",
|
|
63
|
+
"idempotency_in_progress": "A request with this idempotency key is already being processed.",
|
|
64
|
+
"invalid_url": server_message or "URL is not allowed.",
|
|
65
|
+
"ssrf_blocked": server_message or "URL is not allowed.",
|
|
66
|
+
"fetch_failed": server_message or "Failed to fetch file from URL.",
|
|
67
|
+
"fetch_timeout": "Timed out fetching file from URL.",
|
|
68
|
+
"upload_not_found": "Upload not found.",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if code and code in mapping:
|
|
72
|
+
return mapping[code]
|
|
73
|
+
if status >= 500:
|
|
74
|
+
return "Upload service error. Please retry."
|
|
75
|
+
return server_message or f"Request failed ({status})."
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _parse_error(response: httpx.Response) -> ApiErr:
|
|
79
|
+
try:
|
|
80
|
+
body = response.json()
|
|
81
|
+
except Exception:
|
|
82
|
+
body = None
|
|
83
|
+
|
|
84
|
+
error_obj = (body or {}).get("error", {}) if isinstance(body, dict) else {}
|
|
85
|
+
code = error_obj.get("code") or f"http_{response.status_code}"
|
|
86
|
+
server_message = error_obj.get("message")
|
|
87
|
+
|
|
88
|
+
retry_after_sec = error_obj.get("retryAfterSec")
|
|
89
|
+
if retry_after_sec is None:
|
|
90
|
+
raw_retry = response.headers.get("Retry-After")
|
|
91
|
+
if raw_retry:
|
|
92
|
+
try:
|
|
93
|
+
retry_after_sec = int(raw_retry)
|
|
94
|
+
except ValueError:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
return ApiErr(
|
|
98
|
+
status=response.status_code,
|
|
99
|
+
code=code,
|
|
100
|
+
message=_map_error_message(response.status_code, code, server_message),
|
|
101
|
+
retry_after_sec=retry_after_sec,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _client() -> httpx.AsyncClient:
|
|
106
|
+
return httpx.AsyncClient(
|
|
107
|
+
timeout=httpx.Timeout(mcp_config.api_timeout_ms / 1000),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def api_get(path: str, agent_key: str) -> ApiResult:
|
|
112
|
+
url = f"{mcp_config.api_base_url}{path}"
|
|
113
|
+
async with _client() as client:
|
|
114
|
+
try:
|
|
115
|
+
resp = await client.get(url, headers={"X-Agent-Key": agent_key})
|
|
116
|
+
except httpx.TimeoutException:
|
|
117
|
+
return ApiErr(0, "timeout", f"Request timed out after {mcp_config.api_timeout_ms}ms")
|
|
118
|
+
except httpx.HTTPError as exc:
|
|
119
|
+
return ApiErr(0, "network_error", f"Upload service error: {exc}")
|
|
120
|
+
|
|
121
|
+
if resp.is_success:
|
|
122
|
+
return ApiOk(resp.status_code, resp.json())
|
|
123
|
+
return _parse_error(resp)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def api_post_json(
|
|
127
|
+
path: str,
|
|
128
|
+
agent_key: str,
|
|
129
|
+
body: dict[str, Any],
|
|
130
|
+
extra_headers: dict[str, str] | None = None,
|
|
131
|
+
) -> ApiResult:
|
|
132
|
+
url = f"{mcp_config.api_base_url}{path}"
|
|
133
|
+
headers: dict[str, str] = {"X-Agent-Key": agent_key, "Content-Type": "application/json"}
|
|
134
|
+
if extra_headers:
|
|
135
|
+
headers.update(extra_headers)
|
|
136
|
+
|
|
137
|
+
async with _client() as client:
|
|
138
|
+
try:
|
|
139
|
+
resp = await client.post(url, headers=headers, json=body)
|
|
140
|
+
except httpx.TimeoutException:
|
|
141
|
+
return ApiErr(0, "timeout", f"Request timed out after {mcp_config.api_timeout_ms}ms")
|
|
142
|
+
except httpx.HTTPError as exc:
|
|
143
|
+
return ApiErr(0, "network_error", f"Upload service error: {exc}")
|
|
144
|
+
|
|
145
|
+
if resp.is_success:
|
|
146
|
+
return ApiOk(resp.status_code, resp.json())
|
|
147
|
+
return _parse_error(resp)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def api_post_multipart(
|
|
151
|
+
path: str,
|
|
152
|
+
agent_key: str,
|
|
153
|
+
file_data: bytes,
|
|
154
|
+
file_name: str,
|
|
155
|
+
mime_type: str = "application/octet-stream",
|
|
156
|
+
extra_headers: dict[str, str] | None = None,
|
|
157
|
+
) -> ApiResult:
|
|
158
|
+
url = f"{mcp_config.api_base_url}{path}"
|
|
159
|
+
headers: dict[str, str] = {"X-Agent-Key": agent_key}
|
|
160
|
+
if extra_headers:
|
|
161
|
+
headers.update(extra_headers)
|
|
162
|
+
|
|
163
|
+
files = {"file": (file_name, file_data, mime_type)}
|
|
164
|
+
|
|
165
|
+
async with _client() as client:
|
|
166
|
+
try:
|
|
167
|
+
resp = await client.post(url, headers=headers, files=files)
|
|
168
|
+
except httpx.TimeoutException:
|
|
169
|
+
return ApiErr(0, "timeout", f"Request timed out after {mcp_config.api_timeout_ms}ms")
|
|
170
|
+
except httpx.HTTPError as exc:
|
|
171
|
+
return ApiErr(0, "network_error", f"Upload service error: {exc}")
|
|
172
|
+
|
|
173
|
+
if resp.is_success:
|
|
174
|
+
return ApiOk(resp.status_code, resp.json())
|
|
175
|
+
return _parse_error(resp)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class AuthContext:
|
|
9
|
+
agent_key: str
|
|
10
|
+
token_prefix: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve_auth() -> AuthContext | None:
|
|
14
|
+
"""Resolve API key from environment variable (stdio transport)."""
|
|
15
|
+
env_key = (os.environ.get("AGENTVEE_API_KEY") or "").strip()
|
|
16
|
+
if env_key:
|
|
17
|
+
return AuthContext(agent_key=env_key, token_prefix=env_key[:12])
|
|
18
|
+
return None
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
TransportMode = Literal["http", "stdio"]
|
|
8
|
+
|
|
9
|
+
_transport_mode: TransportMode = "http"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def set_transport_mode(mode: TransportMode) -> None:
|
|
13
|
+
global _transport_mode
|
|
14
|
+
_transport_mode = mode
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_transport_mode() -> TransportMode:
|
|
18
|
+
return _transport_mode
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _optional_env(name: str, fallback: str) -> str:
|
|
22
|
+
val = (os.environ.get(name) or "").strip()
|
|
23
|
+
return val if val else fallback
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _number_env(name: str, fallback: int) -> int:
|
|
27
|
+
raw = os.environ.get(name)
|
|
28
|
+
if not raw:
|
|
29
|
+
return fallback
|
|
30
|
+
try:
|
|
31
|
+
parsed = int(raw)
|
|
32
|
+
return parsed if parsed > 0 else fallback
|
|
33
|
+
except ValueError:
|
|
34
|
+
return fallback
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ThrottleConfig:
|
|
39
|
+
upload_window_ms: int = 15 * 60 * 1000
|
|
40
|
+
upload_max_requests: int = 30
|
|
41
|
+
status_window_ms: int = 15 * 60 * 1000
|
|
42
|
+
status_max_requests: int = 120
|
|
43
|
+
download_url_window_ms: int = 15 * 60 * 1000
|
|
44
|
+
download_url_max_requests: int = 60
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class WaitConfig:
|
|
49
|
+
max_concurrent: int = 50
|
|
50
|
+
max_duration_ms: int = 120_000
|
|
51
|
+
poll_initial_ms: int = 2_000
|
|
52
|
+
poll_max_ms: int = 8_000
|
|
53
|
+
poll_backoff_factor: float = 1.5
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class SessionConfig:
|
|
58
|
+
max_total: int = 1000
|
|
59
|
+
max_per_token: int = 50
|
|
60
|
+
idle_timeout_ms: int = 5 * 60 * 1000
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class McpConfig:
|
|
65
|
+
api_base_url: str = ""
|
|
66
|
+
port: int = 8082
|
|
67
|
+
allowed_origins: list[str] = field(default_factory=list)
|
|
68
|
+
log_level: str = "info"
|
|
69
|
+
max_file_size_bytes: int = 5 * 1024 * 1024
|
|
70
|
+
api_timeout_ms: int = 60_000
|
|
71
|
+
metrics_token: str = ""
|
|
72
|
+
throttle: ThrottleConfig = field(default_factory=ThrottleConfig)
|
|
73
|
+
wait: WaitConfig = field(default_factory=WaitConfig)
|
|
74
|
+
session: SessionConfig = field(default_factory=SessionConfig)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _build_config() -> McpConfig:
|
|
78
|
+
origins_raw = _optional_env("MCP_ALLOWED_ORIGINS", "")
|
|
79
|
+
origins = [o.strip() for o in origins_raw.split(",") if o.strip()]
|
|
80
|
+
|
|
81
|
+
return McpConfig(
|
|
82
|
+
api_base_url=_optional_env(
|
|
83
|
+
"AGENTVEE_API_BASE_URL",
|
|
84
|
+
"https://agentvee-api-develop.fly.dev",
|
|
85
|
+
).rstrip("/"),
|
|
86
|
+
port=_number_env("MCP_PORT", 8082),
|
|
87
|
+
allowed_origins=origins,
|
|
88
|
+
log_level=_optional_env("MCP_LOG_LEVEL", "info"),
|
|
89
|
+
max_file_size_bytes=5 * 1024 * 1024,
|
|
90
|
+
api_timeout_ms=_number_env("MCP_API_TIMEOUT_MS", 60_000),
|
|
91
|
+
metrics_token=_optional_env("MCP_METRICS_TOKEN", ""),
|
|
92
|
+
throttle=ThrottleConfig(),
|
|
93
|
+
wait=WaitConfig(
|
|
94
|
+
max_concurrent=_number_env("MCP_WAIT_MAX_CONCURRENT", 50),
|
|
95
|
+
max_duration_ms=_number_env("MCP_WAIT_MAX_DURATION_MS", 120_000),
|
|
96
|
+
poll_initial_ms=_number_env("MCP_WAIT_POLL_INITIAL_MS", 2_000),
|
|
97
|
+
poll_max_ms=_number_env("MCP_WAIT_POLL_MAX_MS", 8_000),
|
|
98
|
+
),
|
|
99
|
+
session=SessionConfig(
|
|
100
|
+
max_total=_number_env("MCP_SESSION_MAX_TOTAL", 1000),
|
|
101
|
+
max_per_token=_number_env("MCP_SESSION_MAX_PER_TOKEN", 50),
|
|
102
|
+
idle_timeout_ms=_number_env("MCP_SESSION_IDLE_TIMEOUT_MS", 5 * 60 * 1000),
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
mcp_config = _build_config()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from .config import mcp_config, set_transport_mode
|
|
6
|
+
from .security.audit import audit_log
|
|
7
|
+
from .server import create_mcp_server
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
set_transport_mode("http")
|
|
12
|
+
server = create_mcp_server()
|
|
13
|
+
audit_log({"event": "startup", "transport": "streamable-http", "port": mcp_config.port})
|
|
14
|
+
server.run(transport="streamable-http", host="0.0.0.0", port=mcp_config.port)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def audit_log(entry: dict[str, Any]) -> None:
|
|
10
|
+
record = {"ts": datetime.now(timezone.utc).isoformat(), **entry}
|
|
11
|
+
print(json.dumps(record, default=str), file=sys.stderr, flush=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def sanitize_args(tool_name: str, args: dict[str, Any]) -> dict[str, Any]:
|
|
15
|
+
"""Sanitize tool arguments for logging — never log tokens or large payloads."""
|
|
16
|
+
safe: dict[str, Any] = {}
|
|
17
|
+
for key, value in args.items():
|
|
18
|
+
if key == "content" and isinstance(value, str):
|
|
19
|
+
safe["contentSizeBytes"] = len(value) * 3 // 4
|
|
20
|
+
continue
|
|
21
|
+
if isinstance(value, str) and len(value) > 200:
|
|
22
|
+
safe[key] = f"{value[:50]}...[{len(value)} chars]"
|
|
23
|
+
continue
|
|
24
|
+
safe[key] = value
|
|
25
|
+
return safe
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
_started_at = time.monotonic()
|
|
8
|
+
_total_requests = 0
|
|
9
|
+
_auth_failures = 0
|
|
10
|
+
_throttle_rejections = 0
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class _ToolMetrics:
|
|
15
|
+
calls: int = 0
|
|
16
|
+
errors: int = 0
|
|
17
|
+
total_latency_ms: float = 0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_tool_metrics: dict[str, _ToolMetrics] = {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_or_create(tool: str) -> _ToolMetrics:
|
|
24
|
+
if tool not in _tool_metrics:
|
|
25
|
+
_tool_metrics[tool] = _ToolMetrics()
|
|
26
|
+
return _tool_metrics[tool]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def record_tool_call(tool: str, duration_ms: float, is_error: bool) -> None:
|
|
30
|
+
global _total_requests
|
|
31
|
+
_total_requests += 1
|
|
32
|
+
m = _get_or_create(tool)
|
|
33
|
+
m.calls += 1
|
|
34
|
+
m.total_latency_ms += duration_ms
|
|
35
|
+
if is_error:
|
|
36
|
+
m.errors += 1
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def record_auth_failure() -> None:
|
|
40
|
+
global _auth_failures
|
|
41
|
+
_auth_failures += 1
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def record_throttle_rejection() -> None:
|
|
45
|
+
global _throttle_rejections
|
|
46
|
+
_throttle_rejections += 1
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def metrics_snapshot(active_sessions: int = 0, active_waits: int = 0) -> dict[str, Any]:
|
|
50
|
+
tools: dict[str, Any] = {}
|
|
51
|
+
for name, m in _tool_metrics.items():
|
|
52
|
+
tools[name] = {
|
|
53
|
+
"calls": m.calls,
|
|
54
|
+
"errors": m.errors,
|
|
55
|
+
"avgLatencyMs": round(m.total_latency_ms / m.calls) if m.calls > 0 else 0,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
"uptimeSeconds": int(time.monotonic() - _started_at),
|
|
60
|
+
"activeSessions": active_sessions,
|
|
61
|
+
"activeWaits": active_waits,
|
|
62
|
+
"totalRequests": _total_requests,
|
|
63
|
+
"authFailures": _auth_failures,
|
|
64
|
+
"throttleRejections": _throttle_rejections,
|
|
65
|
+
"tools": tools,
|
|
66
|
+
}
|