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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Toggle Theme
+
+
+
+
+
+
+
+ {{server_name}}
+ v{{server_version}}
+ {{server_description}}
+
+
+
+
+
+
+
+
Connection Setup
+
+
+
+
+
+
+
+ Add via Cursor Settings UI (Settings > Features > MCP > Add New MCP Server ):
+
+
+
Copy JSON
+
{
+ "mcpServers": {
+ // your other mcp servers
+ "{{server_slug}}": {
+ "url": "{{mcp_endpoint}}"
+ }
+ }
+}
+
+
+
+
+
+
+ Connect remote tools directly via Claude's Web UI:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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.
+
+
+
+
+
+ Add
+
+
+
+
+
+
+
+ Configure custom tools directly via ChatGPT's Web UI:
+
+
+
+
+
+
Icon (optional)
+
+
+
+ PNG only. Best results at 256 x 256 px or larger. Max file size: 10 KB
+
+
+
+
+
+
+
+
+
+
+
+ I understand and want to continue
+ 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):
+
+
+
Copy JSON
+
{
+ "mcpServers": {
+ // your other mcp servers
+ "{{server_slug}}": {
+ "serverUrl": "{{mcp_endpoint}}"
+ }
+ }
+}
+
+
+
+
+
+
+ Add the following configuration block to your Codex configuration file (~/.codex/config.toml):
+
+
+
Copy TOML
+
[mcp_servers.{{server_slug}}]
+url = "{{mcp_endpoint}}"
+
+
+
+
+
+
+ Connect directly using the Server-Sent Events endpoint:
+
+
+
Copy 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''
+ f"{_SCHEMA_ICON}View Input Schema "
+ if tool.get("inputSchema")
+ else ""
+ )
+ cards.append(
+ f'"
+ )
+ 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 "