diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fad9904..910f9c0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,4 +27,4 @@ jobs: pip install -e ".[dev]" - name: Run pytest with coverage - run: pytest tests --cov=nitrostack --cov-report=term-missing + run: pytest tests --cov=nitrostack --cov-report=term-missing --timeout=60 diff --git a/.gitignore b/.gitignore index 2cab5e6..513a6b4 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,15 @@ flight-book-app/ my-pizza-app/ my-test-server/ new-app/ +my-app/ +y-server/ + +# Vendored SDK copies from `nitrostack-py init` (user projects only) +vendor/ + +# Local preview captures +.landing-preview.png +*-landing-preview.png # Node widgets (if installed locally in examples/templates) node_modules/ diff --git a/MANIFEST.in b/MANIFEST.in index 1fd03b5..b3d51d6 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,6 @@ recursive-include nitrostack/templates * recursive-include nitrostack/cli/templates * +recursive-include nitrostack/transports/assets * include nitrostack/templates/starter/.python-version include nitrostack/templates/starter/.gitignore include nitrostack/templates/starter/uv.toml diff --git a/README.md b/README.md index 91f98b7..6074a49 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ nitrostack-py dev --port 4000 --widget 4001 nitrostack-py start --port 4000 --widget 4001 ``` +`init` and `pack` vendor this SDK into `vendor/nitrostack` and pin it with `[tool.uv.sources]`. The dependency name stays `nitrostack` in `pyproject.toml` and `requirements.txt`, so `nitrostack-py install` and NitroCloud still run `uv sync`. Do not put a `file:` URL in `[project].dependencies` — that breaks the cloud wheel metadata. + Once scaffolded, follow the next steps printed by the CLI to run your server, configure environment variables, and try it out. --- @@ -260,6 +262,7 @@ NitroStack apps can run over three transports, selected via `MCP_TRANSPORT_TYPE` - **`stdio`** (default outside production): JSON-RPC over stdin/stdout — the standard mode for desktop MCP clients (Claude Desktop, Cursor, etc.). - **`http`**: Streamable HTTP + legacy SSE over a real network port, for cloud/remote deployments. Exposes: + - `GET /` — documentation landing page (connection setup for Cursor/Claude/ChatGPT plus the registered tool catalog). Same page TypeScript NitroStack serves on Open URL. - `POST/GET/DELETE /mcp` — Streamable HTTP (session-based JSON-RPC + SSE streaming). `/mcp` and `/mcp/` are equivalent; the server does not 307 between them (MCP Inspector needs the no-slash URL for its SSE GET). - `GET /sse` + `POST /mcp/messages/` — legacy HTTP+SSE for older clients (trailing slash required so messages aren't swallowed by the Streamable HTTP `/mcp` mount) - `GET /mcp/health` — health check (`status`, active session count, uptime) diff --git a/examples/__pycache__/calculator_server.cpython-312.pyc b/examples/__pycache__/calculator_server.cpython-312.pyc deleted file mode 100644 index f0de664..0000000 Binary files a/examples/__pycache__/calculator_server.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/__pycache__/__init__.cpython-312.pyc b/nitrostack/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index d4322cd..0000000 Binary files a/nitrostack/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/auth/__pycache__/api_key.cpython-312.pyc b/nitrostack/auth/__pycache__/api_key.cpython-312.pyc deleted file mode 100644 index f3ae70a..0000000 Binary files a/nitrostack/auth/__pycache__/api_key.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/auth/__pycache__/config.cpython-312.pyc b/nitrostack/auth/__pycache__/config.cpython-312.pyc deleted file mode 100644 index f7f1b56..0000000 Binary files a/nitrostack/auth/__pycache__/config.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/auth/__pycache__/jwt.cpython-312.pyc b/nitrostack/auth/__pycache__/jwt.cpython-312.pyc deleted file mode 100644 index ebacbe9..0000000 Binary files a/nitrostack/auth/__pycache__/jwt.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/auth/__pycache__/oauth.cpython-312.pyc b/nitrostack/auth/__pycache__/oauth.cpython-312.pyc deleted file mode 100644 index f451c9a..0000000 Binary files a/nitrostack/auth/__pycache__/oauth.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/cli/main.py b/nitrostack/cli/main.py index d624ad8..1ab03b3 100644 --- a/nitrostack/cli/main.py +++ b/nitrostack/cli/main.py @@ -1299,6 +1299,8 @@ def init_project(name: str = None, template: str = None, skip_install: bool = Fa name=os.path.basename(os.path.abspath(name)), description=description, ) + from nitrostack.cli.vendor import ensure_vendored_nitrostack + ensure_vendored_nitrostack(name) lock_with_uv(name) widget_routes = ensure_python_widgets(name) print("\n\033[32m✓\033[0m Project created") @@ -1313,14 +1315,19 @@ def init_project(name: str = None, template: str = None, skip_install: bool = Fa lines = f.readlines() new_lines = [] for line in lines: - if line.startswith("SERVER_DESC="): + if line.startswith("SERVER_NAME="): + new_lines.append(f'SERVER_NAME="{os.path.basename(os.path.abspath(name))}"\n') + elif line.startswith("SERVER_DESC="): new_lines.append(f'SERVER_DESC="{description}"\n') elif line.startswith("SERVER_AUTHOR="): new_lines.append(f'SERVER_AUTHOR="{author}"\n') else: new_lines.append(line) + has_name = any(line.startswith("SERVER_NAME=") for line in new_lines) has_desc = any(line.startswith("SERVER_DESC=") for line in new_lines) has_author = any(line.startswith("SERVER_AUTHOR=") for line in new_lines) + if not has_name: + new_lines.append(f'SERVER_NAME="{os.path.basename(os.path.abspath(name))}"\n') if not has_desc: new_lines.append(f'SERVER_DESC="{description}"\n') if not has_author: diff --git a/nitrostack/cli/pack.py b/nitrostack/cli/pack.py index 717203e..b2a805c 100644 --- a/nitrostack/cli/pack.py +++ b/nitrostack/cli/pack.py @@ -460,6 +460,8 @@ def pack_project( Returns a dict with ``files``, ``wheel`` (path or planned name), and ``dry_run``. """ root = os.path.abspath(root or os.getcwd()) + from nitrostack.cli.vendor import ensure_vendored_nitrostack + ensure_vendored_nitrostack(root) files = collect_pack_files(root) name, version = _project_name_and_version(root) dist_name = _pep503_wheel_name(_normalize_dist_name(name)) diff --git a/nitrostack/cli/vendor.py b/nitrostack/cli/vendor.py new file mode 100644 index 0000000..af35dd7 --- /dev/null +++ b/nitrostack/cli/vendor.py @@ -0,0 +1,216 @@ +"""Vendor the running NitroStack SDK into a scaffolded project. + +Cloud deploy zips the project (not the developer's .venv) and `uv sync`s +from `pyproject.toml`. Putting a PEP 508 `file:` URL in `[project].dependencies` +breaks NitroCloud's Python uv builder (`Failed to parse metadata from built +wheel`). Keep the dependency name as `nitrostack` and point uv at the copy +with `[tool.uv.sources]`. +""" +from __future__ import annotations + +import os +import re +import shutil +from pathlib import Path +from typing import Optional + +from nitrostack.cli._shared import write_text_atomic + +VENDOR_REL = os.path.join("vendor", "nitrostack") +_PLAIN_REQUIREMENT = "nitrostack" +_PATH_PINS = frozenset({"./vendor/nitrostack", "-e ./vendor/nitrostack"}) +_NAMED_REQUIREMENT = re.compile( + r"^(?:-e\s+|--editable\s+)?nitrostack" + r"(?:\s*(?:===|==|!=|~=|>=|<=|>|<)\s*\S+)?" + r"(?:\s*@\s*\S+)?$", + re.IGNORECASE, +) + +_SKIP_DIR_NAMES = { + "__pycache__", + ".venv", + "venv", + ".git", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + "node_modules", + "templates", + ".egg-info", +} + +_QUOTED_NITROSTACK = re.compile( + r'(["\'])nitrostack(?:\s*(?:===|==|!=|~=|>=|<=|>|<)\s*[^"\']+)?' + r'(?:\s*@\s*file:[^"\']+)?\1', + re.IGNORECASE, +) + + +def running_nitrostack_root() -> Path: + """Directory that contains the `nitrostack` package used by this CLI.""" + import nitrostack + + package_dir = Path(nitrostack.__file__).resolve().parent + parent = package_dir.parent + pyproject = parent / "pyproject.toml" + if pyproject.is_file() and "name" in pyproject.read_text(encoding="utf-8"): + return parent + return package_dir + + +def _ignore(_directory: str, names: list) -> list: + skipped = [] + for name in names: + if name in _SKIP_DIR_NAMES or name.endswith(".egg-info"): + skipped.append(name) + return skipped + + +def _distribution_version() -> str: + """Version of the SDK this CLI is running, without a hardcoded fallback.""" + import nitrostack + + declared = getattr(nitrostack, "__version__", None) + if isinstance(declared, str) and declared.strip(): + return declared.strip() + try: + from importlib.metadata import version as dist_version + + found = dist_version("nitrostack") + except Exception: + found = "" + if found.strip(): + return found.strip() + raise RuntimeError( + "Cannot determine the nitrostack package version. " + "Install the SDK so its distribution metadata is available." + ) + + +def _write_minimal_pyproject(dest: Path) -> None: + version = _distribution_version() + dest.mkdir(parents=True, exist_ok=True) + (dest / "pyproject.toml").write_text( + "[build-system]\n" + 'requires = ["setuptools>=61.0.0"]\n' + 'build-backend = "setuptools.build_meta"\n\n' + "[project]\n" + 'name = "nitrostack"\n' + f'version = "{version}"\n' + 'description = "NitroStack Python SDK (vendored)"\n' + 'requires-python = ">=3.10"\n' + "dependencies = [\n" + ' "mcp>=2,<3",\n' + ' "pydantic>=2.0.0",\n' + ' "starlette>=0.30.0",\n' + ' "uvicorn>=0.20.0",\n' + ' "watchfiles>=0.18.0",\n' + ' "anyio>=4.0.0",\n' + ' "packaging>=23.0.0",\n' + ' "tomli>=2.0.0; python_version < \'3.11\'",\n' + "]\n\n" + "[project.scripts]\n" + 'nitrostack-py = "nitrostack.cli.main:main"\n\n' + "[tool.setuptools.packages.find]\n" + 'include = ["nitrostack*"]\n\n' + "[tool.setuptools]\n" + "include-package-data = true\n\n" + "[tool.setuptools.package-data]\n" + 'nitrostack = ["transports/assets/*"]\n', + encoding="utf-8", + ) + + +def copy_running_nitrostack(dest: Path) -> None: + """Copy the CLI's nitrostack package into ``dest`` (a installable tree).""" + if dest.exists(): + shutil.rmtree(dest) + dest.mkdir(parents=True, exist_ok=True) + + root = running_nitrostack_root() + src_package = root / "nitrostack" + if not src_package.is_dir(): + src_package = root + shutil.copytree(src_package, dest / "nitrostack", ignore=_ignore, dirs_exist_ok=True) + + src_pyproject = root / "pyproject.toml" + if src_pyproject.is_file() and src_package != root: + shutil.copy2(src_pyproject, dest / "pyproject.toml") + manifest = root / "MANIFEST.in" + if manifest.is_file(): + shutil.copy2(manifest, dest / "MANIFEST.in") + else: + _write_minimal_pyproject(dest) + + assets = dest / "nitrostack" / "transports" / "assets" / "landing.html" + if not assets.is_file(): + raise RuntimeError( + f"Vendored SDK is missing the documentation page at {assets}. " + "Update nitrostack and retry." + ) + + +def _pin_text(text: str) -> str: + """Keep `[project].dependencies` as a plain name (valid wheel Requires-Dist).""" + return _QUOTED_NITROSTACK.sub(lambda match: f"{match.group(1)}nitrostack{match.group(1)}", text) + + +def _pin_requirements(path: Path) -> None: + """Keep the requirement name ``nitrostack`` so uv resolves ``[tool.uv.sources]``.""" + if not path.is_file(): + return + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + out = [] + for line in lines: + stripped = line.strip() + if stripped in _PATH_PINS or _NAMED_REQUIREMENT.match(stripped): + out.append(f"{_PLAIN_REQUIREMENT}\n") + else: + out.append(line) + path.write_text("".join(out), encoding="utf-8") + + +def _ensure_uv_source(text: str) -> str: + if re.search(r"^\[tool\.uv\.sources\]", text, re.MULTILINE): + if re.search(r"(?m)^nitrostack\s*=", text): + return re.sub( + r"(?m)^nitrostack\s*=\s*.*$", + 'nitrostack = { path = "vendor/nitrostack" }', + text, + count=1, + ) + return re.sub( + r"(?m)^(\[tool\.uv\.sources\]\s*)", + r'\1nitrostack = { path = "vendor/nitrostack" }\n', + text, + count=1, + ) + if not text.endswith("\n"): + text += "\n" + return text + '\n[tool.uv.sources]\nnitrostack = { path = "vendor/nitrostack" }\n' + + +def pin_project_to_vendor(project: Path) -> None: + pyproject = project / "pyproject.toml" + if pyproject.is_file(): + text = pyproject.read_text(encoding="utf-8") + write_text_atomic(str(pyproject), _ensure_uv_source(_pin_text(text))) + _pin_requirements(project / "requirements.txt") + + +def ensure_vendored_nitrostack(project: str, *, quiet: bool = False) -> Optional[str]: + """Copy this CLI's SDK into ``project/vendor/nitrostack`` and pin deps. + + Returns the vendor path, or None when the directory is not a project. + """ + root = Path(os.path.abspath(project)) + if not (root / "pyproject.toml").is_file() and not (root / "requirements.txt").is_file(): + return None + if (root / "nitrostack" / "transports" / "assets" / "landing.html").is_file(): + return None + dest = root / VENDOR_REL + copy_running_nitrostack(dest) + pin_project_to_vendor(root) + if not quiet: + print(f"Vendored NitroStack SDK at {dest}") + return str(dest) diff --git a/nitrostack/core/__pycache__/additional_decorators.cpython-312.pyc b/nitrostack/core/__pycache__/additional_decorators.cpython-312.pyc deleted file mode 100644 index 7c978a4..0000000 Binary files a/nitrostack/core/__pycache__/additional_decorators.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/core/__pycache__/app.cpython-312.pyc b/nitrostack/core/__pycache__/app.cpython-312.pyc deleted file mode 100644 index 2f7c654..0000000 Binary files a/nitrostack/core/__pycache__/app.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/core/__pycache__/context.cpython-312.pyc b/nitrostack/core/__pycache__/context.cpython-312.pyc deleted file mode 100644 index 6731ee2..0000000 Binary files a/nitrostack/core/__pycache__/context.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/core/__pycache__/decorators.cpython-312.pyc b/nitrostack/core/__pycache__/decorators.cpython-312.pyc deleted file mode 100644 index a92614e..0000000 Binary files a/nitrostack/core/__pycache__/decorators.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/core/__pycache__/di.cpython-312.pyc b/nitrostack/core/__pycache__/di.cpython-312.pyc deleted file mode 100644 index 0d33e9b..0000000 Binary files a/nitrostack/core/__pycache__/di.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/core/__pycache__/errors.cpython-312.pyc b/nitrostack/core/__pycache__/errors.cpython-312.pyc deleted file mode 100644 index 734f478..0000000 Binary files a/nitrostack/core/__pycache__/errors.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/core/__pycache__/module.cpython-312.pyc b/nitrostack/core/__pycache__/module.cpython-312.pyc deleted file mode 100644 index 63d733e..0000000 Binary files a/nitrostack/core/__pycache__/module.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/core/__pycache__/pipeline.cpython-312.pyc b/nitrostack/core/__pycache__/pipeline.cpython-312.pyc deleted file mode 100644 index 879e925..0000000 Binary files a/nitrostack/core/__pycache__/pipeline.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/core/__pycache__/task.cpython-312.pyc b/nitrostack/core/__pycache__/task.cpython-312.pyc deleted file mode 100644 index d3cec68..0000000 Binary files a/nitrostack/core/__pycache__/task.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/core/app.py b/nitrostack/core/app.py index a0bc83e..9d9b069 100644 --- a/nitrostack/core/app.py +++ b/nitrostack/core/app.py @@ -128,6 +128,59 @@ def resolve_http_host() -> str: return (os.environ.get("HOST") or "127.0.0.1").strip() or "127.0.0.1" +def _read_dotenv_value(root: str, key: str) -> Optional[str]: + path = os.path.join(root, ".env") + if not os.path.isfile(path): + return None + try: + with open(path, encoding="utf-8") as handle: + for raw in handle: + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + name, value = line.split("=", 1) + if name.strip() != key: + continue + return value.strip().strip('"').strip("'") or None + except OSError: + return None + return None + + +def _project_identity(root: Optional[str] = None) -> Dict[str, Optional[str]]: + """Name / version / description for the `GET /` documentation page.""" + cwd = root or os.getcwd() + name = os.environ.get("SERVER_NAME") or _read_dotenv_value(cwd, "SERVER_NAME") + description = os.environ.get("SERVER_DESC") or _read_dotenv_value(cwd, "SERVER_DESC") + version = None + path = os.path.join(cwd, "pyproject.toml") + if os.path.isfile(path): + try: + text = Path(path).read_text(encoding="utf-8") + except OSError: + text = "" + if not name: + match = re.search(r'(?m)^name\s*=\s*"([^"]+)"', text) + name = match.group(1) if match else None + if not description: + match = re.search(r'(?m)^description\s*=\s*"([^"]+)"', text) + description = match.group(1) if match else None + match = re.search(r'(?m)^version\s*=\s*"([^"]+)"', text) + version = match.group(1) if match else None + if not name: + name = os.path.basename(os.path.abspath(cwd)) or None + return {"name": name, "version": version, "description": description} + + +def _apply_project_identity(config: "ServerConfig") -> None: + ident = _project_identity() + generic = (config.name or "").strip().lower() in {"", "app", "mcp-server"} + if generic and ident.get("name"): + config.name = ident["name"] + if not config.description and ident.get("description"): + config.description = ident["description"] + + @dataclass class ServerConfig: name: str @@ -149,6 +202,8 @@ class ServerConfig: session_timeout_ms: Optional[int] = None json_response: bool = False extensions: Optional[Dict[str, str]] = None + # Subtitle on the `GET /` documentation page. + description: Optional[str] = None def mcp_app(module: Type, server: ServerConfig): @@ -524,6 +579,7 @@ def __init__(self, app_class: Type): self.server_config = ServerConfig(name=app_class._mcp_module_config.name or "mcp-server") else: raise ValueError("Invalid application class. Must be decorated with @mcp_app or @module.") + _apply_project_identity(self.server_config) self.mcp_server: Optional[NitroStackMcpServer] = None era_resolution = resolve_protocol_era_resolution( diff --git a/nitrostack/events/__pycache__/event_emitter.cpython-312.pyc b/nitrostack/events/__pycache__/event_emitter.cpython-312.pyc deleted file mode 100644 index 7d8820e..0000000 Binary files a/nitrostack/events/__pycache__/event_emitter.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/templates/flight-booking/.gitignore b/nitrostack/templates/flight-booking/.gitignore index 01e5284..ef4f256 100644 --- a/nitrostack/templates/flight-booking/.gitignore +++ b/nitrostack/templates/flight-booking/.gitignore @@ -5,3 +5,4 @@ __pycache__/ .env !.env.example .DS_Store +vendor/ diff --git a/nitrostack/templates/pizzaz/.gitignore b/nitrostack/templates/pizzaz/.gitignore index 01e5284..ef4f256 100644 --- a/nitrostack/templates/pizzaz/.gitignore +++ b/nitrostack/templates/pizzaz/.gitignore @@ -5,3 +5,4 @@ __pycache__/ .env !.env.example .DS_Store +vendor/ diff --git a/nitrostack/templates/starter/.gitignore b/nitrostack/templates/starter/.gitignore index 01e5284..ef4f256 100644 --- a/nitrostack/templates/starter/.gitignore +++ b/nitrostack/templates/starter/.gitignore @@ -5,3 +5,4 @@ __pycache__/ .env !.env.example .DS_Store +vendor/ diff --git a/nitrostack/testing/__pycache__/__init__.cpython-312.pyc b/nitrostack/testing/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index b3e6d2d..0000000 Binary files a/nitrostack/testing/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/transports/assets/landing.html b/nitrostack/transports/assets/landing.html new file mode 100644 index 0000000..029c526 --- /dev/null +++ b/nitrostack/transports/assets/landing.html @@ -0,0 +1,1469 @@ + + + + + + {{server_name}} - MCP Server Documentation + + + + + + +
+
+ +
+ +
+
+ +
+

{{server_name}}

+ v{{server_version}} +

{{server_description}}

+
+ +
+
+ + + + Connection Setup +
+ +
+ + + + + + +
+ + +
+

+ Add via Cursor Settings UI (Settings > Features > MCP > Add New MCP Server): +

+
+ +
{
+  "mcpServers": {
+    // your other mcp servers
+    "{{server_slug}}": {
+      "url": "{{mcp_endpoint}}"
+    }
+  }
+}
+
+
+ + +
+

+ Connect remote tools directly via Claude's Web UI: +

+ +
+
+
+ Add custom connector + BETA +
+
+ +
+ Connect Claude to your data and tools. Learn more about connectors or get started with pre-built ones. +
+ +
+
+ +
+
+ + +
+
+ +
+ + + + Advanced settings +
+ +
+ Only use connectors from developers you trust. Anthropic does not control which tools developers make available and cannot verify that they will work as intended or that they won't change. +
+ + + +
+ +
+
+
+ + +
+

+ Configure custom tools directly via ChatGPT's Web UI: +

+ +
+
+ New App +
+ + +
+
+ + + +
+
+ PNG only. Best results at 256 x 256 px or larger. Max file size: 10 KB +
+
+ +
+
+ + +
+ +
+ + +
+ +
+
+ +
+ + +
+
+
+ + +
+
+ +
+ + +
+
+ +
+
+ + + + Custom MCP servers introduce risk. Learn more +
+ +
+ +
+ + OpenAI hasn't reviewed this MCP server. Attackers may attempt to steal your data or trick the model into taking unintended actions, including destroying data. +
+
+
+ + +
+
+ + +
+

+ Add the following configuration block under mcpServers in your Antigravity configuration file (~/.gemini/config/mcp_config.json): +

+
+ +
{
+  "mcpServers": {
+    // your other mcp servers
+    "{{server_slug}}": {
+      "serverUrl": "{{mcp_endpoint}}"
+    }
+  }
+}
+
+
+ + +
+

+ Add the following configuration block to your Codex configuration file (~/.codex/config.toml): +

+
+ +
[mcp_servers.{{server_slug}}]
+url = "{{mcp_endpoint}}"
+
+
+ + +
+

+ Connect directly using the Server-Sent Events endpoint: +

+
+ +
{{mcp_endpoint}}
+
+
+
+ +
+ + + + Available Tools +
+ +
+ + + + +
+ + {{tools_section}} + + +
+ + + + + + + diff --git a/nitrostack/transports/assets/logo.png b/nitrostack/transports/assets/logo.png new file mode 100644 index 0000000..ac9909f Binary files /dev/null and b/nitrostack/transports/assets/logo.png differ diff --git a/nitrostack/transports/dispatch.py b/nitrostack/transports/dispatch.py index f4005b9..7dbe253 100644 --- a/nitrostack/transports/dispatch.py +++ b/nitrostack/transports/dispatch.py @@ -54,6 +54,7 @@ supported_protocol_versions_for_era, ) from nitrostack.runtime.stateless import ( + has_incoming_session_id, is_unsupported_protocol_version, request_protocol_version, sessionless_strips_incoming_session_id, @@ -407,6 +408,22 @@ def reject_unsupported_protocol_version_header( request, request_headers, self._context.resolved_era() ) + def reject_incoming_session_id( + self, + request_headers: dict[str, str], + request_id: Any = None, + ) -> Optional[tuple[int, dict[str, Any]]]: + """Reject ``Mcp-Session-Id`` on sessionless engines before it is stripped.""" + if not has_incoming_session_id(request_headers): + return None + if not sessionless_strips_incoming_session_id(self._context.wire_mode): + return None + return 400, jsonrpc_error( + request_id, + JsonRpcErrorCode.INVALID_REQUEST, + "Invalid Request: Mcp-Session-Id is not supported", + ) + def reject_method_policy( self, raw_body: bytes, diff --git a/nitrostack/transports/http.py b/nitrostack/transports/http.py index a343747..c915005 100644 --- a/nitrostack/transports/http.py +++ b/nitrostack/transports/http.py @@ -14,7 +14,7 @@ session-count API or creation hook, so this is tracked via a thin ASGI middleware watching the `mcp-session-id` header) - A `/mcp/health` endpoint -- A root documentation page at `GET /` (browsers opening the HTTP port) +- A documentation landing page at `GET /` (connection setup + tool catalog) - Chrome DevTools discovery stubs at `GET /json` and `GET /json/version` so inspector probes do not 404 - JSON 404s for OAuth discovery / DCR (`/register`) so MCP Inspector does not @@ -26,7 +26,6 @@ from __future__ import annotations import contextlib -import html import logging import os import sys @@ -40,6 +39,7 @@ from starlette.routing import Mount, Route from starlette.types import ASGIApp, Receive, Scope, Send +from nitrostack.transports.landing_page import render_landing_page from nitrostack.widgets.preview_page import render_preview_page from nitrostack.core.di import DIContainer from pydantic_core import PydanticUndefined @@ -89,56 +89,22 @@ def _server_meta(mcp_app: "McpApplication") -> Dict[str, str]: } -def _landing_html( - name: str, - version: str, - endpoint: str, - public_mcp_url: Optional[str] = None, -) -> str: - safe_name = html.escape(name) - safe_version = html.escape(version) - mcp_path = html.escape(public_mcp_url or (endpoint.rstrip("/") or "/mcp")) - health_path = html.escape(f"{(endpoint.rstrip('/') or '/mcp')}/health") - return f""" - - - - - {safe_name} - - - -
-

{safe_name}

-

NitroStack MCP server v{safe_version}. This is not a website — connect with an MCP client.

- -
- - -""" +def _landing_tools(mcp_app: "McpApplication") -> List[Dict[str, Any]]: + tools: List[Dict[str, Any]] = [] + for entry in getattr(mcp_app, "_tools", {}).values(): + try: + schema = mcp_app._tool_input_schema(entry.input_model) + except Exception: + logger.exception("Landing page schema failed for %s", entry.config.name) + schema = None + tools.append( + { + "name": entry.config.name, + "description": entry.config.description or "", + "inputSchema": schema, + } + ) + return tools def _env_list(name: str) -> List[str]: @@ -599,11 +565,12 @@ async def health_check(request): async def root_page(request): meta = _server_meta(mcp_app) return HTMLResponse( - _landing_html( - meta["name"], - meta["version"], - endpoint, - public_mcp_url=_request_public_mcp_url(request), + render_landing_page( + name=meta["name"], + version=meta["version"], + description=getattr(getattr(mcp_app, "server_config", None), "description", None), + mcp_endpoint=_request_public_mcp_url(request), + tools=_landing_tools(mcp_app), ) ) diff --git a/nitrostack/transports/landing_page.py b/nitrostack/transports/landing_page.py new file mode 100644 index 0000000..0ca1972 --- /dev/null +++ b/nitrostack/transports/landing_page.py @@ -0,0 +1,120 @@ +""" +`GET /` documentation page, ported from the TypeScript SDK's +`StreamableHttpTransport.generateDocumentationPage`. + +The markup lives in `assets/landing.html` with `{{placeholder}}` tokens. +A project can override the header logo with `assets/logo.png` or +`src/assets/logo.png` in the working directory. +""" +from __future__ import annotations + +import base64 +import functools +import html +import json +import os +import re +from pathlib import Path +from typing import Any, Dict, List, Optional + +_ASSETS = Path(__file__).parent / "assets" +_PROJECT_LOGO_PATHS = ("assets/logo.png", "src/assets/logo.png") +DEFAULT_DESCRIPTION = "A powerful MCP server built with NitroStack" + +_SCHEMA_ICON = ( + '' + '' +) + + +@functools.lru_cache(maxsize=1) +def _template() -> str: + return (_ASSETS / "landing.html").read_text(encoding="utf-8") + + +@functools.lru_cache(maxsize=1) +def _default_logo() -> str: + return base64.b64encode((_ASSETS / "logo.png").read_bytes()).decode("ascii") + + +def _logo_base64() -> str: + cwd = Path(os.getcwd()) + for rel in _PROJECT_LOGO_PATHS: + path = cwd / rel + if path.is_file(): + try: + return base64.b64encode(path.read_bytes()).decode("ascii") + except OSError: + continue + return _default_logo() + + +def format_title(name: str) -> str: + if "/" in name or "\\" in name: + return name + return " ".join(word[:1].upper() + word[1:] for word in re.split(r"[-_]+", name)) + + +def _tools_section(tools: List[Dict[str, Any]]) -> str: + if not tools: + return ( + '
\n' + "

No tools are currently registered on this server.

\n" + "
" + ) + cards = [] + for idx, tool in enumerate(tools): + name = html.escape(tool.get("name") or "") + desc = tool.get("description") or "" + schema_btn = ( + f'" + if tool.get("inputSchema") + else "" + ) + cards.append( + f'
\n' + f'
' + f'{name}
\n' + f'

{html.escape(desc or "No description available")}

\n' + f" {schema_btn}\n" + "
" + ) + return '
\n' + "\n".join(cards) + "\n
" + + +def _script_json(value: Any) -> str: + """JSON safe to inline inside `` breakout).""" + return ( + json.dumps(value) + .replace("<", "\\u003c") + .replace(">", "\\u003e") + .replace("&", "\\u0026") + .replace("\u2028", "\\u2028") + .replace("\u2029", "\\u2029") + ) + + +def render_landing_page( + *, + name: str, + version: str, + mcp_endpoint: str, + tools: List[Dict[str, Any]], + description: Optional[str] = None, +) -> str: + title = format_title(name) + values = { + "logo_base64": _logo_base64(), + "server_slug": html.escape(re.sub(r"\s+", "-", title.lower())), + "server_name": html.escape(title), + "server_version": html.escape(version), + "server_description": html.escape(description or DEFAULT_DESCRIPTION), + "mcp_endpoint": html.escape(mcp_endpoint), + "tools_section": _tools_section(tools), + "tools_json": _script_json(tools), + } + return re.sub(r"\{\{(\w+)\}\}", lambda m: values.get(m.group(1), m.group(0)), _template()) diff --git a/nitrostack/transports/middleware.py b/nitrostack/transports/middleware.py index 314f097..318c0ea 100644 --- a/nitrostack/transports/middleware.py +++ b/nitrostack/transports/middleware.py @@ -68,6 +68,14 @@ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None buffered_body: Optional[bytes] = None header_snapshot: Optional[tuple[tuple[bytes, bytes], ...]] = None if method == "POST" and path in self.mcp_paths and self.pipeline is not None: + raw_headers = decode_asgi_headers(list(scope.get("headers") or [])) + rejected_session = self.pipeline.reject_incoming_session_id(raw_headers) + if rejected_session is not None: + buffered_body = await self._read_body(receive) + await self._send_pipeline_response( + scope, send, raw_headers, rejected_session, body=buffered_body + ) + return scope = self._strip_sessionless_scope(scope) buffered_body = await self._read_body(receive) handled = await self._try_pre_dispatch(scope, buffered_body, send) @@ -92,7 +100,6 @@ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None scope = scope_with_header_snapshot(scope, header_snapshot) if path in self.mcp_paths and self.pipeline is not None: - scope = self._strip_sessionless_scope(scope) raw_headers = decode_asgi_headers(list(scope.get("headers") or [])) if method == "GET": rejected = self.pipeline.reject_method_policy(b"", raw_headers) @@ -101,6 +108,13 @@ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None scope, send, raw_headers, rejected, body=b"" ) return + rejected_session = self.pipeline.reject_incoming_session_id(raw_headers) + if rejected_session is not None: + await self._send_pipeline_response( + scope, send, raw_headers, rejected_session, body=b"" + ) + return + scope = self._strip_sessionless_scope(scope) await self._forward_with_stateless_headers( scope, receive, send, body=buffered_body diff --git a/pyproject.toml b/pyproject.toml index f3ad8a6..5b4a3b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ dev = [ "pytest>=7.4.0", "pytest-cov>=4.1.0", + "pytest-timeout>=2.3.0", "httpx>=0.27.0", "tox>=4.0.0", ] @@ -37,7 +38,7 @@ include = ["nitrostack*"] include-package-data = true [tool.setuptools.package-data] -nitrostack = ["templates/**/*", "templates/**/.*", "cli/templates/*"] +nitrostack = ["templates/**/*", "templates/**/.*", "cli/templates/*", "transports/assets/*"] [tool.ruff] line-length = 100 @@ -64,7 +65,7 @@ strict_optional = true testpaths = ["tests"] python_files = ["test_*.py"] python_functions = ["test_*"] -addopts = "-q" +addopts = "-q --timeout=60" filterwarnings = ["ignore::DeprecationWarning"] [tool.coverage.run] diff --git a/tests/__pycache__/test_basic.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_basic.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index 51d1b21..0000000 Binary files a/tests/__pycache__/test_basic.cpython-312-pytest-9.0.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_initial_tool.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_initial_tool.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index cd8dfb8..0000000 Binary files a/tests/__pycache__/test_initial_tool.cpython-312-pytest-9.0.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_oauth.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_oauth.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index a575ff8..0000000 Binary files a/tests/__pycache__/test_oauth.cpython-312-pytest-9.0.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_production.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_production.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index bb3738a..0000000 Binary files a/tests/__pycache__/test_production.cpython-312-pytest-9.0.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_tasks.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_tasks.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index 505d942..0000000 Binary files a/tests/__pycache__/test_tasks.cpython-312-pytest-9.0.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_widget_metadata.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_widget_metadata.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index e1f1730..0000000 Binary files a/tests/__pycache__/test_widget_metadata.cpython-312-pytest-9.0.2.pyc and /dev/null differ diff --git a/tests/test_cli.py b/tests/test_cli.py index 9be8571..2aaf2ed 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -885,9 +885,17 @@ def test_init_rewrites_pyproject_identity_and_copies_uv_files(template): pyproject = open(os.path.join(project, "pyproject.toml"), encoding="utf-8").read() assert 'name = "my-app"' in pyproject assert 'description = "Rewritten description"' in pyproject + assert 'nitrostack = { path = "vendor/nitrostack" }' in pyproject + assert "file:./vendor" not in pyproject + assert os.path.isfile(os.path.join(project, "vendor", "nitrostack", "nitrostack", "transports", "assets", "landing.html")) + env_text = open(os.path.join(project, ".env"), encoding="utf-8").read() + assert 'SERVER_NAME="My_App"' in env_text assert os.path.isfile(os.path.join(project, ".python-version")) assert os.path.isfile(os.path.join(project, "uv.toml")) assert os.path.isfile(os.path.join(project, "requirements.txt")) + requirements = open(os.path.join(project, "requirements.txt"), encoding="utf-8").read() + assert "nitrostack" in requirements + assert "./vendor/nitrostack" not in requirements finally: sys.stdin = original_stdin os.chdir(original_cwd) diff --git a/tests/test_cli_install.py b/tests/test_cli_install.py index 0a3c952..2088914 100644 --- a/tests/test_cli_install.py +++ b/tests/test_cli_install.py @@ -58,6 +58,7 @@ def test_install_editable_with_extras(tmp_path): with patch("nitrostack.cli.install._run_pip") as run_pip: install_dependencies(cwd=str(tmp_path), production=False) run_pip.assert_called_once_with(["-e", ".[dev]"], cwd=str(tmp_path)) + assert not (tmp_path / "vendor").exists() def test_install_production_skips_extras_and_dev_files(tmp_path): @@ -66,6 +67,7 @@ def test_install_production_skips_extras_and_dev_files(tmp_path): with patch("nitrostack.cli.install._run_pip") as run_pip: install_dependencies(cwd=str(tmp_path), production=True) run_pip.assert_called_once_with(["-e", "."], cwd=str(tmp_path)) + assert not (tmp_path / "vendor").exists() def test_install_requirements_txt_and_dev_file(tmp_path): @@ -134,9 +136,9 @@ def test_requirements_uses_local_nitrostack(tmp_path): assert requirements_uses_local_nitrostack(str(tmp_path / "missing.txt")) is False -def test_install_uses_uv_sync_when_uv_and_pyproject(tmp_path): +def test_install_keeps_named_nitrostack_and_uses_uv_sync(tmp_path): (tmp_path / "pyproject.toml").write_text( - "[project]\nname = 'demo'\ndependencies = ['nitrostack']\n", + '[project]\nname = "demo"\ndependencies = ["nitrostack"]\n', encoding="utf-8", ) (tmp_path / "requirements.txt").write_text("nitrostack\n", encoding="utf-8") @@ -146,14 +148,19 @@ def test_install_uses_uv_sync_when_uv_and_pyproject(tmp_path): install_dependencies(cwd=str(tmp_path), production=False) run_uv.assert_called_once_with(["sync"], cwd=str(tmp_path)) run_pip.assert_not_called() + assert (tmp_path / "requirements.txt").read_text(encoding="utf-8").strip() == "nitrostack" + assert not (tmp_path / "vendor").exists() -def test_install_uv_production_passes_no_dev(tmp_path): +def test_install_uv_production_syncs_without_vendoring(tmp_path): (tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n", encoding="utf-8") with patch("nitrostack.cli.install._uv_bin", return_value="/usr/bin/uv"): with patch("nitrostack.cli.install._run_uv") as run_uv: - install_dependencies(cwd=str(tmp_path), production=True) + with patch("nitrostack.cli.install._run_pip") as run_pip: + install_dependencies(cwd=str(tmp_path), production=True) run_uv.assert_called_once_with(["sync", "--no-dev"], cwd=str(tmp_path)) + run_pip.assert_not_called() + assert not (tmp_path / "vendor").exists() def test_install_uses_pip_when_local_nitrostack_pin(tmp_path): diff --git a/tests/test_mcp20_foundation.py b/tests/test_mcp20_foundation.py index 23ee0c3..9d57c28 100644 --- a/tests/test_mcp20_foundation.py +++ b/tests/test_mcp20_foundation.py @@ -50,14 +50,13 @@ def test_supported_versions(self): assert MODERN_PROTOCOL_VERSION in SUPPORTED_PROTOCOL_VERSIONS def test_supported_versions_per_era(self): + legacy_versions = frozenset({LEGACY_PROTOCOL_VERSION, "2025-11-25"}) assert supported_protocol_versions_for_era("modern") == frozenset( {MODERN_PROTOCOL_VERSION} ) - assert supported_protocol_versions_for_era("legacy") == frozenset( - {LEGACY_PROTOCOL_VERSION} - ) + assert supported_protocol_versions_for_era("legacy") == legacy_versions assert supported_protocol_versions_for_era("auto") == frozenset( - {MODERN_PROTOCOL_VERSION, LEGACY_PROTOCOL_VERSION} + {MODERN_PROTOCOL_VERSION, *legacy_versions} ) assert protocol_era_for_wire_mode("reject") == "modern" assert protocol_era_for_wire_mode("stateless") == "auto" diff --git a/tests/test_mcp20_stateless_http.py b/tests/test_mcp20_stateless_http.py index 615dec1..5cb3c00 100644 --- a/tests/test_mcp20_stateless_http.py +++ b/tests/test_mcp20_stateless_http.py @@ -572,7 +572,9 @@ async def _run(): def test_tools_call_requires_mcp_name(self): async def _run(): pipeline = StatelessIngressPipeline( - IngressContext("srv", "1.0.0", MODERN_PROTOCOL_VERSION) + IngressContext( + "srv", "1.0.0", MODERN_PROTOCOL_VERSION, wire_mode="reject" + ) ) body = json.dumps( { @@ -1731,7 +1733,8 @@ class DualClientApp: assert not state.session_manager._server_instances assert initialized.status_code == 202, initialized.text - assert initialized.json() == {} + if initialized.content: + assert initialized.json() == {} initialized_headers = { key.lower(): value for key, value in initialized.headers.items() } @@ -2962,7 +2965,9 @@ async def _run(): def test_resources_read_and_prompts_get_require_mcp_name(self): async def _run(): pipeline = StatelessIngressPipeline( - IngressContext("srv", "1.0.0", MODERN_PROTOCOL_VERSION) + IngressContext( + "srv", "1.0.0", MODERN_PROTOCOL_VERSION, wire_mode="reject" + ) ) read_body = json.dumps( { diff --git a/tests/test_transport_http.py b/tests/test_transport_http.py index 1ebf277..6586243 100644 --- a/tests/test_transport_http.py +++ b/tests/test_transport_http.py @@ -56,10 +56,64 @@ def test_landing_html_escapes_server_name(): with TestClient(http_app) as client: page = client.get("/") assert page.status_code == 200 - assert "", "inputSchema": {}}], + ) + assert "