diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index a1d8741..fad9904 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -2,9 +2,9 @@ name: Python SDK Test Suite
on:
push:
- branches: [ main, master ]
+ branches: [ main, master, develop ]
pull_request:
- branches: [ main, master ]
+ branches: [ main, master, develop ]
jobs:
test:
@@ -14,28 +14,17 @@ jobs:
python-version: ["3.10", "3.11", "3.12"]
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v4
+ uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
- pip install pytest
- pip install -e .
+ pip install -e ".[dev]"
- - name: Run Integration Tests
- run: |
- python tests/test_basic.py
- python tests/test_tasks.py
- python tests/test_initial_tool.py
- python tests/test_oauth.py
- python tests/test_production.py
- python tests/test_widget_metadata.py
- python tests/test_transports.py
- python tests/test_cli.py
- pytest tests/test_cli.py -v
- python tests/test_tool_input_schema.py
+ - name: Run pytest with coverage
+ run: pytest tests --cov=nitrostack --cov-report=term-missing
diff --git a/nitrostack/__init__.py b/nitrostack/__init__.py
index 25b4a8a..37df45d 100644
--- a/nitrostack/__init__.py
+++ b/nitrostack/__init__.py
@@ -48,6 +48,11 @@
TaskAlreadyTerminalError,
InvalidTaskTransitionError,
TaskExpiredError,
+ ConfigurationError,
+ DependencyResolutionError,
+ OAuthError,
+ TokenInactiveError,
+ AudienceMismatchError,
)
from nitrostack.core.pipeline import (
use_guards,
@@ -77,6 +82,21 @@
from nitrostack.auth.oauth import (
OAuthModule,
OAuthService,
+ generate_www_authenticate_header,
+)
+from nitrostack.auth.pkce import (
+ generate_code_challenge,
+ generate_code_verifier,
+ generate_pkce_params,
+ is_valid_code_verifier,
+ validate_pkce_support,
+ verify_pkce,
+)
+from nitrostack.auth.scopes import (
+ has_all_scopes,
+ has_any_scope,
+ has_scope,
+ require_scopes,
)
from nitrostack.auth.config import (
ConfigModule,
@@ -144,6 +164,22 @@
"JWTModule",
"OAuthModule",
"OAuthService",
+ "generate_www_authenticate_header",
+ "generate_code_challenge",
+ "generate_code_verifier",
+ "generate_pkce_params",
+ "is_valid_code_verifier",
+ "validate_pkce_support",
+ "verify_pkce",
+ "has_all_scopes",
+ "has_any_scope",
+ "has_scope",
+ "require_scopes",
+ "ConfigurationError",
+ "DependencyResolutionError",
+ "OAuthError",
+ "TokenInactiveError",
+ "AudienceMismatchError",
"ConfigModule",
"ConfigService",
"NitroTestingModule",
diff --git a/nitrostack/auth/oauth.py b/nitrostack/auth/oauth.py
index 54cabfc..feb6d28 100644
--- a/nitrostack/auth/oauth.py
+++ b/nitrostack/auth/oauth.py
@@ -15,6 +15,7 @@
build_registration_response,
is_client_registration_enabled,
)
+from nitrostack.core.errors import AudienceMismatchError, ConfigurationError, TokenInactiveError
def is_oauth_required() -> bool:
@@ -29,6 +30,25 @@ def is_oauth_required() -> bool:
_oauth_fail_open_warned = False
+def generate_www_authenticate_header(
+ *,
+ realm: str = "mcp",
+ resource_metadata: Optional[str] = None,
+ error: Optional[str] = None,
+ error_description: Optional[str] = None,
+) -> str:
+ """RFC 6750 / RFC 9728 WWW-Authenticate value for protected MCP resources."""
+ parts = [f'Bearer realm="{realm}"']
+ if resource_metadata:
+ parts.append(f'resource_metadata="{resource_metadata}"')
+ if error:
+ parts.append(f'error="{error}"')
+ if error_description:
+ escaped = error_description.replace('"', "'")
+ parts.append(f'error_description="{escaped}"')
+ return ", ".join(parts)
+
+
def warn_if_oauth_fail_open() -> None:
"""Loud warning when OAuth is wired but tokens are not enforced."""
global _oauth_fail_open_warned
@@ -125,16 +145,35 @@ def start_discovery_server(self) -> None:
registration_path = "/oauth/v2/register"
def _write_json(handler: BaseHTTPRequestHandler, status: int, payload: Dict[str, Any]) -> None:
+ raw = json.dumps(payload).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json")
+ handler.send_header("Content-Length", str(len(raw)))
+ handler.send_header("Connection", "close")
+ handler.end_headers()
+ handler.wfile.write(raw)
+
+ def _write_empty(handler: BaseHTTPRequestHandler, status: int) -> None:
+ handler.send_response(status)
+ handler.send_header("Content-Length", "0")
+ handler.send_header("Connection", "close")
handler.end_headers()
- handler.wfile.write(json.dumps(payload).encode("utf-8"))
class DiscoveryHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
# Suppress server logging to stdout/stderr to keep stdio clean
pass
+ def do_OPTIONS(self):
+ # TS discovery handlers honor CORS preflight without leaking credentials.
+ self.send_response(200)
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
+ self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
+ self.send_header("Content-Length", "0")
+ self.send_header("Connection", "close")
+ self.end_headers()
+
def do_GET(self):
if self.path == "/.well-known/oauth-protected-resource":
_write_json(self, 200, build_protected_resource_metadata(service_instance))
@@ -148,13 +187,11 @@ def do_GET(self):
build_authorization_server_metadata(service_instance, registration_endpoint),
)
else:
- self.send_response(404)
- self.end_headers()
+ _write_empty(self, 404)
def do_POST(self):
if self.path != registration_path:
- self.send_response(404)
- self.end_headers()
+ _write_empty(self, 404)
return
if not is_client_registration_enabled(service_instance):
@@ -197,6 +234,16 @@ def run_server():
self._thread = threading.Thread(target=run_server, daemon=True)
self._thread.start()
+ def raise_if_invalid(self, token_info: Dict[str, Any]) -> Dict[str, Any]:
+ """Raise ``TokenInactiveError`` / ``AudienceMismatchError`` for an introspection result."""
+ if token_info.get("error") == "audience_mismatch" or (
+ token_info.get("active") and not self._validate_audience(token_info)
+ ):
+ raise AudienceMismatchError(self.audience, token_info.get("aud") or token_info.get("resource"))
+ if not token_info.get("active"):
+ raise TokenInactiveError()
+ return token_info
+
def stop_discovery_server(self) -> None:
if self._server:
self._server.shutdown()
@@ -379,6 +426,12 @@ def for_root(
static_client_id: Optional[str] = None,
static_client_secret: Optional[str] = None,
):
+ if not resource_uri or not str(resource_uri).strip():
+ raise ConfigurationError("OAuthModule.for_root requires resource_uri")
+ if not authorization_servers:
+ raise ConfigurationError(
+ "OAuthModule.for_root requires at least one authorization server"
+ )
service = OAuthService(
resource_uri=resource_uri,
authorization_servers=authorization_servers,
diff --git a/nitrostack/auth/scopes.py b/nitrostack/auth/scopes.py
new file mode 100644
index 0000000..4dd9952
--- /dev/null
+++ b/nitrostack/auth/scopes.py
@@ -0,0 +1,69 @@
+"""Scope helpers for AuthContext (TS ``hasScope`` / ``@RequireScopes``)."""
+from __future__ import annotations
+
+import inspect
+from functools import wraps
+from typing import Any, Callable, Iterable, Sequence
+
+from nitrostack.core.context import AuthContext, ExecutionContext
+
+
+def _scope_list(auth: AuthContext | None) -> list:
+ if auth is None:
+ return []
+ scopes = getattr(auth, "scopes", None) or []
+ if isinstance(scopes, str):
+ return [part for part in scopes.split() if part]
+ return list(scopes)
+
+
+def has_scope(auth: AuthContext | None, scope: str) -> bool:
+ return scope in _scope_list(auth)
+
+
+def has_any_scope(auth: AuthContext | None, scopes: Iterable[str]) -> bool:
+ have = set(_scope_list(auth))
+ return any(scope in have for scope in scopes)
+
+
+def has_all_scopes(auth: AuthContext | None, scopes: Iterable[str]) -> bool:
+ have = set(_scope_list(auth))
+ return all(scope in have for scope in scopes)
+
+
+def require_scopes(*needed: str) -> Callable:
+ """Reject the handler when ``context.auth`` is missing any of ``needed``.
+
+ Works with async and sync handlers; the wrapper itself is always async.
+ """
+
+ def decorator(func: Callable) -> Callable:
+ @wraps(func)
+ async def wrapper(*args: Any, **kwargs: Any):
+ context = kwargs.get("context")
+ if context is None:
+ for arg in args:
+ if isinstance(arg, ExecutionContext):
+ context = arg
+ break
+ auth = getattr(context, "auth", None) if context is not None else None
+ if auth is None:
+ raise PermissionError("Not authenticated")
+ if needed and not has_all_scopes(auth, needed):
+ raise PermissionError(f"Missing required scopes: {', '.join(needed)}")
+ result = func(*args, **kwargs)
+ if inspect.isawaitable(result):
+ return await result
+ return result
+
+ return wrapper
+
+ return decorator
+
+
+def scopes_from_sequence(values: Sequence[str] | str | None) -> list:
+ if values is None:
+ return []
+ if isinstance(values, str):
+ return [part for part in values.split() if part]
+ return list(values)
diff --git a/nitrostack/core/app.py b/nitrostack/core/app.py
index 53b11d6..0b3da71 100644
--- a/nitrostack/core/app.py
+++ b/nitrostack/core/app.py
@@ -22,6 +22,7 @@
from nitrostack.core.app_mode import get_app_mode, get_widget_mime_type, is_mcp_app_mode, is_openai_mode
from nitrostack.core.di import DIContainer
from nitrostack.core.errors import (
+ DependencyResolutionError,
PromptNotFoundError,
ResourceNotFoundError,
TaskAlreadyTerminalError,
@@ -238,6 +239,81 @@ class _PromptEntry:
method: Callable
+_AUTH_META_KEYS = ("authorization", "x-api-key", "token", "_oauth", "headers")
+
+
+def _auth_metadata_from_request_ctx(rc: Any) -> Dict[str, Any]:
+ """Copy host-sent auth slots from MCP request ``_meta`` into ExecutionContext.
+
+ Real transport headers (``rc.request.headers``) take precedence over
+ client-supplied ``_meta`` values: ``_meta`` is part of the JSON-RPC payload
+ and fully client-controlled, so it must not override credentials that were
+ presented (or vetted) at the transport layer. ``_meta`` remains the only
+ source on transports without HTTP headers (e.g. STDIO).
+ """
+ extra: Dict[str, Any] = {}
+ if rc is None:
+ return extra
+
+ raw_meta = getattr(rc, "meta", None)
+ data: Dict[str, Any] = {}
+ if raw_meta is not None:
+ extra_fields = getattr(raw_meta, "model_extra", None) or getattr(raw_meta, "__pydantic_extra__", None)
+ if isinstance(extra_fields, dict):
+ data.update(extra_fields)
+ if hasattr(raw_meta, "model_dump"):
+ try:
+ dumped = raw_meta.model_dump(exclude_none=True)
+ if isinstance(dumped, dict):
+ data.update(dumped)
+ except Exception:
+ pass
+ elif isinstance(raw_meta, dict):
+ data.update(raw_meta)
+ else:
+ for key in _AUTH_META_KEYS:
+ value = getattr(raw_meta, key, None)
+ if value is not None:
+ data[key] = value
+
+ auth = data.get("authorization") or data.get("Authorization")
+ if isinstance(auth, str) and auth.strip():
+ extra["authorization"] = auth
+ api_key = data.get("x-api-key")
+ if isinstance(api_key, str) and api_key.strip():
+ extra["x-api-key"] = api_key
+ token = data.get("token")
+ if isinstance(token, str) and token.strip():
+ extra["token"] = token
+ oauth = data.get("_oauth")
+ if isinstance(oauth, str) and oauth.strip():
+ extra["_oauth"] = oauth
+ headers = data.get("headers")
+ if isinstance(headers, dict):
+ extra["headers"] = headers
+ if "authorization" not in extra:
+ header_auth = headers.get("authorization") or headers.get("Authorization")
+ if isinstance(header_auth, str) and header_auth.strip():
+ extra["authorization"] = header_auth
+ if "x-api-key" not in extra:
+ header_key = headers.get("x-api-key") or headers.get("X-API-Key")
+ if isinstance(header_key, str) and header_key.strip():
+ extra["x-api-key"] = header_key
+
+ request = getattr(rc, "request", None)
+ headers_obj = getattr(request, "headers", None) if request is not None else None
+ if headers_obj is not None:
+ try:
+ http_auth = headers_obj.get("authorization") or headers_obj.get("Authorization")
+ if isinstance(http_auth, str) and http_auth.strip():
+ extra["authorization"] = http_auth
+ http_key = headers_obj.get("x-api-key") or headers_obj.get("X-API-Key")
+ if isinstance(http_key, str) and http_key.strip():
+ extra["x-api-key"] = http_key
+ except Exception:
+ pass
+ return extra
+
class McpApplication:
def __init__(self, app_class: Type):
self.app_class = app_class
@@ -277,6 +353,7 @@ def _bootstrap(self) -> None:
self._resolve_modules(self.root_module, resolved_modules)
container = DIContainer.get_instance()
+ self._assert_declared_dependencies(resolved_modules, container)
# Instantiate all providers and controllers to populate container
for mod in resolved_modules:
@@ -327,6 +404,37 @@ def _bootstrap(self) -> None:
# instances (e.g. one per HTTP session) can be configured identically.
self._setup_handlers(self.mcp_server)
+ def _assert_declared_dependencies(
+ self, resolved_modules: Set[Type], container: DIContainer
+ ) -> None:
+ """Fail at bootstrap when a string ``deps=[...]`` token was never registered."""
+ missing: List[str] = []
+ seen: Set[Type] = set()
+
+ def walk(cls: Any) -> None:
+ if not isinstance(cls, type) or cls in seen:
+ return
+ seen.add(cls)
+ for dep in getattr(cls, "_mcp_deps", []) or []:
+ if isinstance(dep, str):
+ if not container.has_value(dep) and dep not in container._registry:
+ missing.append(f"{cls.__name__} -> '{dep}'")
+ elif isinstance(dep, type):
+ walk(dep)
+
+ for mod in resolved_modules:
+ mod_config = getattr(mod, "_mcp_module_config", None)
+ if not mod_config:
+ continue
+ for cls in [*mod_config.providers, *mod_config.controllers]:
+ walk(cls)
+
+ if missing:
+ raise DependencyResolutionError(
+ "Missing dependency at app bootstrap (referenced in deps=[...] "
+ "but never registered): " + "; ".join(missing)
+ )
+
def _resolve_modules(self, module_class: Type, resolved_modules: Set[Type]) -> None:
if module_class in resolved_modules:
return
@@ -738,6 +846,7 @@ async def _call_tool(self, name: str, arguments: Dict[str, Any]):
if getattr(rc, "meta", None) is not None:
progress_token = rc.meta.progressToken
session = getattr(rc, "session", None)
+ auth_meta = _auth_metadata_from_request_ctx(rc)
is_task = (task_metadata is not None) or (cfg.task_support == "required")
if cfg.task_support == "forbidden":
@@ -752,7 +861,7 @@ async def background_execution():
task_ctx = ExecutionContext(
request_id=str(uuid.uuid4()),
tool_name=cfg.name,
- metadata={"input": input_instance},
+ metadata={"input": input_instance, **auth_meta},
)
task_ctx.task = TaskContext(
task_id,
@@ -788,21 +897,28 @@ async def background_execution():
asyncio.create_task(background_execution())
return types.CreateTaskResult(task=self._task_data_to_mcp_task(task))
- ctx = ExecutionContext(request_id=str(uuid.uuid4()), tool_name=cfg.name, metadata={"input": input_instance})
- result = await run_pipeline(
- handler=entry.method,
- handler_instance=entry.instance,
- args=(input_instance, ctx),
- kwargs={},
- context=ctx,
- guards=guards,
- middleware=middleware,
- interceptors=interceptors,
- pipes=pipes,
- filters=filters,
- param_name="input",
- param_type=entry.input_model,
- )
+ ctx = ExecutionContext(request_id=str(uuid.uuid4()), tool_name=cfg.name, metadata={"input": input_instance, **auth_meta})
+ try:
+ result = await run_pipeline(
+ handler=entry.method,
+ handler_instance=entry.instance,
+ args=(input_instance, ctx),
+ kwargs={},
+ context=ctx,
+ guards=guards,
+ middleware=middleware,
+ interceptors=interceptors,
+ pipes=pipes,
+ filters=filters,
+ param_name="input",
+ param_type=entry.input_model,
+ )
+ except Exception as exc:
+ logger.exception("Tool %s failed", cfg.name)
+ return types.CallToolResult(
+ content=[types.TextContent(type="text", text=str(exc))],
+ isError=True,
+ )
return self._to_call_tool_result(result, entry.component, ctx)
async def _read_resource(self, uri: str) -> List[ReadResourceContents]:
diff --git a/nitrostack/core/di.py b/nitrostack/core/di.py
index f57d61a..b135a75 100644
--- a/nitrostack/core/di.py
+++ b/nitrostack/core/di.py
@@ -17,6 +17,8 @@ def reset(cls) -> None:
def __init__(self):
self._registry: Dict[Any, Type] = {}
self._instances: Dict[Any, Any] = {}
+ # List (not set) so the circular-dependency error reports the chain in order.
+ self._resolving: list = []
def register(self, cls: Type) -> None:
"""Register a provider class."""
@@ -59,17 +61,25 @@ def resolve(self, token: Any) -> Any:
if cls is None:
raise DependencyResolutionError(f"Dependency '{token}' is not registered in the DIContainer.")
+ cycle_key = cls
+ if cycle_key in self._resolving:
+ chain = " -> ".join(getattr(item, "__name__", str(item)) for item in (*self._resolving, cycle_key))
+ raise DependencyResolutionError(f"Circular dependency detected: {chain}")
+
# 4. Resolve dependencies of the class
deps = getattr(cls, "_mcp_deps", [])
resolved_args = []
- for dep in deps:
- resolved_args.append(self.resolve(dep))
-
- # 5. Instantiate the class
+ self._resolving.append(cycle_key)
try:
+ for dep in deps:
+ resolved_args.append(self.resolve(dep))
instance = cls(*resolved_args)
+ except DependencyResolutionError:
+ raise
except Exception as e:
raise DependencyResolutionError(f"Failed to instantiate class '{cls.__name__}' due to: {e}") from e
+ finally:
+ self._resolving.remove(cycle_key)
# 6. Cache and return the singleton instance
self._instances[token] = instance
diff --git a/nitrostack/core/errors.py b/nitrostack/core/errors.py
index cfcf5bf..b5f1c50 100644
--- a/nitrostack/core/errors.py
+++ b/nitrostack/core/errors.py
@@ -30,6 +30,29 @@ class ConfigurationError(Exception):
pass
+class OAuthError(Exception):
+ """Base class for OAuth / token-validation failures."""
+
+
+class TokenInactiveError(OAuthError):
+ """Raised when introspection or JWT verification reports an inactive token."""
+
+ def __init__(self, message: str = "OAuth token is inactive or revoked"):
+ super().__init__(message)
+
+
+class AudienceMismatchError(OAuthError):
+ """Raised when a token audience / resource indicator does not match."""
+
+ def __init__(self, expected: Any = None, actual: Any = None):
+ self.expected = expected
+ self.actual = actual
+ if expected is not None:
+ super().__init__(f"Token audience mismatch: expected {expected!r}, got {actual!r}")
+ else:
+ super().__init__("Token audience mismatch")
+
+
class TaskNotFoundError(Exception):
"""Raised when a task ID is not present in the TaskManager store."""
diff --git a/nitrostack/core/pipeline.py b/nitrostack/core/pipeline.py
index 337fa51..6bc5091 100644
--- a/nitrostack/core/pipeline.py
+++ b/nitrostack/core/pipeline.py
@@ -249,14 +249,18 @@ async def call_target():
current_next = call_target
for interceptor_cls in reversed(interceptors):
interceptor = container.resolve(interceptor_cls)
- def make_interceptor_next(nxt):
- return lambda: interceptor.intercept(context, nxt)
+
+ def make_interceptor_next(nxt, icpt=interceptor):
+ return lambda: icpt.intercept(context, nxt)
+
current_next = make_interceptor_next(current_next)
for middleware_cls in reversed(middleware):
mw = container.resolve(middleware_cls)
- def make_middleware_next(nxt):
- return lambda: mw.use(context, nxt)
+
+ def make_middleware_next(nxt, middleware=mw):
+ return lambda: middleware.use(context, nxt)
+
current_next = make_middleware_next(current_next)
# Execute the chain
diff --git a/nitrostack/events/event_emitter.py b/nitrostack/events/event_emitter.py
index c2b409c..769895d 100644
--- a/nitrostack/events/event_emitter.py
+++ b/nitrostack/events/event_emitter.py
@@ -1,6 +1,7 @@
-import sys
import inspect
-from typing import Any, Callable, Dict, List, Tuple, Optional
+import sys
+from typing import Any, Callable, Dict, List, Optional, Tuple
+
class EventEmitter:
_instance = None
@@ -29,24 +30,67 @@ def register_listener(self, event_name: str, func: Callable, class_type: Optiona
def bind_instance(self, event_name: str, func: Callable, instance: Any) -> None:
if event_name not in self._bound_listeners:
self._bound_listeners[event_name] = []
-
- # Bind the unbound method to the class instance
- # Standard Python descriptor binding: func.__get__(instance, type(instance))
+
bound_func = func.__get__(instance, type(instance))
self._bound_listeners[event_name].append(bound_func)
+ def on(self, event_name: str, listener: Callable) -> None:
+ self._bound_listeners.setdefault(event_name, []).append(listener)
+
+ def off(self, event_name: str, listener: Callable) -> None:
+ bound = self._bound_listeners.get(event_name)
+ if not bound:
+ return
+ self._bound_listeners[event_name] = [item for item in bound if item is not listener]
+
+ def once(self, event_name: str, listener: Callable) -> Callable:
+ def wrapper(payload: Any):
+ self.off(event_name, wrapper)
+ return listener(payload)
+
+ self.on(event_name, wrapper)
+ return wrapper
+
+ def listener_count(self, event_name: str) -> int:
+ return len(self._bound_listeners.get(event_name, []))
+
+ def event_names(self) -> List[str]:
+ return [name for name, items in self._bound_listeners.items() if items]
+
+ def remove_all_listeners(self, event_name: Optional[str] = None) -> None:
+ if event_name is None:
+ self._bound_listeners.clear()
+ return
+ self._bound_listeners.pop(event_name, None)
+
async def emit(self, event_name: str, payload: Any) -> None:
- listeners = self._bound_listeners.get(event_name, [])
+ listeners = list(self._bound_listeners.get(event_name, []))
for listener in listeners:
try:
- if inspect.iscoroutinefunction(listener):
- await listener(payload)
- else:
- listener(payload)
+ result = listener(payload)
+ if inspect.iscoroutine(result):
+ await result
except Exception as e:
sys.stderr.write(f"Event emitter error: handler for '{event_name}' failed: {e}\n")
sys.stderr.flush()
+ def emit_sync(self, event_name: str, payload: Any) -> None:
+ listeners = list(self._bound_listeners.get(event_name, []))
+ for listener in listeners:
+ try:
+ result = listener(payload)
+ if inspect.iscoroutine(result):
+ result.close()
+ sys.stderr.write(
+ f"Event emitter warning: async handler for '{event_name}' "
+ "cannot run inside emit_sync; use 'await emit()' instead\n"
+ )
+ sys.stderr.flush()
+ except Exception as e:
+ sys.stderr.write(f"Event emitter error: handler for '{event_name}' failed: {e}\n")
+ sys.stderr.flush()
+
+
def on_event(event_name: str):
"""
Decorator to mark a service or controller method as an event listener.
diff --git a/pyproject.toml b/pyproject.toml
index dcaaabe..6df79b3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,6 +19,14 @@ dependencies = [
"tomli>=2.0.0; python_version < '3.11'",
]
+[project.optional-dependencies]
+dev = [
+ "pytest>=7.4.0",
+ "pytest-cov>=4.1.0",
+ "httpx>=0.27.0",
+ "tox>=4.0.0",
+]
+
[project.scripts]
nitrostack-py = "nitrostack.cli.main:main"
@@ -48,3 +56,29 @@ python_version = "3.10"
warn_unused_configs = true
ignore_missing_imports = true
strict_optional = true
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+python_files = ["test_*.py"]
+python_functions = ["test_*"]
+addopts = "-q"
+filterwarnings = ["ignore::DeprecationWarning"]
+
+[tool.coverage.run]
+source = ["nitrostack"]
+omit = [
+ "nitrostack/templates/*",
+ "nitrostack/cli/templates/*",
+ "*/tests/*",
+]
+
+[tool.coverage.report]
+show_missing = true
+skip_empty = true
+# Phase 6 DoD floor; current suite measures ~86%.
+fail_under = 50
+exclude_lines = [
+ "pragma: no cover",
+ "if TYPE_CHECKING:",
+ "if __name__ == .__main__.:",
+]
diff --git a/tests/test_app_mode.py b/tests/test_app_mode.py
new file mode 100644
index 0000000..e11c0e1
--- /dev/null
+++ b/tests/test_app_mode.py
@@ -0,0 +1,34 @@
+"""Phase 4 — NITROSTACK_APP_MODE parsing (Python-only)."""
+from __future__ import annotations
+
+import os
+import sys
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from nitrostack.core.app_mode import (
+ RESOURCE_MIME_TYPE_MCP_APP,
+ RESOURCE_MIME_TYPE_OPENAI,
+ get_app_mode,
+ get_widget_mime_type,
+ is_mcp_app_mode,
+ is_openai_mode,
+)
+
+
+def test_default_mode_is_universal(monkeypatch):
+ monkeypatch.delenv("NITROSTACK_APP_MODE", raising=False)
+ assert get_app_mode() == "universal"
+ assert is_mcp_app_mode() is True
+ assert is_openai_mode() is True
+ assert get_widget_mime_type() == RESOURCE_MIME_TYPE_MCP_APP
+
+
+def test_mcp_app_and_openai_aliases(monkeypatch):
+ monkeypatch.setenv("NITROSTACK_APP_MODE", "mcp-app")
+ assert get_app_mode() == "mcp-app"
+ assert is_openai_mode() is False
+ monkeypatch.setenv("NITROSTACK_APP_MODE", "openai")
+ assert get_app_mode() == "openai"
+ assert is_mcp_app_mode() is False
+ assert get_widget_mime_type() == RESOURCE_MIME_TYPE_OPENAI
diff --git a/tests/test_auth_modules.py b/tests/test_auth_modules.py
new file mode 100644
index 0000000..cd19843
--- /dev/null
+++ b/tests/test_auth_modules.py
@@ -0,0 +1,180 @@
+"""Phase 2 gaps — API key, JWT, and Config modules (Python-only)."""
+from __future__ import annotations
+
+import os
+import time
+import sys
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from nitrostack import ConfigModule, ConfigService, DIContainer
+from nitrostack.auth.api_key import ApiKeyModule, ApiKeyService
+from nitrostack.auth.jwt import JWTModule, JWTService
+from nitrostack.core.errors import ConfigurationError
+
+
+def setup_function() -> None:
+ DIContainer.reset()
+
+
+def teardown_function() -> None:
+ DIContainer.reset()
+
+
+def test_api_key_loads_numbered_suffixes(monkeypatch):
+ monkeypatch.delenv("API_KEY", raising=False)
+ monkeypatch.setenv("API_KEY_1", "alpha")
+ monkeypatch.setenv("API_KEY_2", "beta")
+ ApiKeyModule.for_root(hashed=False)
+ service = DIContainer.get_instance().resolve(ApiKeyService)
+ assert service.validate("alpha") is True
+ assert service.validate("beta") is True
+ assert service.validate("nope") is False
+
+
+def test_api_key_validate_plain_and_hashed(monkeypatch):
+ monkeypatch.setenv("API_KEY", "plain-secret")
+ ApiKeyModule.for_root(hashed=False)
+ service = DIContainer.get_instance().resolve(ApiKeyService)
+ assert service.validate("plain-secret") is True
+ assert service.validate("wrong") is False
+ generated = service.generate_key("sk")
+ assert generated.startswith("sk_")
+
+ DIContainer.reset()
+ hashed = service.hash_key("plain-secret")
+ monkeypatch.setenv("API_KEY", hashed)
+ ApiKeyModule.for_root(hashed=True)
+ hashed_service = DIContainer.get_instance().resolve(ApiKeyService)
+ assert hashed_service.validate("plain-secret") is True
+ assert hashed_service.validate("nope") is False
+
+
+def test_jwt_create_and_verify_roundtrip(monkeypatch):
+ monkeypatch.setenv("JWT_SECRET", "unit-test-secret")
+ JWTModule.for_root(secret_env_var="JWT_SECRET", audience="mcp", issuer="nitro")
+ jwt = DIContainer.get_instance().resolve(JWTService)
+ token = jwt.create_token({"sub": "user-1"})
+ payload = jwt.verify_token(token)
+ assert payload["sub"] == "user-1"
+ assert payload["aud"] == "mcp"
+ assert payload["iss"] == "nitro"
+ try:
+ jwt.verify_token("not.a.jwt")
+ raise AssertionError("invalid jwt should fail")
+ except ValueError:
+ pass
+
+
+def test_config_module_defaults_and_validation_error(tmp_path):
+ env_file = tmp_path / ".env"
+ env_file.write_text("APP_NAME=phase6\n", encoding="utf-8")
+ ConfigModule.for_root(env_file_path=str(env_file), defaults={"PORT": "3000"})
+ cfg = DIContainer.get_instance().resolve(ConfigService)
+ assert cfg.get("APP_NAME") == "phase6"
+ assert cfg.get("PORT") == "3000"
+ assert cfg.get_or_throw("APP_NAME") == "phase6"
+
+ DIContainer.reset()
+ try:
+ ConfigModule.for_root(ignore_env_file=True, defaults={}, validate=lambda _c: False)
+ raise AssertionError("invalid config should fail")
+ except ConfigurationError:
+ pass
+
+
+
+def test_jwt_rejects_expired_audience_issuer_and_bad_signature(monkeypatch):
+ monkeypatch.setenv("JWT_SECRET", "unit-test-secret")
+ JWTModule.for_root(secret_env_var="JWT_SECRET", audience="mcp", issuer="nitro")
+ jwt = DIContainer.get_instance().resolve(JWTService)
+
+ expired = jwt.create_token({"sub": "user-1", "exp": int(time.time()) - 10})
+ try:
+ jwt.verify_token(expired)
+ raise AssertionError("expired jwt should fail")
+ except ValueError as exc:
+ assert "expired" in str(exc).lower()
+
+ wrong_aud = jwt.create_token({"sub": "user-1", "aud": "other-api"})
+ try:
+ jwt.verify_token(wrong_aud)
+ raise AssertionError("wrong audience should fail")
+ except ValueError as exc:
+ assert "audience" in str(exc).lower()
+
+ wrong_iss = jwt.create_token({"sub": "user-1", "iss": "other-issuer"})
+ try:
+ jwt.verify_token(wrong_iss)
+ raise AssertionError("wrong issuer should fail")
+ except ValueError as exc:
+ assert "issuer" in str(exc).lower()
+
+ token = jwt.create_token({"sub": "user-1"})
+ header, payload, signature = token.split(".")
+ try:
+ jwt.verify_token(f"{header}.{payload}.{signature[:-2]}aa")
+ raise AssertionError("tampered jwt should fail")
+ except ValueError as exc:
+ assert "signature" in str(exc).lower() or "invalid" in str(exc).lower()
+
+
+def test_jwt_parses_expires_in_units(monkeypatch):
+ monkeypatch.setenv("JWT_SECRET", "unit-test-secret")
+ now = int(time.time())
+ for expires_in, minimum in (("30m", 29 * 60), ("45s", 40), ("3600", 3500)):
+ DIContainer.reset()
+ JWTModule.for_root(secret_env_var="JWT_SECRET", expires_in=expires_in)
+ jwt = DIContainer.get_instance().resolve(JWTService)
+ payload = jwt.verify_token(jwt.create_token({"sub": "ttl"}))
+ assert payload["exp"] >= now + minimum
+
+
+def test_config_env_comments_quotes_and_get_or_throw(tmp_path, monkeypatch):
+ env_file = tmp_path / ".env"
+ env_file.write_text(
+ "# comment line\n\nQUOTED=\"hello world\"\nSINGLE='one'\nBARE=plain\n",
+ encoding="utf-8",
+ )
+ ConfigModule.for_root(env_file_path=str(env_file), defaults={"FALLBACK": "yes"})
+ cfg = DIContainer.get_instance().resolve(ConfigService)
+ assert cfg.get("QUOTED") == "hello world"
+ assert cfg.get("SINGLE") == "one"
+ assert cfg.get("BARE") == "plain"
+ assert cfg.get("FALLBACK") == "yes"
+ assert cfg.get_or_throw("BARE") == "plain"
+ try:
+ cfg.get_or_throw("MISSING_REQUIRED_KEY")
+ raise AssertionError("missing key should throw")
+ except KeyError:
+ pass
+
+ # ConfigService leaks env-file values into os.environ; undo that so the
+ # ignore_env_file half of this test actually proves the file is skipped.
+ for leaked in ("QUOTED", "SINGLE", "BARE"):
+ monkeypatch.delenv(leaked, raising=False)
+ DIContainer.reset()
+ ConfigModule.for_root(env_file_path=str(env_file), ignore_env_file=True, defaults={"ONLY": "default"})
+ ignored = DIContainer.get_instance().resolve(ConfigService)
+ assert ignored.get("ONLY") == "default"
+ assert ignored.get("BARE") is None
+
+
+def test_config_unreadable_env_file_writes_stderr(tmp_path, capsys):
+ blocked = tmp_path / "not-a-file"
+ blocked.mkdir()
+ ConfigModule.for_root(env_file_path=str(blocked), defaults={"OK": "1"})
+ captured = capsys.readouterr()
+ assert "ConfigModule warning" in captured.err
+ assert "Failed to read" in captured.err
+ assert DIContainer.get_instance().resolve(ConfigService).get("OK") == "1"
+
+
+def test_api_key_empty_string_is_rejected(monkeypatch):
+ monkeypatch.setenv("API_KEY", "")
+ monkeypatch.delenv("API_KEY_1", raising=False)
+ monkeypatch.delenv("API_KEY_2", raising=False)
+ ApiKeyModule.for_root(hashed=False)
+ service = DIContainer.get_instance().resolve(ApiKeyService)
+ assert service.validate("") is False
+ assert service.validate("anything") is False
diff --git a/tests/test_basic.py b/tests/test_basic.py
index 99abd02..8477671 100644
--- a/tests/test_basic.py
+++ b/tests/test_basic.py
@@ -75,5 +75,9 @@ async def run_tests():
print("\nAll integration tests passed successfully!")
+def test_calculator_server_integration():
+ asyncio.run(run_tests())
+
+
if __name__ == "__main__":
asyncio.run(run_tests())
diff --git a/tests/test_cli_install.py b/tests/test_cli_install.py
new file mode 100644
index 0000000..9d7ef40
--- /dev/null
+++ b/tests/test_cli_install.py
@@ -0,0 +1,74 @@
+"""Phase 5 gap — `nitrostack-py install` without invoking a real pip."""
+from __future__ import annotations
+
+import os
+import sys
+from unittest.mock import patch
+
+import pytest
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from nitrostack.cli.install import (
+ _optional_extra_names,
+ _run_pip,
+ install_dependencies,
+)
+
+
+def test_optional_extra_names_parses_pyproject():
+ text = """
+[project]
+name = "demo"
+
+[project.optional-dependencies]
+dev = ["pytest"]
+test = ["httpx"]
+
+[tool.ruff]
+line-length = 100
+"""
+ assert _optional_extra_names(text) == ["dev", "test"]
+
+
+def test_optional_extra_names_empty_when_section_missing():
+ assert _optional_extra_names("[project]\nname = 'x'\n") == []
+
+
+def test_install_requires_project_manifest(tmp_path):
+ with pytest.raises(RuntimeError, match="pyproject.toml or requirements.txt"):
+ install_dependencies(cwd=str(tmp_path))
+
+
+def test_install_editable_with_extras(tmp_path):
+ (tmp_path / "pyproject.toml").write_text(
+ "[project]\nname = 'demo'\n\n[project.optional-dependencies]\ndev = ['pytest']\n",
+ encoding="utf-8",
+ )
+ 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))
+
+
+def test_install_production_skips_extras_and_dev_files(tmp_path):
+ (tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n", encoding="utf-8")
+ (tmp_path / "requirements-dev.txt").write_text("pytest\n", encoding="utf-8")
+ 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))
+
+
+def test_install_requirements_txt_and_dev_file(tmp_path):
+ (tmp_path / "requirements.txt").write_text("starlette\n", encoding="utf-8")
+ (tmp_path / "requirements-dev.txt").write_text("pytest\n", encoding="utf-8")
+ with patch("nitrostack.cli.install._run_pip") as run_pip:
+ install_dependencies(cwd=str(tmp_path), production=False)
+ assert run_pip.call_args_list[0].args[0] == ["-r", str(tmp_path / "requirements.txt")]
+ assert run_pip.call_args_list[1].args[0] == ["-r", str(tmp_path / "requirements-dev.txt")]
+
+
+def test_run_pip_raises_on_nonzero_exit(tmp_path):
+ with patch("nitrostack.cli.install.subprocess.run") as run:
+ run.return_value.returncode = 3
+ with pytest.raises(RuntimeError, match="exit code 3"):
+ _run_pip(["-e", "."], cwd=str(tmp_path))
diff --git a/tests/test_decorators_extra.py b/tests/test_decorators_extra.py
new file mode 100644
index 0000000..fdd64fa
--- /dev/null
+++ b/tests/test_decorators_extra.py
@@ -0,0 +1,137 @@
+"""Phase 6 — cache, rate_limit, and health_check decorators."""
+from __future__ import annotations
+
+import asyncio
+import os
+import sys
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from nitrostack.core.additional_decorators import (
+ copy_mcp_attributes,
+ HealthCheckRegistry,
+ cache,
+ health_check,
+ rate_limit,
+)
+from nitrostack.core.context import ExecutionContext
+
+
+def test_cache_expires_after_ttl(monkeypatch):
+ calls = {"n": 0}
+
+ class Svc:
+ @cache(ttl=1)
+ async def add(self, value: int) -> int:
+ calls["n"] += 1
+ return value
+
+ svc = Svc()
+ clock = {"now": 100.0}
+
+ def fake_time():
+ return clock["now"]
+
+ monkeypatch.setattr("nitrostack.core.additional_decorators.time.time", fake_time)
+ assert asyncio.run(svc.add(1)) == 1
+ assert asyncio.run(svc.add(1)) == 1
+ assert calls["n"] == 1
+ clock["now"] = 102.0
+ assert asyncio.run(svc.add(1)) == 1
+ assert calls["n"] == 2
+
+
+def test_cache_returns_same_result_within_ttl():
+ calls = {"n": 0}
+
+ class Svc:
+ @cache(ttl=60)
+ async def add(self, value: int, context: ExecutionContext | None = None) -> int:
+ calls["n"] += 1
+ return value + 1
+
+ svc = Svc()
+ first = asyncio.run(svc.add(2))
+ second = asyncio.run(svc.add(2))
+ assert first == second == 3
+ assert calls["n"] == 1
+
+
+def test_rate_limit_raises_after_max():
+ class Svc:
+ @rate_limit(max=2, window=60)
+ async def ping(self) -> str:
+ return "ok"
+
+ svc = Svc()
+ assert asyncio.run(svc.ping()) == "ok"
+ assert asyncio.run(svc.ping()) == "ok"
+ try:
+ asyncio.run(svc.ping())
+ raise AssertionError("rate limit should fire")
+ except ValueError as exc:
+ assert "Rate limit exceeded" in str(exc)
+
+
+def test_health_check_registry_reports_status():
+ HealthCheckRegistry._checks.clear()
+ HealthCheckRegistry._bound_checks.clear()
+
+ class Probe:
+ @health_check("db")
+ def db_ok(self) -> bool:
+ return True
+
+ HealthCheckRegistry.bind_instance("db", Probe.db_ok, Probe())
+ results = HealthCheckRegistry.run_all()
+ assert results["db"] == "healthy"
+ HealthCheckRegistry._checks.clear()
+ HealthCheckRegistry._bound_checks.clear()
+
+
+def test_health_check_registry_records_errors():
+ HealthCheckRegistry._checks.clear()
+ HealthCheckRegistry._bound_checks.clear()
+
+ def boom():
+ raise RuntimeError("down")
+
+ HealthCheckRegistry._bound_checks["svc"] = boom
+ results = HealthCheckRegistry.run_all()
+ assert results["svc"].startswith("error:")
+ HealthCheckRegistry._checks.clear()
+ HealthCheckRegistry._bound_checks.clear()
+
+
+def test_health_check_async_and_unhealthy():
+ HealthCheckRegistry._checks.clear()
+ HealthCheckRegistry._bound_checks.clear()
+
+ async def async_ok():
+ return True
+
+ def down():
+ return False
+
+ HealthCheckRegistry._bound_checks["async"] = async_ok
+ HealthCheckRegistry._bound_checks["down"] = down
+ results = HealthCheckRegistry.run_all()
+ assert results["async"] == "healthy"
+ assert results["down"] == "unhealthy"
+ HealthCheckRegistry._checks.clear()
+ HealthCheckRegistry._bound_checks.clear()
+
+
+
+def test_copy_mcp_attributes_copies_mcp_prefixed_fields():
+ def src():
+ return None
+
+ def dst():
+ return None
+
+ src._mcp_tool_config = {"name": "copied"}
+ src._mcp_guards = ["G"]
+ copy_mcp_attributes(src, dst)
+ assert dst._mcp_tool_config == {"name": "copied"}
+ assert dst._mcp_guards == ["G"]
diff --git a/tests/test_di_edge_cases.py b/tests/test_di_edge_cases.py
new file mode 100644
index 0000000..fba5321
--- /dev/null
+++ b/tests/test_di_edge_cases.py
@@ -0,0 +1,122 @@
+"""Phase 6 — DI edge cases (circular deps, missing tokens, singletons)."""
+from __future__ import annotations
+
+import asyncio
+import os
+import sys
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from nitrostack import DIContainer, injectable, module
+from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app
+from nitrostack.core.errors import DependencyResolutionError
+
+
+def setup_function() -> None:
+ DIContainer.reset()
+
+
+def teardown_function() -> None:
+ DIContainer.reset()
+
+
+def test_missing_string_token_raises_at_resolve():
+ container = DIContainer.get_instance()
+
+ @injectable(deps=["NeverRegistered"])
+ class NeedsMissing:
+ def __init__(self, missing):
+ self.missing = missing
+
+ try:
+ container.resolve(NeedsMissing)
+ raise AssertionError("expected DependencyResolutionError")
+ except DependencyResolutionError as exc:
+ assert "NeverRegistered" in str(exc)
+
+
+def test_circular_dependency_raises_clear_error():
+ @injectable(deps=[])
+ class ServiceA:
+ def __init__(self, other=None):
+ self.other = other
+
+ @injectable(deps=[])
+ class ServiceB:
+ def __init__(self, other=None):
+ self.other = other
+
+ ServiceA._mcp_deps = [ServiceB]
+ ServiceB._mcp_deps = [ServiceA]
+
+ container = DIContainer.get_instance()
+ try:
+ container.resolve(ServiceA)
+ raise AssertionError("expected circular dependency error")
+ except DependencyResolutionError as exc:
+ assert "Circular dependency" in str(exc)
+ assert "ServiceA" in str(exc)
+ assert "ServiceB" in str(exc)
+
+
+def test_singleton_same_instance_across_resolves():
+ @injectable()
+ class Counter:
+ def __init__(self):
+ self.n = 0
+
+ container = DIContainer.get_instance()
+ first = container.resolve(Counter)
+ second = container.resolve(Counter)
+ first.n = 7
+ assert first is second
+ assert second.n == 7
+
+
+def test_register_value_and_has_value():
+ container = DIContainer.get_instance()
+
+ class Token:
+ pass
+
+ instance = Token()
+ assert container.has_value(Token) is False
+ container.register_value(Token, instance)
+ assert container.has_value(Token) is True
+ assert container.resolve(Token) is instance
+
+
+def test_constructor_failure_is_wrapped():
+ @injectable()
+ class Broken:
+ def __init__(self):
+ raise RuntimeError("cannot build")
+
+ try:
+ DIContainer.get_instance().resolve(Broken)
+ raise AssertionError("expected DependencyResolutionError")
+ except DependencyResolutionError as exc:
+ assert "Broken" in str(exc)
+ assert "cannot build" in str(exc)
+
+
+def test_missing_dep_fails_at_app_bootstrap():
+ @injectable(deps=["NeverRegistered"])
+ class NeedsMissing:
+ def __init__(self, missing):
+ self.missing = missing
+
+ @module(name="broken-boot", controllers=[NeedsMissing])
+ class BrokenModule:
+ pass
+
+ @mcp_app(module=BrokenModule, server=ServerConfig(name="broken-boot"))
+ class BrokenApp:
+ pass
+
+ try:
+ asyncio.run(McpApplicationFactory.create(BrokenApp))
+ raise AssertionError("expected DependencyResolutionError at bootstrap")
+ except DependencyResolutionError as exc:
+ assert "bootstrap" in str(exc)
+ assert "NeverRegistered" in str(exc)
diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py
new file mode 100644
index 0000000..6ea778f
--- /dev/null
+++ b/tests/test_error_handling.py
@@ -0,0 +1,161 @@
+"""Phase 6 — exception types through filters and MCP tool error shape."""
+from __future__ import annotations
+
+import asyncio
+import os
+import sys
+from typing import Any
+from unittest.mock import MagicMock
+
+from pydantic import BaseModel
+from starlette.testclient import TestClient
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from nitrostack import (
+ AudienceMismatchError,
+ DIContainer,
+ ExecutionContext,
+ InvalidTaskTransitionError,
+ PromptNotFoundError,
+ ResourceNotFoundError,
+ TaskAlreadyTerminalError,
+ TaskCancelledError,
+ TaskExpiredError,
+ TaskNotFoundError,
+ TokenInactiveError,
+ ToolExecutionError,
+ ValidationError,
+ injectable,
+ module,
+ tool,
+)
+from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app
+from nitrostack.core.pipeline import run_pipeline
+from nitrostack.transports.http import build_http_app
+
+
+def setup_function() -> None:
+ DIContainer.reset()
+
+
+def teardown_function() -> None:
+ DIContainer.reset()
+
+
+class CatchAllFilter:
+ async def catch(self, error: Exception, context: ExecutionContext) -> Any:
+ return {"filter": type(error).__name__, "message": str(error)}
+
+
+def test_custom_exceptions_are_catchable_by_filter():
+ DIContainer.get_instance().register_value(CatchAllFilter, CatchAllFilter())
+
+ errors = [
+ ValidationError("bad input"),
+ ResourceNotFoundError("missing"),
+ PromptNotFoundError("no prompt"),
+ TaskNotFoundError("t1"),
+ TaskExpiredError("t1"),
+ TaskAlreadyTerminalError("t1", "completed"),
+ InvalidTaskTransitionError("working", "cancelled"),
+ TaskCancelledError("t1"),
+ ToolExecutionError("tool boom"),
+ TokenInactiveError(),
+ AudienceMismatchError("expected", "actual"),
+ ]
+
+ for err in errors:
+
+ async def handler(*args, boom=err, **kwargs):
+ raise boom
+
+ result = asyncio.run(
+ run_pipeline(
+ handler=handler,
+ handler_instance=None,
+ args=(None,),
+ kwargs={},
+ context=ExecutionContext(request_id="e", tool_name="x", logger=MagicMock()),
+ guards=[],
+ middleware=[],
+ interceptors=[],
+ pipes=[],
+ filters=[CatchAllFilter],
+ )
+ )
+ assert result["filter"] == type(err).__name__
+
+
+class EmptyInput(BaseModel):
+ pass
+
+
+@injectable()
+class BoomController:
+ @tool(name="explode", description="raise", input_schema=EmptyInput)
+ async def explode(self, input: EmptyInput, context: ExecutionContext) -> dict:
+ raise RuntimeError("secret internals must not leak as HTML")
+
+
+@module(name="boom", controllers=[BoomController])
+class BoomModule:
+ pass
+
+
+def test_uncaught_tool_error_is_mcp_error_not_traceback():
+ @mcp_app(module=BoomModule, server=ServerConfig(name="boom-server"))
+ class BoomApp:
+ pass
+
+ app = asyncio.run(McpApplicationFactory.create(BoomApp))
+ http_app = build_http_app(app, enable_cors=True, stateless=True, json_response=True)
+ with TestClient(http_app) as client:
+ resp = client.post(
+ "/mcp",
+ headers={"Content-Type": "application/json", "Accept": "application/json, text/event-stream"},
+ json={
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "tools/call",
+ "params": {"name": "explode", "arguments": {}},
+ },
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ result = body["result"]
+ assert result["isError"] is True
+ text = result["content"][0]["text"]
+ assert "secret internals" in text
+ assert "Traceback" not in text
+ assert " None:
+ EventEmitter.reset()
+
+
+def teardown_function() -> None:
+ EventEmitter.reset()
+
+
+def test_on_event_registers_listener_and_emit_invokes_bound_method():
+ seen = []
+
+ class Listener:
+ @on_event("order.created")
+ async def handle(self, payload):
+ seen.append(payload)
+
+ emitter = EventEmitter.get_instance()
+ emitter.bind_instance("order.created", Listener.handle, Listener())
+ asyncio.run(emitter.emit("order.created", {"id": 9}))
+ assert seen == [{"id": 9}]
+
+
+def test_emit_swallows_handler_errors():
+ class Broken:
+ @on_event("x")
+ def handle(self, payload):
+ raise RuntimeError("listener boom")
+
+ emitter = EventEmitter.get_instance()
+ emitter.bind_instance("x", Broken.handle, Broken())
+ asyncio.run(emitter.emit("x", {}))
+
+
+def test_on_off_once_and_counts():
+ emitter = EventEmitter.get_instance()
+ seen = []
+
+ def keep(payload):
+ seen.append(("keep", payload))
+
+ def once_fn(payload):
+ seen.append(("once", payload))
+
+ emitter.on("ping", keep)
+ emitter.once("ping", once_fn)
+ assert emitter.listener_count("ping") == 2
+ assert "ping" in emitter.event_names()
+
+ asyncio.run(emitter.emit("ping", 1))
+ asyncio.run(emitter.emit("ping", 2))
+ assert seen == [("keep", 1), ("once", 1), ("keep", 2)]
+
+ emitter.off("ping", keep)
+ asyncio.run(emitter.emit("ping", 3))
+ assert ("keep", 3) not in seen
+
+ emitter.on("other", keep)
+ emitter.remove_all_listeners("other")
+ assert emitter.listener_count("other") == 0
+ emitter.remove_all_listeners()
+ assert emitter.event_names() == []
+
+
+def test_emit_sync_and_no_listeners():
+ emitter = EventEmitter.get_instance()
+ seen = []
+ emitter.on("sync", lambda payload: seen.append(payload))
+ emitter.emit_sync("sync", "ok")
+ assert seen == ["ok"]
+ asyncio.run(emitter.emit("missing", None))
+ emitter.emit_sync("missing", None)
diff --git a/tests/test_lifecycle_http.py b/tests/test_lifecycle_http.py
new file mode 100644
index 0000000..86441b0
--- /dev/null
+++ b/tests/test_lifecycle_http.py
@@ -0,0 +1,266 @@
+"""Phase 6 — HTTP tools/call through a pipe (Python-only integration)."""
+from __future__ import annotations
+
+import asyncio
+import os
+import sys
+
+from pydantic import BaseModel
+from starlette.testclient import TestClient
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from nitrostack import DIContainer, ExecutionContext, injectable, module, tool, use_guards, use_pipes
+from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app
+from nitrostack.auth.jwt import JWTModule, JWTService
+from nitrostack.core.pipeline import JwtGuard, PipeMetadata
+from nitrostack.transports.http import build_http_app
+
+JSON_HEADERS = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
+
+
+def setup_function() -> None:
+ DIContainer.reset()
+
+
+def teardown_function() -> None:
+ DIContainer.reset()
+
+
+class GreetInput(BaseModel):
+ name: str = "world"
+
+
+class AllowAllGuard:
+ async def can_activate(self, context: ExecutionContext) -> bool:
+ return True
+
+
+class UpperNamePipe:
+ async def transform(self, value, metadata: PipeMetadata):
+ if hasattr(value, "name"):
+ value.name = str(value.name).upper()
+ return value
+
+
+@injectable()
+class GreetController:
+ @tool(name="greet", description="Greet after the pipe", input_schema=GreetInput)
+ @use_guards(AllowAllGuard)
+ @use_pipes(UpperNamePipe)
+ async def greet(self, input: GreetInput, context: ExecutionContext) -> dict:
+ return {"hello": input.name}
+
+ @tool(
+ name="slow_greet",
+ description="Task-mode greet after the pipe",
+ input_schema=GreetInput,
+ task_support="required",
+ )
+ @use_guards(AllowAllGuard)
+ @use_pipes(UpperNamePipe)
+ async def slow_greet(self, input: GreetInput, context: ExecutionContext) -> dict:
+ if context.task is not None:
+ context.task.update_progress("started")
+ context.task.update_progress("finishing")
+ return {"hello": input.name}
+
+
+@module(name="lifecycle", controllers=[GreetController], providers=[AllowAllGuard, UpperNamePipe])
+class LifecycleModule:
+ pass
+
+
+def test_http_tool_call_runs_pipe_then_handler():
+ @mcp_app(module=LifecycleModule, server=ServerConfig(name="lifecycle", stateless=True))
+ class App:
+ pass
+
+ app = asyncio.run(McpApplicationFactory.create(App))
+ http_app = build_http_app(app, enable_cors=True, stateless=True, json_response=True)
+ with TestClient(http_app) as client:
+ health = client.get("/mcp/health")
+ assert health.status_code == 200
+ resp = client.post(
+ "/mcp",
+ headers=JSON_HEADERS,
+ json={
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "tools/call",
+ "params": {"name": "greet", "arguments": {"name": "ada"}},
+ },
+ )
+ assert resp.status_code == 200, resp.text
+ payload = resp.json()["result"]
+ assert payload["isError"] is False
+ assert payload["structuredContent"]["hello"] == "ADA"
+
+
+def test_http_guard_pipe_task_progress_and_completion():
+ @mcp_app(module=LifecycleModule, server=ServerConfig(name="lifecycle-task", stateless=True))
+ class App:
+ pass
+
+ app = asyncio.run(McpApplicationFactory.create(App))
+ http_app = build_http_app(app, enable_cors=True, stateless=True, json_response=True)
+ with TestClient(http_app) as client:
+ created = client.post(
+ "/mcp",
+ headers=JSON_HEADERS,
+ json={
+ "jsonrpc": "2.0",
+ "id": 3,
+ "method": "tools/call",
+ "params": {
+ "name": "slow_greet",
+ "arguments": {"name": "ada"},
+ "task": {"ttl": 60},
+ "_meta": {"progressToken": "tok-life"},
+ },
+ },
+ )
+ assert created.status_code == 200, created.text
+ created_body = created.json()["result"]
+ task_id = created_body.get("task", {}).get("taskId") or created_body.get("taskId")
+ assert task_id, created_body
+
+ status = None
+ for _ in range(40):
+ polled = client.post(
+ "/mcp",
+ headers=JSON_HEADERS,
+ json={
+ "jsonrpc": "2.0",
+ "id": 4,
+ "method": "tasks/get",
+ "params": {"taskId": task_id},
+ },
+ )
+ assert polled.status_code == 200, polled.text
+ status = polled.json()["result"].get("status")
+ if status in {"completed", "failed", "cancelled"}:
+ break
+ asyncio.run(asyncio.sleep(0.05))
+
+ result = client.post(
+ "/mcp",
+ headers=JSON_HEADERS,
+ json={
+ "jsonrpc": "2.0",
+ "id": 5,
+ "method": "tasks/result",
+ "params": {"taskId": task_id},
+ },
+ )
+ assert result.status_code == 200, result.text
+ payload = result.json()["result"]
+ assert payload.get("isError") is False
+ hello = (payload.get("structuredContent") or {}).get("hello")
+ if hello is None:
+ hello = payload["content"][0]["text"]
+ assert "ADA" in hello
+ else:
+ assert hello == "ADA"
+ assert status == "completed"
+
+
+
+@injectable()
+class JwtGreetController:
+ @tool(name="jwt_greet", description="Greet after JWT guard", input_schema=GreetInput)
+ @use_guards(JwtGuard)
+ @use_pipes(UpperNamePipe)
+ async def greet(self, input: GreetInput, context: ExecutionContext) -> dict:
+ return {"hello": input.name, "sub": getattr(context.auth, "subject", None)}
+
+
+@module(
+ name="jwt-lifecycle",
+ controllers=[JwtGreetController],
+ providers=[JwtGuard, UpperNamePipe],
+)
+class JwtLifecycleModule:
+ pass
+
+
+def test_http_jwt_guard_allows_valid_token_and_rejects_missing(monkeypatch):
+ monkeypatch.setenv("JWT_SECRET", "lifecycle-jwt")
+ JWTModule.for_root(secret_env_var="JWT_SECRET", audience="mcp", issuer="nitro")
+ token = DIContainer.get_instance().resolve(JWTService).create_token({"sub": "ada"})
+
+ @mcp_app(module=JwtLifecycleModule, server=ServerConfig(name="jwt-lifecycle", stateless=True))
+ class App:
+ pass
+
+ app = asyncio.run(McpApplicationFactory.create(App))
+ http_app = build_http_app(app, enable_cors=True, stateless=True, json_response=True)
+ with TestClient(http_app) as client:
+ denied = client.post(
+ "/mcp",
+ headers=JSON_HEADERS,
+ json={
+ "jsonrpc": "2.0",
+ "id": 10,
+ "method": "tools/call",
+ "params": {"name": "jwt_greet", "arguments": {"name": "ada"}},
+ },
+ )
+ assert denied.status_code == 200, denied.text
+ denied_body = denied.json()["result"]
+ assert denied_body.get("isError") is True
+
+ allowed = client.post(
+ "/mcp",
+ headers=JSON_HEADERS,
+ json={
+ "jsonrpc": "2.0",
+ "id": 11,
+ "method": "tools/call",
+ "params": {
+ "name": "jwt_greet",
+ "arguments": {"name": "ada"},
+ "_meta": {"authorization": f"Bearer {token}"},
+ },
+ },
+ )
+ assert allowed.status_code == 200, allowed.text
+ payload = allowed.json()["result"]
+ assert payload.get("isError") is False, payload
+ content = payload.get("structuredContent") or {}
+ assert content.get("hello") == "ADA"
+ assert content.get("sub") == "ada"
+
+
+def test_transport_headers_take_precedence_over_meta():
+ """Client-controlled _meta must not override real transport credentials."""
+ from nitrostack.core.app import _auth_metadata_from_request_ctx
+
+ class FakeRequest:
+ headers = {"authorization": "Bearer real-token", "x-api-key": "real-key"}
+
+ class FakeCtx:
+ meta = {
+ "authorization": "Bearer meta-token",
+ "x-api-key": "meta-key",
+ "_oauth": "meta-oauth",
+ }
+ request = FakeRequest()
+
+ extra = _auth_metadata_from_request_ctx(FakeCtx())
+ assert extra["authorization"] == "Bearer real-token"
+ assert extra["x-api-key"] == "real-key"
+ # _meta-only slots with no transport counterpart still pass through.
+ assert extra["_oauth"] == "meta-oauth"
+
+
+def test_meta_auth_used_when_transport_has_no_headers():
+ """STDIO-style contexts (no HTTP request) fall back to _meta auth."""
+ from nitrostack.core.app import _auth_metadata_from_request_ctx
+
+ class FakeCtx:
+ meta = {"authorization": "Bearer meta-token"}
+ request = None
+
+ extra = _auth_metadata_from_request_ctx(FakeCtx())
+ assert extra["authorization"] == "Bearer meta-token"
diff --git a/tests/test_logger.py b/tests/test_logger.py
new file mode 100644
index 0000000..5567f5a
--- /dev/null
+++ b/tests/test_logger.py
@@ -0,0 +1,44 @@
+"""Phase 6 — FileLogger stays off stdout for STDIO safety."""
+from __future__ import annotations
+
+import os
+import sys
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from nitrostack.core.context import FileLogger
+
+
+def test_file_logger_writes_to_file(tmp_path, monkeypatch):
+ monkeypatch.delenv("MCP_TRANSPORT_TYPE", raising=False)
+ monkeypatch.delenv("NITROSTACK_LOG_TO_STDOUT", raising=False)
+ log_path = tmp_path / "nitrostack.log"
+ logger = FileLogger(log_file=str(log_path), name="phase6-logger")
+ logger.info("phase-6 coverage")
+ logger.debug("dbg", meta={"k": 1})
+ logger.warn("warn-line")
+ logger.error("err-line")
+ text = log_path.read_text(encoding="utf-8")
+ assert "phase-6 coverage" in text
+ assert "warn-line" in text
+ assert "err-line" in text
+
+
+def test_file_logger_can_write_stdout(monkeypatch, capsys):
+ monkeypatch.setenv("NITROSTACK_LOG_TO_STDOUT", "true")
+ logger = FileLogger(name="phase6-stdout")
+ logger.info("to-stdout")
+ captured = capsys.readouterr()
+ assert "to-stdout" in captured.out
+
+
+
+def test_file_logger_falls_back_to_stderr_when_file_unwritable(tmp_path, capsys, monkeypatch):
+ monkeypatch.delenv("MCP_TRANSPORT_TYPE", raising=False)
+ monkeypatch.delenv("NITROSTACK_LOG_TO_STDOUT", raising=False)
+ blocked = tmp_path / "no-such-dir" / "nested" / "nitrostack.log"
+ logger = FileLogger(log_file=str(blocked), name="phase6-unwritable-logger")
+ logger.info("fell-back")
+ captured = capsys.readouterr()
+ assert "fell-back" in captured.err
+ assert not blocked.exists()
diff --git a/tests/test_module.py b/tests/test_module.py
new file mode 100644
index 0000000..251db47
--- /dev/null
+++ b/tests/test_module.py
@@ -0,0 +1,59 @@
+"""Phase 0/6 — @module metadata (Python-only)."""
+from __future__ import annotations
+
+import asyncio
+import os
+import sys
+
+from pydantic import BaseModel
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from nitrostack import ExecutionContext, injectable, module, tool
+from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app
+from nitrostack.core.di import DIContainer
+
+
+def setup_function() -> None:
+ DIContainer.reset()
+
+
+def teardown_function() -> None:
+ DIContainer.reset()
+
+
+def test_module_decorator_attaches_config():
+ @module(name="billing", controllers=[], providers=[], imports=[], exports=[])
+ class BillingModule:
+ pass
+
+ cfg = BillingModule._mcp_module_config
+ assert cfg.name == "billing"
+ assert cfg.controllers == []
+ assert cfg.providers == []
+
+
+def test_nested_module_imports_are_resolved_at_bootstrap():
+ class PingInput(BaseModel):
+ value: str = "x"
+
+ @injectable()
+ class ChildController:
+ @tool(name="nested_ping", description="from imported module", input_schema=PingInput)
+ async def ping(self, input: PingInput, context: ExecutionContext) -> str:
+ return "pong"
+
+ @module(name="nested-child", controllers=[ChildController])
+ class NestedChildModule:
+ pass
+
+ @module(name="nested-parent", controllers=[], imports=[NestedChildModule])
+ class NestedParentModule:
+ pass
+
+ @mcp_app(module=NestedParentModule, server=ServerConfig(name="nested-imports"))
+ class NestedApp:
+ pass
+
+ app = asyncio.run(McpApplicationFactory.create(NestedApp))
+ assert "nested_ping" in app._tools
diff --git a/tests/test_oauth.py b/tests/test_oauth.py
index f8b8112..b1e140e 100644
--- a/tests/test_oauth.py
+++ b/tests/test_oauth.py
@@ -572,6 +572,401 @@ def test_introspection_client_credentials_resolve_from_env():
print("Success! Introspection client credentials resolve from the environment.")
+def test_for_root_requires_resource_and_servers():
+ DIContainer.reset()
+ from nitrostack.core.errors import ConfigurationError
+
+ try:
+ OAuthModule.for_root(
+ resource_uri="",
+ authorization_servers=["http://auth.example"],
+ scopes_supported=["read"],
+ )
+ raise AssertionError("empty resource_uri should fail")
+ except ConfigurationError:
+ pass
+
+ try:
+ OAuthModule.for_root(
+ resource_uri="http://localhost/mcp",
+ authorization_servers=[],
+ scopes_supported=["read"],
+ )
+ raise AssertionError("empty authorization_servers should fail")
+ except ConfigurationError:
+ pass
+
+
+def test_introspect_http_active_and_inactive():
+ DIContainer.reset()
+ OAuthModule.for_root(
+ resource_uri="http://localhost/mcp",
+ authorization_servers=["http://auth.example"],
+ scopes_supported=["read"],
+ token_introspection_endpoint="http://auth.example/introspect",
+ token_introspection_client_id="cid",
+ token_introspection_client_secret="csecret",
+ audience="http://localhost/mcp",
+ )
+ service = DIContainer.get_instance().resolve(OAuthService)
+
+ class _Resp:
+ def __init__(self, payload):
+ self._payload = payload
+
+ def read(self):
+ import json
+
+ return json.dumps(self._payload).encode("utf-8")
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ return False
+
+ with patch("urllib.request.urlopen", return_value=_Resp({"active": True, "sub": "u1", "aud": "http://localhost/mcp", "scope": "read"})):
+ info = asyncio.run(service.introspect_token("tok-ok"))
+ assert info["active"] is True
+ assert info["sub"] == "u1"
+
+ with patch("urllib.request.urlopen", return_value=_Resp({"active": False})):
+ info = asyncio.run(service.introspect_token("tok-dead"))
+ assert info["active"] is False
+
+ with patch("urllib.request.urlopen", return_value=_Resp({"active": True, "aud": "https://other.example"})):
+ info = asyncio.run(service.introspect_token("tok-aud"))
+ assert info["active"] is False
+
+ with patch("urllib.request.urlopen", side_effect=OSError("down")):
+ info = asyncio.run(service.introspect_token("tok-err"))
+ assert info["active"] is False
+
+
+def test_introspect_jwks_success_and_failure():
+ DIContainer.reset()
+ OAuthModule.for_root(
+ resource_uri="http://localhost/mcp",
+ authorization_servers=["http://auth.example"],
+ scopes_supported=["read"],
+ jwks_uri="https://auth.example/jwks",
+ audience="http://localhost/mcp",
+ )
+ service = DIContainer.get_instance().resolve(OAuthService)
+
+ with patch("jwt.PyJWKClient") as mock_client_cls, patch("jwt.decode") as mock_decode:
+ mock_client_cls.return_value.get_signing_key_from_jwt.return_value.key = "pub"
+ mock_decode.return_value = {"sub": "jwks-user", "aud": "http://localhost/mcp", "scope": "read"}
+ info = asyncio.run(service.introspect_token("a.b.c"))
+ assert info["active"] is True
+ assert info["sub"] == "jwks-user"
+
+ service._jwks_clients.clear()
+ service._token_cache.clear()
+ with patch("jwt.PyJWKClient") as mock_client_cls, patch("jwt.decode") as mock_decode:
+ mock_client_cls.return_value.get_signing_key_from_jwt.return_value.key = "pub"
+ mock_decode.side_effect = ValueError("bad sig")
+ info = asyncio.run(service.introspect_token("a.b.c"))
+ assert info["active"] is False
+
+
+def test_raise_if_invalid_and_www_authenticate():
+ DIContainer.reset()
+ OAuthModule.for_root(
+ resource_uri="http://localhost/mcp",
+ authorization_servers=["http://auth.example"],
+ scopes_supported=["read"],
+ audience="http://localhost/mcp",
+ )
+ service = DIContainer.get_instance().resolve(OAuthService)
+ from nitrostack import AudienceMismatchError, TokenInactiveError, generate_www_authenticate_header
+
+ try:
+ service.raise_if_invalid({"active": False})
+ raise AssertionError("inactive should raise")
+ except TokenInactiveError:
+ pass
+ try:
+ service.raise_if_invalid({"active": False, "error": "audience_mismatch", "aud": "x"})
+ raise AssertionError("audience should raise")
+ except AudienceMismatchError:
+ pass
+
+ # Active token whose aud does not match the configured audience must raise
+ # via the _validate_audience branch (not just the explicit error sentinel).
+ try:
+ service.raise_if_invalid({"active": True, "aud": "https://other.example.com"})
+ raise AssertionError("active token with wrong audience should raise")
+ except AudienceMismatchError:
+ pass
+
+ ok = {"active": True, "aud": "http://localhost/mcp", "sub": "u1"}
+ assert service.raise_if_invalid(ok) is ok
+
+ header = generate_www_authenticate_header(
+ realm="mcp",
+ resource_metadata="http://localhost/.well-known/oauth-protected-resource",
+ error="invalid_token",
+ error_description='expired',
+ )
+ assert header.startswith("Bearer realm=")
+ assert "resource_metadata=" in header
+ assert "invalid_token" in header
+
+
+def test_oauth_guard_token_slots(monkeypatch):
+ DIContainer.reset()
+ monkeypatch.setenv("OAUTH_REQUIRED", "true")
+ OAuthModule.for_root(
+ resource_uri="http://localhost/mcp",
+ authorization_servers=["http://auth.example"],
+ scopes_supported=["read"],
+ token_introspection_endpoint="http://auth.example/introspect",
+ )
+ service = DIContainer.get_instance().resolve(OAuthService)
+ guard = OAuthGuard()
+ payload = {"active": True, "sub": "slot-user", "scope": "read"}
+
+ ctx = ExecutionContext(
+ request_id="s",
+ tool_name="t",
+ logger=MagicMock(),
+ metadata={"headers": {"Authorization": "Bearer slot-token"}},
+ )
+ with patch.object(service, "introspect_token", return_value=payload) as mock:
+ assert asyncio.run(guard.can_activate(ctx)) is True
+ mock.assert_called_once_with("slot-token")
+ assert ctx.auth.subject == "slot-user"
+
+ ctx2 = ExecutionContext(
+ request_id="s2",
+ tool_name="t",
+ logger=MagicMock(),
+ metadata={"_oauth": "meta-token"},
+ )
+ with patch.object(service, "introspect_token", return_value=payload) as mock:
+ assert asyncio.run(guard.can_activate(ctx2)) is True
+ mock.assert_called_once_with("meta-token")
+
+
+def test_audience_defaults_to_resource_uri_or_custom():
+ defaulted = OAuthService(
+ resource_uri="https://api.example.com/mcp",
+ authorization_servers=["https://idp.example.com"],
+ scopes_supported=["read"],
+ )
+ assert defaulted.audience == "https://api.example.com/mcp"
+
+ custom = OAuthService(
+ resource_uri="https://api.example.com/mcp",
+ authorization_servers=["https://idp.example.com"],
+ scopes_supported=["read"],
+ audience="custom-audience",
+ )
+ assert custom.audience == "custom-audience"
+
+
+def test_token_cache_disabled_when_ttl_zero():
+ service = OAuthService(
+ resource_uri="https://api.example.com",
+ authorization_servers=["https://idp.example.com"],
+ scopes_supported=["read"],
+ token_cache_seconds=0,
+ )
+ service._cache_set("t1", {"active": True})
+ assert service._cache_get("t1") is None
+
+
+def test_oauth_guard_required_rejects_wrong_audience_end_to_end():
+ """TS oauth.extended: valid-looking token with wrong aud is rejected when required."""
+ DIContainer.reset()
+ os.environ["OAUTH_REQUIRED"] = "true"
+ try:
+ OAuthModule.for_root(
+ resource_uri="https://api.example.com",
+ authorization_servers=["https://idp.example.com"],
+ scopes_supported=["read"],
+ token_introspection_endpoint="https://idp.example.com/introspect",
+ audience="https://api.example.com",
+ )
+ service = DIContainer.get_instance().resolve(OAuthService)
+ guard = OAuthGuard()
+ ctx = ExecutionContext(
+ request_id="aud-e2e",
+ tool_name="t",
+ logger=MagicMock(),
+ metadata={"authorization": "Bearer looks-valid"},
+ )
+ with patch.object(
+ service,
+ "_introspect_via_endpoint",
+ return_value={"active": True, "aud": "https://other.example.com", "sub": "u"},
+ ):
+ assert asyncio.run(guard.can_activate(ctx)) is False
+ finally:
+ os.environ.pop("OAUTH_REQUIRED", None)
+ DIContainer.reset()
+
+
+def _oauth_free_port() -> int:
+ import socket
+
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.bind(("127.0.0.1", 0))
+ return sock.getsockname()[1]
+
+
+def _http(port: int, method: str, path: str, body: bytes | None = None, content_type: str | None = None):
+ import http.client
+
+ conn = http.client.HTTPConnection("127.0.0.1", port, timeout=1)
+ try:
+ headers = {"Connection": "close"}
+ if content_type:
+ headers["Content-Type"] = content_type
+ conn.request(method, path, body=body, headers=headers)
+ resp = conn.getresponse()
+ return resp.status, resp.read()
+ finally:
+ conn.close()
+
+
+def _wait_discovery(port: int, path: str = "/.well-known/oauth-protected-resource"):
+ import json
+
+ last_error = None
+ for _ in range(40):
+ try:
+ status, raw = _http(port, "GET", path)
+ if status == 200:
+ return status, json.loads(raw.decode("utf-8"))
+ last_error = f"HTTP {status}: {raw[:200]!r}"
+ except Exception as exc:
+ last_error = exc
+ time.sleep(0.05)
+ raise AssertionError(f"discovery server on {port} did not start: {last_error}")
+
+
+def test_discovery_server_idempotent_start_and_dcr_http(monkeypatch):
+ """TS oauth-module.discovery: DCR 404/200 + no leaked hardcoded secrets over live HTTP."""
+ import json
+
+ DIContainer.reset()
+ port = _oauth_free_port()
+ monkeypatch.setenv("OAUTH_DISCOVERY_PORT", str(port))
+ OAuthModule.for_root(
+ resource_uri="https://api.example.com",
+ authorization_servers=["https://idp.example.com"],
+ scopes_supported=["read"],
+ discovery_port=port,
+ enable_client_registration=True,
+ static_client_id="configured-client",
+ static_client_secret="configured-secret",
+ )
+ service = DIContainer.get_instance().resolve(OAuthService)
+ service.start_discovery_server()
+ try:
+ _wait_discovery(port)
+ first_server = service._server
+ service.start_discovery_server()
+ assert service._server is first_server
+ status, body = _wait_discovery(port, "/.well-known/oauth-authorization-server")
+ assert status == 200
+ assert body.get("registration_endpoint") == "/oauth/v2/register"
+ assert "378036683838275586" not in json.dumps(body)
+
+ status, raw = _http(
+ port,
+ "POST",
+ "/oauth/v2/register",
+ body=json.dumps({"redirect_uris": ["app://cb"]}).encode("utf-8"),
+ content_type="application/json",
+ )
+ payload = json.loads(raw.decode("utf-8"))
+ assert status == 200
+ assert payload["client_id"] == "configured-client"
+ assert payload["client_secret"] == "configured-secret"
+ assert "378036683838275586" not in json.dumps(payload)
+
+ status, _raw = _http(port, "POST", "/oauth/v2/register", body=b"{not-json", content_type="application/json")
+ assert status == 200
+
+ status, raw = _http(port, "OPTIONS", "/.well-known/oauth-protected-resource")
+ assert status == 200
+ assert raw == b""
+
+ status, _raw = _http(port, "POST", "/oauth/v2/other", body=b"{}")
+ assert status == 404
+ finally:
+ service.stop_discovery_server()
+ DIContainer.reset()
+
+
+def test_discovery_dcr_404_when_disabled_or_missing_client_id(monkeypatch):
+ import json
+
+ DIContainer.reset()
+ port = _oauth_free_port()
+ monkeypatch.setenv("OAUTH_DISCOVERY_PORT", str(port))
+ OAuthModule.for_root(
+ resource_uri="https://api.example.com",
+ authorization_servers=["https://idp.example.com"],
+ scopes_supported=["read"],
+ discovery_port=port,
+ )
+ service = DIContainer.get_instance().resolve(OAuthService)
+ service.start_discovery_server()
+ try:
+ _wait_discovery(port)
+ status, body = _wait_discovery(port, "/.well-known/oauth-authorization-server")
+ assert status == 200
+ assert "registration_endpoint" not in body
+
+ status, raw = _http(port, "POST", "/oauth/v2/register", body=b"{}", content_type="application/json")
+ assert status == 404
+ payload = json.loads(raw.decode("utf-8"))
+ assert payload["error"] == "not_found"
+ assert "378036683838275586" not in json.dumps(payload)
+ finally:
+ service.stop_discovery_server()
+ DIContainer.reset()
+
+ port2 = _oauth_free_port()
+ monkeypatch.setenv("OAUTH_DISCOVERY_PORT", str(port2))
+ OAuthModule.for_root(
+ resource_uri="https://api.example.com",
+ authorization_servers=["https://idp.example.com"],
+ scopes_supported=["read"],
+ discovery_port=port2,
+ enable_client_registration=True,
+ )
+ service2 = DIContainer.get_instance().resolve(OAuthService)
+ service2.start_discovery_server()
+ try:
+ _wait_discovery(port2)
+ status, _raw = _http(port2, "POST", "/oauth/v2/register", body=b"{}", content_type="application/json")
+ assert status == 404
+ finally:
+ service2.stop_discovery_server()
+ DIContainer.reset()
+
+
+def test_oauth_module_is_auth_required(monkeypatch):
+ monkeypatch.delenv("OAUTH_REQUIRED", raising=False)
+ assert OAuthModule.is_auth_required() is False
+ monkeypatch.setenv("OAUTH_REQUIRED", "true")
+ assert OAuthModule.is_auth_required() is True
+
+
+def test_stop_discovery_when_never_started():
+ service = OAuthService(
+ resource_uri="https://api.example.com",
+ authorization_servers=["https://idp.example.com"],
+ scopes_supported=["read"],
+ )
+ service.stop_discovery_server()
+
+
if __name__ == "__main__":
test_oauth_guard_validation()
test_pkce_round_trip()
@@ -596,3 +991,14 @@ def test_introspection_client_credentials_resolve_from_env():
test_explicit_introspection_arg_beats_env()
test_introspection_client_credentials_resolve_from_env()
print("\nAll OAuth tests passed successfully!")
+ test_for_root_requires_resource_and_servers()
+ test_introspect_http_active_and_inactive()
+ test_introspect_jwks_success_and_failure()
+ test_raise_if_invalid_and_www_authenticate()
+ test_oauth_guard_token_slots()
+ test_audience_defaults_to_resource_uri_or_custom()
+ test_token_cache_disabled_when_ttl_zero()
+ test_oauth_guard_required_rejects_wrong_audience_end_to_end()
+ test_discovery_server_idempotent_start_and_dcr_http()
+ test_discovery_dcr_404_when_disabled_or_missing_client_id()
+ test_stop_discovery_when_never_started()
diff --git a/tests/test_oauth_discovery.py b/tests/test_oauth_discovery.py
new file mode 100644
index 0000000..55b5d88
--- /dev/null
+++ b/tests/test_oauth_discovery.py
@@ -0,0 +1,96 @@
+"""Phase 2 — OAuth discovery metadata server (Python-only)."""
+from __future__ import annotations
+
+import json
+import os
+import socket
+import sys
+import time
+import urllib.error
+import urllib.request
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from nitrostack import DIContainer
+from nitrostack.auth.oauth import OAuthModule, OAuthService, is_oauth_required, warn_if_oauth_fail_open
+
+
+def setup_function() -> None:
+ DIContainer.reset()
+
+
+def teardown_function() -> None:
+ DIContainer.reset()
+ os.environ.pop("OAUTH_REQUIRED", None)
+
+
+def _free_port() -> int:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.bind(("127.0.0.1", 0))
+ return sock.getsockname()[1]
+
+
+def test_is_oauth_required_only_true_for_true_string(monkeypatch):
+ monkeypatch.delenv("OAUTH_REQUIRED", raising=False)
+ assert is_oauth_required() is False
+ monkeypatch.setenv("OAUTH_REQUIRED", "false")
+ assert is_oauth_required() is False
+ monkeypatch.setenv("OAUTH_REQUIRED", "true")
+ assert is_oauth_required() is True
+
+
+def test_discovery_server_serves_protected_resource_metadata(monkeypatch):
+ port = _free_port()
+ monkeypatch.setenv("OAUTH_DISCOVERY_PORT", str(port))
+ OAuthModule.for_root(
+ resource_uri="http://localhost:3000/mcp",
+ authorization_servers=["http://auth.example/oauth"],
+ scopes_supported=["read"],
+ discovery_port=port,
+ )
+ service = DIContainer.get_instance().resolve(OAuthService)
+ service.start_discovery_server()
+ try:
+ body = None
+ for _ in range(20):
+ try:
+ with urllib.request.urlopen(
+ f"http://127.0.0.1:{port}/.well-known/oauth-protected-resource",
+ timeout=0.5,
+ ) as resp:
+ body = json.loads(resp.read().decode("utf-8"))
+ break
+ except OSError:
+ time.sleep(0.05)
+ assert body is not None
+ assert body["resource"] == "http://localhost:3000/mcp"
+ assert body["authorization_servers"] == ["http://auth.example/oauth"]
+ assert body["scopes_supported"] == ["read"]
+
+ with urllib.request.urlopen(
+ f"http://127.0.0.1:{port}/.well-known/oauth-authorization-server",
+ timeout=0.5,
+ ) as resp:
+ as_meta = json.loads(resp.read().decode("utf-8"))
+ assert as_meta["issuer"] == "http://auth.example/oauth"
+
+ try:
+ urllib.request.urlopen(f"http://127.0.0.1:{port}/nope", timeout=0.5)
+ raise AssertionError("unknown path should 404")
+ except urllib.error.HTTPError as exc:
+ assert exc.code == 404
+ finally:
+ service.stop_discovery_server()
+
+
+def test_warn_if_oauth_fail_open_writes_once(monkeypatch, capsys):
+ monkeypatch.delenv("OAUTH_REQUIRED", raising=False)
+ import nitrostack.auth.oauth as oauth
+
+ oauth._oauth_fail_open_warned = False
+ warn_if_oauth_fail_open()
+ warn_if_oauth_fail_open()
+ err = capsys.readouterr().err
+ assert "OAuth is configured" in err
+ assert oauth._oauth_fail_open_warned is True
+ oauth._oauth_fail_open_warned = False
diff --git a/tests/test_pipeline_order.py b/tests/test_pipeline_order.py
new file mode 100644
index 0000000..1805a9c
--- /dev/null
+++ b/tests/test_pipeline_order.py
@@ -0,0 +1,287 @@
+"""Phase 6 — pipeline invocation order and short-circuit behavior."""
+from __future__ import annotations
+
+import asyncio
+import time
+import os
+import sys
+from typing import Any, Callable, List
+from unittest.mock import MagicMock
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from nitrostack import DIContainer, ExecutionContext
+from nitrostack.auth.api_key import ApiKeyModule, ApiKeyService
+from nitrostack.auth.jwt import JWTModule, JWTService
+from nitrostack.core.pipeline import (
+ ApiKeyGuard,
+ JwtGuard,
+ PipeMetadata,
+ run_pipeline,
+ use_filters,
+ use_guards,
+ use_interceptors,
+ use_middleware,
+ use_pipes,
+)
+
+
+def setup_function() -> None:
+ DIContainer.reset()
+
+
+def teardown_function() -> None:
+ DIContainer.reset()
+
+
+def _ctx() -> ExecutionContext:
+ return ExecutionContext(request_id="p6", tool_name="probe", logger=MagicMock())
+
+
+class RecordingGuard:
+ def __init__(self, name: str, allow: bool, log: List[str]):
+ self.name = name
+ self.allow = allow
+ self.log = log
+
+ async def can_activate(self, context: ExecutionContext) -> bool:
+ self.log.append(f"guard:{self.name}")
+ return self.allow
+
+
+class RecordingPipe:
+ def __init__(self, name: str, log: List[str]):
+ self.name = name
+ self.log = log
+
+ async def transform(self, value: Any, metadata: PipeMetadata) -> Any:
+ self.log.append(f"pipe:{self.name}")
+ return value
+
+
+class RecordingMiddleware:
+ def __init__(self, name: str, log: List[str]):
+ self.name = name
+ self.log = log
+
+ async def use(self, context: ExecutionContext, next_fn: Callable) -> Any:
+ self.log.append(f"middleware:{self.name}:before")
+ result = await next_fn()
+ self.log.append(f"middleware:{self.name}:after")
+ return result
+
+
+class RecordingInterceptor:
+ def __init__(self, name: str, log: List[str]):
+ self.name = name
+ self.log = log
+
+ async def intercept(self, context: ExecutionContext, next_fn: Callable) -> Any:
+ self.log.append(f"interceptor:{self.name}:before")
+ result = await next_fn()
+ self.log.append(f"interceptor:{self.name}:after")
+ return result
+
+
+class RecordingFilter:
+ def __init__(self, name: str, log: List[str]):
+ self.name = name
+ self.log = log
+
+ async def catch(self, error: Exception, context: ExecutionContext) -> Any:
+ self.log.append(f"filter:{self.name}")
+ return {"caught": str(error)}
+
+
+def _register(cls, instance) -> None:
+ DIContainer.get_instance().register_value(cls, instance)
+
+
+def test_pipeline_order_guards_pipes_middleware_interceptors_handler():
+ log: List[str] = []
+ _register(RecordingGuard, RecordingGuard("g", True, log))
+ _register(RecordingPipe, RecordingPipe("p", log))
+ _register(RecordingMiddleware, RecordingMiddleware("m", log))
+ _register(RecordingInterceptor, RecordingInterceptor("i", log))
+
+ async def handler(value, context):
+ log.append("handler")
+ return value
+
+ asyncio.run(
+ run_pipeline(
+ handler=handler,
+ handler_instance=None,
+ args=("in",),
+ kwargs={"context": _ctx()},
+ context=_ctx(),
+ guards=[RecordingGuard],
+ middleware=[RecordingMiddleware],
+ interceptors=[RecordingInterceptor],
+ pipes=[RecordingPipe],
+ filters=[],
+ )
+ )
+ assert log == [
+ "guard:g",
+ "pipe:p",
+ "middleware:m:before",
+ "interceptor:i:before",
+ "handler",
+ "interceptor:i:after",
+ "middleware:m:after",
+ ]
+
+
+def test_denied_guard_short_circuits_before_pipes():
+ log: List[str] = []
+ _register(RecordingGuard, RecordingGuard("deny", False, log))
+ _register(RecordingPipe, RecordingPipe("p", log))
+ _register(RecordingMiddleware, RecordingMiddleware("m", log))
+
+ async def handler(value, context):
+ log.append("handler")
+ return value
+
+ try:
+ asyncio.run(
+ run_pipeline(
+ handler=handler,
+ handler_instance=None,
+ args=("in",),
+ kwargs={},
+ context=_ctx(),
+ guards=[RecordingGuard],
+ middleware=[RecordingMiddleware],
+ interceptors=[],
+ pipes=[RecordingPipe],
+ filters=[],
+ )
+ )
+ raise AssertionError("denied guard should raise")
+ except PermissionError:
+ pass
+ assert log == ["guard:deny"]
+
+
+def test_handler_exception_is_caught_by_filter():
+ log: List[str] = []
+ _register(RecordingFilter, RecordingFilter("f", log))
+
+ async def handler(*args, **kwargs):
+ log.append("handler")
+ raise ValueError("boom")
+
+ result = asyncio.run(
+ run_pipeline(
+ handler=handler,
+ handler_instance=None,
+ args=("in",),
+ kwargs={},
+ context=_ctx(),
+ guards=[],
+ middleware=[],
+ interceptors=[],
+ pipes=[],
+ filters=[RecordingFilter],
+ )
+ )
+ assert result == {"caught": "boom"}
+ assert log == ["handler", "filter:f"]
+
+
+def test_pipeline_decorators_attach_metadata():
+ class G:
+ async def can_activate(self, context):
+ return True
+
+ class M:
+ async def use(self, context, next_fn):
+ return await next_fn()
+
+ class I:
+ async def intercept(self, context, next_fn):
+ return await next_fn()
+
+ class P:
+ async def transform(self, value, metadata):
+ return value
+
+ class F:
+ async def catch(self, error, context):
+ return error
+
+ @use_guards(G)
+ @use_middleware(M)
+ @use_interceptors(I)
+ @use_pipes(P)
+ @use_filters(F)
+ async def handler():
+ return None
+
+ assert handler._mcp_guards == [G]
+ assert handler._mcp_middleware == [M]
+ assert handler._mcp_interceptors == [I]
+ assert handler._mcp_pipes == [P]
+ assert handler._mcp_filters == [F]
+
+
+def test_api_key_guard_rejects_missing_key():
+ allowed = asyncio.run(ApiKeyGuard().can_activate(_ctx()))
+ assert allowed is False
+
+
+def test_api_key_guard_matches_env(monkeypatch):
+ monkeypatch.setenv("API_KEY", "secret-key")
+ ctx = _ctx()
+ ctx.metadata["x-api-key"] = "secret-key"
+ assert asyncio.run(ApiKeyGuard().can_activate(ctx)) is True
+ ctx.metadata["x-api-key"] = "wrong"
+ assert asyncio.run(ApiKeyGuard().can_activate(ctx)) is False
+
+
+def test_jwt_guard_rejects_missing_bearer():
+ assert asyncio.run(JwtGuard().can_activate(_ctx())) is False
+
+
+def test_jwt_guard_accepts_valid_token(monkeypatch):
+ monkeypatch.setenv("JWT_SECRET", "phase6-jwt")
+ JWTModule.for_root(secret_env_var="JWT_SECRET", audience="mcp", issuer="nitro")
+ token = DIContainer.get_instance().resolve(JWTService).create_token({"sub": "ada"})
+ ctx = _ctx()
+ ctx.metadata["authorization"] = f"Bearer {token}"
+ assert asyncio.run(JwtGuard().can_activate(ctx)) is True
+ assert ctx.auth is not None
+ assert ctx.auth.subject == "ada"
+
+
+def test_api_key_guard_uses_api_key_service(monkeypatch):
+ monkeypatch.setenv("API_KEY", "from-service")
+ ApiKeyModule.for_root(hashed=False)
+ ctx = _ctx()
+ ctx.metadata["x-api-key"] = "from-service"
+ assert asyncio.run(ApiKeyGuard().can_activate(ctx)) is True
+ assert DIContainer.get_instance().resolve(ApiKeyService).validate("from-service") is True
+
+
+
+def test_jwt_guard_rejects_expired_and_mismatched_claims(monkeypatch):
+ monkeypatch.setenv("JWT_SECRET", "phase6-jwt")
+ JWTModule.for_root(secret_env_var="JWT_SECRET", audience="mcp", issuer="nitro")
+ jwt = DIContainer.get_instance().resolve(JWTService)
+ guard = JwtGuard()
+
+ expired = jwt.create_token({"sub": "ada", "exp": int(time.time()) - 5})
+ ctx = _ctx()
+ ctx.metadata["authorization"] = f"Bearer {expired}"
+ assert asyncio.run(guard.can_activate(ctx)) is False
+
+ wrong_aud = jwt.create_token({"sub": "ada", "aud": "other"})
+ ctx = _ctx()
+ ctx.metadata["authorization"] = f"Bearer {wrong_aud}"
+ assert asyncio.run(guard.can_activate(ctx)) is False
+
+ wrong_iss = jwt.create_token({"sub": "ada", "iss": "other"})
+ ctx = _ctx()
+ ctx.metadata["authorization"] = f"Bearer {wrong_iss}"
+ assert asyncio.run(guard.can_activate(ctx)) is False
diff --git a/tests/test_pkce.py b/tests/test_pkce.py
new file mode 100644
index 0000000..df857ee
--- /dev/null
+++ b/tests/test_pkce.py
@@ -0,0 +1,45 @@
+"""Phase 2/6 — PKCE S256 round-trip (Python-only)."""
+from __future__ import annotations
+
+import os
+import sys
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from nitrostack.auth.pkce import (
+ generate_code_challenge,
+ generate_code_verifier,
+ generate_pkce_params,
+ is_valid_code_verifier,
+ validate_pkce_support,
+ verify_pkce,
+)
+
+
+def test_s256_round_trip_and_mismatch():
+ verifier = generate_code_verifier()
+ assert is_valid_code_verifier(verifier)
+ challenge = generate_code_challenge(verifier, "S256")
+ assert verify_pkce(verifier, challenge, "S256") is True
+ other = generate_code_verifier()
+ assert verify_pkce(other, challenge, "S256") is False
+
+
+def test_plain_method_and_params():
+ params = generate_pkce_params("plain")
+ assert params["code_challenge_method"] == "plain"
+ assert params["code_challenge"] == params["code_verifier"]
+ assert verify_pkce(params["code_verifier"], params["code_challenge"], "plain")
+
+
+def test_verifier_length_and_charset():
+ assert is_valid_code_verifier("a" * 42) is False
+ assert is_valid_code_verifier("a" * 129) is False
+ assert is_valid_code_verifier("a" * 43) is True
+ assert is_valid_code_verifier("bad verifier!") is False
+
+
+def test_pkce_support_requires_s256():
+ assert validate_pkce_support([]) is False
+ assert validate_pkce_support(["plain"]) is False
+ assert validate_pkce_support(["plain", "S256"]) is True
diff --git a/tests/test_scopes.py b/tests/test_scopes.py
new file mode 100644
index 0000000..e584307
--- /dev/null
+++ b/tests/test_scopes.py
@@ -0,0 +1,67 @@
+"""Phase 2/6 — scope helpers and @require_scopes (Python-only)."""
+from __future__ import annotations
+
+import asyncio
+import os
+import sys
+from unittest.mock import MagicMock
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from nitrostack import AuthContext, ExecutionContext, has_all_scopes, has_any_scope, has_scope, require_scopes
+from nitrostack.auth.scopes import scopes_from_sequence
+
+
+def test_scope_predicates():
+ auth = AuthContext(subject="u", scopes=["read", "write"])
+ assert has_scope(auth, "read") is True
+ assert has_scope(auth, "admin") is False
+ assert has_any_scope(auth, ["admin", "write"]) is True
+ assert has_all_scopes(auth, ["read", "write"]) is True
+ assert has_all_scopes(auth, ["read", "admin"]) is False
+ assert has_scope(None, "read") is False
+
+
+def test_require_scopes_allows_and_denies():
+ @require_scopes("read")
+ async def handler(context: ExecutionContext) -> str:
+ return "ok"
+
+ ctx = ExecutionContext(request_id="s", tool_name="t", logger=MagicMock())
+ try:
+ asyncio.run(handler(context=ctx))
+ raise AssertionError("unauthenticated should fail")
+ except PermissionError:
+ pass
+
+ ctx.auth = AuthContext(subject="u", scopes=["read"])
+ assert asyncio.run(handler(context=ctx)) == "ok"
+
+ ctx.auth = AuthContext(subject="u", scopes=["other"])
+ try:
+ asyncio.run(handler(context=ctx))
+ raise AssertionError("missing scope should fail")
+ except PermissionError as exc:
+ assert "Missing required scopes" in str(exc)
+
+
+
+def test_scopes_from_sequence_and_require_scopes_starargs():
+ assert scopes_from_sequence(None) == []
+ assert scopes_from_sequence("read write") == ["read", "write"]
+ assert scopes_from_sequence(["admin"]) == ["admin"]
+
+ @require_scopes("read", "write")
+ async def handler(context: ExecutionContext) -> str:
+ return "ok"
+
+ ctx = ExecutionContext(request_id="s", tool_name="t", logger=MagicMock())
+ ctx.auth = AuthContext(subject="u", scopes=["read"])
+ try:
+ asyncio.run(handler(ctx))
+ raise AssertionError("partial scopes should fail")
+ except PermissionError:
+ pass
+
+ ctx.auth = AuthContext(subject="u", scopes=["read", "write"])
+ assert asyncio.run(handler(ctx)) == "ok"
diff --git a/tests/test_stdio.py b/tests/test_stdio.py
new file mode 100644
index 0000000..2c50f84
--- /dev/null
+++ b/tests/test_stdio.py
@@ -0,0 +1,36 @@
+"""Phase 3 gap — stdio wrapper keeps the binary buffer, text goes to stderr."""
+from __future__ import annotations
+
+import os
+import sys
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from nitrostack.transports.stdio import SafeStdoutWrapper, safe_stdio_transport
+
+
+def test_safe_stdout_writes_text_to_stderr(capsys):
+ original = sys.stdout
+ wrapper = SafeStdoutWrapper(original)
+ wrapper.write("hello-stdio")
+ wrapper.flush()
+ captured = capsys.readouterr()
+ assert "hello-stdio" in captured.err
+ assert wrapper.buffer is original.buffer
+
+
+def test_safe_stdio_transport_restores_stdout():
+ original = sys.stdout
+ with safe_stdio_transport():
+ assert isinstance(sys.stdout, SafeStdoutWrapper)
+ sys.stdout.write("during")
+ assert sys.stdout is original
+
+
+def test_safe_stdout_getattr_forwards_unknown_attrs():
+ class Fake:
+ buffer = object()
+ encoding = "utf-8"
+
+ wrapper = SafeStdoutWrapper(Fake())
+ assert wrapper.encoding == "utf-8"
diff --git a/tests/test_testing_module.py b/tests/test_testing_module.py
new file mode 100644
index 0000000..e717be6
--- /dev/null
+++ b/tests/test_testing_module.py
@@ -0,0 +1,69 @@
+"""Phase 6 — NitroTestingModule harness (Python-only)."""
+from __future__ import annotations
+
+import asyncio
+import os
+import sys
+
+from pydantic import BaseModel
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from nitrostack import (
+ DIContainer,
+ ExecutionContext,
+ NitroTestingModule,
+ injectable,
+ module,
+ prompt,
+ resource,
+ tool,
+)
+def setup_function() -> None:
+ DIContainer.reset()
+
+
+def teardown_function() -> None:
+ DIContainer.reset()
+
+
+class EchoIn(BaseModel):
+ value: str = "hi"
+
+
+@injectable()
+class HarnessController:
+ @tool(name="echo", description="echo", input_schema=EchoIn)
+ async def echo(self, input: EchoIn, context: ExecutionContext) -> dict:
+ return {"value": input.value}
+
+ @resource(uri="note://hello", name="hello", description="json note")
+ async def note(self, context: ExecutionContext) -> dict:
+ return {"ok": True}
+
+ @prompt(name="greet", description="greet")
+ async def greet(self, arguments, context: ExecutionContext) -> list:
+ return [{"role": "user", "content": "hello"}]
+
+
+@module(name="harness", controllers=[HarnessController])
+class HarnessModule:
+ pass
+
+
+def test_testing_module_tool_resource_prompt():
+ harness = asyncio.run(NitroTestingModule.create(HarnessModule))
+ assert asyncio.run(harness.call_tool("echo", {"value": "ada"})) == {"value": "ada"}
+ assert asyncio.run(harness.read_resource("note://hello")) == {"ok": True}
+ messages = asyncio.run(harness.get_prompt("greet", {}))
+ assert messages[0].role == "user"
+
+
+def test_testing_module_requires_bootstrapped_server():
+ harness = NitroTestingModule.__new__(NitroTestingModule)
+ harness.app = type("A", (), {"mcp_server": None})()
+ try:
+ asyncio.run(harness.call_tool("echo", {}))
+ raise AssertionError("expected RuntimeError")
+ except RuntimeError as exc:
+ assert "bootstrapped" in str(exc)
diff --git a/tests/test_transport_http.py b/tests/test_transport_http.py
new file mode 100644
index 0000000..9c0b8e6
--- /dev/null
+++ b/tests/test_transport_http.py
@@ -0,0 +1,124 @@
+"""Phase 6 — extra HTTP transport cases (Python-only)."""
+from __future__ import annotations
+
+import asyncio
+import time
+import os
+import sys
+
+from pydantic import BaseModel
+from starlette.testclient import TestClient
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from nitrostack import DIContainer, ExecutionContext, injectable, module, tool
+from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app
+from nitrostack.transports.http import RequestTraceMiddleware, SessionCapMiddleware, build_http_app
+
+JSON_HEADERS = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
+
+
+def setup_function() -> None:
+ DIContainer.reset()
+
+
+def teardown_function() -> None:
+ DIContainer.reset()
+
+
+class EchoIn(BaseModel):
+ value: str = "x"
+
+
+@injectable()
+class HttpExtraController:
+ @tool(name="echo", description="echo", input_schema=EchoIn)
+ async def echo(self, input: EchoIn, context: ExecutionContext) -> str:
+ return input.value
+
+
+@module(name="http-extra", controllers=[HttpExtraController])
+class HttpExtraModule:
+ pass
+
+
+def _app(name: str = "http-extra"):
+ @mcp_app(module=HttpExtraModule, server=ServerConfig(name=name, stateless=False))
+ class App:
+ pass
+
+ return asyncio.run(McpApplicationFactory.create(App))
+
+
+def test_landing_html_escapes_server_name():
+ app = _app(name='')
+ http_app = build_http_app(app, enable_cors=True, stateless=True, json_response=True)
+ with TestClient(http_app) as client:
+ page = client.get("/")
+ assert page.status_code == 200
+ assert "