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.
Files changed (43) hide show
  1. yuuno_mcp-0.1.0/PKG-INFO +153 -0
  2. yuuno_mcp-0.1.0/README.md +125 -0
  3. yuuno_mcp-0.1.0/pyproject.toml +53 -0
  4. yuuno_mcp-0.1.0/setup.cfg +4 -0
  5. yuuno_mcp-0.1.0/src/yuuno_mcp/__init__.py +3 -0
  6. yuuno_mcp-0.1.0/src/yuuno_mcp/auth.py +151 -0
  7. yuuno_mcp-0.1.0/src/yuuno_mcp/cli.py +61 -0
  8. yuuno_mcp-0.1.0/src/yuuno_mcp/client.py +387 -0
  9. yuuno_mcp-0.1.0/src/yuuno_mcp/config.py +107 -0
  10. yuuno_mcp-0.1.0/src/yuuno_mcp/dag/__init__.py +1 -0
  11. yuuno_mcp-0.1.0/src/yuuno_mcp/dag/constants.py +128 -0
  12. yuuno_mcp-0.1.0/src/yuuno_mcp/dag/layout.py +179 -0
  13. yuuno_mcp-0.1.0/src/yuuno_mcp/dag/models.py +353 -0
  14. yuuno_mcp-0.1.0/src/yuuno_mcp/dag/ops.py +403 -0
  15. yuuno_mcp-0.1.0/src/yuuno_mcp/dag/scaffold.py +77 -0
  16. yuuno_mcp-0.1.0/src/yuuno_mcp/health.py +64 -0
  17. yuuno_mcp-0.1.0/src/yuuno_mcp/server.py +119 -0
  18. yuuno_mcp-0.1.0/src/yuuno_mcp/tools/__init__.py +1 -0
  19. yuuno_mcp-0.1.0/src/yuuno_mcp/tools/common.py +134 -0
  20. yuuno_mcp-0.1.0/src/yuuno_mcp/tools/dag.py +817 -0
  21. yuuno_mcp-0.1.0/src/yuuno_mcp/tools/docs.py +470 -0
  22. yuuno_mcp-0.1.0/src/yuuno_mcp/tools/generate.py +560 -0
  23. yuuno_mcp-0.1.0/src/yuuno_mcp/tools/runs.py +230 -0
  24. yuuno_mcp-0.1.0/src/yuuno_mcp/tools/sessions.py +144 -0
  25. yuuno_mcp-0.1.0/src/yuuno_mcp/tools/structure.py +422 -0
  26. yuuno_mcp-0.1.0/src/yuuno_mcp.egg-info/PKG-INFO +153 -0
  27. yuuno_mcp-0.1.0/src/yuuno_mcp.egg-info/SOURCES.txt +41 -0
  28. yuuno_mcp-0.1.0/src/yuuno_mcp.egg-info/dependency_links.txt +1 -0
  29. yuuno_mcp-0.1.0/src/yuuno_mcp.egg-info/entry_points.txt +2 -0
  30. yuuno_mcp-0.1.0/src/yuuno_mcp.egg-info/requires.txt +11 -0
  31. yuuno_mcp-0.1.0/src/yuuno_mcp.egg-info/top_level.txt +1 -0
  32. yuuno_mcp-0.1.0/tests/test_auth.py +126 -0
  33. yuuno_mcp-0.1.0/tests/test_client.py +260 -0
  34. yuuno_mcp-0.1.0/tests/test_health.py +104 -0
  35. yuuno_mcp-0.1.0/tests/test_layout.py +136 -0
  36. yuuno_mcp-0.1.0/tests/test_ops.py +279 -0
  37. yuuno_mcp-0.1.0/tests/test_parity.py +81 -0
  38. yuuno_mcp-0.1.0/tests/test_tools.py +415 -0
  39. yuuno_mcp-0.1.0/tests/test_tools_dag.py +689 -0
  40. yuuno_mcp-0.1.0/tests/test_tools_docs.py +127 -0
  41. yuuno_mcp-0.1.0/tests/test_tools_generate.py +492 -0
  42. yuuno_mcp-0.1.0/tests/test_tools_runs.py +227 -0
  43. yuuno_mcp-0.1.0/tests/test_tools_structure.py +363 -0
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """yuuno-mcp — MCP server for yuuno.tech adflow canvases."""
2
+
3
+ __version__ = "0.1.0"
@@ -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()