yuuno-mcp 0.1.0__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.
- yuuno_mcp-0.1.0/PKG-INFO +153 -0
- yuuno_mcp-0.1.0/README.md +125 -0
- yuuno_mcp-0.1.0/pyproject.toml +53 -0
- yuuno_mcp-0.1.0/setup.cfg +4 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp/__init__.py +3 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp/auth.py +151 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp/cli.py +61 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp/client.py +387 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp/config.py +107 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp/dag/__init__.py +1 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp/dag/constants.py +128 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp/dag/layout.py +179 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp/dag/models.py +353 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp/dag/ops.py +403 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp/dag/scaffold.py +77 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp/health.py +64 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp/server.py +119 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp/tools/__init__.py +1 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp/tools/common.py +134 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp/tools/dag.py +817 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp/tools/docs.py +470 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp/tools/generate.py +560 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp/tools/runs.py +230 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp/tools/sessions.py +144 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp/tools/structure.py +422 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp.egg-info/PKG-INFO +153 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp.egg-info/SOURCES.txt +41 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp.egg-info/dependency_links.txt +1 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp.egg-info/entry_points.txt +2 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp.egg-info/requires.txt +11 -0
- yuuno_mcp-0.1.0/src/yuuno_mcp.egg-info/top_level.txt +1 -0
- yuuno_mcp-0.1.0/tests/test_auth.py +126 -0
- yuuno_mcp-0.1.0/tests/test_client.py +260 -0
- yuuno_mcp-0.1.0/tests/test_health.py +104 -0
- yuuno_mcp-0.1.0/tests/test_layout.py +136 -0
- yuuno_mcp-0.1.0/tests/test_ops.py +279 -0
- yuuno_mcp-0.1.0/tests/test_parity.py +81 -0
- yuuno_mcp-0.1.0/tests/test_tools.py +415 -0
- yuuno_mcp-0.1.0/tests/test_tools_dag.py +689 -0
- yuuno_mcp-0.1.0/tests/test_tools_docs.py +127 -0
- yuuno_mcp-0.1.0/tests/test_tools_generate.py +492 -0
- yuuno_mcp-0.1.0/tests/test_tools_runs.py +227 -0
- yuuno_mcp-0.1.0/tests/test_tools_structure.py +363 -0
yuuno_mcp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yuuno-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for yuuno.tech adflow — build and run AI media canvases from Claude/Cursor.
|
|
5
|
+
Author: yuuno.tech
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://app.yuuno.tech
|
|
8
|
+
Project-URL: Documentation, https://app.yuuno.tech/settings/organization/api
|
|
9
|
+
Keywords: mcp,model-context-protocol,yuuno,adflow,ai-video,llm-tools
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Multimedia :: Video
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: fastmcp~=3.4
|
|
20
|
+
Requires-Dist: httpx
|
|
21
|
+
Provides-Extra: http
|
|
22
|
+
Requires-Dist: uvicorn[standard]; extra == "http"
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
26
|
+
Requires-Dist: respx; extra == "dev"
|
|
27
|
+
Requires-Dist: uvicorn[standard]; extra == "dev"
|
|
28
|
+
|
|
29
|
+
# yuuno-mcp
|
|
30
|
+
|
|
31
|
+
MCP server for [yuuno.tech](https://app.yuuno.tech) **adflow** — drive the
|
|
32
|
+
node-based AI media canvas from an MCP client (Claude Code, Claude Desktop,
|
|
33
|
+
Cursor, …). You become an agent on the board: create sessions, wire up
|
|
34
|
+
image/video/audio/text generation nodes, run flows, and read results — the
|
|
35
|
+
same canvas the in-app chat edits, so your changes appear live in the browser.
|
|
36
|
+
|
|
37
|
+
## Get an API key
|
|
38
|
+
|
|
39
|
+
Mint a workspace key at
|
|
40
|
+
**app.yuuno.tech → Settings → Organization → API**
|
|
41
|
+
(`https://app.yuuno.tech/settings/organization/api`). It's a 40-character hex
|
|
42
|
+
string scoped to your workspace. Rotating it (Reset) invalidates the previous
|
|
43
|
+
key for every connected client in that workspace.
|
|
44
|
+
|
|
45
|
+
## Connect
|
|
46
|
+
|
|
47
|
+
### Claude Code
|
|
48
|
+
|
|
49
|
+
Local (stdio, recommended — works everywhere):
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
claude mcp add yuuno -e YUUNO_API_KEY=<your-40-hex-key> -- uvx yuuno-mcp
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Hosted (Streamable HTTP — no local install):
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
claude mcp add --transport http yuuno https://mcp.yuuno.tech/mcp \
|
|
59
|
+
--header "Authorization: Bearer <your-40-hex-key>"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Claude Desktop
|
|
63
|
+
|
|
64
|
+
Add to `claude_desktop_config.json` (stdio; Desktop connectors don't pass
|
|
65
|
+
custom headers, so use stdio):
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"mcpServers": {
|
|
70
|
+
"yuuno": {
|
|
71
|
+
"command": "uvx",
|
|
72
|
+
"args": ["yuuno-mcp"],
|
|
73
|
+
"env": { "YUUNO_API_KEY": "<your-40-hex-key>" }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Cursor
|
|
80
|
+
|
|
81
|
+
Add to `~/.cursor/mcp.json` (or a project `.cursor/mcp.json`):
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"mcpServers": {
|
|
86
|
+
"yuuno": {
|
|
87
|
+
"url": "https://mcp.yuuno.tech/mcp",
|
|
88
|
+
"headers": { "Authorization": "Bearer ${env:YUUNO_API_KEY}" }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Cursor also supports stdio (`"command": "uvx", "args": ["yuuno-mcp"]`).
|
|
95
|
+
|
|
96
|
+
> **Client support note:** remote (hosted HTTP + `Authorization` header) works
|
|
97
|
+
> today in Claude Code and Cursor. Claude Desktop and claude.ai custom
|
|
98
|
+
> connectors don't pass static headers — use stdio (`uvx`) for Desktop; OAuth
|
|
99
|
+
> for claude.ai is a later release.
|
|
100
|
+
|
|
101
|
+
## First moves for the agent
|
|
102
|
+
|
|
103
|
+
Call **`get_cheatsheet`** once at the start of a session — it explains the 18
|
|
104
|
+
node types, the DAG op rules, the run lifecycle, and model selection. Then a
|
|
105
|
+
typical build is: `list_models` → `create_session` → `apply_ops_batch` (build
|
|
106
|
+
the graph) → `auto_layout` → `estimate_credits` → `run_flow` → `wait_for_run`.
|
|
107
|
+
`list_examples` returns ready-to-apply op recipes.
|
|
108
|
+
|
|
109
|
+
## Tools (37)
|
|
110
|
+
|
|
111
|
+
- **Sessions** — `whoami`, `create_session`, `list_sessions`, `get_dag`,
|
|
112
|
+
`copy_session`, `delete_session`
|
|
113
|
+
- **DAG ops** — `add_node`, `add_node_with_inputs`, `update_node`,
|
|
114
|
+
`remove_node`, `add_edge`, `update_edge`, `remove_edge`, `apply_ops_batch`,
|
|
115
|
+
`set_uploaded_assets`
|
|
116
|
+
- **Structure** — `add_group`, `move_to_group`, `ungroup`, `replace_edge`,
|
|
117
|
+
`insert_node_on_edge`, `bypass_node`, `list_node_io`, `auto_layout`
|
|
118
|
+
- **Runs** — `run_flow`, `get_run_status`, `list_runs`, `wait_for_run`,
|
|
119
|
+
`estimate_credits`
|
|
120
|
+
- **Generate / catalog** — `execute_utility_node`, `wait_for_task`,
|
|
121
|
+
`generate_text`, `generate_asset`, `list_models`, `get_model_schema`,
|
|
122
|
+
`upload_asset`
|
|
123
|
+
- **Docs** — `get_cheatsheet`, `list_examples` (also exposed as MCP resources
|
|
124
|
+
`yuuno://cheatsheet` and `yuuno://examples/<name>`)
|
|
125
|
+
|
|
126
|
+
## Configuration
|
|
127
|
+
|
|
128
|
+
| var | default | purpose |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| `YUUNO_API_KEY` | — | workspace API key (40 hex), stdio mode |
|
|
131
|
+
| `YUUNO_BACKEND_URL` | `https://api.yuuno.tech` | Django backend base URL |
|
|
132
|
+
| `YUUNO_APP_URL` | `https://app.yuuno.tech` | SPA base URL (canvas links) |
|
|
133
|
+
| `YUUNO_WORKSPACE_ID` | — | optional workspace uuid (`X-API-ID`), stdio only |
|
|
134
|
+
| `YUUNO_JWT` | — | dev fallback: SimpleJWT access token |
|
|
135
|
+
| `MCP_HTTP_HOST` / `MCP_HTTP_PORT` | `127.0.0.1` / `8765` | `--transport http` bind |
|
|
136
|
+
|
|
137
|
+
In hosted HTTP mode the key is never held server-side — each request carries
|
|
138
|
+
its own `Authorization: Bearer <key>`, verified against the backend, and the
|
|
139
|
+
server proxies that exact key to Django (no cross-tenant state).
|
|
140
|
+
|
|
141
|
+
## Development
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
cd mcp
|
|
145
|
+
python3.12 -m venv .venv
|
|
146
|
+
.venv/bin/pip install -e '.[dev]'
|
|
147
|
+
.venv/bin/python -m pytest tests -q
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The contract-parity test (`tests/test_parity.py`) imports
|
|
151
|
+
`adflow.dag_protocol.constants` from the monorepo root and asserts the mirrored
|
|
152
|
+
constants match; it auto-skips outside the monorepo. Deploy artifacts (systemd
|
|
153
|
+
units, nginx config, operator runbook) are under [`deploy/`](deploy/).
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# yuuno-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for [yuuno.tech](https://app.yuuno.tech) **adflow** — drive the
|
|
4
|
+
node-based AI media canvas from an MCP client (Claude Code, Claude Desktop,
|
|
5
|
+
Cursor, …). You become an agent on the board: create sessions, wire up
|
|
6
|
+
image/video/audio/text generation nodes, run flows, and read results — the
|
|
7
|
+
same canvas the in-app chat edits, so your changes appear live in the browser.
|
|
8
|
+
|
|
9
|
+
## Get an API key
|
|
10
|
+
|
|
11
|
+
Mint a workspace key at
|
|
12
|
+
**app.yuuno.tech → Settings → Organization → API**
|
|
13
|
+
(`https://app.yuuno.tech/settings/organization/api`). It's a 40-character hex
|
|
14
|
+
string scoped to your workspace. Rotating it (Reset) invalidates the previous
|
|
15
|
+
key for every connected client in that workspace.
|
|
16
|
+
|
|
17
|
+
## Connect
|
|
18
|
+
|
|
19
|
+
### Claude Code
|
|
20
|
+
|
|
21
|
+
Local (stdio, recommended — works everywhere):
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
claude mcp add yuuno -e YUUNO_API_KEY=<your-40-hex-key> -- uvx yuuno-mcp
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Hosted (Streamable HTTP — no local install):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
claude mcp add --transport http yuuno https://mcp.yuuno.tech/mcp \
|
|
31
|
+
--header "Authorization: Bearer <your-40-hex-key>"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Claude Desktop
|
|
35
|
+
|
|
36
|
+
Add to `claude_desktop_config.json` (stdio; Desktop connectors don't pass
|
|
37
|
+
custom headers, so use stdio):
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"mcpServers": {
|
|
42
|
+
"yuuno": {
|
|
43
|
+
"command": "uvx",
|
|
44
|
+
"args": ["yuuno-mcp"],
|
|
45
|
+
"env": { "YUUNO_API_KEY": "<your-40-hex-key>" }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Cursor
|
|
52
|
+
|
|
53
|
+
Add to `~/.cursor/mcp.json` (or a project `.cursor/mcp.json`):
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"mcpServers": {
|
|
58
|
+
"yuuno": {
|
|
59
|
+
"url": "https://mcp.yuuno.tech/mcp",
|
|
60
|
+
"headers": { "Authorization": "Bearer ${env:YUUNO_API_KEY}" }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Cursor also supports stdio (`"command": "uvx", "args": ["yuuno-mcp"]`).
|
|
67
|
+
|
|
68
|
+
> **Client support note:** remote (hosted HTTP + `Authorization` header) works
|
|
69
|
+
> today in Claude Code and Cursor. Claude Desktop and claude.ai custom
|
|
70
|
+
> connectors don't pass static headers — use stdio (`uvx`) for Desktop; OAuth
|
|
71
|
+
> for claude.ai is a later release.
|
|
72
|
+
|
|
73
|
+
## First moves for the agent
|
|
74
|
+
|
|
75
|
+
Call **`get_cheatsheet`** once at the start of a session — it explains the 18
|
|
76
|
+
node types, the DAG op rules, the run lifecycle, and model selection. Then a
|
|
77
|
+
typical build is: `list_models` → `create_session` → `apply_ops_batch` (build
|
|
78
|
+
the graph) → `auto_layout` → `estimate_credits` → `run_flow` → `wait_for_run`.
|
|
79
|
+
`list_examples` returns ready-to-apply op recipes.
|
|
80
|
+
|
|
81
|
+
## Tools (37)
|
|
82
|
+
|
|
83
|
+
- **Sessions** — `whoami`, `create_session`, `list_sessions`, `get_dag`,
|
|
84
|
+
`copy_session`, `delete_session`
|
|
85
|
+
- **DAG ops** — `add_node`, `add_node_with_inputs`, `update_node`,
|
|
86
|
+
`remove_node`, `add_edge`, `update_edge`, `remove_edge`, `apply_ops_batch`,
|
|
87
|
+
`set_uploaded_assets`
|
|
88
|
+
- **Structure** — `add_group`, `move_to_group`, `ungroup`, `replace_edge`,
|
|
89
|
+
`insert_node_on_edge`, `bypass_node`, `list_node_io`, `auto_layout`
|
|
90
|
+
- **Runs** — `run_flow`, `get_run_status`, `list_runs`, `wait_for_run`,
|
|
91
|
+
`estimate_credits`
|
|
92
|
+
- **Generate / catalog** — `execute_utility_node`, `wait_for_task`,
|
|
93
|
+
`generate_text`, `generate_asset`, `list_models`, `get_model_schema`,
|
|
94
|
+
`upload_asset`
|
|
95
|
+
- **Docs** — `get_cheatsheet`, `list_examples` (also exposed as MCP resources
|
|
96
|
+
`yuuno://cheatsheet` and `yuuno://examples/<name>`)
|
|
97
|
+
|
|
98
|
+
## Configuration
|
|
99
|
+
|
|
100
|
+
| var | default | purpose |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `YUUNO_API_KEY` | — | workspace API key (40 hex), stdio mode |
|
|
103
|
+
| `YUUNO_BACKEND_URL` | `https://api.yuuno.tech` | Django backend base URL |
|
|
104
|
+
| `YUUNO_APP_URL` | `https://app.yuuno.tech` | SPA base URL (canvas links) |
|
|
105
|
+
| `YUUNO_WORKSPACE_ID` | — | optional workspace uuid (`X-API-ID`), stdio only |
|
|
106
|
+
| `YUUNO_JWT` | — | dev fallback: SimpleJWT access token |
|
|
107
|
+
| `MCP_HTTP_HOST` / `MCP_HTTP_PORT` | `127.0.0.1` / `8765` | `--transport http` bind |
|
|
108
|
+
|
|
109
|
+
In hosted HTTP mode the key is never held server-side — each request carries
|
|
110
|
+
its own `Authorization: Bearer <key>`, verified against the backend, and the
|
|
111
|
+
server proxies that exact key to Django (no cross-tenant state).
|
|
112
|
+
|
|
113
|
+
## Development
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
cd mcp
|
|
117
|
+
python3.12 -m venv .venv
|
|
118
|
+
.venv/bin/pip install -e '.[dev]'
|
|
119
|
+
.venv/bin/python -m pytest tests -q
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The contract-parity test (`tests/test_parity.py`) imports
|
|
123
|
+
`adflow.dag_protocol.constants` from the monorepo root and asserts the mirrored
|
|
124
|
+
constants match; it auto-skips outside the monorepo. Deploy artifacts (systemd
|
|
125
|
+
units, nginx config, operator runbook) are under [`deploy/`](deploy/).
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "yuuno-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server for yuuno.tech adflow — build and run AI media canvases from Claude/Cursor."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = { text = "Proprietary" }
|
|
12
|
+
authors = [{ name = "yuuno.tech" }]
|
|
13
|
+
keywords = ["mcp", "model-context-protocol", "yuuno", "adflow", "ai-video", "llm-tools"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Topic :: Multimedia :: Video",
|
|
21
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"fastmcp~=3.4",
|
|
25
|
+
"httpx",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
# Hosted Streamable-HTTP transport (uvicorn yuuno_mcp.server:app). Not needed
|
|
30
|
+
# for the stdio transport (uvx yuuno-mcp), which is the default.
|
|
31
|
+
http = [
|
|
32
|
+
"uvicorn[standard]",
|
|
33
|
+
]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest",
|
|
36
|
+
"pytest-asyncio",
|
|
37
|
+
"respx",
|
|
38
|
+
"uvicorn[standard]",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://app.yuuno.tech"
|
|
43
|
+
Documentation = "https://app.yuuno.tech/settings/organization/api"
|
|
44
|
+
|
|
45
|
+
[project.scripts]
|
|
46
|
+
yuuno-mcp = "yuuno_mcp.cli:main"
|
|
47
|
+
|
|
48
|
+
[tool.setuptools.packages.find]
|
|
49
|
+
where = ["src"]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
asyncio_mode = "auto"
|
|
53
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Hosted-HTTP token verification (Bearer API key → backend probe).
|
|
2
|
+
|
|
3
|
+
Transports:
|
|
4
|
+
|
|
5
|
+
- stdio (single-tenant): no verifier involved; the user's API key comes
|
|
6
|
+
from ``YUUNO_API_KEY`` in the environment (see server.default_client_factory).
|
|
7
|
+
- hosted Streamable HTTP (multi-tenant): each request carries the caller's
|
|
8
|
+
key as ``Authorization: Bearer <40-hex>``; FastMCP's auth layer verifies
|
|
9
|
+
it via :class:`YuunoTokenVerifier` and tools recover it per-request via
|
|
10
|
+
``get_access_token()``.
|
|
11
|
+
|
|
12
|
+
The verifier is a thin gate: it checks the strict 40-hex format, then probes
|
|
13
|
+
the Django backend (default ``GET /api/v1/me/`` with ``X-API-KEY``). Probe
|
|
14
|
+
outcomes are tri-state:
|
|
15
|
+
|
|
16
|
+
- VALID (2xx) → cached positive for 60s.
|
|
17
|
+
- INVALID (401/403) → cached negative for 60s.
|
|
18
|
+
- TRANSIENT (other statuses / transport error) → NEVER cached; a
|
|
19
|
+
previously-verified key keeps working for up to 10 minutes (stale-grace),
|
|
20
|
+
otherwise the single request is rejected without poisoning the cache.
|
|
21
|
+
|
|
22
|
+
Real authorization is enforced by Django on every proxied call — tools
|
|
23
|
+
always forward the caller's own key.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
import time
|
|
30
|
+
from typing import Literal
|
|
31
|
+
|
|
32
|
+
import httpx
|
|
33
|
+
from fastmcp.server.auth import AccessToken, TokenVerifier
|
|
34
|
+
|
|
35
|
+
from yuuno_mcp.config import Settings
|
|
36
|
+
|
|
37
|
+
# Strict: 40 lowercase/uppercase hex chars, no prefix (secrets.token_hex(20)).
|
|
38
|
+
API_KEY_RE = re.compile(r"^[0-9a-fA-F]{40}$")
|
|
39
|
+
|
|
40
|
+
VERIFY_CACHE_TTL_SECONDS = 60.0
|
|
41
|
+
STALE_GRACE_SECONDS = 600.0
|
|
42
|
+
MAX_CACHE_ENTRIES = 1024
|
|
43
|
+
|
|
44
|
+
ProbeOutcome = Literal["valid", "invalid", "transient"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_valid_key_format(token: str) -> bool:
|
|
48
|
+
"""True iff *token* is a well-formed 40-hex yuuno API key."""
|
|
49
|
+
return bool(API_KEY_RE.fullmatch(token or ""))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class YuunoTokenVerifier(TokenVerifier):
|
|
53
|
+
"""Verify a Bearer API key against the yuuno backend.
|
|
54
|
+
|
|
55
|
+
Rejects anything that is not exactly 40 hex chars, then probes
|
|
56
|
+
``GET {backend}{probe_path}`` (default /api/v1/me/) with
|
|
57
|
+
``X-API-KEY: <key>``. See module
|
|
58
|
+
docstring for the tri-state caching policy.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
settings: Settings,
|
|
64
|
+
*,
|
|
65
|
+
http_client: httpx.AsyncClient | None = None,
|
|
66
|
+
cache_ttl: float = VERIFY_CACHE_TTL_SECONDS,
|
|
67
|
+
stale_grace: float = STALE_GRACE_SECONDS,
|
|
68
|
+
max_entries: int = MAX_CACHE_ENTRIES,
|
|
69
|
+
) -> None:
|
|
70
|
+
super().__init__()
|
|
71
|
+
self._settings = settings
|
|
72
|
+
self._probe_url = settings.backend_url + settings.auth_probe_path
|
|
73
|
+
self._cache_ttl = cache_ttl
|
|
74
|
+
self._stale_grace = stale_grace
|
|
75
|
+
self._max_entries = max_entries
|
|
76
|
+
# key -> (inserted_monotonic, verified: bool)
|
|
77
|
+
self._cache: dict[str, tuple[float, bool]] = {}
|
|
78
|
+
self._http = http_client
|
|
79
|
+
|
|
80
|
+
async def _probe(self, token: str) -> ProbeOutcome:
|
|
81
|
+
"""Tri-state backend probe.
|
|
82
|
+
|
|
83
|
+
2xx → valid; 401/403 → invalid (the backend affirmatively rejected
|
|
84
|
+
the key); anything else (5xx, 429, unexpected 4xx like a
|
|
85
|
+
misconfigured probe path, transport errors) → transient — the
|
|
86
|
+
backend did NOT judge the key, so the outcome must not be cached.
|
|
87
|
+
"""
|
|
88
|
+
client = self._http
|
|
89
|
+
owns_client = client is None
|
|
90
|
+
if owns_client:
|
|
91
|
+
client = httpx.AsyncClient(timeout=10.0)
|
|
92
|
+
try:
|
|
93
|
+
response = await client.get(self._probe_url, headers={"X-API-KEY": token})
|
|
94
|
+
except httpx.HTTPError:
|
|
95
|
+
return "transient"
|
|
96
|
+
finally:
|
|
97
|
+
if owns_client:
|
|
98
|
+
await client.aclose()
|
|
99
|
+
if response.is_success:
|
|
100
|
+
return "valid"
|
|
101
|
+
if response.status_code in (401, 403):
|
|
102
|
+
return "invalid"
|
|
103
|
+
return "transient"
|
|
104
|
+
|
|
105
|
+
def _cache_put(self, token: str, verified: bool, now: float) -> None:
|
|
106
|
+
self._cache[token] = (now, verified)
|
|
107
|
+
if len(self._cache) <= self._max_entries:
|
|
108
|
+
return
|
|
109
|
+
# Sweep entries past their maximum useful lifetime (stale-grace
|
|
110
|
+
# covers the positive case; negatives expire earlier anyway).
|
|
111
|
+
cutoff = now - self._stale_grace
|
|
112
|
+
for key in [k for k, (t, _) in self._cache.items() if t < cutoff]:
|
|
113
|
+
del self._cache[key]
|
|
114
|
+
# Still over? Drop oldest-inserted first.
|
|
115
|
+
while len(self._cache) > self._max_entries:
|
|
116
|
+
oldest = min(self._cache.items(), key=lambda kv: kv[1][0])[0]
|
|
117
|
+
del self._cache[oldest]
|
|
118
|
+
|
|
119
|
+
async def verify_token(self, token: str) -> AccessToken | None:
|
|
120
|
+
if not is_valid_key_format(token):
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
now = time.monotonic()
|
|
124
|
+
entry = self._cache.get(token)
|
|
125
|
+
if entry is not None and now - entry[0] < self._cache_ttl:
|
|
126
|
+
verified = entry[1]
|
|
127
|
+
else:
|
|
128
|
+
outcome = await self._probe(token)
|
|
129
|
+
if outcome == "valid":
|
|
130
|
+
self._cache_put(token, True, now)
|
|
131
|
+
verified = True
|
|
132
|
+
elif outcome == "invalid":
|
|
133
|
+
self._cache_put(token, False, now)
|
|
134
|
+
verified = False
|
|
135
|
+
else: # transient — never cached
|
|
136
|
+
# Stale-grace: keep serving a previously-verified key for up
|
|
137
|
+
# to 10 min so one backend 503 can't lock a valid tenant out.
|
|
138
|
+
verified = (
|
|
139
|
+
entry is not None
|
|
140
|
+
and entry[1]
|
|
141
|
+
and now - entry[0] < self._stale_grace
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if not verified:
|
|
145
|
+
return None
|
|
146
|
+
return AccessToken(
|
|
147
|
+
token=token,
|
|
148
|
+
client_id="yuuno-api-key",
|
|
149
|
+
scopes=[],
|
|
150
|
+
expires_at=None,
|
|
151
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Command-line entry point: ``yuuno-mcp [--transport stdio|http]``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from yuuno_mcp import __version__
|
|
9
|
+
from yuuno_mcp.config import Settings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv: list[str] | None = None) -> None:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="yuuno-mcp",
|
|
15
|
+
description=(
|
|
16
|
+
"MCP server for yuuno.tech adflow. stdio mode (default) expects "
|
|
17
|
+
"YUUNO_API_KEY in the environment; http mode serves Streamable "
|
|
18
|
+
"HTTP with per-request Bearer keys."
|
|
19
|
+
),
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--transport",
|
|
23
|
+
choices=("stdio", "http"),
|
|
24
|
+
default="stdio",
|
|
25
|
+
help="Transport to serve (default: stdio)",
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--version", action="version", version=f"yuuno-mcp {__version__}"
|
|
29
|
+
)
|
|
30
|
+
args = parser.parse_args(argv)
|
|
31
|
+
|
|
32
|
+
settings = Settings.from_env()
|
|
33
|
+
|
|
34
|
+
if args.transport == "http":
|
|
35
|
+
import uvicorn
|
|
36
|
+
|
|
37
|
+
# Reuse the module-level ASGI app (the same object systemd runs via
|
|
38
|
+
# ``uvicorn yuuno_mcp.server:app``) so both entry points are identical.
|
|
39
|
+
from yuuno_mcp.server import app
|
|
40
|
+
|
|
41
|
+
uvicorn.run(app, host=settings.http_host, port=settings.http_port)
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
# stdio: single-tenant; a missing key fails fast with guidance instead of
|
|
45
|
+
# failing on the first tool call.
|
|
46
|
+
if not settings.api_key and not settings.jwt:
|
|
47
|
+
print(
|
|
48
|
+
"yuuno-mcp: YUUNO_API_KEY is not set. Generate an API key at "
|
|
49
|
+
f"{settings.app_url}/settings/organization/api and add it to "
|
|
50
|
+
"your MCP server env config.",
|
|
51
|
+
file=sys.stderr,
|
|
52
|
+
)
|
|
53
|
+
raise SystemExit(2)
|
|
54
|
+
|
|
55
|
+
from yuuno_mcp.server import create_server
|
|
56
|
+
|
|
57
|
+
create_server(settings).run()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
main()
|