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.
Files changed (45) hide show
  1. nous/__init__.py +3 -0
  2. nous/genai/__init__.py +56 -0
  3. nous/genai/__main__.py +3 -0
  4. nous/genai/_internal/__init__.py +1 -0
  5. nous/genai/_internal/capability_rules.py +476 -0
  6. nous/genai/_internal/config.py +102 -0
  7. nous/genai/_internal/errors.py +63 -0
  8. nous/genai/_internal/http.py +951 -0
  9. nous/genai/_internal/json_schema.py +54 -0
  10. nous/genai/cli.py +1316 -0
  11. nous/genai/client.py +719 -0
  12. nous/genai/mcp_cli.py +275 -0
  13. nous/genai/mcp_server.py +1080 -0
  14. nous/genai/providers/__init__.py +15 -0
  15. nous/genai/providers/aliyun.py +535 -0
  16. nous/genai/providers/anthropic.py +483 -0
  17. nous/genai/providers/gemini.py +1606 -0
  18. nous/genai/providers/openai.py +1909 -0
  19. nous/genai/providers/tuzi.py +1158 -0
  20. nous/genai/providers/volcengine.py +273 -0
  21. nous/genai/reference/__init__.py +17 -0
  22. nous/genai/reference/catalog.py +206 -0
  23. nous/genai/reference/mappings.py +467 -0
  24. nous/genai/reference/mode_overrides.py +26 -0
  25. nous/genai/reference/model_catalog.py +82 -0
  26. nous/genai/reference/model_catalog_data/__init__.py +1 -0
  27. nous/genai/reference/model_catalog_data/aliyun.py +98 -0
  28. nous/genai/reference/model_catalog_data/anthropic.py +10 -0
  29. nous/genai/reference/model_catalog_data/google.py +45 -0
  30. nous/genai/reference/model_catalog_data/openai.py +44 -0
  31. nous/genai/reference/model_catalog_data/tuzi_anthropic.py +21 -0
  32. nous/genai/reference/model_catalog_data/tuzi_google.py +19 -0
  33. nous/genai/reference/model_catalog_data/tuzi_openai.py +75 -0
  34. nous/genai/reference/model_catalog_data/tuzi_web.py +136 -0
  35. nous/genai/reference/model_catalog_data/volcengine.py +107 -0
  36. nous/genai/tools/__init__.py +13 -0
  37. nous/genai/tools/output_parser.py +119 -0
  38. nous/genai/types.py +416 -0
  39. nous/py.typed +1 -0
  40. nous_genai-0.1.0.dist-info/METADATA +200 -0
  41. nous_genai-0.1.0.dist-info/RECORD +45 -0
  42. nous_genai-0.1.0.dist-info/WHEEL +5 -0
  43. nous_genai-0.1.0.dist-info/entry_points.txt +4 -0
  44. nous_genai-0.1.0.dist-info/licenses/LICENSE +190 -0
  45. nous_genai-0.1.0.dist-info/top_level.txt +1 -0
@@ -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()