nous-genai 0.1.0__py3-none-any.whl
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.
- nous/__init__.py +3 -0
- nous/genai/__init__.py +56 -0
- nous/genai/__main__.py +3 -0
- nous/genai/_internal/__init__.py +1 -0
- nous/genai/_internal/capability_rules.py +476 -0
- nous/genai/_internal/config.py +102 -0
- nous/genai/_internal/errors.py +63 -0
- nous/genai/_internal/http.py +951 -0
- nous/genai/_internal/json_schema.py +54 -0
- nous/genai/cli.py +1316 -0
- nous/genai/client.py +719 -0
- nous/genai/mcp_cli.py +275 -0
- nous/genai/mcp_server.py +1080 -0
- nous/genai/providers/__init__.py +15 -0
- nous/genai/providers/aliyun.py +535 -0
- nous/genai/providers/anthropic.py +483 -0
- nous/genai/providers/gemini.py +1606 -0
- nous/genai/providers/openai.py +1909 -0
- nous/genai/providers/tuzi.py +1158 -0
- nous/genai/providers/volcengine.py +273 -0
- nous/genai/reference/__init__.py +17 -0
- nous/genai/reference/catalog.py +206 -0
- nous/genai/reference/mappings.py +467 -0
- nous/genai/reference/mode_overrides.py +26 -0
- nous/genai/reference/model_catalog.py +82 -0
- nous/genai/reference/model_catalog_data/__init__.py +1 -0
- nous/genai/reference/model_catalog_data/aliyun.py +98 -0
- nous/genai/reference/model_catalog_data/anthropic.py +10 -0
- nous/genai/reference/model_catalog_data/google.py +45 -0
- nous/genai/reference/model_catalog_data/openai.py +44 -0
- nous/genai/reference/model_catalog_data/tuzi_anthropic.py +21 -0
- nous/genai/reference/model_catalog_data/tuzi_google.py +19 -0
- nous/genai/reference/model_catalog_data/tuzi_openai.py +75 -0
- nous/genai/reference/model_catalog_data/tuzi_web.py +136 -0
- nous/genai/reference/model_catalog_data/volcengine.py +107 -0
- nous/genai/tools/__init__.py +13 -0
- nous/genai/tools/output_parser.py +119 -0
- nous/genai/types.py +416 -0
- nous/py.typed +1 -0
- nous_genai-0.1.0.dist-info/METADATA +200 -0
- nous_genai-0.1.0.dist-info/RECORD +45 -0
- nous_genai-0.1.0.dist-info/WHEEL +5 -0
- nous_genai-0.1.0.dist-info/entry_points.txt +4 -0
- nous_genai-0.1.0.dist-info/licenses/LICENSE +190 -0
- nous_genai-0.1.0.dist-info/top_level.txt +1 -0
nous/genai/mcp_server.py
ADDED
|
@@ -0,0 +1,1080 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import base64
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
from hmac import compare_digest
|
|
11
|
+
from collections import OrderedDict
|
|
12
|
+
from contextvars import ContextVar
|
|
13
|
+
from dataclasses import asdict, dataclass, replace
|
|
14
|
+
from typing import Any, TypedDict, cast
|
|
15
|
+
from urllib.parse import parse_qs
|
|
16
|
+
from uuid import uuid4
|
|
17
|
+
|
|
18
|
+
from .client import Client
|
|
19
|
+
from .types import (
|
|
20
|
+
GenerateRequest,
|
|
21
|
+
GenerateResponse,
|
|
22
|
+
OutputImageSpec,
|
|
23
|
+
sniff_image_mime_type,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_MCP_GENERATE_REQUEST_SCHEMA: dict = {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"title": "GenerateRequest",
|
|
29
|
+
"description": 'nous-genai request object. `model` must be "{provider}:{model_id}" (e.g. "openai:gpt-4o-mini").',
|
|
30
|
+
"required": ["model", "input", "output"],
|
|
31
|
+
"properties": {
|
|
32
|
+
"model": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"pattern": r"^[^\s:]+:[^\s]+$",
|
|
35
|
+
"description": 'Model string in the form "{provider}:{model_id}".',
|
|
36
|
+
"examples": ["openai:gpt-4o-mini"],
|
|
37
|
+
},
|
|
38
|
+
"input": {
|
|
39
|
+
"description": 'Chat messages (or a shorthand prompt string). Preferred: [{"role":"user","content":[{"type":"text","text":"..."}]}]. Shorthand also accepted: "..." or [{"role":"user","content":"..."}].',
|
|
40
|
+
"examples": [
|
|
41
|
+
[
|
|
42
|
+
{
|
|
43
|
+
"role": "user",
|
|
44
|
+
"content": [
|
|
45
|
+
{"type": "text", "text": "Generate an image of a cute cat"}
|
|
46
|
+
],
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
],
|
|
50
|
+
"anyOf": [
|
|
51
|
+
{"type": "string"},
|
|
52
|
+
{
|
|
53
|
+
"type": "object",
|
|
54
|
+
"required": ["role", "content"],
|
|
55
|
+
"properties": {
|
|
56
|
+
"role": {
|
|
57
|
+
"type": "string",
|
|
58
|
+
"enum": ["system", "user", "assistant", "tool"],
|
|
59
|
+
},
|
|
60
|
+
"content": {
|
|
61
|
+
"description": 'List of Part objects (preferred) or a shorthand string. Preferred: [{"type":"text","text":"..."}].',
|
|
62
|
+
"anyOf": [
|
|
63
|
+
{"type": "string"},
|
|
64
|
+
{"type": "object"},
|
|
65
|
+
{"type": "array", "items": {"type": "object"}},
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"type": "array",
|
|
72
|
+
"minItems": 1,
|
|
73
|
+
"items": {
|
|
74
|
+
"type": "object",
|
|
75
|
+
"required": ["role", "content"],
|
|
76
|
+
"properties": {
|
|
77
|
+
"role": {
|
|
78
|
+
"type": "string",
|
|
79
|
+
"enum": ["system", "user", "assistant", "tool"],
|
|
80
|
+
},
|
|
81
|
+
"content": {
|
|
82
|
+
"description": 'List of Part objects (preferred) or a shorthand string. Preferred: [{"type":"text","text":"..."}].',
|
|
83
|
+
"anyOf": [
|
|
84
|
+
{"type": "string"},
|
|
85
|
+
{"type": "object"},
|
|
86
|
+
{
|
|
87
|
+
"type": "array",
|
|
88
|
+
"minItems": 1,
|
|
89
|
+
"examples": [
|
|
90
|
+
[{"type": "text", "text": "Hello"}]
|
|
91
|
+
],
|
|
92
|
+
"items": {
|
|
93
|
+
"type": "object",
|
|
94
|
+
"required": ["type"],
|
|
95
|
+
"properties": {
|
|
96
|
+
"type": {"type": "string"},
|
|
97
|
+
"mime_type": {"type": "string"},
|
|
98
|
+
"source": {
|
|
99
|
+
"anyOf": [
|
|
100
|
+
{
|
|
101
|
+
"type": "object",
|
|
102
|
+
"required": [
|
|
103
|
+
"kind",
|
|
104
|
+
"encoding",
|
|
105
|
+
"data",
|
|
106
|
+
],
|
|
107
|
+
"properties": {
|
|
108
|
+
"kind": {
|
|
109
|
+
"const": "bytes"
|
|
110
|
+
},
|
|
111
|
+
"encoding": {
|
|
112
|
+
"const": "base64"
|
|
113
|
+
},
|
|
114
|
+
"data": {
|
|
115
|
+
"type": "string"
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"type": "object",
|
|
121
|
+
"required": ["kind", "url"],
|
|
122
|
+
"properties": {
|
|
123
|
+
"kind": {
|
|
124
|
+
"const": "url"
|
|
125
|
+
},
|
|
126
|
+
"url": {
|
|
127
|
+
"type": "string"
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"type": "object",
|
|
133
|
+
"required": [
|
|
134
|
+
"kind",
|
|
135
|
+
"provider",
|
|
136
|
+
"id",
|
|
137
|
+
],
|
|
138
|
+
"properties": {
|
|
139
|
+
"kind": {
|
|
140
|
+
"const": "ref"
|
|
141
|
+
},
|
|
142
|
+
"provider": {
|
|
143
|
+
"type": "string"
|
|
144
|
+
},
|
|
145
|
+
"id": {
|
|
146
|
+
"type": "string"
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{"type": "null"},
|
|
151
|
+
]
|
|
152
|
+
},
|
|
153
|
+
"text": {"type": "string"},
|
|
154
|
+
"embedding": {
|
|
155
|
+
"type": "array",
|
|
156
|
+
"items": {"type": "number"},
|
|
157
|
+
},
|
|
158
|
+
"meta": {"type": "object"},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
"output": {
|
|
170
|
+
"type": "object",
|
|
171
|
+
"required": ["modalities"],
|
|
172
|
+
"properties": {
|
|
173
|
+
"modalities": {"type": "array", "items": {"type": "string"}},
|
|
174
|
+
"image": {
|
|
175
|
+
"type": "object",
|
|
176
|
+
"properties": {
|
|
177
|
+
"format": {
|
|
178
|
+
"type": "string",
|
|
179
|
+
"description": 'For image-only output, omit to default to "url" in MCP.',
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
"params": {"type": "object"},
|
|
186
|
+
"wait": {"type": "boolean"},
|
|
187
|
+
"tools": {
|
|
188
|
+
"anyOf": [{"type": "array", "items": {"type": "object"}}, {"type": "null"}]
|
|
189
|
+
},
|
|
190
|
+
"tool_choice": {"anyOf": [{"type": "object"}, {"type": "null"}]},
|
|
191
|
+
"provider_options": {"type": "object"},
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class ProvidersInfo(TypedDict):
|
|
197
|
+
supported: list[str]
|
|
198
|
+
configured: list[str]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class ModelInfo(TypedDict):
|
|
202
|
+
model: str
|
|
203
|
+
modes: list[str]
|
|
204
|
+
input_modalities: list[str]
|
|
205
|
+
output_modalities: list[str]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class AvailableModelsInfo(TypedDict):
|
|
209
|
+
models: list[ModelInfo]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class McpGenerateResponseBase(TypedDict):
|
|
213
|
+
id: str
|
|
214
|
+
provider: str
|
|
215
|
+
model: str
|
|
216
|
+
status: str
|
|
217
|
+
output: list[dict[str, Any]]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class McpGenerateResponse(McpGenerateResponseBase, total=False):
|
|
221
|
+
usage: dict[str, Any] | None
|
|
222
|
+
job: dict[str, Any] | None
|
|
223
|
+
error: dict[str, Any] | None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
_REQUEST_TOKEN: ContextVar[str | None] = ContextVar(
|
|
227
|
+
"nous_genai_mcp_request_token", default=None
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@dataclass(frozen=True, slots=True)
|
|
232
|
+
class McpTokenScope:
|
|
233
|
+
providers_all: frozenset[str]
|
|
234
|
+
models: dict[str, frozenset[str]]
|
|
235
|
+
providers: frozenset[str]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
_DENY_ALL_SCOPE = McpTokenScope(
|
|
239
|
+
providers_all=frozenset(), models={}, providers=frozenset()
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _parse_mcp_token_scopes(raw: str) -> dict[str, McpTokenScope]:
|
|
244
|
+
raw = raw.strip()
|
|
245
|
+
if not raw:
|
|
246
|
+
return {}
|
|
247
|
+
|
|
248
|
+
# Accept a JSON dict: {"token": ["openai", "openai:gpt-4o-mini"], ...}
|
|
249
|
+
if raw[0] == "{":
|
|
250
|
+
try:
|
|
251
|
+
parsed = json.loads(raw)
|
|
252
|
+
except json.JSONDecodeError:
|
|
253
|
+
parsed = None
|
|
254
|
+
if isinstance(parsed, dict):
|
|
255
|
+
items: list[str] = []
|
|
256
|
+
for token, allow in parsed.items():
|
|
257
|
+
if not isinstance(token, str) or not token.strip():
|
|
258
|
+
raise ValueError(
|
|
259
|
+
"invalid NOUS_GENAI_MCP_TOKEN_RULES: token must be a non-empty string"
|
|
260
|
+
)
|
|
261
|
+
if allow is None:
|
|
262
|
+
items.append(f"{token.strip()}: []")
|
|
263
|
+
continue
|
|
264
|
+
if not isinstance(allow, list) or not all(
|
|
265
|
+
isinstance(x, str) for x in allow
|
|
266
|
+
):
|
|
267
|
+
raise ValueError(
|
|
268
|
+
"invalid NOUS_GENAI_MCP_TOKEN_RULES: each token value must be a list of strings"
|
|
269
|
+
)
|
|
270
|
+
joined = " ".join(a.strip() for a in allow if a.strip())
|
|
271
|
+
items.append(f"{token.strip()}: [{joined}]")
|
|
272
|
+
raw = ";".join(items)
|
|
273
|
+
|
|
274
|
+
# Bracket syntax: token: [provider provider:model_id ...]; token2: [...]
|
|
275
|
+
text = raw.replace("\\n", "\n")
|
|
276
|
+
entries: list[str] = []
|
|
277
|
+
for line in text.splitlines():
|
|
278
|
+
for part in line.split(";"):
|
|
279
|
+
stripped = part.strip()
|
|
280
|
+
if stripped:
|
|
281
|
+
entries.append(stripped)
|
|
282
|
+
|
|
283
|
+
scopes: dict[str, McpTokenScope] = {}
|
|
284
|
+
for entry in entries:
|
|
285
|
+
if entry.startswith("#"):
|
|
286
|
+
continue
|
|
287
|
+
if ":" not in entry:
|
|
288
|
+
raise ValueError(
|
|
289
|
+
f"invalid NOUS_GENAI_MCP_TOKEN_RULES entry (missing ':'): {entry}"
|
|
290
|
+
)
|
|
291
|
+
token, spec = entry.split(":", 1)
|
|
292
|
+
token = token.strip()
|
|
293
|
+
spec = spec.strip()
|
|
294
|
+
if not token:
|
|
295
|
+
raise ValueError(
|
|
296
|
+
f"invalid NOUS_GENAI_MCP_TOKEN_RULES entry (empty token): {entry}"
|
|
297
|
+
)
|
|
298
|
+
if token in scopes:
|
|
299
|
+
raise ValueError(
|
|
300
|
+
f"invalid NOUS_GENAI_MCP_TOKEN_RULES (duplicate token): {token}"
|
|
301
|
+
)
|
|
302
|
+
if not (spec.startswith("[") and spec.endswith("]")):
|
|
303
|
+
raise ValueError(
|
|
304
|
+
f"invalid NOUS_GENAI_MCP_TOKEN_RULES entry (expected '[...]'): {entry}"
|
|
305
|
+
)
|
|
306
|
+
inner = spec[1:-1].strip()
|
|
307
|
+
providers_all: set[str] = set()
|
|
308
|
+
models: dict[str, set[str]] = {}
|
|
309
|
+
for item in inner.replace(",", " ").split():
|
|
310
|
+
if not item:
|
|
311
|
+
continue
|
|
312
|
+
if ":" in item:
|
|
313
|
+
provider, model_id = item.split(":", 1)
|
|
314
|
+
provider = provider.strip().lower()
|
|
315
|
+
model_id = model_id.strip()
|
|
316
|
+
if not provider or not model_id:
|
|
317
|
+
raise ValueError(
|
|
318
|
+
f"invalid NOUS_GENAI_MCP_TOKEN_RULES entry item: {item}"
|
|
319
|
+
)
|
|
320
|
+
if model_id == "*":
|
|
321
|
+
providers_all.add(provider)
|
|
322
|
+
continue
|
|
323
|
+
models.setdefault(provider, set()).add(model_id)
|
|
324
|
+
continue
|
|
325
|
+
providers_all.add(item.strip().lower())
|
|
326
|
+
|
|
327
|
+
providers_all_fs = frozenset(providers_all)
|
|
328
|
+
models_fs = {p: frozenset(ids) for p, ids in models.items() if ids}
|
|
329
|
+
providers_fs = providers_all_fs | frozenset(models_fs.keys())
|
|
330
|
+
scopes[token] = McpTokenScope(
|
|
331
|
+
providers_all=providers_all_fs, models=models_fs, providers=providers_fs
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
return scopes
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _env_int(name: str, default: int) -> int:
|
|
338
|
+
raw = os.environ.get(name)
|
|
339
|
+
if raw is None:
|
|
340
|
+
return default
|
|
341
|
+
try:
|
|
342
|
+
value = int(raw)
|
|
343
|
+
except ValueError:
|
|
344
|
+
return default
|
|
345
|
+
return value
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _get_host_port() -> tuple[str, int]:
|
|
349
|
+
host = os.environ.get("NOUS_GENAI_MCP_HOST", "").strip() or "127.0.0.1"
|
|
350
|
+
port = _env_int("NOUS_GENAI_MCP_PORT", 6001)
|
|
351
|
+
if port < 1:
|
|
352
|
+
port = 1
|
|
353
|
+
if port > 65535:
|
|
354
|
+
port = 65535
|
|
355
|
+
return host, port
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def build_server(
|
|
359
|
+
*,
|
|
360
|
+
proxy_url: str | None = None,
|
|
361
|
+
host: str | None = None,
|
|
362
|
+
port: int | None = None,
|
|
363
|
+
model_keywords: list[str] | None = None,
|
|
364
|
+
bearer_token: str | None = None,
|
|
365
|
+
token_scopes: dict[str, McpTokenScope] | None = None,
|
|
366
|
+
):
|
|
367
|
+
"""
|
|
368
|
+
Build a FastMCP server that exposes:
|
|
369
|
+
- generate: Client.generate wrapper (MCP-friendly defaults)
|
|
370
|
+
- list_providers: discover providers configured on this server
|
|
371
|
+
- list_available_models: list available models for a provider (fully-qualified)
|
|
372
|
+
- list_all_available_models: list available models across all providers (fully-qualified)
|
|
373
|
+
|
|
374
|
+
Notes for LLM tool callers:
|
|
375
|
+
- Model must be "{provider}:{model_id}".
|
|
376
|
+
- Call `list_providers` first if you don't know which providers are usable.
|
|
377
|
+
"""
|
|
378
|
+
try:
|
|
379
|
+
from mcp.server.fastmcp import FastMCP
|
|
380
|
+
except ModuleNotFoundError as e: # pragma: no cover
|
|
381
|
+
raise SystemExit(
|
|
382
|
+
"missing dependency: install `mcp` to run the MCP server (e.g. `uv sync`)"
|
|
383
|
+
) from e
|
|
384
|
+
|
|
385
|
+
try:
|
|
386
|
+
from pydantic import Field, WithJsonSchema
|
|
387
|
+
except ModuleNotFoundError as e: # pragma: no cover
|
|
388
|
+
raise SystemExit(
|
|
389
|
+
"missing dependency: install `mcp` to run the MCP server (e.g. `uv sync`)"
|
|
390
|
+
) from e
|
|
391
|
+
from typing import Annotated
|
|
392
|
+
from starlette.requests import Request
|
|
393
|
+
from starlette.responses import Response
|
|
394
|
+
|
|
395
|
+
os.environ["NOUS_GENAI_TRANSPORT"] = "mcp"
|
|
396
|
+
if host is None or port is None:
|
|
397
|
+
host, port = _get_host_port()
|
|
398
|
+
server = FastMCP(name="nous-genai", host=host, port=port)
|
|
399
|
+
|
|
400
|
+
keywords: list[str] = []
|
|
401
|
+
for raw in model_keywords or []:
|
|
402
|
+
if not isinstance(raw, str):
|
|
403
|
+
continue
|
|
404
|
+
for part in raw.split(","):
|
|
405
|
+
keyword = part.strip().lower()
|
|
406
|
+
if keyword:
|
|
407
|
+
keywords.append(keyword)
|
|
408
|
+
|
|
409
|
+
def _model_allowed(model: str) -> bool:
|
|
410
|
+
if not keywords:
|
|
411
|
+
return True
|
|
412
|
+
candidate = model.strip().lower()
|
|
413
|
+
return any(k in candidate for k in keywords)
|
|
414
|
+
|
|
415
|
+
max_artifacts = _env_int("NOUS_GENAI_MAX_ARTIFACTS", 64)
|
|
416
|
+
if max_artifacts < 1:
|
|
417
|
+
max_artifacts = 1
|
|
418
|
+
max_artifact_bytes = _env_int("NOUS_GENAI_MAX_ARTIFACT_BYTES", 64 * 1024 * 1024)
|
|
419
|
+
if max_artifact_bytes < 0:
|
|
420
|
+
max_artifact_bytes = 0
|
|
421
|
+
artifact_url_ttl_seconds = _env_int("NOUS_GENAI_ARTIFACT_URL_TTL_SECONDS", 600)
|
|
422
|
+
if artifact_url_ttl_seconds < 1:
|
|
423
|
+
artifact_url_ttl_seconds = 1
|
|
424
|
+
|
|
425
|
+
def _public_base_url() -> str:
|
|
426
|
+
base = os.environ.get("NOUS_GENAI_MCP_PUBLIC_BASE_URL", "").strip()
|
|
427
|
+
if base:
|
|
428
|
+
return base.rstrip("/")
|
|
429
|
+
if host in {"0.0.0.0", "::"}:
|
|
430
|
+
return f"http://127.0.0.1:{port}"
|
|
431
|
+
return f"http://{host}:{port}"
|
|
432
|
+
|
|
433
|
+
base_url = _public_base_url()
|
|
434
|
+
signing_token = (
|
|
435
|
+
bearer_token.strip()
|
|
436
|
+
if isinstance(bearer_token, str) and bearer_token.strip()
|
|
437
|
+
else None
|
|
438
|
+
)
|
|
439
|
+
token_scopes = token_scopes or {}
|
|
440
|
+
|
|
441
|
+
def _b64url(data: bytes) -> str:
|
|
442
|
+
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
|
443
|
+
|
|
444
|
+
def _signing_key() -> str | None:
|
|
445
|
+
current = _REQUEST_TOKEN.get()
|
|
446
|
+
if isinstance(current, str) and current:
|
|
447
|
+
return current
|
|
448
|
+
return signing_token
|
|
449
|
+
|
|
450
|
+
def _artifact_sig(key: str, artifact_id: str, exp: int) -> str:
|
|
451
|
+
msg = f"{artifact_id}.{exp}".encode("utf-8", errors="strict")
|
|
452
|
+
digest = hmac.new(
|
|
453
|
+
key.encode("utf-8", errors="strict"), msg, hashlib.sha256
|
|
454
|
+
).digest()
|
|
455
|
+
return _b64url(digest)
|
|
456
|
+
|
|
457
|
+
def _artifact_url(artifact_id: str) -> str:
|
|
458
|
+
url = f"{base_url}/artifact/{artifact_id}"
|
|
459
|
+
key = _signing_key()
|
|
460
|
+
if key is None:
|
|
461
|
+
return url
|
|
462
|
+
exp = int(time.time()) + artifact_url_ttl_seconds
|
|
463
|
+
sig = _artifact_sig(key, artifact_id, exp)
|
|
464
|
+
return f"{url}?exp={exp}&sig={sig}"
|
|
465
|
+
|
|
466
|
+
@dataclass(slots=True)
|
|
467
|
+
class _ArtifactItem:
|
|
468
|
+
data: bytes
|
|
469
|
+
mime_type: str | None
|
|
470
|
+
owner_token: str | None
|
|
471
|
+
|
|
472
|
+
artifacts: OrderedDict[str, _ArtifactItem] = OrderedDict()
|
|
473
|
+
artifacts_total_bytes = 0
|
|
474
|
+
|
|
475
|
+
def _evict_one() -> None:
|
|
476
|
+
nonlocal artifacts_total_bytes
|
|
477
|
+
_, item = artifacts.popitem(last=False)
|
|
478
|
+
artifacts_total_bytes -= len(item.data)
|
|
479
|
+
|
|
480
|
+
def _enforce_artifact_limits() -> None:
|
|
481
|
+
while len(artifacts) > max_artifacts:
|
|
482
|
+
_evict_one()
|
|
483
|
+
if max_artifact_bytes <= 0:
|
|
484
|
+
while artifacts:
|
|
485
|
+
_evict_one()
|
|
486
|
+
return
|
|
487
|
+
while artifacts and artifacts_total_bytes > max_artifact_bytes:
|
|
488
|
+
_evict_one()
|
|
489
|
+
|
|
490
|
+
def _artifact_owner() -> str | None:
|
|
491
|
+
token = _REQUEST_TOKEN.get()
|
|
492
|
+
if isinstance(token, str) and token:
|
|
493
|
+
return token
|
|
494
|
+
return signing_token
|
|
495
|
+
|
|
496
|
+
def _store_artifact(data: bytes, mime_type: str | None) -> str | None:
|
|
497
|
+
nonlocal artifacts_total_bytes
|
|
498
|
+
if max_artifact_bytes <= 0:
|
|
499
|
+
return None
|
|
500
|
+
if len(data) > max_artifact_bytes:
|
|
501
|
+
return None
|
|
502
|
+
artifact_id = uuid4().hex
|
|
503
|
+
artifacts[artifact_id] = _ArtifactItem(
|
|
504
|
+
data=data, mime_type=mime_type, owner_token=_artifact_owner()
|
|
505
|
+
)
|
|
506
|
+
artifacts_total_bytes += len(data)
|
|
507
|
+
artifacts.move_to_end(artifact_id)
|
|
508
|
+
_enforce_artifact_limits()
|
|
509
|
+
return artifact_id if artifact_id in artifacts else None
|
|
510
|
+
|
|
511
|
+
class _McpArtifactStore:
|
|
512
|
+
def put(self, data: bytes, mime_type: str | None) -> str | None:
|
|
513
|
+
return _store_artifact(data, mime_type)
|
|
514
|
+
|
|
515
|
+
def url(self, artifact_id: str) -> str:
|
|
516
|
+
return _artifact_url(artifact_id)
|
|
517
|
+
|
|
518
|
+
client = Client(
|
|
519
|
+
proxy_url=proxy_url,
|
|
520
|
+
artifact_store=_McpArtifactStore(),
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
@server.custom_route(
|
|
524
|
+
"/artifact/{artifact_id}", methods=["GET", "HEAD"], include_in_schema=False
|
|
525
|
+
)
|
|
526
|
+
async def artifact_route(request: Request) -> Response:
|
|
527
|
+
artifact_id = request.path_params.get("artifact_id")
|
|
528
|
+
if not isinstance(artifact_id, str) or not artifact_id:
|
|
529
|
+
return Response(status_code=404)
|
|
530
|
+
item = artifacts.get(artifact_id)
|
|
531
|
+
if item is None:
|
|
532
|
+
return Response(status_code=404)
|
|
533
|
+
owner = item.owner_token
|
|
534
|
+
if owner is not None:
|
|
535
|
+
token = _REQUEST_TOKEN.get()
|
|
536
|
+
if token is None or not compare_digest(token, owner):
|
|
537
|
+
return Response(status_code=404)
|
|
538
|
+
artifacts.move_to_end(artifact_id)
|
|
539
|
+
if item.mime_type is None:
|
|
540
|
+
guessed = sniff_image_mime_type(item.data)
|
|
541
|
+
if guessed is not None:
|
|
542
|
+
item.mime_type = guessed
|
|
543
|
+
headers = {"Content-Length": str(len(item.data))}
|
|
544
|
+
if request.method.upper() == "HEAD":
|
|
545
|
+
return Response(
|
|
546
|
+
content=b"",
|
|
547
|
+
media_type=item.mime_type or "application/octet-stream",
|
|
548
|
+
headers=headers,
|
|
549
|
+
)
|
|
550
|
+
return Response(
|
|
551
|
+
content=item.data,
|
|
552
|
+
media_type=item.mime_type or "application/octet-stream",
|
|
553
|
+
headers=headers,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
@server.resource("genai://artifact/{artifact_id}", mime_type="application/json")
|
|
557
|
+
def read_artifact(artifact_id: str) -> dict[str, Any]:
|
|
558
|
+
item = artifacts.get(artifact_id)
|
|
559
|
+
if item is None:
|
|
560
|
+
raise ValueError("artifact not found")
|
|
561
|
+
owner = item.owner_token
|
|
562
|
+
if owner is not None:
|
|
563
|
+
token = _REQUEST_TOKEN.get()
|
|
564
|
+
if token is None or not compare_digest(token, owner):
|
|
565
|
+
raise ValueError("artifact not found")
|
|
566
|
+
artifacts.move_to_end(artifact_id)
|
|
567
|
+
if item.mime_type is None:
|
|
568
|
+
item.mime_type = sniff_image_mime_type(item.data)
|
|
569
|
+
return {
|
|
570
|
+
"id": artifact_id,
|
|
571
|
+
"mime_type": item.mime_type,
|
|
572
|
+
"bytes": len(item.data),
|
|
573
|
+
"url": _artifact_url(artifact_id),
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
def _scope() -> McpTokenScope | None:
|
|
577
|
+
if not token_scopes:
|
|
578
|
+
return None
|
|
579
|
+
token = _REQUEST_TOKEN.get()
|
|
580
|
+
if not token:
|
|
581
|
+
return None
|
|
582
|
+
return token_scopes.get(token, _DENY_ALL_SCOPE)
|
|
583
|
+
|
|
584
|
+
def _provider_allowed(provider: str) -> bool:
|
|
585
|
+
scope = _scope()
|
|
586
|
+
if scope is None:
|
|
587
|
+
return True
|
|
588
|
+
return provider in scope.providers
|
|
589
|
+
|
|
590
|
+
def _model_allowed_by_scope(model: str) -> bool:
|
|
591
|
+
scope = _scope()
|
|
592
|
+
if scope is None:
|
|
593
|
+
return True
|
|
594
|
+
if ":" not in model:
|
|
595
|
+
return False
|
|
596
|
+
provider, model_id = model.split(":", 1)
|
|
597
|
+
provider = provider.strip().lower()
|
|
598
|
+
model_id = model_id.strip()
|
|
599
|
+
if not provider or not model_id:
|
|
600
|
+
return False
|
|
601
|
+
if provider in scope.providers_all:
|
|
602
|
+
return True
|
|
603
|
+
allowed = scope.models.get(provider)
|
|
604
|
+
if allowed is None:
|
|
605
|
+
return False
|
|
606
|
+
return model_id in allowed
|
|
607
|
+
|
|
608
|
+
def list_providers() -> ProvidersInfo:
|
|
609
|
+
"""
|
|
610
|
+
List providers supported by this MCP server.
|
|
611
|
+
|
|
612
|
+
Returns:
|
|
613
|
+
- supported: providers known by the SDK catalog
|
|
614
|
+
- configured: providers that have credentials configured and can be used
|
|
615
|
+
"""
|
|
616
|
+
from .reference import get_model_catalog
|
|
617
|
+
from ._internal.errors import GenAIError
|
|
618
|
+
|
|
619
|
+
supported = sorted(get_model_catalog().keys())
|
|
620
|
+
if token_scopes:
|
|
621
|
+
supported = [p for p in supported if _provider_allowed(p)]
|
|
622
|
+
configured: list[str] = []
|
|
623
|
+
for p in supported:
|
|
624
|
+
try:
|
|
625
|
+
client._adapter(p) # type: ignore[attr-defined]
|
|
626
|
+
except GenAIError:
|
|
627
|
+
continue
|
|
628
|
+
configured.append(p)
|
|
629
|
+
return {"supported": supported, "configured": configured}
|
|
630
|
+
|
|
631
|
+
def list_available_models(
|
|
632
|
+
provider: str, *, timeout_ms: int | None = None
|
|
633
|
+
) -> AvailableModelsInfo:
|
|
634
|
+
"""
|
|
635
|
+
List available models (sdk catalog ∩ remotely available) for a provider.
|
|
636
|
+
|
|
637
|
+
Always returns fully-qualified model strings like "openai:gpt-4o-mini".
|
|
638
|
+
|
|
639
|
+
Returns:
|
|
640
|
+
- models: list[object], each includes:
|
|
641
|
+
- model: "{provider}:{model_id}"
|
|
642
|
+
- modes: ["sync","stream","job","async"] (varies by provider/model)
|
|
643
|
+
- input_modalities: ["text","image",...]
|
|
644
|
+
- output_modalities: ["text","image",...]
|
|
645
|
+
"""
|
|
646
|
+
from .client import _normalize_provider
|
|
647
|
+
from .reference import get_sdk_supported_models_for_provider
|
|
648
|
+
|
|
649
|
+
p = _normalize_provider(provider)
|
|
650
|
+
if not _provider_allowed(p):
|
|
651
|
+
raise ValueError(f"provider not allowed: {p}")
|
|
652
|
+
ids = client.list_available_models(p, timeout_ms=timeout_ms)
|
|
653
|
+
rows = get_sdk_supported_models_for_provider(p)
|
|
654
|
+
by_model_id = {r["model_id"]: r for r in rows}
|
|
655
|
+
|
|
656
|
+
models: list[ModelInfo] = []
|
|
657
|
+
for model_id in ids:
|
|
658
|
+
row = by_model_id.get(model_id)
|
|
659
|
+
if row is None:
|
|
660
|
+
continue
|
|
661
|
+
model = row["model"]
|
|
662
|
+
if not _model_allowed(model) or not _model_allowed_by_scope(model):
|
|
663
|
+
continue
|
|
664
|
+
models.append(
|
|
665
|
+
{
|
|
666
|
+
"model": model,
|
|
667
|
+
"modes": row["modes"],
|
|
668
|
+
"input_modalities": row["input_modalities"],
|
|
669
|
+
"output_modalities": row["output_modalities"],
|
|
670
|
+
}
|
|
671
|
+
)
|
|
672
|
+
return {"models": models}
|
|
673
|
+
|
|
674
|
+
def list_all_available_models(
|
|
675
|
+
*, timeout_ms: int | None = None
|
|
676
|
+
) -> AvailableModelsInfo:
|
|
677
|
+
"""
|
|
678
|
+
List available models (sdk catalog ∩ remotely available) across all providers.
|
|
679
|
+
|
|
680
|
+
Always returns fully-qualified model strings like "openai:gpt-4o-mini".
|
|
681
|
+
|
|
682
|
+
Returns:
|
|
683
|
+
- models: list[object], each includes:
|
|
684
|
+
- model: "{provider}:{model_id}"
|
|
685
|
+
- modes: ["sync","stream","job","async"] (varies by provider/model)
|
|
686
|
+
- input_modalities: ["text","image",...]
|
|
687
|
+
- output_modalities: ["text","image",...]
|
|
688
|
+
"""
|
|
689
|
+
scope = _scope()
|
|
690
|
+
if scope is None:
|
|
691
|
+
from .reference import get_sdk_supported_models
|
|
692
|
+
|
|
693
|
+
rows = get_sdk_supported_models()
|
|
694
|
+
by_model = {r["model"]: r for r in rows}
|
|
695
|
+
|
|
696
|
+
out_models: list[ModelInfo] = []
|
|
697
|
+
for model in client.list_all_available_models(timeout_ms=timeout_ms):
|
|
698
|
+
if not _model_allowed(model) or not _model_allowed_by_scope(model):
|
|
699
|
+
continue
|
|
700
|
+
row = by_model.get(model)
|
|
701
|
+
if row is None:
|
|
702
|
+
continue
|
|
703
|
+
out_models.append(
|
|
704
|
+
{
|
|
705
|
+
"model": model,
|
|
706
|
+
"modes": row["modes"],
|
|
707
|
+
"input_modalities": row["input_modalities"],
|
|
708
|
+
"output_modalities": row["output_modalities"],
|
|
709
|
+
}
|
|
710
|
+
)
|
|
711
|
+
return {"models": out_models}
|
|
712
|
+
|
|
713
|
+
models: list[ModelInfo] = []
|
|
714
|
+
for provider in sorted(scope.providers):
|
|
715
|
+
models.extend(
|
|
716
|
+
list_available_models(provider, timeout_ms=timeout_ms)["models"]
|
|
717
|
+
)
|
|
718
|
+
models.sort(key=lambda row: row["model"])
|
|
719
|
+
return {"models": models}
|
|
720
|
+
|
|
721
|
+
def generate(
|
|
722
|
+
request: dict[str, Any], *, stream: bool = False
|
|
723
|
+
) -> McpGenerateResponse:
|
|
724
|
+
"""
|
|
725
|
+
MCP-friendly wrapper of `Client.generate`.
|
|
726
|
+
|
|
727
|
+
Behavior:
|
|
728
|
+
- Enforces non-stream tool behavior (MCP tool call returns one result).
|
|
729
|
+
- For image-only output, defaults to URL output when format is unspecified.
|
|
730
|
+
- Large base64 binary outputs are stored on this server and returned as a URL.
|
|
731
|
+
|
|
732
|
+
Notes:
|
|
733
|
+
- `request.model` must be "{provider}:{model_id}" (e.g. "openai:gpt-4o-mini").
|
|
734
|
+
- `request.input` preferred format: [{"role":"user","content":[{"type":"text","text":"..."}]}].
|
|
735
|
+
Shorthand accepted: `request.input="..."` or [{"role":"user","content":"..."}] (auto-coerced to a text Part).
|
|
736
|
+
- Call `list_providers` / `list_available_models` / `list_all_available_models` first when in doubt.
|
|
737
|
+
|
|
738
|
+
Returns:
|
|
739
|
+
- id/provider/model/status/output (+ optional usage/job/error)
|
|
740
|
+
"""
|
|
741
|
+
if stream:
|
|
742
|
+
raise ValueError(
|
|
743
|
+
"stream=true is not supported for MCP tool calls; use stream=false"
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
from pydantic import TypeAdapter, ValidationError
|
|
747
|
+
|
|
748
|
+
if not isinstance(request, dict):
|
|
749
|
+
raise ValueError("request must be an object")
|
|
750
|
+
|
|
751
|
+
req_dict: dict[str, Any] = dict(request)
|
|
752
|
+
msgs = req_dict.get("input")
|
|
753
|
+
if isinstance(msgs, str):
|
|
754
|
+
msgs = [{"role": "user", "content": msgs}]
|
|
755
|
+
elif isinstance(msgs, dict):
|
|
756
|
+
msgs = [msgs]
|
|
757
|
+
if isinstance(msgs, list):
|
|
758
|
+
coerced_msgs: list[Any] = []
|
|
759
|
+
for msg in msgs:
|
|
760
|
+
if isinstance(msg, str):
|
|
761
|
+
coerced_msgs.append({"role": "user", "content": msg})
|
|
762
|
+
continue
|
|
763
|
+
if not isinstance(msg, dict):
|
|
764
|
+
coerced_msgs.append(msg)
|
|
765
|
+
continue
|
|
766
|
+
m = dict(msg)
|
|
767
|
+
if not isinstance(m.get("role"), str):
|
|
768
|
+
m["role"] = "user"
|
|
769
|
+
content = m.get("content")
|
|
770
|
+
if isinstance(content, str):
|
|
771
|
+
m["content"] = [{"type": "text", "text": content}]
|
|
772
|
+
elif isinstance(content, dict):
|
|
773
|
+
part = dict(content)
|
|
774
|
+
if "type" not in part and isinstance(part.get("text"), str):
|
|
775
|
+
part["type"] = "text"
|
|
776
|
+
m["content"] = [part]
|
|
777
|
+
elif isinstance(content, list):
|
|
778
|
+
parts: list[Any] = []
|
|
779
|
+
for part in content:
|
|
780
|
+
if isinstance(part, str):
|
|
781
|
+
parts.append({"type": "text", "text": part})
|
|
782
|
+
elif (
|
|
783
|
+
isinstance(part, dict)
|
|
784
|
+
and "type" not in part
|
|
785
|
+
and isinstance(part.get("text"), str)
|
|
786
|
+
):
|
|
787
|
+
p = dict(part)
|
|
788
|
+
p["type"] = "text"
|
|
789
|
+
parts.append(p)
|
|
790
|
+
else:
|
|
791
|
+
parts.append(part)
|
|
792
|
+
m["content"] = parts
|
|
793
|
+
coerced_msgs.append(m)
|
|
794
|
+
req_dict["input"] = coerced_msgs
|
|
795
|
+
|
|
796
|
+
out_spec = req_dict.get("output")
|
|
797
|
+
if isinstance(out_spec, str):
|
|
798
|
+
req_dict["output"] = {"modalities": [out_spec]}
|
|
799
|
+
elif isinstance(out_spec, dict) and isinstance(out_spec.get("modalities"), str):
|
|
800
|
+
o = dict(out_spec)
|
|
801
|
+
o["modalities"] = [out_spec["modalities"]]
|
|
802
|
+
req_dict["output"] = o
|
|
803
|
+
|
|
804
|
+
try:
|
|
805
|
+
req = TypeAdapter(GenerateRequest).validate_python(req_dict)
|
|
806
|
+
except ValidationError as e:
|
|
807
|
+
raise ValueError(str(e)) from e
|
|
808
|
+
|
|
809
|
+
if not _model_allowed(req.model):
|
|
810
|
+
raise ValueError(f"model not allowed by server filter: {req.model}")
|
|
811
|
+
if not _model_allowed_by_scope(req.model):
|
|
812
|
+
raise ValueError(f"model not allowed: {req.model}")
|
|
813
|
+
|
|
814
|
+
if set(req.output.modalities) == {"image"}:
|
|
815
|
+
img = req.output.image or OutputImageSpec()
|
|
816
|
+
if not img.format:
|
|
817
|
+
img = replace(img, format="url")
|
|
818
|
+
req = replace(req, output=replace(req.output, image=img))
|
|
819
|
+
|
|
820
|
+
resp = client.generate(req, stream=False)
|
|
821
|
+
if not isinstance(resp, GenerateResponse):
|
|
822
|
+
raise ValueError(
|
|
823
|
+
"provider returned stream response; expected non-stream response"
|
|
824
|
+
)
|
|
825
|
+
return cast(McpGenerateResponse, asdict(resp))
|
|
826
|
+
|
|
827
|
+
generate.__annotations__["request"] = Annotated[
|
|
828
|
+
dict[str, Any], WithJsonSchema(_MCP_GENERATE_REQUEST_SCHEMA)
|
|
829
|
+
]
|
|
830
|
+
list_available_models.__annotations__["provider"] = Annotated[
|
|
831
|
+
str,
|
|
832
|
+
Field(
|
|
833
|
+
description='Provider name (e.g. "openai"). Call `list_providers` first if unknown.',
|
|
834
|
+
examples=["openai"],
|
|
835
|
+
),
|
|
836
|
+
]
|
|
837
|
+
list_available_models.__annotations__["timeout_ms"] = Annotated[
|
|
838
|
+
int | None,
|
|
839
|
+
Field(
|
|
840
|
+
default=None,
|
|
841
|
+
description="Remote request timeout in milliseconds.",
|
|
842
|
+
),
|
|
843
|
+
]
|
|
844
|
+
list_all_available_models.__annotations__["timeout_ms"] = Annotated[
|
|
845
|
+
int | None,
|
|
846
|
+
Field(
|
|
847
|
+
default=None,
|
|
848
|
+
description="Remote request timeout in milliseconds.",
|
|
849
|
+
),
|
|
850
|
+
]
|
|
851
|
+
|
|
852
|
+
server.tool(structured_output=True)(generate)
|
|
853
|
+
server.tool(structured_output=True)(list_providers)
|
|
854
|
+
server.tool(structured_output=True)(list_available_models)
|
|
855
|
+
server.tool(structured_output=True)(list_all_available_models)
|
|
856
|
+
return server
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def build_http_app(server: Any) -> Any:
|
|
860
|
+
from starlette.routing import Mount, Route
|
|
861
|
+
|
|
862
|
+
app = server.streamable_http_app()
|
|
863
|
+
sse = server.sse_app()
|
|
864
|
+
sse_path = server.settings.sse_path
|
|
865
|
+
message_path = server.settings.message_path.rstrip("/")
|
|
866
|
+
|
|
867
|
+
for route in sse.router.routes:
|
|
868
|
+
if isinstance(route, Route) and route.path == sse_path:
|
|
869
|
+
app.router.routes.append(route)
|
|
870
|
+
continue
|
|
871
|
+
if isinstance(route, Mount) and route.path.rstrip("/") == message_path:
|
|
872
|
+
app.router.routes.append(route)
|
|
873
|
+
continue
|
|
874
|
+
return app
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def main(argv: list[str] | None = None) -> None:
|
|
878
|
+
from ._internal.config import load_env_files
|
|
879
|
+
|
|
880
|
+
load_env_files()
|
|
881
|
+
|
|
882
|
+
parser = argparse.ArgumentParser(
|
|
883
|
+
prog="genai-mcp-server",
|
|
884
|
+
description="nous-genai MCP server (Streamable HTTP: /mcp, SSE: /sse)",
|
|
885
|
+
)
|
|
886
|
+
parser.add_argument(
|
|
887
|
+
"--proxy",
|
|
888
|
+
dest="proxy_url",
|
|
889
|
+
help="HTTP proxy URL for provider requests (e.g. http://127.0.0.1:7890)",
|
|
890
|
+
)
|
|
891
|
+
parser.add_argument(
|
|
892
|
+
"--bearer-token",
|
|
893
|
+
dest="bearer_token",
|
|
894
|
+
help="Require HTTP Authorization: Bearer <token> for all endpoints (or set NOUS_GENAI_MCP_BEARER_TOKEN).",
|
|
895
|
+
)
|
|
896
|
+
parser.add_argument(
|
|
897
|
+
"--model-keyword",
|
|
898
|
+
dest="model_keywords",
|
|
899
|
+
action="append",
|
|
900
|
+
help='Only expose models whose "{provider}:{model_id}" contains this substring (case-insensitive). Repeatable; comma-separated also accepted.',
|
|
901
|
+
)
|
|
902
|
+
args = parser.parse_args(argv)
|
|
903
|
+
bearer = (
|
|
904
|
+
args.bearer_token or os.environ.get("NOUS_GENAI_MCP_BEARER_TOKEN") or ""
|
|
905
|
+
).strip()
|
|
906
|
+
token_rules = (os.environ.get("NOUS_GENAI_MCP_TOKEN_RULES") or "").strip()
|
|
907
|
+
if token_rules and bearer:
|
|
908
|
+
raise SystemExit(
|
|
909
|
+
"set either NOUS_GENAI_MCP_BEARER_TOKEN/--bearer-token or NOUS_GENAI_MCP_TOKEN_RULES, not both"
|
|
910
|
+
)
|
|
911
|
+
token_scopes: dict[str, McpTokenScope] = {}
|
|
912
|
+
if token_rules:
|
|
913
|
+
try:
|
|
914
|
+
token_scopes = _parse_mcp_token_scopes(token_rules)
|
|
915
|
+
except ValueError as e:
|
|
916
|
+
raise SystemExit(str(e)) from e
|
|
917
|
+
if not token_scopes:
|
|
918
|
+
raise SystemExit("invalid NOUS_GENAI_MCP_TOKEN_RULES: no tokens configured")
|
|
919
|
+
|
|
920
|
+
server_host, server_port = _get_host_port()
|
|
921
|
+
server = build_server(
|
|
922
|
+
proxy_url=args.proxy_url,
|
|
923
|
+
host=server_host,
|
|
924
|
+
port=server_port,
|
|
925
|
+
model_keywords=args.model_keywords,
|
|
926
|
+
bearer_token=bearer,
|
|
927
|
+
token_scopes=token_scopes,
|
|
928
|
+
)
|
|
929
|
+
app = build_http_app(server)
|
|
930
|
+
if token_scopes:
|
|
931
|
+
app.add_middleware(_BearerAuthMiddleware, tokens=token_scopes)
|
|
932
|
+
elif bearer:
|
|
933
|
+
app.add_middleware(_BearerAuthMiddleware, token=bearer)
|
|
934
|
+
|
|
935
|
+
try:
|
|
936
|
+
import uvicorn
|
|
937
|
+
except ModuleNotFoundError as e: # pragma: no cover
|
|
938
|
+
raise SystemExit(
|
|
939
|
+
"missing dependency: install `uvicorn` to run the MCP server (e.g. `uv sync`)"
|
|
940
|
+
) from e
|
|
941
|
+
|
|
942
|
+
uvicorn.run(
|
|
943
|
+
app,
|
|
944
|
+
host=server_host,
|
|
945
|
+
port=server_port,
|
|
946
|
+
log_level=server.settings.log_level.lower(),
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
class _BearerAuthMiddleware:
|
|
951
|
+
def __init__(
|
|
952
|
+
self,
|
|
953
|
+
app: Any,
|
|
954
|
+
*,
|
|
955
|
+
token: str | None = None,
|
|
956
|
+
tokens: dict[str, McpTokenScope] | None = None,
|
|
957
|
+
) -> None:
|
|
958
|
+
self.app = app
|
|
959
|
+
token = token.strip() if isinstance(token, str) else ""
|
|
960
|
+
tokens = tokens or {}
|
|
961
|
+
if token and tokens:
|
|
962
|
+
raise ValueError("pass either token=... or tokens=..., not both")
|
|
963
|
+
if not token and not tokens:
|
|
964
|
+
raise ValueError("missing auth config: pass token=... or tokens=...")
|
|
965
|
+
self._tokens = [token] if token else list(tokens.keys())
|
|
966
|
+
|
|
967
|
+
def _match_bearer(self, scope: Any) -> str | None:
|
|
968
|
+
raw = None
|
|
969
|
+
for k, v in scope.get("headers") or []:
|
|
970
|
+
if k.lower() == b"authorization":
|
|
971
|
+
raw = v
|
|
972
|
+
break
|
|
973
|
+
if not raw:
|
|
974
|
+
return None
|
|
975
|
+
|
|
976
|
+
try:
|
|
977
|
+
header = raw.decode("utf-8", errors="replace").strip()
|
|
978
|
+
except Exception:
|
|
979
|
+
return None
|
|
980
|
+
|
|
981
|
+
if not header.lower().startswith("bearer "):
|
|
982
|
+
return None
|
|
983
|
+
|
|
984
|
+
token = header[7:].strip()
|
|
985
|
+
for candidate in self._tokens:
|
|
986
|
+
if compare_digest(token, candidate):
|
|
987
|
+
return candidate
|
|
988
|
+
return None
|
|
989
|
+
|
|
990
|
+
def _match_artifact_sig(self, scope: Any) -> str | None:
|
|
991
|
+
if not self._tokens:
|
|
992
|
+
return None
|
|
993
|
+
|
|
994
|
+
path = scope.get("path")
|
|
995
|
+
if not isinstance(path, str) or not path.startswith("/artifact/"):
|
|
996
|
+
return None
|
|
997
|
+
|
|
998
|
+
method = (scope.get("method") or "").upper()
|
|
999
|
+
if method not in {"GET", "HEAD"}:
|
|
1000
|
+
return None
|
|
1001
|
+
|
|
1002
|
+
artifact_id = path[len("/artifact/") :].strip("/")
|
|
1003
|
+
if not artifact_id:
|
|
1004
|
+
return None
|
|
1005
|
+
|
|
1006
|
+
qs = scope.get("query_string") or b""
|
|
1007
|
+
try:
|
|
1008
|
+
query = parse_qs(
|
|
1009
|
+
qs.decode("utf-8", errors="replace"), keep_blank_values=True
|
|
1010
|
+
)
|
|
1011
|
+
except Exception:
|
|
1012
|
+
return None
|
|
1013
|
+
|
|
1014
|
+
exp_raw = (query.get("exp") or [None])[0]
|
|
1015
|
+
sig_raw = (query.get("sig") or [None])[0]
|
|
1016
|
+
if (
|
|
1017
|
+
not isinstance(exp_raw, str)
|
|
1018
|
+
or not exp_raw.strip()
|
|
1019
|
+
or not isinstance(sig_raw, str)
|
|
1020
|
+
or not sig_raw.strip()
|
|
1021
|
+
):
|
|
1022
|
+
return None
|
|
1023
|
+
|
|
1024
|
+
try:
|
|
1025
|
+
exp = int(exp_raw.strip())
|
|
1026
|
+
except ValueError:
|
|
1027
|
+
return None
|
|
1028
|
+
|
|
1029
|
+
if int(time.time()) > exp:
|
|
1030
|
+
return None
|
|
1031
|
+
|
|
1032
|
+
msg = f"{artifact_id}.{exp}".encode("utf-8", errors="strict")
|
|
1033
|
+
sig = sig_raw.strip()
|
|
1034
|
+
for token in self._tokens:
|
|
1035
|
+
digest = hmac.new(
|
|
1036
|
+
token.encode("utf-8", errors="strict"), msg, hashlib.sha256
|
|
1037
|
+
).digest()
|
|
1038
|
+
expected = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
|
1039
|
+
if compare_digest(sig, expected):
|
|
1040
|
+
return token
|
|
1041
|
+
return None
|
|
1042
|
+
|
|
1043
|
+
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
|
|
1044
|
+
if scope.get("type") != "http":
|
|
1045
|
+
await self.app(scope, receive, send)
|
|
1046
|
+
return
|
|
1047
|
+
|
|
1048
|
+
token = self._match_bearer(scope) or self._match_artifact_sig(scope)
|
|
1049
|
+
if token is None:
|
|
1050
|
+
await _send_unauthorized(send)
|
|
1051
|
+
return
|
|
1052
|
+
|
|
1053
|
+
ctx = _REQUEST_TOKEN.set(token)
|
|
1054
|
+
try:
|
|
1055
|
+
await self.app(scope, receive, send)
|
|
1056
|
+
finally:
|
|
1057
|
+
_REQUEST_TOKEN.reset(ctx)
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
async def _send_unauthorized(send: Any) -> None:
|
|
1061
|
+
body = b'{"error":"invalid_token","error_description":"Authentication required"}'
|
|
1062
|
+
await send(
|
|
1063
|
+
{
|
|
1064
|
+
"type": "http.response.start",
|
|
1065
|
+
"status": 401,
|
|
1066
|
+
"headers": [
|
|
1067
|
+
(b"content-type", b"application/json"),
|
|
1068
|
+
(b"content-length", str(len(body)).encode()),
|
|
1069
|
+
(
|
|
1070
|
+
b"www-authenticate",
|
|
1071
|
+
b'Bearer error="invalid_token", error_description="Authentication required"',
|
|
1072
|
+
),
|
|
1073
|
+
],
|
|
1074
|
+
}
|
|
1075
|
+
)
|
|
1076
|
+
await send({"type": "http.response.body", "body": body})
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
if __name__ == "__main__": # pragma: no cover
|
|
1080
|
+
main()
|